1import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 clampPreviewMiniPlayerPosition,
6 PREVIEW_MINI_PLAYER_EDGE_GAP,
7 type PreviewMiniPlayerObstacles,
8 resizePreviewMiniPlayer,
9 resolveDeviceMiniPlayerCornerRadius,
10 resolveDeviceMiniPlayerSourceSize,
11 resolvePreviewMiniPlayerFrame,
12 resolvePreviewMiniPlayerSourceSize,
13} from "./previewMiniPlayerLayout";
14
15const container = { width: 1_000, height: 700 };
16const source = { width: 1_600, height: 1_000 };
17const gap = PREVIEW_MINI_PLAYER_EDGE_GAP;
18// A centered composer stack with margins on each side.
19const composer = { left: 100, right: 900, height: 150 };
20const obstacles: PreviewMiniPlayerObstacles = { composer, detailsCard: null };
21const tallComposer: PreviewMiniPlayerObstacles = {
22 composer: { ...composer, height: 300 },
23 detailsCard: null,
24};
25// The inline details card docked to the top-right corner.
26const detailsCard = { left: 680, right: 988, bottom: 260 };
27const withCard: PreviewMiniPlayerObstacles = { composer, detailsCard };
28
29describe("resolvePreviewMiniPlayerSourceSize", () => {
30 it("uses the device viewport scaled by zoom", () => {
31 expect(
32 resolvePreviewMiniPlayerSourceSize({ _tag: "freeform", width: 390, height: 844 }, null, 2),
33 ).toEqual({ width: 780, height: 1_688 });
34 });
35
36 it("uses the size the fill viewport had when it was floated", () => {
37 expect(
38 resolvePreviewMiniPlayerSourceSize(
39 FILL_PREVIEW_VIEWPORT,
40 { x: 0, y: 0, width: 640, height: 400, scale: 0.5, scrollLeft: 0, scrollTop: 0 },
41 1,
42 ),
43 ).toEqual({ width: 1_280, height: 800 });
44 });
45});
46
47describe("resolveDeviceMiniPlayerSourceSize", () => {
48 it("stands in with the platform's phone shape until the stream reports a size", () => {
49 const ios = resolveDeviceMiniPlayerSourceSize("ios", null);
50 expect(ios.width / ios.height).toBeCloseTo(9 / 19.5);
51 const android = resolveDeviceMiniPlayerSourceSize("android", null);
52 expect(android.width / android.height).toBeCloseTo(9 / 20);
53 });
54
55 it("turns a rotated screen into a landscape box", () => {
56 const screen = { width: 1_179, height: 2_556, orientation: "landscape_left" } as const;
57 expect(resolveDeviceMiniPlayerSourceSize("ios", screen)).toEqual({
58 width: 2_556,
59 height: 1_179,
60 });
61 expect(
62 resolveDeviceMiniPlayerSourceSize("android", { ...screen, orientation: "portrait" }),
63 ).toEqual({ width: 1_179, height: 2_556 });
64 });
65
66 it("floats a phone at the minimum width rather than the default box", () => {
67 expect(
68 resolvePreviewMiniPlayerFrame({
69 width: null,
70 position: null,
71 source: resolveDeviceMiniPlayerSourceSize("ios", null),
72 container,
73 }),
74 ).toMatchObject({ width: 240, height: 520 });
75 });
76});
77
78describe("resolveDeviceMiniPlayerCornerRadius", () => {
79 it("rounds an Android player like a phone, scaled with its short side", () => {
80 expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 240, height: 520 })).toBe(34);
81 expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 520, height: 240 })).toBe(34);
82 expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 60, height: 130 })).toBe(12);
83 });
84
85 it("keeps the frame radius for iOS, whose stream has square corners", () => {
86 expect(resolveDeviceMiniPlayerCornerRadius("ios", { width: 240, height: 520 })).toBe(12);
87 expect(resolveDeviceMiniPlayerCornerRadius("ios", { width: 720, height: 1_000 })).toBe(12);
88 });
89});
90
91describe("resolvePreviewMiniPlayerFrame", () => {
92 it("opens at the source aspect ratio in the top-right corner", () => {
93 expect(
94 resolvePreviewMiniPlayerFrame({ width: null, position: null, source, container }),
95 ).toEqual({ x: 668, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 320, height: 200 });
96 });
97
98 it("keeps a tall source at the minimum width instead of the default box", () => {
99 expect(
100 resolvePreviewMiniPlayerFrame({
101 width: null,
102 position: null,
103 source: { width: 390, height: 844 },
104 container,
105 }),
106 ).toEqual({ x: 748, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 240, height: 519 });
107 });
108
109 it("derives height from the stored width", () => {
110 expect(
111 resolvePreviewMiniPlayerFrame({ width: 480, position: { x: 100, y: 80 }, source, container }),
112 ).toEqual({ x: 100, y: 80, width: 480, height: 300 });
113 });
114
115 it("shrinks to the space above the composer without losing the stored width", () => {
116 const frame = resolvePreviewMiniPlayerFrame({
117 width: 800,
118 position: { x: 100, y: 80 },
119 source,
120 container,
121 obstacles: tallComposer,
122 });
123 expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 });
124 });
125
126 it("keeps a tall frame parked beside the composer across layout passes", () => {
127 // The frame an edge resize produced in the left margin, resolved again from
128 // the stored width and position on the next render.
129 const phone = { width: 390, height: 844 };
130 const beside = { composer: { left: 300, right: 900, height: 300 }, detailsCard: null };
131 const resized = resizePreviewMiniPlayer({
132 start: { x: 12, y: 100, width: 240, height: 519 },
133 direction: "east",
134 delta: { x: 10, y: 0 },
135 source: phone,
136 container,
137 obstacles: beside,
138 });
139 expect(
140 resolvePreviewMiniPlayerFrame({
141 width: resized.width,
142 position: { x: resized.x, y: resized.y },
143 source: phone,
144 container,
145 obstacles: beside,
146 }),
147 ).toEqual(resized);
148 });
149
150 it("can grow beyond the source rendered size", () => {
151 expect(
152 resolvePreviewMiniPlayerFrame({
153 width: 900,
154 position: { x: 12, y: 12 },
155 source: { width: 480, height: 320 },
156 container,
157 }),
158 ).toMatchObject({ width: 900, height: 600 });
159 });
160});
161
162describe("resizePreviewMiniPlayer", () => {
163 const start = { x: 300, y: 200, width: 320, height: 200 };
164
165 it("keeps the aspect ratio when dragging the right edge", () => {
166 expect(
167 resizePreviewMiniPlayer({
168 start,
169 direction: "east",
170 delta: { x: 160, y: 0 },
171 source,
172 container,
173 }),
174 ).toEqual({ x: 300, y: 200, width: 480, height: 300 });
175 });
176
177 it("anchors the right edge when dragging from the left", () => {
178 expect(
179 resizePreviewMiniPlayer({
180 start,
181 direction: "west",
182 delta: { x: -160, y: 0 },
183 source,
184 container,
185 }),
186 ).toEqual({ x: 140, y: 200, width: 480, height: 300 });
187 });
188
189 it("anchors the bottom edge when dragging the top up", () => {
190 expect(
191 resizePreviewMiniPlayer({
192 start,
193 direction: "north",
194 delta: { x: 0, y: -100 },
195 source,
196 container,
197 }),
198 ).toEqual({ x: 300, y: 100, width: 480, height: 300 });
199 });
200
201 it("does not jump when corner motion crosses opposing dominant axes", () => {
202 const frames = [-99, -100, -101].map((y) =>
203 resizePreviewMiniPlayer({
204 start,
205 direction: "southeast",
206 delta: { x: 160, y },
207 source,
208 container,
209 }),
210 );
211 expect(Math.abs(frames[0]!.width - frames[2]!.width)).toBeLessThanOrEqual(2);
212 });
213
214 it("projects corner motion onto the aspect-ratio diagonal", () => {
215 expect(
216 resizePreviewMiniPlayer({
217 start,
218 direction: "southeast",
219 delta: { x: 20, y: 100 },
220 source,
221 container,
222 }),
223 ).toEqual({ x: 300, y: 200, width: 379, height: 237 });
224 });
225
226 it("stops at the container edge in the drag direction", () => {
227 expect(
228 resizePreviewMiniPlayer({
229 start: { x: 600, y: 12, width: 320, height: 200 },
230 direction: "east",
231 delta: { x: 500, y: 0 },
232 source,
233 container,
234 }),
235 ).toEqual({ x: 600, y: 12, width: 388, height: 243 });
236 });
237
238 it("shifts the player when the free axis would overflow", () => {
239 expect(
240 resizePreviewMiniPlayer({
241 start: { x: 12, y: 400, width: 320, height: 200 },
242 direction: "east",
243 delta: { x: 300, y: 0 },
244 source,
245 container,
246 }),
247 ).toEqual({ x: 12, y: 300, width: 620, height: 388 });
248 });
249
250 it("lets a player beside a tall composer keep its height on an edge drag", () => {
251 // A portrait player parked in the margin left of the composer, already
252 // taller than the rows above the composer, nudged from its right edge.
253 const phone = { width: 390, height: 844 };
254 const start = { x: 12, y: 100, width: 240, height: 519 };
255 const beside = { composer: { left: 300, right: 900, height: 300 }, detailsCard: null };
256 expect(
257 resizePreviewMiniPlayer({
258 start,
259 direction: "east",
260 delta: { x: 10, y: 0 },
261 source: phone,
262 container,
263 obstacles: beside,
264 }),
265 ).toEqual({ x: 12, y: 100, width: 250, height: 541 });
266 // The same drag with the composer under the player is still held above it.
267 expect(
268 resizePreviewMiniPlayer({
269 start: { ...start, x: 400 },
270 direction: "east",
271 delta: { x: 10, y: 0 },
272 source: phone,
273 container,
274 obstacles: beside,
275 }),
276 ).toMatchObject({ height: 376 });
277 });
278
279 it("stops growing downward at the composer beneath the player's columns", () => {
280 expect(
281 resizePreviewMiniPlayer({
282 start: { x: 300, y: 100, width: 320, height: 200 },
283 direction: "south",
284 delta: { x: 0, y: 400 },
285 source,
286 container,
287 obstacles: tallComposer,
288 }),
289 ).toEqual({ x: 300, y: 100, width: 461, height: 288 });
290 });
291
292 it("respects the minimum size", () => {
293 expect(
294 resizePreviewMiniPlayer({
295 start,
296 direction: "southeast",
297 delta: { x: -300, y: -300 },
298 source,
299 container,
300 }),
301 ).toEqual({ x: 300, y: 200, width: 240, height: 150 });
302 });
303});
304
305describe("resolvePreviewMiniPlayerFrame with the details card", () => {
306 it("opens under the inline details card with right edges aligned", () => {
307 expect(
308 resolvePreviewMiniPlayerFrame({
309 width: null,
310 position: null,
311 source,
312 container,
313 obstacles: withCard,
314 }),
315 ).toEqual({ x: 988 - 320, y: 260 + gap, width: 320, height: 200 });
316 });
317
318 it("opens left of the card when it cannot fit beneath it", () => {
319 // A tall card leaves too few rows above the composer for a full player.
320 const tallCard = { ...withCard, detailsCard: { ...detailsCard, bottom: 480 } };
321 expect(
322 resolvePreviewMiniPlayerFrame({
323 width: null,
324 position: null,
325 source,
326 container,
327 obstacles: tallCard,
328 }),
329 ).toEqual({ x: 680 - gap - 320, y: 700 - 150 - gap - 200, width: 320, height: 200 });
330 });
331
332 it("keeps a stored player's size when the card covers its columns", () => {
333 // The card leaves ~290 rows beneath it; fitting into them would collapse
334 // a 480-wide player to ~1px tall. The frame keeps its size and the clamp
335 // relocates it to the open columns left of the card.
336 const frame = resolvePreviewMiniPlayerFrame({
337 width: 480,
338 position: { x: 700, y: 280 },
339 source,
340 container,
341 obstacles: withCard,
342 });
343 expect(frame).toEqual({
344 x: 680 - gap - 480,
345 y: 700 - 150 - gap - 300,
346 width: 480,
347 height: 300,
348 });
349 });
350});
351
352describe("clampPreviewMiniPlayerPosition", () => {
353 const player = { width: 360, height: 240 };
354
355 it("keeps a dragged player within the chat viewport", () => {
356 expect(clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, player)).toEqual({
357 x: 628,
358 y: gap,
359 });
360 });
361
362 it("keeps the player above a growing composer", () => {
363 expect(
364 clampPreviewMiniPlayerPosition({ x: 500, y: 448 }, container, player, {
365 composer: { ...composer, height: 160 },
366 detailsCard: null,
367 }),
368 ).toEqual({ x: 500, y: 288 });
369 });
370
371 it("lets the player drop into the margin beside the composer", () => {
372 expect(
373 clampPreviewMiniPlayerPosition(
374 { x: 20, y: 500 },
375 container,
376 { width: 60, height: 150 },
377 obstacles,
378 ),
379 ).toEqual({ x: 20, y: 500 });
380 });
381
382 it("slides sideways past the composer when that is the shorter move", () => {
383 expect(
384 clampPreviewMiniPlayerPosition(
385 { x: 850, y: 500 },
386 container,
387 { width: 60, height: 150 },
388 obstacles,
389 ),
390 ).toEqual({ x: composer.right + gap, y: 500 });
391 });
392
393 it("keeps the player out from under the details card", () => {
394 // Dragged up into the card's columns: pushed down to its bottom edge.
395 expect(clampPreviewMiniPlayerPosition({ x: 700, y: 40 }, container, player, withCard)).toEqual({
396 x: 628,
397 y: 260 + gap,
398 });
399 });
400
401 it("slides sideways out from under the card when that is the shorter move", () => {
402 expect(
403 clampPreviewMiniPlayerPosition(
404 { x: 660, y: 40 },
405 container,
406 { width: 60, height: 150 },
407 withCard,
408 ),
409 ).toEqual({ x: 680 - gap - 60, y: 40 });
410 });
411
412 it("clears the card and the composer together in a tight column", () => {
413 // Its own columns have too few rows between the card and the composer,
414 // so it slides left of the card, where the rows above the composer suffice.
415 const tight = { composer: { left: 0, right: 1_000, height: 300 }, detailsCard };
416 expect(clampPreviewMiniPlayerPosition({ x: 700, y: 600 }, container, player, tight)).toEqual({
417 x: 680 - gap - 360,
418 y: 700 - 300 - gap - 240,
419 });
420 // Too tall for any column: the card wins and the player sits below it.
421 expect(
422 clampPreviewMiniPlayerPosition(
423 { x: 700, y: 600 },
424 container,
425 { width: 360, height: 400 },
426 tight,
427 ),
428 ).toEqual({ x: 628, y: 260 + gap });
429 });
430
431 it("stays inside the container when a full-width card leaves no open rows", () => {
432 // A card reaching past the player's last row would push the fallback
433 // below the container; the clamp keeps the bottom edge visible instead.
434 const wall = {
435 composer,
436 detailsCard: { left: 0, right: 1_000, bottom: 690 },
437 };
438 expect(clampPreviewMiniPlayerPosition({ x: 300, y: 500 }, container, player, wall)).toEqual({
439 x: 300,
440 y: 700 - gap - player.height,
441 });
442 });
443
444 it("sits above the composer when it is too wide for either margin", () => {
445 expect(
446 clampPreviewMiniPlayerPosition(
447 { x: 100, y: 100 },
448 container,
449 { width: 976, height: 500 },
450 obstacles,
451 ),
452 ).toEqual({ x: gap, y: 700 - 150 - gap - 500 });
453 });
454});