feat: coordinate adaptive audio intent
This commit is contained in:
298
tests/unit/audio-intent-coordinator.test.ts
Normal file
298
tests/unit/audio-intent-coordinator.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createAudioIntentCoordinator,
|
||||
type AudioIntentSample,
|
||||
} from '@/audio/audio-intent-coordinator';
|
||||
import type { SectionId } from '@/content/types';
|
||||
|
||||
const sectionIds = [
|
||||
'start', 'profile', 'experience', 'infrastructure',
|
||||
'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||
] as const satisfies readonly SectionId[];
|
||||
|
||||
const baseSample = (
|
||||
overrides: Partial<AudioIntentSample> = {},
|
||||
): AudioIntentSample => ({
|
||||
now: 0,
|
||||
visible: true,
|
||||
scrollY: 0,
|
||||
activeSectionId: 'start',
|
||||
acceleration: 0,
|
||||
sectionActivation: {
|
||||
layoutRevision: 1,
|
||||
activeHeadingOffsetVh: -0.1,
|
||||
nextHeadingOffsetVh: 0.8,
|
||||
},
|
||||
navigation: {
|
||||
epoch: 1,
|
||||
source: 'passive-scroll',
|
||||
phase: 'idle',
|
||||
destinationId: null,
|
||||
signDestinationId: null,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('audio intent coordinator', () => {
|
||||
it('commits once only after 6vh hysteresis and 400 ms dwell', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 10,
|
||||
scrollY: 10,
|
||||
activeSectionId: 'profile',
|
||||
sectionActivation: {
|
||||
layoutRevision: 1,
|
||||
activeHeadingOffsetVh: -0.059,
|
||||
nextHeadingOffsetVh: 0.8,
|
||||
},
|
||||
}))).toMatchObject({ chapter: null, needsFrame: false });
|
||||
|
||||
expect(coordinator.sample(baseSample({ now: 100, scrollY: 10, activeSectionId: 'profile' })))
|
||||
.toMatchObject({ chapter: null, needsFrame: true });
|
||||
expect(coordinator.sample(baseSample({ now: 499, scrollY: 10, activeSectionId: 'profile' })))
|
||||
.toMatchObject({ chapter: null });
|
||||
expect(coordinator.sample(baseSample({ now: 500, scrollY: 10, activeSectionId: 'profile' })).chapter)
|
||||
.toMatchObject({ sectionId: 'profile', mood: 'open', generation: 1 });
|
||||
expect(coordinator.sample(baseSample({ now: 900, scrollY: 10, activeSectionId: 'profile' })).chapter)
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it('clears a passive candidate on reversal or structural remeasurement', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
coordinator.sample(baseSample({ now: 0, scrollY: 10, activeSectionId: 'profile' }));
|
||||
expect(coordinator.sample(baseSample({ now: 250, scrollY: 9, activeSectionId: 'start' })).needsFrame)
|
||||
.toBe(false);
|
||||
coordinator.sample(baseSample({ now: 300, scrollY: 12, activeSectionId: 'profile' }));
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 800,
|
||||
scrollY: 12,
|
||||
activeSectionId: 'profile',
|
||||
sectionActivation: {
|
||||
layoutRevision: 2,
|
||||
activeHeadingOffsetVh: -0.2,
|
||||
nextHeadingOffsetVh: 0.7,
|
||||
},
|
||||
}))).toMatchObject({ chapter: null, needsFrame: false });
|
||||
});
|
||||
|
||||
it('commits only the latest settled explicit destination', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
coordinator.sample(baseSample({
|
||||
navigation: {
|
||||
epoch: 2,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'travelling',
|
||||
destinationId: 'contact',
|
||||
signDestinationId: null,
|
||||
},
|
||||
}));
|
||||
coordinator.sample(baseSample({
|
||||
activeSectionId: 'toolkit',
|
||||
navigation: {
|
||||
epoch: 3,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'travelling',
|
||||
destinationId: 'toolkit',
|
||||
signDestinationId: null,
|
||||
},
|
||||
}));
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 600,
|
||||
activeSectionId: 'toolkit',
|
||||
navigation: {
|
||||
epoch: 3,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'settled',
|
||||
destinationId: 'toolkit',
|
||||
signDestinationId: null,
|
||||
},
|
||||
}))).toMatchObject({
|
||||
chapter: { sectionId: 'toolkit', mood: 'resolve', generation: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('dead-bands tiny input and caps programmatic travel below lift entry', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
expect(coordinator.sample(baseSample({ now: 0, acceleration: 0.07 })).drive).toBeNull();
|
||||
expect(coordinator.sample(baseSample({ now: 40, acceleration: 0.7 })).drive)
|
||||
.toEqual({ energy: expect.closeTo((0.7 - 0.08) / 0.92), source: 'physical' });
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 80,
|
||||
acceleration: 1,
|
||||
navigation: {
|
||||
epoch: 2,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'travelling',
|
||||
destinationId: 'contact',
|
||||
signDestinationId: null,
|
||||
},
|
||||
})).drive).toEqual({ energy: 0.35, source: 'navigation' });
|
||||
expect(coordinator.sample(baseSample({ now: 120, acceleration: 0 })).drive)
|
||||
.toEqual({ energy: 0, source: 'idle' });
|
||||
});
|
||||
|
||||
it('rate-limits small drive changes but emits exact threshold, source, and zero crossings', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
const physicalAcceleration = (energy: number): number => 0.08 + energy * 0.92;
|
||||
|
||||
expect(coordinator.sample(baseSample({ now: 0, acceleration: physicalAcceleration(0.3) })).drive)
|
||||
.toEqual({ energy: expect.closeTo(0.3), source: 'physical' });
|
||||
expect(coordinator.sample(baseSample({ now: 10, acceleration: physicalAcceleration(0.31) })).drive)
|
||||
.toBeNull();
|
||||
expect(coordinator.sample(baseSample({ now: 11, acceleration: physicalAcceleration(0.32) })).drive)
|
||||
.toEqual({ energy: expect.closeTo(0.32), source: 'physical' });
|
||||
expect(coordinator.sample(baseSample({ now: 12, acceleration: physicalAcceleration(0.62) })).drive)
|
||||
.toEqual({ energy: expect.closeTo(0.62), source: 'physical' });
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 13,
|
||||
acceleration: 0.2,
|
||||
navigation: {
|
||||
epoch: 2,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'travelling',
|
||||
destinationId: 'contact',
|
||||
signDestinationId: null,
|
||||
},
|
||||
})).drive).toEqual({ energy: 0.2, source: 'navigation' });
|
||||
expect(coordinator.sample(baseSample({ now: 14, acceleration: 0 })).drive)
|
||||
.toEqual({ energy: 0, source: 'idle' });
|
||||
});
|
||||
|
||||
it('cannot turn a layout-only active-section shift into a passive commit', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 100,
|
||||
activeSectionId: 'profile',
|
||||
sectionActivation: {
|
||||
layoutRevision: 2,
|
||||
activeHeadingOffsetVh: -0.2,
|
||||
nextHeadingOffsetVh: 0.7,
|
||||
},
|
||||
}))).toMatchObject({ chapter: null, needsFrame: false });
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 600,
|
||||
activeSectionId: 'profile',
|
||||
sectionActivation: {
|
||||
layoutRevision: 2,
|
||||
activeHeadingOffsetVh: -0.2,
|
||||
nextHeadingOffsetVh: 0.7,
|
||||
},
|
||||
})).chapter).toBeNull();
|
||||
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 650,
|
||||
scrollY: 2,
|
||||
activeSectionId: 'profile',
|
||||
sectionActivation: {
|
||||
layoutRevision: 2,
|
||||
activeHeadingOffsetVh: -0.2,
|
||||
nextHeadingOffsetVh: 0.7,
|
||||
},
|
||||
})).needsFrame).toBe(true);
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 1_050,
|
||||
scrollY: 2,
|
||||
activeSectionId: 'profile',
|
||||
sectionActivation: {
|
||||
layoutRevision: 2,
|
||||
activeHeadingOffsetVh: -0.2,
|
||||
nextHeadingOffsetVh: 0.7,
|
||||
},
|
||||
})).chapter).toMatchObject({ sectionId: 'profile' });
|
||||
});
|
||||
|
||||
it('defers a genuine settlement across remeasurement but ignores old settled state', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
coordinator.sample(baseSample({
|
||||
navigation: {
|
||||
epoch: 2,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'travelling',
|
||||
destinationId: 'contact',
|
||||
signDestinationId: null,
|
||||
},
|
||||
}));
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 100,
|
||||
activeSectionId: 'contact',
|
||||
sectionActivation: {
|
||||
layoutRevision: 2,
|
||||
activeHeadingOffsetVh: -0.2,
|
||||
nextHeadingOffsetVh: null,
|
||||
},
|
||||
navigation: {
|
||||
epoch: 2,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'settled',
|
||||
destinationId: 'contact',
|
||||
signDestinationId: null,
|
||||
},
|
||||
}))).toMatchObject({ chapter: null, needsFrame: true });
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 116,
|
||||
activeSectionId: 'contact',
|
||||
sectionActivation: {
|
||||
layoutRevision: 2,
|
||||
activeHeadingOffsetVh: -0.2,
|
||||
nextHeadingOffsetVh: null,
|
||||
},
|
||||
navigation: {
|
||||
epoch: 2,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'settled',
|
||||
destinationId: 'contact',
|
||||
signDestinationId: null,
|
||||
},
|
||||
})).chapter).toMatchObject({ sectionId: 'contact' });
|
||||
});
|
||||
|
||||
it('starts one final dwell after wheel events advance passive epochs', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
const passive = (epoch: number) => ({
|
||||
epoch,
|
||||
source: 'passive-scroll' as const,
|
||||
phase: 'idle' as const,
|
||||
destinationId: null,
|
||||
signDestinationId: null,
|
||||
});
|
||||
coordinator.sample(baseSample({
|
||||
now: 100,
|
||||
scrollY: 24,
|
||||
activeSectionId: 'profile',
|
||||
navigation: passive(2),
|
||||
}));
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 180,
|
||||
scrollY: 48,
|
||||
activeSectionId: 'profile',
|
||||
navigation: passive(3),
|
||||
})).needsFrame).toBe(true);
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 580,
|
||||
scrollY: 48,
|
||||
activeSectionId: 'profile',
|
||||
navigation: passive(3),
|
||||
})).chapter).toMatchObject({ sectionId: 'profile' });
|
||||
});
|
||||
|
||||
it('suppresses a navigation that settled while hidden', () => {
|
||||
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||
const settled = {
|
||||
epoch: 2,
|
||||
source: 'explicit-navigation',
|
||||
phase: 'settled',
|
||||
destinationId: 'contact',
|
||||
signDestinationId: null,
|
||||
} as const;
|
||||
coordinator.reset({
|
||||
navigation: settled,
|
||||
scrollY: 0,
|
||||
sectionActivation: baseSample().sectionActivation,
|
||||
});
|
||||
expect(coordinator.sample(baseSample({
|
||||
now: 1_000,
|
||||
activeSectionId: 'contact',
|
||||
navigation: settled,
|
||||
})).chapter).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user