import { describe, expect, test } from "bun:test"; import { toInstantWidgetLayoutSizeUpdate, toTimedWidgetLayoutSizeUpdate, } from "./widget-layout-size-update.js"; import { areWidgetLayoutSizeUpdatesEqual, createWidgetLayoutTransitionSchedule, DEFAULT_WIDGET_LAYOUT_TRANSITION_DURATION_MS, DEFAULT_WIDGET_LAYOUT_TRANSITION_EASING, DEFAULT_WIDGET_LAYOUT_TRANSITION_POLICY, getWidgetLayoutPlanNowMs, INSTANT_WIDGET_LAYOUT_TRANSITION_POLICY, planWidgetLayoutTransition, resolveWidgetLayoutCubicBezierProgress, resolveWidgetLayoutResumedEasing, resolveWidgetLayoutTransitionDelayMs, resolveWidgetLayoutTransitionExecution, type WidgetLayoutInstantOnlyTransitionPlan, type WidgetLayoutTimedCapableTransitionPlan, type WidgetLayoutTransitionPlan, } from "./widget-layout-transition-plan.js"; describe("widget layout transition plan", () => { test("exposes the canonical default timed transition", () => { expect(DEFAULT_WIDGET_LAYOUT_TRANSITION_DURATION_MS).toBe(260); expect(DEFAULT_WIDGET_LAYOUT_TRANSITION_EASING).toEqual([0.22, 1, 0.36, 1]); expect(Object.isFrozen(DEFAULT_WIDGET_LAYOUT_TRANSITION_EASING)).toBe(true); expect(DEFAULT_WIDGET_LAYOUT_TRANSITION_POLICY).toEqual({ kind: "timed", durationMs: DEFAULT_WIDGET_LAYOUT_TRANSITION_DURATION_MS, easing: DEFAULT_WIDGET_LAYOUT_TRANSITION_EASING, }); expect(Object.isFrozen(DEFAULT_WIDGET_LAYOUT_TRANSITION_POLICY)).toBe(true); }); test("schedules coordinated transitions and clamps late consumers", () => { const schedule = createWidgetLayoutTransitionSchedule({ sequence: 1, transactionId: "view-1:layout-1", nowEpochMs: 1_000, }); expect(schedule).toEqual({ sequence: 1, transactionId: "view-1:layout-1", startAtEpochMs: 1_096, }); expect(resolveWidgetLayoutTransitionDelayMs(schedule, 1_010)).toBe(86); expect(resolveWidgetLayoutTransitionDelayMs(schedule, 1_100)).toBe(0); expect(resolveWidgetLayoutTransitionDelayMs(undefined, 1_000)).toBe(0); expect( resolveWidgetLayoutTransitionExecution({ schedule, durationMs: 180, nowEpochMs: 1_100, }), ).toEqual({ delayMs: 0, durationMs: 176, elapsedMs: 4, isComplete: false, timelineOffsetMs: -4, }); expect( resolveWidgetLayoutTransitionExecution({ schedule, durationMs: 180, nowEpochMs: 1_300, }), ).toEqual({ delayMs: 0, durationMs: 0, elapsedMs: 180, isComplete: true, timelineOffsetMs: -180, }); expect( createWidgetLayoutTransitionSchedule({ sequence: 2, transactionId: "view-1:layout-2", nowEpochMs: 1_000, leadTimeMs: -10, }), ).toEqual({ sequence: 2, transactionId: "view-1:layout-2", startAtEpochMs: 1_000, }); }); test("resumes cubic-bezier easing from the shared timeline progress", () => { const easing = [0.25, 0.1, 0.25, 1] as const; const elapsedProgress = 0.4; const initialProgress = resolveWidgetLayoutCubicBezierProgress( easing, elapsedProgress, ); const halfway = resolveWidgetLayoutResumedEasing({ easing, elapsedProgress, remainingProgress: 0.5, }); const expectedTimelineProgress = resolveWidgetLayoutCubicBezierProgress( easing, 0.7, ); expect(halfway.initialProgress).toBeCloseTo(initialProgress, 6); expect( halfway.initialProgress + (1 - halfway.initialProgress) * halfway.progress, ).toBeCloseTo(expectedTimelineProgress, 6); }); test("reconstructs overshooting easing across an endpoint crossing", () => { const easing = [1 / 3, 2, 2 / 3, 1 / 3] as const; const elapsedProgress = 0.5; for (const remainingProgress of [0, 0.25, 0.5, 1]) { const resumed = resolveWidgetLayoutResumedEasing({ easing, elapsedProgress, remainingProgress, }); const reconstructed = resumed.initialProgress + (1 - resumed.initialProgress) * resumed.progress; const expected = resolveWidgetLayoutCubicBezierProgress( easing, elapsedProgress + (1 - elapsedProgress) * remainingProgress, ); expect(Number.isFinite(resumed.progress)).toBe(true); expect(reconstructed).toBeCloseTo(expected, 6); } }); test("rejects schedules without a transaction identity", () => { expect(() => createWidgetLayoutTransitionSchedule({ sequence: 1, transactionId: " ", nowEpochMs: 1_000, }), ).toThrow(RangeError); }); function expectInstantOnlyPlan( _plan: WidgetLayoutInstantOnlyTransitionPlan, ): void {} function expectLegacyTransitionPlan( _plan: WidgetLayoutTransitionPlan, ): void {} function expectTimedCapablePlan( _plan: WidgetLayoutTimedCapableTransitionPlan, ): void {} test("uses the browser monotonic clock for layout plan timestamps", () => { const originalPerformanceDescriptor = Object.getOwnPropertyDescriptor( globalThis, "performance", ); try { Object.defineProperty(globalThis, "performance", { configurable: true, value: { now: () => 123.45, }, }); expect(getWidgetLayoutPlanNowMs()).toBe(123.45); } finally { if (originalPerformanceDescriptor) { Object.defineProperty( globalThis, "performance", originalPerformanceDescriptor, ); } else { Reflect.deleteProperty(globalThis, "performance"); } } }); test("falls back to Date.now when the monotonic clock is unavailable", () => { const originalNow = Date.now; const originalPerformanceDescriptor = Object.getOwnPropertyDescriptor( globalThis, "performance", ); try { Date.now = () => 1_700; Object.defineProperty(globalThis, "performance", { configurable: true, value: { now: () => Number.NaN, }, }); expect(getWidgetLayoutPlanNowMs()).toBe(1_700); } finally { Date.now = originalNow; if (originalPerformanceDescriptor) { Object.defineProperty( globalThis, "performance", originalPerformanceDescriptor, ); } else { Reflect.deleteProperty(globalThis, "performance"); } } }); test("plans the first instant layout update at the requested time", () => { const next = toInstantWidgetLayoutSizeUpdate({ width: 320, height: 240 }); expectInstantOnlyPlan( planWidgetLayoutTransition({ next, nowMs: 1_000, }), ); expectLegacyTransitionPlan( planWidgetLayoutTransition({ next, nowMs: 1_000, }), ); expect( planWidgetLayoutTransition({ next, nowMs: 1_000, }), ).toEqual({ kind: "instant", size: { width: 320, height: 240 }, update: { size: { width: 320, height: 240 }, transition: { kind: "instant", durationMs: 0, }, }, startedAtMs: 1_000, settledAtMs: 1_000, }); }); test("keeps repeated identical instant updates explicit as unchanged", () => { const previous = toInstantWidgetLayoutSizeUpdate({ width: 320, height: 240, }); const next = toInstantWidgetLayoutSizeUpdate({ width: 320, height: 240 }); expect( planWidgetLayoutTransition({ previous, next, nowMs: 1_250, }), ).toMatchObject({ kind: "unchanged", observedAtMs: 1_250, }); expect(areWidgetLayoutSizeUpdatesEqual(previous, next)).toBe(true); }); test("plans size input as instant by default", () => { const previous = toInstantWidgetLayoutSizeUpdate({ width: 320, height: 240, }); const plan = planWidgetLayoutTransition({ previous, size: { width: 320, height: 360 }, nowMs: 1_500, }); expectInstantOnlyPlan(plan); expect(plan).toEqual({ kind: "instant", size: { width: 320, height: 360 }, update: { size: { width: 320, height: 360 }, transition: { kind: "instant", durationMs: 0, }, }, startedAtMs: 1_500, settledAtMs: 1_500, }); }); test("keeps explicit instant transition policy instant-only", () => { const plan = planWidgetLayoutTransition({ size: { width: 320, height: 360 }, nowMs: 1_500, transitionPolicy: INSTANT_WIDGET_LAYOUT_TRANSITION_POLICY, }); expectInstantOnlyPlan(plan); expect(plan.kind).toBe("instant"); }); test("compares the full timed transition shape before it is live", () => { const base = { size: { width: 320, height: 240 }, durationMs: 180, } as const; expect( areWidgetLayoutSizeUpdatesEqual( toTimedWidgetLayoutSizeUpdate({ ...base, easing: [0.2, 0, 0, 1] }), toTimedWidgetLayoutSizeUpdate({ ...base, easing: [0.2, 0, 0, 1] }), ), ).toBe(true); expect( areWidgetLayoutSizeUpdatesEqual( toTimedWidgetLayoutSizeUpdate({ ...base, easing: [0.2, 0, 0, 1] }), toTimedWidgetLayoutSizeUpdate({ ...base, easing: [0.3, 0, 0, 1] }), ), ).toBe(false); }); test("treats size changes as instant while the live protocol is instant-only", () => { const previous = toInstantWidgetLayoutSizeUpdate({ width: 320, height: 240, }); const next = toInstantWidgetLayoutSizeUpdate({ width: 320, height: 360 }); expect( planWidgetLayoutTransition({ previous, next, nowMs: 1_500, }), ).toMatchObject({ kind: "instant", size: { width: 320, height: 360 }, startedAtMs: 1_500, settledAtMs: 1_500, }); }); test("plans explicit timed updates with settled time from duration", () => { const next = toTimedWidgetLayoutSizeUpdate({ size: { width: 320, height: 360 }, durationMs: 180, easing: [0.2, 0, 0, 1], }); expect( planWidgetLayoutTransition({ next, nowMs: 1_500, }), ).toEqual({ kind: "timed", size: { width: 320, height: 360 }, update: { size: { width: 320, height: 360 }, transition: { kind: "timed", durationMs: 180, easing: { kind: "cubic-bezier", points: [0.2, 0, 0, 1], }, }, }, startedAtMs: 1_500, settledAtMs: 1_680, }); }); test("plans timed transition policy from size input", () => { const plan = planWidgetLayoutTransition({ size: { width: 320, height: 360 }, nowMs: 1_500, transitionPolicy: { kind: "timed", durationMs: 180, easing: [0.2, 0, 0, 1], }, }); expectTimedCapablePlan(plan); expect(plan).toEqual({ kind: "timed", size: { width: 320, height: 360 }, update: { size: { width: 320, height: 360 }, transition: { kind: "timed", durationMs: 180, easing: { kind: "cubic-bezier", points: [0.2, 0, 0, 1], }, }, }, startedAtMs: 1_500, settledAtMs: 1_680, }); }); test("rejects invalid timed transition policy through protocol helpers", () => { expect(() => planWidgetLayoutTransition({ size: { width: 320, height: 360 }, nowMs: 1_500, transitionPolicy: { kind: "timed", durationMs: 0, easing: [0.2, 0, 0, 1], }, }), ).toThrow(RangeError); }); test("does not leak mutable references from the next update", () => { const next = { size: { width: 320, height: 240 }, transition: { kind: "instant", durationMs: 0 }, } as const; const plan = planWidgetLayoutTransition({ next, nowMs: 1_000, }); (next.size as { height: number }).height = 360; (next.transition as { durationMs: number }).durationMs = 1; expect(plan.size).toEqual({ width: 320, height: 240 }); expect(plan.update.size).toEqual({ width: 320, height: 240 }); expect(plan.update.transition.durationMs).toBe(0); }); test("does not leak mutable timed transition points from the next update", () => { const next = toTimedWidgetLayoutSizeUpdate({ size: { width: 320, height: 240 }, durationMs: 180, easing: [0.2, 0, 0, 1], }); const plan = planWidgetLayoutTransition({ next, nowMs: 1_000, }); (next.transition.easing.points as unknown as number[])[0] = 0.4; expect(plan.update.transition.kind).toBe("timed"); if (plan.update.transition.kind !== "timed") { throw new Error("expected timed transition"); } expect(plan.update.transition.easing.points).toEqual([0.2, 0, 0, 1]); }); test("rejects non-finite plan time", () => { expect(() => planWidgetLayoutTransition({ next: toInstantWidgetLayoutSizeUpdate({ width: 320, height: 240 }), nowMs: Number.NaN, }), ).toThrow(RangeError); }); });