fix: make nightdrive signage reliable and legible
This commit is contained in:
@@ -206,6 +206,7 @@ describe("navigation epochs and effects", () => {
|
||||
epoch: 2,
|
||||
source: "passive-scroll",
|
||||
destinationId: null,
|
||||
signDestinationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,6 +284,23 @@ describe("navigation epochs and effects", () => {
|
||||
epoch: 2,
|
||||
source: "history-restoration",
|
||||
destinationId: "profile",
|
||||
signDestinationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks only an explicitly requested navigator epoch for roadside signage", () => {
|
||||
const harness = createRuntime(true);
|
||||
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||
|
||||
controller.navigate("profile", target("profile"));
|
||||
expect(controller.state().signDestinationId).toBeNull();
|
||||
|
||||
controller.navigate("experience", target("experience"), { requestSign: true });
|
||||
expect(controller.state()).toMatchObject({
|
||||
epoch: 2,
|
||||
source: "explicit-navigation",
|
||||
destinationId: "experience",
|
||||
signDestinationId: "experience",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -466,6 +466,7 @@ describe('useScrollDrive', () => {
|
||||
epoch: 0,
|
||||
source: 'passive-scroll' as const,
|
||||
destinationId: null,
|
||||
signDestinationId: null,
|
||||
});
|
||||
const motionAllowed = shallowRef(true);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ const sample = (
|
||||
): SignControllerSample => ({
|
||||
now: 0,
|
||||
source: 'passive-scroll',
|
||||
navigationEpoch: 0,
|
||||
navigationDestinationId: null,
|
||||
direction: 1,
|
||||
acceleration: 0,
|
||||
deltaY: 16,
|
||||
@@ -26,12 +28,12 @@ describe('clock-driven sign lifecycle', () => {
|
||||
expect(SIGN_ROUTE_ORDER.map((id) => signRouteFor(id))).toEqual([
|
||||
{ id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0 },
|
||||
{ id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55, dwellOffsetX: -1.6 },
|
||||
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -103,6 +105,36 @@ describe('clock-driven sign lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the portrait section gap instead of latching behind a full-width panel', () => {
|
||||
const controller = createSignController();
|
||||
expect(controller.sample(sample({
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 844,
|
||||
candidateDistanceVh: 0.3,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 16,
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 844,
|
||||
candidateDistanceVh: 0.22,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate', viewportProfile: 'tall' });
|
||||
});
|
||||
|
||||
it('uses the short-landscape section gap when no horizontal corridor is wide enough', () => {
|
||||
const controller = createSignController();
|
||||
expect(controller.sample(sample({
|
||||
viewportWidth: 844,
|
||||
viewportHeight: 390,
|
||||
candidateDistanceVh: 0.42,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 16,
|
||||
viewportWidth: 844,
|
||||
viewportHeight: 390,
|
||||
candidateDistanceVh: 0.38,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate', viewportProfile: 'short' });
|
||||
});
|
||||
|
||||
it('keeps the latched viewport profile immutable across a responsive resize', () => {
|
||||
const controller = createSignController();
|
||||
expect(controller.sample(sample())).toMatchObject({ viewportProfile: 'wide' });
|
||||
@@ -166,7 +198,6 @@ describe('clock-driven sign lifecycle', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['explicit navigation', { source: 'explicit-navigation' as const }],
|
||||
['history restoration', { source: 'history-restoration' as const }],
|
||||
['initial positioning', { source: 'initial' as const }],
|
||||
['disabled motion', { motionAllowed: false }],
|
||||
@@ -178,6 +209,459 @@ describe('clock-driven sign lifecycle', () => {
|
||||
expect(controller.needsFrame()).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes an eligible navigator destination once without requiring scroll input', () => {
|
||||
const controller = createSignController();
|
||||
const navigation = sample({
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'selected-builds',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 2,
|
||||
});
|
||||
|
||||
expect(controller.sample(navigation)).toMatchObject({
|
||||
targetId: 'selected-builds',
|
||||
phase: 'anticipate',
|
||||
navigationArrival: true,
|
||||
});
|
||||
expect(controller.sample({ ...navigation, now: 1_200 })).toMatchObject({
|
||||
targetId: 'selected-builds',
|
||||
phase: 'dwell',
|
||||
});
|
||||
expect(controller.sample({ ...navigation, now: 2_600 })).toMatchObject({
|
||||
targetId: 'selected-builds',
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
});
|
||||
expect(controller.sample({ ...navigation, now: 3_300 })).toBeNull();
|
||||
expect(controller.sample({ ...navigation, now: 4_000 })).toBeNull();
|
||||
expect(controller.needsFrame()).toBe(false);
|
||||
});
|
||||
|
||||
it('coalesces rapid navigator clicks to the latest concealed target without label mutation', () => {
|
||||
const controller = createSignController();
|
||||
|
||||
expect(controller.sample(sample({
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
||||
expect(controller.sample(sample({
|
||||
now: 100,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'selected-builds',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
}))).toMatchObject({ targetId: 'selected-builds', phase: 'anticipate' });
|
||||
expect(controller.sample(sample({
|
||||
now: 1_300,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'selected-builds',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
}))).toMatchObject({ targetId: 'selected-builds', phase: 'dwell' });
|
||||
});
|
||||
|
||||
it('does not queue a second presentation for a repeated same-target navigator click', () => {
|
||||
const controller = createSignController();
|
||||
const navigation = sample({
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'selected-builds',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
});
|
||||
controller.sample(navigation);
|
||||
controller.sample({ ...navigation, now: 1_200 });
|
||||
|
||||
expect(controller.sample({
|
||||
...navigation,
|
||||
now: 1_300,
|
||||
navigationEpoch: 2,
|
||||
})).toMatchObject({ targetId: 'selected-builds', phase: 'dwell' });
|
||||
controller.sample({ ...navigation, now: 2_600, navigationEpoch: 2 });
|
||||
controller.sample({ ...navigation, now: 3_300, navigationEpoch: 2 });
|
||||
expect(controller.sample({
|
||||
...navigation,
|
||||
now: 3_650,
|
||||
navigationEpoch: 2,
|
||||
})).toBeNull();
|
||||
expect(controller.sample({
|
||||
...navigation,
|
||||
now: 4_000,
|
||||
navigationEpoch: 3,
|
||||
activeSectionId: 'selected-builds',
|
||||
})).toBeNull();
|
||||
expect(controller.needsFrame()).toBe(false);
|
||||
});
|
||||
|
||||
it('promotes a rearmed passive sign when the prior navigator target is clicked again', () => {
|
||||
const controller = createSignController();
|
||||
const navigation = sample({
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
});
|
||||
controller.sample(navigation);
|
||||
controller.sample({ ...navigation, now: 1_200, activeSectionId: 'profile' });
|
||||
controller.sample({ ...navigation, now: 2_600, activeSectionId: 'profile' });
|
||||
controller.sample({ ...navigation, now: 3_300, activeSectionId: 'profile' });
|
||||
controller.sample(sample({
|
||||
now: 3_400,
|
||||
direction: -1,
|
||||
deltaY: -800,
|
||||
crossedBoundaries: 1,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.8,
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 1,
|
||||
deltaY: 120,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.5,
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 4_850,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
candidateId: 'experience',
|
||||
}));
|
||||
|
||||
expect(controller.sample({
|
||||
...navigation,
|
||||
now: 4_900,
|
||||
navigationEpoch: 2,
|
||||
activeSectionId: 'profile',
|
||||
})).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'dwell',
|
||||
navigationArrival: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a full readable dwell when navigation promotes a late passive dwell', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
|
||||
const promoted = sample({
|
||||
now: 2_590,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
});
|
||||
expect(controller.sample(promoted)).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'dwell',
|
||||
navigationArrival: true,
|
||||
});
|
||||
expect(controller.sample({ ...promoted, now: 3_989 })).toMatchObject({
|
||||
phase: 'dwell',
|
||||
navigationArrival: true,
|
||||
});
|
||||
expect(controller.sample({ ...promoted, now: 3_990 })).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
navigationArrival: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('promotes a rearmed sign queued during cooldown when its navigator target is clicked', () => {
|
||||
const controller = createSignController();
|
||||
const navigation = sample({
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
});
|
||||
controller.sample(navigation);
|
||||
controller.sample({ ...navigation, now: 1_200, activeSectionId: 'profile' });
|
||||
controller.sample({ ...navigation, now: 2_600, activeSectionId: 'profile' });
|
||||
controller.sample(sample({
|
||||
now: 2_800,
|
||||
direction: -1,
|
||||
deltaY: -800,
|
||||
crossedBoundaries: 1,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.8,
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 3_300,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.8,
|
||||
}));
|
||||
expect(controller.sample(sample({
|
||||
now: 3_400,
|
||||
direction: 1,
|
||||
deltaY: 120,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.5,
|
||||
}))).toBeNull();
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
expect(controller.sample({
|
||||
...navigation,
|
||||
now: 3_450,
|
||||
navigationEpoch: 2,
|
||||
activeSectionId: 'profile',
|
||||
})).toBeNull();
|
||||
|
||||
expect(controller.sample({
|
||||
...navigation,
|
||||
now: 3_650,
|
||||
navigationEpoch: 2,
|
||||
activeSectionId: 'profile',
|
||||
})).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'anticipate',
|
||||
navigationArrival: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a readable label and queues only the latest navigator destination', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 1_300,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'experience',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 1_400,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'toolkit',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
controller.sample(sample({
|
||||
now: 2_600,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'toolkit',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 3_300,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'toolkit',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
}));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'toolkit',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
}))).toMatchObject({
|
||||
targetId: 'toolkit',
|
||||
phase: 'anticipate',
|
||||
navigationArrival: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels an obsolete queued destination when navigation returns to the visible target', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 1_300,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'toolkit',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'toolkit',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 1_400,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'dwell',
|
||||
navigationArrival: true,
|
||||
});
|
||||
|
||||
controller.sample(sample({
|
||||
now: 2_800,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 3_500,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_850,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toBeNull();
|
||||
expect(controller.needsFrame()).toBe(false);
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_900,
|
||||
direction: -1,
|
||||
deltaY: -40,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.6,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 4_000,
|
||||
direction: 1,
|
||||
deltaY: 40,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.5,
|
||||
}))).toBeNull();
|
||||
});
|
||||
|
||||
it('honors a navigator return to a previously completed route after a readable sign', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({
|
||||
now: 3_650,
|
||||
activeSectionId: 'profile',
|
||||
candidateId: 'experience',
|
||||
candidateDistanceVh: 0.5,
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 4_850,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
candidateId: 'experience',
|
||||
}));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 5_000,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({ targetId: 'experience', phase: 'dwell' });
|
||||
controller.sample(sample({
|
||||
now: 6_250,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 6_950,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 7_300,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'anticipate',
|
||||
navigationArrival: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('queues a fresh navigator arrival for the cooldown route after moving elsewhere', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_400,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'anticipate',
|
||||
navigationArrival: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['multiple crossed sections', { crossedBoundaries: 2 }],
|
||||
['a jump over 0.75 viewport heights', { deltaY: 601 }],
|
||||
@@ -435,6 +919,69 @@ describe('clock-driven sign lifecycle', () => {
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||
});
|
||||
|
||||
it('keeps a passed sign opaque until its spatial departure is well underway', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 2_985,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0.55,
|
||||
opacity: 1,
|
||||
});
|
||||
expect(controller.sample(sample({
|
||||
now: 3_230,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0.9,
|
||||
opacity: expect.closeTo(0.04, 1),
|
||||
});
|
||||
});
|
||||
|
||||
it('discards a queued forward jump after a meaningful reverse retreat', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
|
||||
controller.sample(sample({
|
||||
now: 1_300,
|
||||
deltaY: 2_400,
|
||||
crossedBoundaries: 4,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 1_500,
|
||||
direction: -1,
|
||||
deltaY: -2_400,
|
||||
crossedBoundaries: 4,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.8,
|
||||
}));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'selected-builds',
|
||||
candidateDistanceVh: 0.4,
|
||||
}))).toBeNull();
|
||||
});
|
||||
|
||||
it('does not retrigger a completed route sign during small boundary oscillations', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
@@ -459,6 +1006,164 @@ describe('clock-driven sign lifecycle', () => {
|
||||
}))).toBeNull();
|
||||
});
|
||||
|
||||
it('re-arms a completed sign only after retreating beyond the trigger hysteresis', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_500,
|
||||
direction: -1,
|
||||
deltaY: -600,
|
||||
crossedBoundaries: 1,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.8,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 1,
|
||||
deltaY: 120,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.5,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('does not let a departing sign overwrite a meaningful reverse rearm', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 2_800,
|
||||
direction: -1,
|
||||
deltaY: -800,
|
||||
crossedBoundaries: 1,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.8,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
||||
controller.sample(sample({
|
||||
now: 3_300,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.8,
|
||||
}));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_400,
|
||||
direction: 1,
|
||||
deltaY: 120,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.5,
|
||||
}))).toBeNull();
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.5,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('re-arms every later route after one large reverse retreat', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({
|
||||
now: 3_650,
|
||||
activeSectionId: 'profile',
|
||||
candidateId: 'experience',
|
||||
candidateDistanceVh: 0.5,
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 4_850,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
candidateId: 'experience',
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 6_250,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}));
|
||||
controller.sample(sample({
|
||||
now: 6_950,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}));
|
||||
|
||||
controller.sample(sample({
|
||||
now: 7_100,
|
||||
direction: -1,
|
||||
deltaY: -2_400,
|
||||
crossedBoundaries: 2,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.8,
|
||||
}));
|
||||
controller.sample(sample({ now: 7_300 }));
|
||||
controller.sample(sample({ now: 8_500, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 9_900, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 10_600, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 10_950,
|
||||
activeSectionId: 'profile',
|
||||
candidateId: 'experience',
|
||||
candidateDistanceVh: 0.5,
|
||||
}))).toMatchObject({ targetId: 'experience', phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unrequested explicit jump', 'explicit-navigation' as const],
|
||||
['history restoration', 'history-restoration' as const],
|
||||
])('starts a fresh passive traversal after %s', (_label, source) => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_400,
|
||||
source,
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: null,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'start',
|
||||
candidateId: null,
|
||||
candidateDistanceVh: null,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_500,
|
||||
source: 'passive-scroll',
|
||||
navigationEpoch: 2,
|
||||
direction: 1,
|
||||
deltaY: 120,
|
||||
activeSectionId: 'start',
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 0.5,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('starts a fresh passive sign traversal after explicit navigation', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
@@ -469,6 +1174,8 @@ describe('clock-driven sign lifecycle', () => {
|
||||
expect(controller.sample(sample({
|
||||
now: 3_400,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'contact',
|
||||
activeSectionId: 'contact',
|
||||
candidateId: null,
|
||||
candidateDistanceVh: null,
|
||||
@@ -476,6 +1183,8 @@ describe('clock-driven sign lifecycle', () => {
|
||||
expect(controller.sample(sample({
|
||||
now: 3_500,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'start',
|
||||
activeSectionId: 'start',
|
||||
}))).toBeNull();
|
||||
|
||||
@@ -484,4 +1193,64 @@ describe('clock-driven sign lifecycle', () => {
|
||||
phase: 'anticipate',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a stale navigator sign re-complete a traversal restarted at Start', () => {
|
||||
const controller = createSignController();
|
||||
const profileNavigation = sample({
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 1,
|
||||
navigationDestinationId: 'profile',
|
||||
candidateId: null,
|
||||
candidateDistanceVh: null,
|
||||
});
|
||||
controller.sample(profileNavigation);
|
||||
controller.sample({
|
||||
...profileNavigation,
|
||||
now: 1_200,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
});
|
||||
controller.sample({
|
||||
...profileNavigation,
|
||||
now: 2_600,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
});
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 2_800,
|
||||
source: 'explicit-navigation',
|
||||
navigationEpoch: 2,
|
||||
navigationDestinationId: 'start',
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'start',
|
||||
candidateId: null,
|
||||
candidateDistanceVh: null,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_900,
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 844,
|
||||
candidateDistanceVh: 0.22,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
||||
expect(controller.sample(sample({
|
||||
now: 3_300,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'start',
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 844,
|
||||
}))).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'anticipate',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -565,11 +565,15 @@ describe("procedural world models", () => {
|
||||
const sign = createSign(resources);
|
||||
for (const name of [
|
||||
"sign-backplate",
|
||||
"sign-face-panel",
|
||||
"sign-face",
|
||||
"sign-housing",
|
||||
"sign-edge-light",
|
||||
"sign-post",
|
||||
"sign-post-secondary",
|
||||
"sign-bracket",
|
||||
"sign-back-rails",
|
||||
"sign-fasteners",
|
||||
"sign-footing",
|
||||
]) {
|
||||
expect(sign.group.getObjectByName(name), name).toBeDefined();
|
||||
@@ -577,8 +581,14 @@ describe("procedural world models", () => {
|
||||
const face = sign.group.getObjectByName("sign-face") as {
|
||||
userData: { textureUpdateCount: number };
|
||||
material: {
|
||||
depthWrite: boolean;
|
||||
opacity: number;
|
||||
map: { anisotropy: number; image: { width: number; height: number } };
|
||||
toneMapped: boolean;
|
||||
map: {
|
||||
anisotropy: number;
|
||||
image: { width: number; height: number };
|
||||
premultiplyAlpha: boolean;
|
||||
};
|
||||
};
|
||||
geometry: {
|
||||
boundingBox: { min: { x: number; y: number }; max: { x: number; y: number } } | null;
|
||||
@@ -606,7 +616,25 @@ describe("procedural world models", () => {
|
||||
expect(faceWidth / faceHeight).toBeCloseTo(4, 6);
|
||||
expect(face.material.map.image).toMatchObject({ width: 2_048, height: 512 });
|
||||
expect(face.material.map.anisotropy).toBeGreaterThanOrEqual(8);
|
||||
expect(face.material.map.premultiplyAlpha).toBe(true);
|
||||
expect(face.material.depthWrite).toBe(false);
|
||||
expect(face.material.toneMapped).toBe(false);
|
||||
expect(face.material).toBeInstanceOf(THREE.MeshBasicMaterial);
|
||||
const facePanel = sign.group.getObjectByName("sign-face-panel") as { material: unknown };
|
||||
expect(facePanel.material).toBeInstanceOf(THREE.MeshStandardMaterial);
|
||||
const allocation = resources.snapshot();
|
||||
let renderablePartCount = 0;
|
||||
(sign.group as unknown as {
|
||||
traverse(callback: (object: { isMesh?: boolean }) => void): void;
|
||||
}).traverse((object) => {
|
||||
if (object.isMesh === true) renderablePartCount += 1;
|
||||
});
|
||||
expect(renderablePartCount).toBe(20);
|
||||
expect(allocation.owned).toMatchObject({
|
||||
geometry: 13,
|
||||
material: 7,
|
||||
texture: 1,
|
||||
});
|
||||
|
||||
sign.update(null);
|
||||
expect((sign.group as { visible: boolean }).visible).toBe(false);
|
||||
@@ -656,6 +684,50 @@ describe("procedural world models", () => {
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("caps the sign texture to the renderer limit without changing its four-to-one sampling ratio", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources, {
|
||||
maximumAnisotropy: 4,
|
||||
maximumTextureSize: 1_024,
|
||||
});
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||
|
||||
expect(sign.inspect(createChaseCamera(16 / 9), 1_440, 900)?.texture).toMatchObject({
|
||||
width: 1_024,
|
||||
height: 256,
|
||||
maximumSize: 1_024,
|
||||
anisotropy: 4,
|
||||
});
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("accepts a new target when the previous face is below the concealed threshold", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
const face = sign.group.getObjectByName("sign-face") as {
|
||||
userData: { textureUpdateCount: number };
|
||||
};
|
||||
|
||||
sign.update({
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
progress: 0.03,
|
||||
opacity: 0.01,
|
||||
});
|
||||
expect(face.userData.textureUpdateCount).toBe(1);
|
||||
|
||||
sign.update({
|
||||
targetId: "experience",
|
||||
phase: "anticipate",
|
||||
progress: 0,
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
expect(face.userData.textureUpdateCount).toBe(2);
|
||||
expect(sign.inspect(createChaseCamera(16 / 9), 1_440, 900)?.corridor).toBe("left");
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("begins sign departure from the rendered approach pose without a teleport", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
@@ -681,21 +753,26 @@ describe("procedural world models", () => {
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("defers responsive pose changes for the complete lifecycle, including concealed anticipation", () => {
|
||||
it("reflows a live sign to a new responsive pose without a resize teleport", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
const camera = createChaseCamera(16 / 9);
|
||||
sign.setViewportSize(1_440, 900);
|
||||
sign.update({ targetId: "profile", phase: "anticipate", progress: 0, opacity: 0 });
|
||||
const dwell = { targetId: "profile", phase: "dwell", progress: 1, opacity: 1 } as const;
|
||||
sign.update(dwell, 0);
|
||||
const resizeOrigin = sign.group.position.clone();
|
||||
|
||||
sign.setViewportSize(844, 390);
|
||||
const concealed = sign.inspect(camera, 844, 390);
|
||||
expect(concealed?.viewportProfile).toBe("wide");
|
||||
sign.update({ targetId: "profile", phase: "approach", progress: 0.5, opacity: 0.7 });
|
||||
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
|
||||
sign.update(dwell, 100);
|
||||
expect(sign.group.position.distanceTo(resizeOrigin)).toBeLessThan(0.001);
|
||||
sign.update(dwell, 260);
|
||||
const midpoint = sign.group.position.clone();
|
||||
expect(midpoint.distanceTo(resizeOrigin)).toBeGreaterThan(0.1);
|
||||
sign.update(dwell, 450);
|
||||
expect(sign.group.position.distanceTo(midpoint)).toBeGreaterThan(0.1);
|
||||
|
||||
sign.update(null);
|
||||
sign.update({ targetId: "experience", phase: "anticipate", progress: 0, opacity: 0 });
|
||||
const camera = createChaseCamera(844 / 390);
|
||||
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("short");
|
||||
resources.dispose();
|
||||
|
||||
@@ -709,23 +786,70 @@ describe("procedural world models", () => {
|
||||
opacity: 1,
|
||||
viewportProfile: "wide",
|
||||
});
|
||||
expect(rebuilt.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
|
||||
expect(rebuilt.inspect(camera, 844, 390)?.viewportProfile).toBe("short");
|
||||
rebuiltResources.dispose();
|
||||
});
|
||||
|
||||
it("departs laterally without crossing the chase camera plane or growing toward the car", () => {
|
||||
it("reflows a visible portrait sign into its navigator-safe pose without a teleport", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
const camera = createChaseCamera(390 / 844);
|
||||
sign.setViewportSize(390, 844);
|
||||
const passiveDwell = {
|
||||
targetId: "profile",
|
||||
phase: "dwell",
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
} as const;
|
||||
sign.update(passiveDwell, 0);
|
||||
const passivePosition = sign.group.position.clone();
|
||||
|
||||
const navigationDwell = { ...passiveDwell, navigationArrival: true as const };
|
||||
sign.update(navigationDwell, 100);
|
||||
expect(sign.group.position.distanceTo(passivePosition)).toBeLessThan(0.001);
|
||||
sign.update(navigationDwell, 260);
|
||||
const midpoint = sign.group.position.clone();
|
||||
expect(midpoint.distanceTo(passivePosition)).toBeGreaterThan(0.1);
|
||||
sign.update(navigationDwell, 450);
|
||||
|
||||
const presentation = sign.inspect(camera, 390, 844);
|
||||
if (presentation === null) throw new Error("Missing navigator-safe Profile sign");
|
||||
expect(sign.group.position.distanceTo(midpoint)).toBeGreaterThan(0.1);
|
||||
expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
|
||||
expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("drives past a sign at constant world scale before carrying it beyond the roadside edge", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
const camera = createChaseCamera(16 / 9);
|
||||
sign.setViewportSize(1_440, 900);
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||
const dwellX = sign.group.position.x;
|
||||
const dwell = sign.inspect(camera, 1_440, 900);
|
||||
if (dwell === null) throw new Error("Missing Profile dwell presentation");
|
||||
const dwellPosition = sign.group.position.clone();
|
||||
const dwellScale = sign.group.scale.x;
|
||||
|
||||
sign.update({ targetId: "profile", phase: "depart", progress: 0.9, opacity: 0.1 });
|
||||
sign.update({ targetId: "profile", phase: "depart", progress: 0.5, opacity: 1 });
|
||||
const passing = sign.inspect(camera, 1_440, 900);
|
||||
if (passing === null) throw new Error("Missing Profile pass-by presentation");
|
||||
|
||||
expect(sign.group.position.x).toBeLessThan(dwellX);
|
||||
expect(sign.group.position.z).toBeLessThanOrEqual(0);
|
||||
expect(sign.group.scale.x).toBeLessThan(dwellScale);
|
||||
expect(sign.group.position.x).toBeLessThan(dwellPosition.x);
|
||||
expect(sign.group.position.y).toBeCloseTo(dwellPosition.y, 6);
|
||||
expect(sign.group.position.z).toBeGreaterThan(dwellPosition.z);
|
||||
expect(sign.group.scale.x).toBeCloseTo(dwellScale, 6);
|
||||
expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width);
|
||||
expect(passing.bounds.left).toBeLessThan(dwell.bounds.left);
|
||||
|
||||
const passingZ = sign.group.position.z;
|
||||
sign.update({ targetId: "profile", phase: "depart", progress: 0.9, opacity: 0.1 });
|
||||
const leaving = sign.inspect(camera, 1_440, 900);
|
||||
if (leaving === null) throw new Error("Missing Profile departure presentation");
|
||||
expect(sign.group.position.z).toBeGreaterThan(passingZ);
|
||||
expect(sign.group.position.z).toBeLessThan(camera.position.z - 2);
|
||||
expect(sign.group.scale.x).toBeCloseTo(dwellScale, 6);
|
||||
expect(leaving.bounds.right).toBeLessThan(passing.bounds.right);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
@@ -746,7 +870,82 @@ describe("procedural world models", () => {
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("places route signs in the corridor opposite their destination content", () => {
|
||||
it("keeps a short-landscape left sign inside the narrow desktop roadside gutter", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
const camera = createChaseCamera(780 / 493);
|
||||
camera.position.set(-1.35, 8.6, 22);
|
||||
camera.lookAt(1.6, 4.8, -128);
|
||||
sign.setViewportSize(780, 493);
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||
|
||||
const presentation = sign.inspect(camera, 780, 493);
|
||||
if (presentation === null) throw new Error("Missing short-landscape Profile sign");
|
||||
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
|
||||
expect(presentation.bounds.right).toBeLessThanOrEqual(265);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("keeps a short-landscape left sign horizontally clear at 844 by 390", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
const camera = createChaseCamera(844 / 390);
|
||||
camera.position.set(-1.35, 8.6, 22);
|
||||
camera.lookAt(1.6, 4.8, -128);
|
||||
sign.setViewportSize(844, 390);
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||
|
||||
const presentation = sign.inspect(camera, 844, 390);
|
||||
if (presentation === null) throw new Error("Missing 844 by 390 Profile sign");
|
||||
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
|
||||
expect(presentation.bounds.right).toBeLessThanOrEqual(288);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("places a portrait navigator arrival below its full-width destination panel", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
const camera = createChaseCamera(390 / 844);
|
||||
sign.setViewportSize(390, 844);
|
||||
sign.update({
|
||||
targetId: "profile",
|
||||
phase: "dwell",
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
navigationArrival: true,
|
||||
});
|
||||
|
||||
const presentation = sign.inspect(camera, 390, 844);
|
||||
if (presentation === null) throw new Error("Missing portrait navigator sign");
|
||||
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
|
||||
expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
|
||||
expect(presentation.bounds.right).toBeLessThanOrEqual(382);
|
||||
expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
|
||||
expect(presentation.bounds.width).toBeGreaterThanOrEqual(85);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("lands a resized Profile lifecycle in the short-landscape safe corridor", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
sign.setViewportSize(1_440, 900);
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||
|
||||
sign.setViewportSize(844, 390);
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 }, 100);
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 }, 450);
|
||||
const camera = createChaseCamera(844 / 390);
|
||||
camera.position.set(-1.35, 8.6, 22);
|
||||
camera.lookAt(1.6, 4.8, -128);
|
||||
const presentation = sign.inspect(camera, 844, 390);
|
||||
if (presentation === null) throw new Error("Missing resized Profile sign");
|
||||
expect(presentation.viewportProfile).toBe("short");
|
||||
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
|
||||
expect(presentation.bounds.right).toBeLessThanOrEqual(288);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("places route signs in their authored transition-safe corridors", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
sign.setViewportAspect(16 / 9);
|
||||
@@ -762,7 +961,7 @@ describe("procedural world models", () => {
|
||||
expect(profileBounds.maxX).toBeLessThan(0.08);
|
||||
|
||||
sign.update(null);
|
||||
sign.update({ targetId: "experience", phase: "dwell", progress: 1, opacity: 1 });
|
||||
sign.update({ targetId: "contact", phase: "dwell", progress: 1, opacity: 1 });
|
||||
sign.group.updateMatrixWorld(true);
|
||||
const experienceBounds = projectedBounds(profileFace, camera);
|
||||
expect(experienceBounds.minX).toBeGreaterThan(0);
|
||||
|
||||
Reference in New Issue
Block a user