From b1774226809c09db60bd1b070dc6bb40f92d6f90 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 11 Aug 2026 16:37:34 -0400 Subject: [PATCH 1/5] Add pure classification operations --- client/dive-common/typeHierarchy.spec.ts | 98 ++++++++++++ client/dive-common/typeHierarchy.ts | 63 ++++++++ testutils/typeHierarchy.spec.json | 186 +++++++++++++++++++++++ 3 files changed, 347 insertions(+) diff --git a/client/dive-common/typeHierarchy.spec.ts b/client/dive-common/typeHierarchy.spec.ts index 1a9778293..274e0998f 100644 --- a/client/dive-common/typeHierarchy.spec.ts +++ b/client/dive-common/typeHierarchy.spec.ts @@ -1,10 +1,14 @@ import fs from 'fs-extra'; import { + acceptPairAsCorrect, compileHierarchy, normalizeTypeHierarchy, + reassignPairs, + removePair, resolveTypeHierarchy, rewriteHierarchyType, selectPairIndex, + setPairConfidence, TypeHierarchyError, } from './typeHierarchy'; @@ -45,11 +49,48 @@ interface SelectionCase { expectedIndex: number; } +interface ReassignmentCase { + name: string; + hierarchy: Record; + pairs: [string, number][]; + replaceType: string; + newType: string; + confidence: number; + expected: [string, number][]; +} + +interface AcceptanceCase { + name: string; + hierarchy: Record; + 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; @@ -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', () => { @@ -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)); + }); + }); }); diff --git a/client/dive-common/typeHierarchy.ts b/client/dive-common/typeHierarchy.ts index c6649ee81..3dc4c761c 100644 --- a/client/dive-common/typeHierarchy.ts +++ b/client/dive-common/typeHierarchy.ts @@ -224,6 +224,20 @@ function ancestorsOf(index: TypeHierarchyIndex, type: string): readonly string[] : []; } +function sortPairsByConfidence( + pairs: readonly (readonly [string, number])[], +): [string, number][] { + return pairs + .map(([type, confidence]) => [type, confidence] as [string, number]) + .sort((left, right) => right[1] - left[1]); +} + +function inLineage(index: TypeHierarchyIndex, type: string, lineageType: string): boolean { + return type === lineageType + || ancestorsOf(index, lineageType).includes(type) + || ancestorsOf(index, type).includes(lineageType); +} + export function rewriteHierarchyType( hierarchy: TypeHierarchy, currentType: string, @@ -264,6 +278,55 @@ export function rewriteHierarchyType( } } +// Assignment replaces the selected claim's lineage while retaining unrelated claims and the +// stored ancestors still implied by the new type. No missing hierarchy members are synthesized. +export function reassignPairs( + index: TypeHierarchyIndex, + pairs: readonly (readonly [string, number])[], + replaceType: string, + newType: string, + confidence: number, +): [string, number][] { + const newAncestors = ancestorsOf(index, newType); + const retained = pairs.filter(([type]) => type !== newType + && (newAncestors.includes(type) || !inLineage(index, type, replaceType))); + return sortPairsByConfidence([...retained, [newType, confidence]]); +} + +// Acceptance is deliberately distinct from assignment: it keeps only the accepted lineage and +// changes only the accepted node's score. The accepted node itself is upserted, but absent +// ancestors and descendants remain absent. +export function acceptPairAsCorrect( + index: TypeHierarchyIndex, + pairs: readonly (readonly [string, number])[], + acceptedType: string, +): [string, number][] { + return setPairConfidence(pairs, acceptedType, 1.0) + .filter(([type]) => inLineage(index, type, acceptedType)); +} + +// Confidence is data, not an instruction. In particular, 1.0 has no destructive behavior. +export function setPairConfidence( + pairs: readonly (readonly [string, number])[], + type: string, + confidence: number, +): [string, number][] { + return sortPairsByConfidence([ + ...pairs.filter(([pairType]) => pairType !== type), + [type, confidence], + ]); +} + +// Pair removal is exact; lineage, subtree, and hierarchy-node removal are separate operations. +export function removePair( + pairs: readonly (readonly [string, number])[], + type: string, +): [string, number][] { + return pairs + .filter(([pairType]) => pairType !== type) + .map(([pairType, confidence]) => [pairType, confidence]); +} + export function selectPairIndex( index: TypeHierarchyIndex, pairs: readonly (readonly [string, number])[], diff --git a/testutils/typeHierarchy.spec.json b/testutils/typeHierarchy.spec.json index 1c8560051..d618e2af8 100644 --- a/testutils/typeHierarchy.spec.json +++ b/testutils/typeHierarchy.spec.json @@ -444,5 +444,191 @@ "passes": [false, true], "expectedIndex": 1 } + ], + "reassignmentCases": [ + { + "name": "unrelated type replaces the whole lineage", + "hierarchy": { "great white shark": "shark", "shark": "fish" }, + "pairs": [["fish", 0.9], ["shark", 0.7], ["great white shark", 0.4], ["bird", 0.2]], + "replaceType": "shark", + "newType": "tuna", + "confidence": 0.7, + "expected": [["tuna", 0.7], ["bird", 0.2]] + }, + { + "name": "descendant assignment keeps stored ancestors", + "hierarchy": { "great white shark": "shark", "shark": "fish" }, + "pairs": [["fish", 0.9], ["shark", 0.7], ["great white shark", 0.4]], + "replaceType": "shark", + "newType": "great white shark", + "confidence": 0.7, + "expected": [["fish", 0.9], ["shark", 0.7], ["great white shark", 0.7]] + }, + { + "name": "ancestor assignment drops deeper claims", + "hierarchy": { "great white shark": "shark", "shark": "fish" }, + "pairs": [["fish", 0.9], ["shark", 0.7], ["great white shark", 0.4]], + "replaceType": "shark", + "newType": "fish", + "confidence": 0.7, + "expected": [["fish", 0.7]] + }, + { + "name": "other branches survive in-branch assignment", + "hierarchy": { "cod": "fish", "shark": "fish", "tern": "bird" }, + "pairs": [["cod", 0.8], ["shark", 0.6], ["tern", 0.3]], + "replaceType": "cod", + "newType": "shark", + "confidence": 0.8, + "expected": [["shark", 0.8], ["tern", 0.3]] + }, + { + "name": "flat assignment replaces only the named pair", + "hierarchy": {}, + "pairs": [["cod", 0.8], ["bird", 0.3]], + "replaceType": "cod", + "newType": "tuna", + "confidence": 0.8, + "expected": [["tuna", 0.8], ["bird", 0.3]] + }, + { + "name": "absent replacement adds the new pair", + "hierarchy": { "great white shark": "shark", "shark": "fish" }, + "pairs": [["bird", 0.5]], + "replaceType": "shark", + "newType": "tuna", + "confidence": 0.5, + "expected": [["bird", 0.5], ["tuna", 0.5]] + }, + { + "name": "assignment confidence one retains unrelated claims", + "hierarchy": { "cod": "fish", "tern": "bird" }, + "pairs": [["cod", 0.8], ["fish", 0.7], ["tern", 0.6]], + "replaceType": "cod", + "newType": "tuna", + "confidence": 1.0, + "expected": [["tuna", 1.0], ["tern", 0.6]] + } + ], + "acceptanceCases": [ + { + "name": "accept leaf keeps stored ancestors", + "hierarchy": { "juvenile cod": "cod", "cod": "fish", "tern": "bird" }, + "pairs": [["cod", 0.9], ["fish", 0.8], ["juvenile cod", 0.7], ["tern", 0.6]], + "acceptedType": "juvenile cod", + "expected": [["juvenile cod", 1.0], ["cod", 0.9], ["fish", 0.8]] + }, + { + "name": "accept intermediate keeps ancestors and descendants", + "hierarchy": { "juvenile cod": "cod", "cod": "fish", "tern": "bird" }, + "pairs": [["tern", 0.95], ["fish", 0.9], ["cod", 0.8], ["juvenile cod", 0.7]], + "acceptedType": "cod", + "expected": [["cod", 1.0], ["fish", 0.9], ["juvenile cod", 0.7]] + }, + { + "name": "accept root keeps independently scored descendants", + "hierarchy": { "cod": "fish", "tuna": "fish", "tern": "bird" }, + "pairs": [["tern", 0.95], ["cod", 0.9], ["tuna", 0.8], ["fish", 0.6]], + "acceptedType": "fish", + "expected": [["fish", 1.0], ["cod", 0.9], ["tuna", 0.8]] + }, + { + "name": "accept does not synthesize missing ancestors", + "hierarchy": { "juvenile cod": "cod", "cod": "fish" }, + "pairs": [["cod", 0.7], ["juvenile cod", 0.6], ["bird", 0.5]], + "acceptedType": "cod", + "expected": [["cod", 1.0], ["juvenile cod", 0.6]] + }, + { + "name": "accept upserts an absent accepted node without ancestors", + "hierarchy": { "juvenile cod": "cod", "cod": "fish" }, + "pairs": [["fish", 0.8], ["juvenile cod", 0.7], ["bird", 0.6]], + "acceptedType": "cod", + "expected": [["cod", 1.0], ["fish", 0.8], ["juvenile cod", 0.7]] + }, + { + "name": "accept preserves non-monotone lineage scores", + "hierarchy": { "juvenile cod": "cod", "cod": "fish" }, + "pairs": [["juvenile cod", 0.95], ["fish", 0.9], ["cod", 0.2]], + "acceptedType": "cod", + "expected": [["cod", 1.0], ["juvenile cod", 0.95], ["fish", 0.9]] + }, + { + "name": "flat acceptance keeps only the accepted pair", + "hierarchy": {}, + "pairs": [["cod", 0.8], ["bird", 0.7]], + "acceptedType": "cod", + "expected": [["cod", 1.0]] + } + ], + "pairConfidenceCases": [ + { + "name": "confidence below one updates only one pair", + "pairs": [["cod", 0.8], ["fish", 0.7]], + "type": "cod", + "confidence": 0.99, + "expected": [["cod", 0.99], ["fish", 0.7]] + }, + { + "name": "confidence one updates only one pair", + "pairs": [["cod", 0.8], ["fish", 0.7]], + "type": "cod", + "confidence": 1.0, + "expected": [["cod", 1.0], ["fish", 0.7]] + }, + { + "name": "confidence upsert inserts a missing pair", + "pairs": [["fish", 0.7]], + "type": "cod", + "confidence": 0.8, + "expected": [["cod", 0.8], ["fish", 0.7]] + }, + { + "name": "confidence update reorders by score", + "pairs": [["cod", 0.8], ["fish", 0.7]], + "type": "cod", + "confidence": 0.6, + "expected": [["fish", 0.7], ["cod", 0.6]] + }, + { + "name": "equal confidence retains existing pair order before upsert", + "pairs": [["bird", 0.8], ["fish", 0.8]], + "type": "cod", + "confidence": 0.8, + "expected": [["bird", 0.8], ["fish", 0.8], ["cod", 0.8]] + }, + { + "name": "confidence upsert coalesces duplicate names", + "pairs": [["cod", 0.8], ["fish", 0.7], ["cod", 0.6]], + "type": "cod", + "confidence": 0.5, + "expected": [["fish", 0.7], ["cod", 0.5]] + } + ], + "pairRemovalCases": [ + { + "name": "exact removal leaves relatives and unrelated pairs", + "pairs": [["juvenile cod", 0.9], ["cod", 0.8], ["fish", 0.7], ["bird", 0.6]], + "type": "cod", + "expected": [["juvenile cod", 0.9], ["fish", 0.7], ["bird", 0.6]] + }, + { + "name": "absent removal leaves values unchanged", + "pairs": [["cod", 0.8], ["fish", 0.7]], + "type": "bird", + "expected": [["cod", 0.8], ["fish", 0.7]] + }, + { + "name": "removal deletes every duplicate exact pair", + "pairs": [["cod", 0.8], ["fish", 0.7], ["cod", 0.6]], + "type": "cod", + "expected": [["fish", 0.7]] + }, + { + "name": "removing the only pair returns empty", + "pairs": [["cod", 1.0]], + "type": "cod", + "expected": [] + } ] } From 930b20e9b0675e0d5a4c86b8c79202f56c3ccd52 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 11 Aug 2026 16:41:14 -0400 Subject: [PATCH 2/5] Add canonical track classification commands --- client/src/BaseAnnotation.ts | 6 ++ client/src/CameraStore.spec.ts | 138 +++++++++++++++++++++++++++++++++ client/src/CameraStore.ts | 75 ++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 client/src/CameraStore.spec.ts diff --git a/client/src/BaseAnnotation.ts b/client/src/BaseAnnotation.ts index 988305e16..0f925b570 100644 --- a/client/src/BaseAnnotation.ts +++ b/client/src/BaseAnnotation.ts @@ -140,6 +140,12 @@ export default abstract class BaseAnnotation { return this.confidencePairs; } + setConfidencePairs(pairs: readonly (readonly [string, number])[]) { + const old = this.confidencePairs; + this.confidencePairs = pairs.map(([type, confidence]) => [type, confidence]); + this.notify('confidencePairs', old); + } + setType(annotationType: string, confidenceVal = 1, replace: string | undefined = undefined) { const old = this.confidencePairs; if (confidenceVal >= 1) { diff --git a/client/src/CameraStore.spec.ts b/client/src/CameraStore.spec.ts new file mode 100644 index 000000000..1a5f3a7bf --- /dev/null +++ b/client/src/CameraStore.spec.ts @@ -0,0 +1,138 @@ +/// +import { compileHierarchy } from 'dive-common/typeHierarchy'; +import CameraStore from './CameraStore'; +import Track, { Feature } from './track'; + +const HIERARCHY_INDEX = compileHierarchy({ + 'great white shark': 'shark', + shark: 'fish', + tern: 'bird', +}); + +const TRACK_ID = 7; + +function features(): Feature[] { + return [{ + frame: 0, + keyframe: true, + bounds: [0, 0, 1, 1], + }]; +} + +function confidencePairs(value: [string, number][]): [string, number][] { + return value.map(([type, confidence]) => [type, confidence]); +} + +function makeTwoCameraStore() { + const markChangesPending = vi.fn(); + const store = new CameraStore({ markChangesPending }); + store.removeCamera('singleCam'); + store.addCamera('left'); + store.addCamera('right'); + + const left = new Track(TRACK_ID, { + confidencePairs: confidencePairs([ + ['fish', 0.9], + ['shark', 0.7], + ['great white shark', 0.4], + ['bird', 0.2], + ]), + features: features(), + }); + const right = new Track(TRACK_ID, { + confidencePairs: confidencePairs([ + ['rock', 0.95], + ['shark', 0.1], + ]), + features: features(), + }); + store.camMap.value.get('left')?.trackStore.insert(left, { imported: true }); + store.camMap.value.get('right')?.trackStore.insert(right, { imported: true }); + markChangesPending.mockClear(); + return { + store, left, right, markChangesPending, + }; +} + +function expectSynchronizedWrite( + fixture: ReturnType, + result: [string, number][], + expected: [string, number][], +) { + const { + left, right, markChangesPending, + } = fixture; + expect(result).toEqual(expected); + expect(left.confidencePairs).toEqual(expected); + expect(right.confidencePairs).toEqual(expected); + expect(left.confidencePairs).not.toBe(right.confidencePairs); + expect(result).not.toBe(left.confidencePairs); + expect(result).not.toBe(right.confidencePairs); + left.confidencePairs.forEach((pair) => expect(right.confidencePairs).not.toContain(pair)); + result.forEach((pair) => expect(left.confidencePairs).not.toContain(pair)); + expect(markChangesPending).toHaveBeenCalledTimes(2); + expect(markChangesPending.mock.calls.map(([change]) => change.cameraName)) + .toEqual(['left', 'right']); +} + +describe('CameraStore classification commands', () => { + it('calculates assignment once from the first camera and synchronizes independent copies', () => { + const fixture = makeTwoCameraStore(); + const result = fixture.store.assignTrackType(TRACK_ID, 'great white shark', { + hierarchyIndex: HIERARCHY_INDEX, + replaceType: 'shark', + confidence: 0.7, + }); + + expectSynchronizedWrite(fixture, result, [ + ['fish', 0.9], + ['shark', 0.7], + ['great white shark', 0.7], + ['bird', 0.2], + ]); + }); + + it('accepts a hierarchy node without changing stored relative scores', () => { + const fixture = makeTwoCameraStore(); + const result = fixture.store.acceptTrackType(TRACK_ID, 'shark', HIERARCHY_INDEX); + + expectSynchronizedWrite(fixture, result, [ + ['shark', 1.0], + ['fish', 0.9], + ['great white shark', 0.4], + ]); + }); + + it.each([0.99, 1.0])( + 'treats confidence %s as a single-pair update', + (confidence) => { + const fixture = makeTwoCameraStore(); + const result = fixture.store.setTrackPairConfidence(TRACK_ID, 'shark', confidence); + + expectSynchronizedWrite(fixture, result, [ + ['shark', confidence], + ['fish', 0.9], + ['great white shark', 0.4], + ['bird', 0.2], + ]); + }, + ); + + it('removes exactly one type and returns the logical result', () => { + const fixture = makeTwoCameraStore(); + const result = fixture.store.removeTrackPair(TRACK_ID, 'shark'); + + expectSynchronizedWrite(fixture, result, [ + ['fish', 0.9], + ['great white shark', 0.4], + ['bird', 0.2], + ]); + }); + + it('rejects a classification command for a missing logical track', () => { + const fixture = makeTwoCameraStore(); + expect(() => fixture.store.setTrackPairConfidence(99, 'fish', 1.0)) + .toThrow('TrackId 99 not found in any camera'); + expect(fixture.markChangesPending).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts index d313806a8..e6319b1fa 100644 --- a/client/src/CameraStore.ts +++ b/client/src/CameraStore.ts @@ -8,6 +8,22 @@ import { AnnotationId, ConfidencePair } from './BaseAnnotation'; import { MarkChangesPending, SortedAnnotation } from './BaseAnnotationStore'; import GroupStore from './GroupStore'; import TrackStore from './TrackStore'; +import { + acceptPairAsCorrect, + compileHierarchy, + reassignPairs, + removePair, + setPairConfidence, + TypeHierarchyIndex, +} from 'dive-common/typeHierarchy'; + +const FLAT_HIERARCHY_INDEX = compileHierarchy({}); + +interface TrackAssignmentOptions { + hierarchyIndex?: TypeHierarchyIndex; + replaceType?: string; + confidence?: number; +} /** * CameraStore is a warapper for holding and collating tracks from multiple cameras. @@ -243,6 +259,65 @@ export default class CameraStore { }); } + private updateTrackConfidencePairs( + id: AnnotationId, + update: (pairs: readonly ConfidencePair[]) => ConfidencePair[], + ): ConfidencePair[] { + const tracks = this.getTrackAll(id); + if (tracks.length === 0) { + throw new Error(`TrackId ${id} not found in any camera`); + } + const canonicalPairs = tracks[0].confidencePairs + .map(([type, confidence]) => [type, confidence] as ConfidencePair); + const nextPairs = update(canonicalPairs); + tracks.forEach((track) => track.setConfidencePairs(nextPairs)); + return nextPairs.map(([type, confidence]) => [type, confidence]); + } + + assignTrackType( + id: AnnotationId, + newType: string, + { + hierarchyIndex = FLAT_HIERARCHY_INDEX, + replaceType, + confidence = 1, + }: TrackAssignmentOptions = {}, + ): ConfidencePair[] { + return this.updateTrackConfidencePairs(id, (pairs) => reassignPairs( + hierarchyIndex, + pairs, + replaceType ?? pairs[0]?.[0] ?? newType, + newType, + confidence, + )); + } + + acceptTrackType( + id: AnnotationId, + acceptedType: string, + hierarchyIndex: TypeHierarchyIndex = FLAT_HIERARCHY_INDEX, + ): ConfidencePair[] { + return this.updateTrackConfidencePairs( + id, + (pairs) => acceptPairAsCorrect(hierarchyIndex, pairs, acceptedType), + ); + } + + setTrackPairConfidence( + id: AnnotationId, + type: string, + confidence: number, + ): ConfidencePair[] { + return this.updateTrackConfidencePairs( + id, + (pairs) => setPairConfidence(pairs, type, confidence), + ); + } + + removeTrackPair(id: AnnotationId, type: string): ConfidencePair[] { + return this.updateTrackConfidencePairs(id, (pairs) => removePair(pairs, type)); + } + removeTypes(id: AnnotationId, types: string[]) { let resultingTypes: ConfidencePair[] = []; this.camMap.value.forEach((camera) => { From 07051c42674d0e3f7db31d9d2c2e8addb34251a7 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 11 Aug 2026 16:49:43 -0400 Subject: [PATCH 3/5] Route classification editors through logical commands --- .../components/BottomPanel.spec.ts | 82 ++++++++++++++++++ client/dive-common/components/BottomPanel.vue | 20 ++++- .../components/TrackDetailsPanel.spec.ts | 29 ++++++- .../components/TrackDetailsPanel.vue | 17 ++-- .../bottombar/BottomBarTrackItemView.spec.ts | 41 ++++++++- .../bottombar/BottomBarTrackItemView.vue | 11 ++- .../sidebar/SideBarTrackItemView.spec.ts | 83 +++++++++++++++++++ .../Tracks/sidebar/SideBarTrackItemView.vue | 5 +- docs/UI-Type-List.md | 4 +- 9 files changed, 271 insertions(+), 21 deletions(-) create mode 100644 client/dive-common/components/BottomPanel.spec.ts create mode 100644 client/src/components/Tracks/sidebar/SideBarTrackItemView.spec.ts diff --git a/client/dive-common/components/BottomPanel.spec.ts b/client/dive-common/components/BottomPanel.spec.ts new file mode 100644 index 000000000..e20697f6c --- /dev/null +++ b/client/dive-common/components/BottomPanel.spec.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/// +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 | undefined; + const Host = defineComponent({ + setup: () => () => h(BottomPanel, { + ref: (instance) => { + if (instance && !(instance instanceof Element)) { + child = instance as InstanceType; + } + }, + 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); + }); +}); diff --git a/client/dive-common/components/BottomPanel.vue b/client/dive-common/components/BottomPanel.vue index aaf8bfcba..0b158cfec 100644 --- a/client/dive-common/components/BottomPanel.vue +++ b/client/dive-common/components/BottomPanel.vue @@ -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', @@ -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 }; }, }); @@ -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)" />