299 lines
9.9 KiB
TypeScript
299 lines
9.9 KiB
TypeScript
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();
|
|
});
|
|
});
|