Skip to content
Open
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
82 changes: 82 additions & 0 deletions client/dive-common/components/BottomPanel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
/// <reference types="vitest" />
import { defineComponent, h, ref } from 'vue';
import { shallowMount } from '@vue/test-utils';
import Track from 'vue-media-annotator/track';
import BottomPanel from './BottomPanel.vue';

const state = vi.hoisted(() => ({ acceptTrackType: vi.fn() }));

vi.mock('vue-media-annotator/provides', () => ({
useCameraStore: () => ({ acceptTrackType: state.acceptTrackType }),
useTrackFilters: () => ({ hierarchyIndex: ref(undefined) }),
}));

describe('BottomPanel track details classification editing', () => {
beforeEach(() => state.acceptTrackType.mockClear());

it('routes Accept Correct Type through the logical-track command', () => {
const track = new Track(1, {
confidencePairs: [['root', 0.9], ['leaf', 0.7]],
features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }],
});
// `@vue/test-utils` types a mount target as a Vue 2 constructor, which a
// `defineComponent` SFC is not, so the panel renders from a host that captures the real
// instance. It stays unstubbed to keep shallow semantics for its own children.
let child: InstanceType<typeof BottomPanel> | undefined;
const Host = defineComponent({
setup: () => () => h(BottomPanel, {
ref: (instance) => {
if (instance && !(instance instanceof Element)) {
child = instance as InstanceType<typeof BottomPanel>;
}
},
props: {
sidebarMode: 'bottom',
controlsRef: null,
controlsCollapsed: false,
lineChartData: [],
eventChartData: {},
groupChartData: {},
datasetType: 'video',
isDefaultImage: false,
clientSettings: {
trackSettings: { newTrackSettings: { mode: 'Track', type: 'unknown' } },
typeSettings: { lockTypes: false, showEmptyTypes: false },
},
trackFilters: {
allTypes: ref(['root', 'leaf']),
hierarchyActive: ref(true),
importTypes: vi.fn(),
},
attributes: [],
frameRate: 30,
readonlyState: false,
disableAnnotationFilters: false,
promptVisible: () => false,
confidenceFilters: { default: 0.1 },
aggregateSeek: vi.fn(),
trackStyleManager: {},
bottomRightPanelView: 'details',
toggleBottomRightPanel: vi.fn(),
selectedTrackForDetails: track,
showConfidenceFirst: true,
showTrackAttributesFirst: true,
editIndividual: null,
setEditIndividual: vi.fn(),
resetEditIndividual: vi.fn(),
addAttribute: vi.fn(),
editAttribute: vi.fn(),
saveThreshold: vi.fn(),
},
}),
});
shallowMount(Host, { stubs: { BottomPanel: false } });
if (!child) {
throw new Error('BottomPanel did not mount');
}
child.acceptTrackType('leaf');

expect(state.acceptTrackType).toHaveBeenCalledWith(1, 'leaf', undefined);
});
});
20 changes: 16 additions & 4 deletions client/dive-common/components/BottomPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type StyleManager from 'vue-media-annotator/StyleManager';
import type TrackFilterControls from 'vue-media-annotator/TrackFilterControls';
import type { AnnotationSettings } from 'dive-common/store/settings';
import type { DatasetType } from 'dive-common/apispec';
import { useCameraStore, useTrackFilters } from 'vue-media-annotator/provides';

export default defineComponent({
name: 'BottomPanel',
Expand Down Expand Up @@ -71,8 +72,19 @@ export default defineComponent({
saveThreshold: { type: Function as PropType<() => void>, required: true },
isStereoDataset: { type: Boolean, default: false },
},
setup() {
return { context };
setup(props) {
const cameraStore = useCameraStore();
const trackFilters = useTrackFilters();
function acceptTrackType(type: string) {
if (props.selectedTrackForDetails) {
cameraStore.acceptTrackType(
props.selectedTrackForDetails.id,
type,
trackFilters.hierarchyIndex.value,
);
}
}
return { acceptTrackType, context };
},
});
</script>
Expand Down Expand Up @@ -212,7 +224,7 @@ export default defineComponent({
v-if="showConfidenceFirst"
:confidence-pairs="selectedTrackForDetails.confidencePairs"
:disabled="false"
@set-type="selectedTrackForDetails.setType($event)"
@set-type="acceptTrackType($event)"
/>
<template v-if="showTrackAttributesFirst">
<AttributeSubsection
Expand Down Expand Up @@ -254,7 +266,7 @@ export default defineComponent({
v-if="!showConfidenceFirst"
:confidence-pairs="selectedTrackForDetails.confidencePairs"
:disabled="false"
@set-type="selectedTrackForDetails.setType($event)"
@set-type="acceptTrackType($event)"
/>
</div>
<div v-else class="pa-3 text-caption grey--text">
Expand Down
29 changes: 27 additions & 2 deletions client/dive-common/components/TrackDetailsPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import TrackDetailsPanel from './TrackDetailsPanel.vue';
const state = vi.hoisted(() => ({
displayPairIndex: vi.fn(() => 1),
track: null as Track | null,
multiSelectList: [] as number[],
acceptTrackType: vi.fn(),
assignTrackType: vi.fn(),
}));

vi.mock('vue-media-annotator/provides', () => ({
Expand All @@ -26,9 +29,10 @@ vi.mock('vue-media-annotator/provides', () => ({
useTrackFilters: () => ({
allTypes: ref(['root', 'leaf']),
displayPairIndex: state.displayPairIndex,
hierarchyIndex: ref(undefined),
}),
useAttributes: () => ref([]),
useMultiSelectList: () => ref([]),
useMultiSelectList: () => ref(state.multiSelectList),
useTime: () => ({ frame: ref(0) }),
useReadOnlyMode: () => ref(false),
useTrackStyleManager: () => ({
Expand All @@ -41,7 +45,8 @@ vi.mock('vue-media-annotator/provides', () => ({
camMap: ref(new Map([['singleCam', { groupStore: undefined }]])),
getAnyTrack: () => state.track,
getAnyPossibleTrack: () => state.track,
setTrackType: vi.fn(),
acceptTrackType: state.acceptTrackType,
assignTrackType: state.assignTrackType,
}),
useSelectedCamera: () => ref('singleCam'),
}));
Expand Down Expand Up @@ -73,6 +78,9 @@ function mountPanel() {
describe('TrackDetailsPanel hierarchy summary', () => {
beforeEach(() => {
state.displayPairIndex.mockReturnValue(1);
state.multiSelectList = [];
state.acceptTrackType.mockClear();
state.assignTrackType.mockClear();
state.track = new Track(1, {
confidencePairs: [['root', 0.9], ['leaf', 0.7]],
features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }],
Expand Down Expand Up @@ -106,4 +114,21 @@ describe('TrackDetailsPanel hierarchy summary', () => {
const { wrapper } = mountPanel();
expect(wrapper.findComponent({ name: 'TrackItem' }).exists()).toBe(false);
});

it('routes Accept Correct Type through the acceptance command', () => {
const { vm } = mountPanel();
vm.acceptTrackType('leaf');
expect(state.acceptTrackType).toHaveBeenCalledWith(1, 'leaf', undefined);
});

it('routes bulk assignment through the same hierarchy-aware command', () => {
state.multiSelectList = [1];
const { vm } = mountPanel();
vm.updateMultiTrackType('new leaf');
vm.updateSelectedTracksType();
expect(state.assignTrackType).toHaveBeenCalledWith(1, 'new leaf', {
hierarchyIndex: undefined,
replaceType: 'leaf',
});
});
});
17 changes: 10 additions & 7 deletions client/dive-common/components/TrackDetailsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,12 @@ export default defineComponent({
});

function updateSelectedTracksType() {
multiSelectList.value.forEach((trackId: number) => {
cameraStore.setTrackType(trackId, multiTrackType.value);
selectedTrackList.value.forEach((track) => {
const pairIndex = Math.max(trackFilters.displayPairIndex(track, 0), 0);
cameraStore.assignTrackType(track.id, multiTrackType.value, {
hierarchyIndex: trackFilters.hierarchyIndex.value,
replaceType: track.confidencePairs[pairIndex]?.[0],
});
});
}

Expand All @@ -254,11 +258,10 @@ export default defineComponent({
return pairs.sort((a, b) => b[1] - a[1]);
});

function setTrackType(type: string) {
function acceptTrackType(type: string) {
const track = selectedTrackList.value[0];
if (!track) return;
const currentType = track.confidencePairs[0]?.[0];
cameraStore.setTrackType(track.id, type, 1, currentType);
cameraStore.acceptTrackType(track.id, type, trackFilters.hierarchyIndex.value);
}

const displayRows = computed(() => selectedTrackList.value.map((track) => {
Expand Down Expand Up @@ -314,7 +317,7 @@ export default defineComponent({
toggleMerge,
unstageFromMerge,
updateSelectedTracksType,
setTrackType,
acceptTrackType,
displayConfidencePairs,
displayRows,
trackFilters,
Expand Down Expand Up @@ -626,7 +629,7 @@ export default defineComponent({
:confidence-pairs="displayConfidencePairs"
:disabled="selectedTrackList.length > 1"
:user-modified="isUserModified"
@set-type="setTrackType($event)"
@set-type="acceptTrackType($event)"
/>
<attribute-subsection
v-if="!multiSelectInProgress"
Expand Down
3 changes: 3 additions & 0 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,9 @@ export default defineComponent({
lookupGroups: cameraStore.lookupGroups,
getTrack: (track: AnnotationId, camera = 'singleCam') => (cameraStore.getTrack(track, camera)),
getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track),
renameTrackPair: (id, currentType, newType) => (
cameraStore.renameTrackPair(id, currentType, newType)
),
groupFilterControls: groupFilters,
setType: setTrackType,
removeTypes,
Expand Down
98 changes: 98 additions & 0 deletions client/dive-common/typeHierarchy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import fs from 'fs-extra';
import {
acceptPairAsCorrect,
compileHierarchy,
normalizeTypeHierarchy,
reassignPairs,
removePair,
resolveTypeHierarchy,
rewriteHierarchyType,
selectPairIndex,
setPairConfidence,
TypeHierarchyError,
} from './typeHierarchy';

Expand Down Expand Up @@ -45,11 +49,48 @@ interface SelectionCase {
expectedIndex: number;
}

interface ReassignmentCase {
name: string;
hierarchy: Record<string, string>;
pairs: [string, number][];
replaceType: string;
newType: string;
confidence: number;
expected: [string, number][];
}

interface AcceptanceCase {
name: string;
hierarchy: Record<string, string>;
pairs: [string, number][];
acceptedType: string;
expected: [string, number][];
}

interface PairConfidenceCase {
name: string;
pairs: [string, number][];
type: string;
confidence: number;
expected: [string, number][];
}

interface PairRemovalCase {
name: string;
pairs: [string, number][];
type: string;
expected: [string, number][];
}

interface TypeHierarchyCorpus {
normalizationCases: NormalizationCase[];
resolutionCases: ResolutionCase[];
renameCases: RenameCase[];
selectionCases: SelectionCase[];
reassignmentCases: ReassignmentCase[];
acceptanceCases: AcceptanceCase[];
pairConfidenceCases: PairConfidenceCase[];
pairRemovalCases: PairRemovalCase[];
}

const corpus = fs.readJSONSync('../testutils/typeHierarchy.spec.json') as TypeHierarchyCorpus;
Expand Down Expand Up @@ -132,6 +173,46 @@ describe('shared type hierarchy corpus', () => {
)).toBe(testCase.expectedIndex);
});
});

describe.each(corpus.reassignmentCases)('reassignment: $name', (testCase) => {
it('matches the shared result', () => {
const hierarchy = normalizeTypeHierarchy(testCase.hierarchy) || {};
expect(reassignPairs(
compileHierarchy(hierarchy),
testCase.pairs,
testCase.replaceType,
testCase.newType,
testCase.confidence,
)).toEqual(testCase.expected);
});
});

describe.each(corpus.acceptanceCases)('acceptance: $name', (testCase) => {
it('matches the shared result', () => {
const hierarchy = normalizeTypeHierarchy(testCase.hierarchy) || {};
expect(acceptPairAsCorrect(
compileHierarchy(hierarchy),
testCase.pairs,
testCase.acceptedType,
)).toEqual(testCase.expected);
});
});

describe.each(corpus.pairConfidenceCases)('pair confidence: $name', (testCase) => {
it('matches the shared result', () => {
expect(setPairConfidence(
testCase.pairs,
testCase.type,
testCase.confidence,
)).toEqual(testCase.expected);
});
});

describe.each(corpus.pairRemovalCases)('pair removal: $name', (testCase) => {
it('matches the shared result', () => {
expect(removePair(testCase.pairs, testCase.type)).toEqual(testCase.expected);
});
});
});

describe('type hierarchy index', () => {
Expand All @@ -148,4 +229,21 @@ describe('type hierarchy index', () => {
'passes and pairs must have the same length',
);
});

it('classification operations do not mutate or reuse their input pairs', () => {
const pairs: [string, number][] = [['cod', 0.8], ['fish', 0.7], ['bird', 0.2]];
const snapshot = pairs.map(([type, confidence]) => [type, confidence] as [string, number]);
const results = [
reassignPairs(index, pairs, 'cod', 'haddock', 0.8),
acceptPairAsCorrect(index, pairs, 'cod'),
setPairConfidence(pairs, 'cod', 1.0),
removePair(pairs, 'bird'),
];

expect(pairs).toEqual(snapshot);
results.forEach((result) => {
expect(result).not.toBe(pairs);
result.forEach((pair) => expect(pairs).not.toContain(pair));
});
});
});
Loading
Loading