1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 DRAFT_HERO_TRANSITION_ANIMATION_ID,
5 runMobileComposerTransition,
6 waitForDraftHeroTransition,
7} from "./draftHeroTransition";
8
9afterEach(() => {
10 vi.unstubAllGlobals();
11});
12
13describe("waitForDraftHeroTransition", () => {
14 it("waits for active draft hero animations and ignores unrelated animations", async () => {
15 let finishTransition: (() => void) | undefined;
16 const transitionFinished = new Promise<void>((resolve) => {
17 finishTransition = resolve;
18 });
19 vi.stubGlobal("document", {
20 getAnimations: () => [
21 { id: "unrelated-animation", finished: new Promise<void>(() => undefined) },
22 { id: DRAFT_HERO_TRANSITION_ANIMATION_ID, finished: transitionFinished },
23 ],
24 });
25
26 let handoffComplete = false;
27 const handoff = waitForDraftHeroTransition().then(() => {
28 handoffComplete = true;
29 });
30 await Promise.resolve();
31 expect(handoffComplete).toBe(false);
32
33 finishTransition?.();
34 await handoff;
35 expect(handoffComplete).toBe(true);
36 });
37
38 it("allows the handoff when an active transition is cancelled", async () => {
39 vi.stubGlobal("document", {
40 getAnimations: () => [
41 {
42 id: DRAFT_HERO_TRANSITION_ANIMATION_ID,
43 finished: Promise.reject(new Error("cancelled")),
44 },
45 ],
46 });
47
48 await expect(waitForDraftHeroTransition()).resolves.toBeUndefined();
49 });
50});
51
52describe("runMobileComposerTransition", () => {
53 it("keeps the route handoff waiting while the mobile composer morph is active", async () => {
54 let finishTransition: (() => void) | undefined;
55 const transitionFinished = new Promise<void>((resolve) => {
56 finishTransition = resolve;
57 });
58 const dataset: Record<string, string> = {};
59 const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
60 vi.stubGlobal("document", {
61 documentElement: { dataset, style },
62 getAnimations: () => [],
63 startViewTransition: (update: () => void | Promise<void>) => {
64 void update();
65 return { finished: transitionFinished };
66 },
67 });
68 vi.stubGlobal("window", {
69 matchMedia: (query: string) => ({ matches: query === "(max-width: 639px)" }),
70 });
71
72 const transition = runMobileComposerTransition(() => undefined, {
73 active: true,
74 durationMs: 240,
75 });
76 await Promise.resolve();
77
78 let handoffComplete = false;
79 const handoff = waitForDraftHeroTransition().then(() => {
80 handoffComplete = true;
81 });
82 await Promise.resolve();
83 expect(handoffComplete).toBe(false);
84
85 finishTransition?.();
86 await Promise.all([transition, handoff]);
87 expect(handoffComplete).toBe(true);
88 });
89
90 it("uses a scoped view transition on mobile", async () => {
91 const dataset: Record<string, string> = {};
92 const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
93 const startViewTransition = vi.fn((update: () => void | Promise<void>) => ({
94 finished: Promise.resolve(update()).then(() => undefined),
95 }));
96 vi.stubGlobal("document", {
97 documentElement: { dataset, style },
98 startViewTransition,
99 });
100 vi.stubGlobal("window", {
101 matchMedia: (query: string) => ({ matches: query === "(max-width: 639px)" }),
102 });
103 const update = vi.fn();
104
105 await runMobileComposerTransition(update, { active: true, durationMs: 360 });
106
107 expect(startViewTransition).toHaveBeenCalledOnce();
108 expect(update).toHaveBeenCalledOnce();
109 expect(style.setProperty).toHaveBeenCalledWith(
110 "--mobile-composer-transition-duration",
111 "360ms",
112 );
113 expect(style.removeProperty).toHaveBeenCalledWith("--mobile-composer-transition-duration");
114 expect(dataset).not.toHaveProperty("mobileComposerRouteTransition");
115 });
116
117 it("updates without a view transition when panel animations are inactive", async () => {
118 const startViewTransition = vi.fn();
119 vi.stubGlobal("document", {
120 documentElement: { dataset: {} },
121 startViewTransition,
122 });
123 vi.stubGlobal("window", {
124 matchMedia: () => ({ matches: true }),
125 });
126 const update = vi.fn();
127
128 await runMobileComposerTransition(update, { active: false, durationMs: 360 });
129
130 expect(startViewTransition).not.toHaveBeenCalled();
131 expect(update).toHaveBeenCalledOnce();
132 });
133
134 it("updates without a view transition when reduced motion is preferred", async () => {
135 const startViewTransition = vi.fn();
136 vi.stubGlobal("document", {
137 documentElement: { dataset: {} },
138 startViewTransition,
139 });
140 vi.stubGlobal("window", {
141 matchMedia: () => ({ matches: true }),
142 });
143 const update = vi.fn();
144
145 await runMobileComposerTransition(update, { active: true, durationMs: 360 });
146
147 expect(startViewTransition).not.toHaveBeenCalled();
148 expect(update).toHaveBeenCalledOnce();
149 });
150});