Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions client/dive-common/components/Types/TypeSettingsPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,14 @@ it('closes the settings popup before opening the import dialog', async () => {
expect(panel.importDialog).toBe(true);
wrapper.destroy();
});

it('opens type management from settings and closes the popup', async () => {
const { wrapper, panel } = mountPanel({ allTypes: [], hierarchyActive: false });
panel.active = true;
const manage = wrapper.findAll('button').wrappers.find((button) => button.text().includes('Manage Types'));
if (!manage) throw new Error('Manage Types button is missing');
await manage.trigger('click');
expect(panel.active).toBe(false);
expect(panel.manageDialog).toBe(true);
wrapper.destroy();
});
47 changes: 21 additions & 26 deletions client/dive-common/components/Types/TypeSettingsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import {
} from 'vue';
import { useReadOnlyMode } from 'vue-media-annotator/provides';
import { clientSettings } from 'dive-common/store/settings';
import TypeManagementDialog from 'vue-media-annotator/components/Types/TypeManagementDialog.vue';
import CategoryImportDialog from './CategoryImportDialog.vue';

export default defineComponent({
name: 'TypeSettingsPanel',
components: { CategoryImportDialog },
components: { CategoryImportDialog, TypeManagementDialog },

props: {
allTypes: {
Expand Down Expand Up @@ -40,6 +41,7 @@ export default defineComponent({
suppressionThreshold: 'Minimum percent of a detection that must lie under suppression regions for it to be hidden (default 99).',
});
const importDialog = ref(false);
const manageDialog = ref(false);
const active = ref(false);
// Always offer the default suppression type even when no annotations use it yet.
const suppressionTypeItems = computed(() => (
Expand All @@ -54,6 +56,7 @@ export default defineComponent({
itemHeight,
help,
importDialog,
manageDialog,
readOnlyMode,
suppressionTypeItems,
};
Expand Down Expand Up @@ -97,41 +100,30 @@ export default defineComponent({
</v-card-title>
<v-card-text>
<v-row>
<v-col>
<v-col class="d-flex align-center">
<v-btn
dense
small
outlined
hide-details
class="mr-2"
:title="help.import"
:disabled="readOnlyMode"
@click="active = false; importDialog = true"
>
<v-icon small>
<v-icon small class="mr-1">
mdi-plus
</v-icon>
Types
Add Types
</v-btn>
</v-col>
<v-col
cols="2"
align="right"
justify="center"
>
<v-tooltip
open-delay="200"
bottom
max-width="200"
<v-btn
small
outlined
@click="active = false; manageDialog = true"
>
<template #activator="{ on }">
<v-icon
small
v-on="on"
>
mdi-help
</v-icon>
</template>
<span>{{ help.import }}</span>
</v-tooltip>
<v-icon small class="mr-1">
mdi-file-tree
</v-icon>
Manage Types
</v-btn>
</v-col>
</v-row>
<v-row>
Expand Down Expand Up @@ -436,6 +428,9 @@ export default defineComponent({
</v-card>
</v-menu>

<v-dialog v-model="manageDialog" max-width="1000" scrollable>
<TypeManagementDialog v-if="manageDialog" @close="manageDialog = false" />
</v-dialog>
<v-dialog
v-model="importDialog"
width="550"
Expand Down
19 changes: 19 additions & 0 deletions client/dive-common/typeHierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,25 @@ export function ancestorsOf(index: TypeHierarchyIndex, type: string): readonly s
: [];
}

/** Direct and indirect children, shallow-to-deep. */
export function descendantsOf(index: TypeHierarchyIndex, type: string): readonly string[] {
const childrenByParent = new Map<string, string[]>();
Object.entries(index.hierarchy).forEach(([child, parent]) => {
const list = childrenByParent.get(parent) ?? [];
list.push(child);
childrenByParent.set(parent, list);
});
const result: string[] = [];
const walk = (node: string) => {
(childrenByParent.get(node) ?? []).forEach((child) => {
result.push(child);
walk(child);
});
};
walk(type);
return result;
}

function sortPairsByConfidence(
pairs: readonly (readonly [string, number])[],
): [string, number][] {
Expand Down
151 changes: 149 additions & 2 deletions client/src/TrackFilterControls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,12 +874,12 @@ describe('useAnnotationFilters', () => {
expect(groupFilters.checkedTypes.value).toEqual([]);
});

it('preserves flat track and group configured-only rename behavior', () => {
it('retains renamed configured track types without changing group rename behavior', () => {
const { filters } = makePairFixture([[['used', 0.8]]]);
filters.importTypes(['unused'], false);
filters.updateTypeName({ currentType: 'unused', newType: 'renamed' });
expect(filters.configuredTypes.value).not.toContain('unused');
expect(filters.configuredTypes.value).not.toContain('renamed');
expect(filters.configuredTypes.value).toContain('renamed');

const cameraStore = makeCameraStore();
const groupFilters = makeGroupFilterControls(cameraStore);
Expand Down Expand Up @@ -1443,3 +1443,150 @@ describe('WoRMS import provenance', () => {
expect(filters.taxonomySavePatch()).toEqual({});
});
});

describe('type management deletion', () => {
it('transfers hidden tracks to unknown while keeping other labels and promoting children', () => {
const { filters, cameraStore, markPending } = makePairFixture([
[['parent', 0.8], ['unknown', 0.9], ['other', 0.4]],
[['child', 1]],
]);
filters.setTypeHierarchy({ parent: 'root', child: 'parent' });
filters.importTypes(['parent']);
filters.checkedTypes.value = [];
filters.setTimeFilters([100, 200]);
expect(filters.typeTrackIds().get('parent')?.size).toBe(1);
expect(filters.deleteTypeWithTracks('parent', 'unknown')).toBe(true);
expect(cameraStore.getTrack(0).confidencePairs).toEqual([['unknown', 0.9], ['other', 0.4]]);
expect(cameraStore.getTrack(1).confidencePairs).toEqual([['child', 1]]);
expect(filters.typeHierarchy.value).toEqual({ child: 'root' });
expect(filters.allTypes.value).not.toContain('parent');
expect(filters.typeHierarchySavePatch()).toEqual({ typeHierarchy: { child: 'root' } });
expect(markPending).toHaveBeenCalled();
});

it('counts secondary-camera labels and preserves each camera vector on transfer', () => {
const { filters, cameraStore } = makePairFixture([[['other', 1]]]);
cameraStore.addCamera('right');
const store = cameraStore.camMap.value.get('right')!.trackStore;
store.insert(new Track(0, { confidencePairs: [['fish', 0.7]], features }));
store.setEnableSorting();
expect(filters.typeTrackIds().get('fish')?.size).toBe(1);
expect(filters.deleteTypeWithTracks('fish', 'unknown')).toBe(true);
expect(cameraStore.getTrack(0).confidencePairs).toEqual([['other', 1]]);
expect(store.get(0).confidencePairs).toEqual([['unknown', 0.7]]);
expect(filters.typeTrackIds().has('fish')).toBe(false);
});

it('deletes whole matching tracks across cameras but keeps descendant-only tracks', () => {
const { filters, cameraStore } = makePairFixture([
[['parent', 0.2], ['other', 1]], [['child', 1]],
]);
cameraStore.addCamera('right');
const store = cameraStore.camMap.value.get('right')!.trackStore;
store.insert(new Track(0, { confidencePairs: [['other', 1]], features }));
store.setEnableSorting();
filters.setTypeHierarchy({ child: 'parent' });
filters.checkedTypes.value = [];
expect(filters.deleteTypeWithTracks('parent', 'delete')).toBe(true);
expect(cameraStore.getTrackAll(0)).toEqual([]);
expect(cameraStore.getTrackAll(1)).toHaveLength(1);
expect(filters.allTypes.value).toContain('child');
expect(filters.allTypes.value).not.toContain('parent');
});

it('does not allow transferring unknown to itself', () => {
const { filters, cameraStore } = makePairFixture([[['unknown', 1]]]);
expect(filters.deleteTypeWithTracks('unknown', 'unknown')).toBe(false);
expect(cameraStore.getTrack(0).confidencePairs).toEqual([['unknown', 1]]);
});

it('keeps unused flat types when renamed', () => {
const { filters } = makePairFixture([]);
filters.importTypes(['old']);
filters.updateTypeDefinition({ currentType: 'old', newType: 'new', parent: undefined });
expect(filters.allTypes.value).toEqual(['new']);
});
});

describe('optional empty parent deletion', () => {
it('keeps empty ancestors by default', () => {
const { filters } = makePairFixture([]);
filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' });
filters.deleteTypeWithTracks('leaf', 'delete');
expect(filters.allTypes.value).toEqual(expect.arrayContaining(['parent', 'root']));
});

it.each(['unknown', 'delete'] as const)('removes the empty ancestor chain after %s', (mode) => {
const { filters, cameraStore } = makePairFixture([[['leaf', 1]]]);
filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' });
filters.importTypes(['leaf', 'parent', 'root']);
filters.deleteTypeWithTracks('leaf', mode, true);
expect(filters.typeHierarchySavePatch()).toEqual({ typeHierarchy: null });
expect(filters.allTypes.value).toEqual(mode === 'unknown' ? ['unknown'] : []);
expect(cameraStore.getTrackAll(0)).toHaveLength(mode === 'unknown' ? 1 : 0);
});

it('stops at parents with other children, including unused siblings', () => {
const { filters } = makePairFixture([]);
filters.setTypeHierarchy({ leaf: 'parent', sibling: 'parent', parent: 'root' });
filters.deleteTypeWithTracks('leaf', 'delete', true);
expect(filters.typeHierarchy.value).toEqual({ sibling: 'parent', parent: 'root' });
});

it('keeps parents with usage on a secondary camera', () => {
const { filters, cameraStore } = makePairFixture([[['other', 1]]]);
cameraStore.addCamera('right');
const store = cameraStore.camMap.value.get('right')!.trackStore;
store.insert(new Track(0, { confidencePairs: [['parent', 1]], features }));
store.setEnableSorting();
filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' });
filters.deleteTypeWithTracks('leaf', 'delete', true);
expect(filters.typeHierarchy.value).toEqual({ parent: 'root' });
expect(store.get(0).confidencePairs).toEqual([['parent', 1]]);
});

it('keeps ancestors receiving promoted children', () => {
const { filters } = makePairFixture([]);
filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' });
filters.deleteTypeWithTracks('parent', 'delete', true);
expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' });
});
});

describe('optional empty child deletion', () => {
it('keeps empty descendants by default', () => {
const { filters } = makePairFixture([]);
filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' });
filters.deleteTypeWithTracks('parent', 'delete');
expect(filters.allTypes.value).toEqual(expect.arrayContaining(['leaf', 'root']));
expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' });
});

it.each(['unknown', 'delete'] as const)('removes empty descendants after %s', (mode) => {
const { filters, cameraStore } = makePairFixture([[['parent', 1]]]);
filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' });
filters.importTypes(['parent', 'leaf', 'root']);
filters.deleteTypeWithTracks('parent', mode, false, true);
expect(filters.typeHierarchySavePatch()).toEqual({ typeHierarchy: null });
expect(filters.allTypes.value).toEqual(mode === 'unknown' ? ['unknown', 'root'] : ['root']);
expect(cameraStore.getTrackAll(0)).toHaveLength(mode === 'unknown' ? 1 : 0);
});

it('stops at descendants with child types still in the hierarchy', () => {
const { filters } = makePairFixture([[['heavy', 1]]]);
filters.setTypeHierarchy({
empty: 'mid', heavy: 'mid', mid: 'parent', parent: 'root',
});
filters.deleteTypeWithTracks('parent', 'delete', false, true);
expect(filters.typeHierarchy.value).toEqual({ heavy: 'mid', mid: 'root' });
expect(filters.allTypes.value).not.toContain('empty');
});

it('keeps descendants with direct track usage', () => {
const { filters } = makePairFixture([[['leaf', 1]]]);
filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' });
filters.deleteTypeWithTracks('parent', 'delete', false, true);
expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' });
expect(filters.allTypes.value).toContain('leaf');
});
});
Loading
Loading