1import { describe, expect, it } from "vite-plus/test";
2import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
3import { resizePreviewMiniPlayer } from "../preview/previewMiniPlayerLayout";
4import {
5 resolveThreadDetailsCardDensity,
6 resolveThreadDetailsCardLayout,
7} from "./threadDetailsCardLayout";
8
9const preview: ChatCanvasPreview = {
10 key: "browser:one",
11 width: 320,
12 position: null,
13 source: { width: 1600, height: 1000 },
14};
15const resolve = (width: number, player: ChatCanvasPreview | null = preview, height = 900) =>
16 resolveChatCanvasLayout({ container: { width, height }, preview: player, composerHeight: 180 });
17const expectClear = (result: ReturnType<typeof resolve>) => {
18 const frame = result.frame!;
19 const chat = result.chat;
20 expect(frame.x + frame.width <= chat.left - 12 || frame.x >= chat.left + chat.width + 12).toBe(
21 true,
22 );
23};
24
25describe("chat canvas layout", () => {
26 it("lifts a growing preview above the composer without snapping at the chat boundary", () => {
27 let previous: ReturnType<typeof resolve> | undefined;
28 for (let width = 480; width <= 1100; width++) {
29 const result = resolve(1344, {
30 ...preview,
31 width,
32 lastInteraction: "resize",
33 position: { x: 1332 - width, y: 888 - Math.round(width / 1.6) },
34 });
35 const frame = result.frame!;
36 if (frame.x < result.chat.left + result.chat.width + 12) {
37 expect(frame.y + frame.height).toBeLessThanOrEqual(708);
38 }
39 expect(result.chat.left).toBeLessThanOrEqual(288);
40 expect(result.chat.width).toBeGreaterThanOrEqual(640);
41 if (previous) {
42 expect(Math.abs(frame.y - previous.frame!.y)).toBeLessThanOrEqual(3);
43 expect(Math.abs(frame.width - previous.frame!.width)).toBeLessThanOrEqual(2);
44 expect(Math.abs(result.chat.width - previous.chat.width)).toBeLessThanOrEqual(1);
45 }
46 previous = result;
47 }
48 expect(previous!.overlapsChat).toBe(true);
49 expect(previous!.frame!.width).toBe(1100);
50 });
51
52 it("centers chat in the whole container without a preview", () => {
53 expect(resolve(1344, null).chat).toEqual({ left: 288, width: 768, insetStart: 0, insetEnd: 0 });
54 expect(
55 resolveChatCanvasLayout({
56 container: { width: 390, height: 900 },
57 preview: null,
58 padding: 12,
59 }).chat,
60 ).toEqual({ left: 12, width: 366, insetStart: 0, insetEnd: 0 });
61 });
62 it("does not move chat when a bottom-right preview fits in its margin", () => {
63 const result = resolve(1600);
64 expect(result.chat.insetEnd).toBe(0);
65 expect(result.frame!.y + result.frame!.height).toBe(888);
66 expectClear(result);
67 });
68 it("moves chat only as far as the preview requires", () => {
69 const result = resolve(1344);
70 expect(result.chat).toEqual({ left: 232, width: 768, insetStart: 0, insetEnd: 112 });
71 expectClear(result);
72 });
73 it("shrinks chat modestly after using the left margin", () => {
74 const result = resolve(1200, { ...preview, width: 480 });
75 expect(result.chat.width).toBe(648);
76 expect(result.chat.left).toBe(48);
77 expectClear(result);
78 });
79 it.each([900, 1000, 1004])("allows dragging across a %spx chat canvas", (width) => {
80 const start = resolve(width, { ...preview, position: { x: width - 332, y: 200 } }).frame!;
81 const moved = resolve(width, {
82 ...preview,
83 position: { x: start.x - 300, y: start.y },
84 lastInteraction: "drag",
85 });
86 expect(moved.frame).toEqual({ ...start, x: start.x - 300 });
87 const returned = resolve(width, {
88 ...preview,
89 position: { x: start.x, y: start.y },
90 lastInteraction: "drag",
91 });
92 expect(returned.frame).toEqual(start);
93 });
94 it("stops leftward dragging before the player can push chat to the right", () => {
95 const centered = resolve(1344, null).chat;
96 for (const x of [1100, 800, 700, 600, 400, 12, -100]) {
97 const result = resolve(1344, { ...preview, position: { x, y: 500 } });
98 expect(result.frame!.x).toBeGreaterThanOrEqual(700);
99 expect(result.chat.left).toBeLessThanOrEqual(centered.left);
100 expect(result.chat.insetStart).toBe(0);
101 expect(result.chat.width).toBeGreaterThanOrEqual(640);
102 expectClear(result);
103 }
104 expect(resolve(1344, { ...preview, position: { x: 12, y: 500 } }).frame!.x).toBe(700);
105 });
106 it("keeps a dragged player above the composer when no readable lane fits beside it", () => {
107 const result = resolve(1000, { ...preview, position: { x: 12, y: 700 } });
108 expect(result.chat).toEqual(resolve(1000, null).chat);
109 expect(result.frame!.x).toBe(12);
110 expect(result.frame!.y + result.frame!.height).toBeLessThanOrEqual(708);
111 });
112 it("allows message overlap on narrow screens while excluding the composer", () => {
113 const result = resolve(1000);
114 expect(result.overlapsChat).toBe(true);
115 expect(result.chat.insetStart).toBe(0);
116 expect(result.chat.insetEnd).toBe(0);
117 expect(result.frame!.y + result.frame!.height).toBeLessThanOrEqual(900 - 180 - 12);
118 expect(result.frame!.width / result.frame!.height).toBeCloseTo(1.6);
119 });
120 it("restores the preferred width when the container grows again", () => {
121 const player = { ...preview, width: 480 };
122 expect(resolve(1200, player).chat.width).toBeLessThan(768);
123 expect(resolve(1800, player).chat.width).toBe(768);
124 expect(player.width).toBe(480);
125 });
126 it("uses the same stable side-by-side frame even as the composer grows", () => {
127 const result = resolve(1344);
128 expect(
129 resolveChatCanvasLayout({
130 container: { width: 1344, height: 900 },
131 preview,
132 composerHeight: 400,
133 }),
134 ).toEqual(result);
135 });
136 it("constrains a dragged preview below the full top-right card instead of folding it", () => {
137 const result = resolveChatCanvasLayout({
138 container: { width: 1584, height: 988 },
139 maxChatWidth: 736,
140 composerHeight: 180,
141 detailsCard: { left: 1260, right: 1572, bottom: 339 },
142 preview: {
143 ...preview,
144 width: 240,
145 source: { width: 240, height: 365 },
146 position: { x: 1260, y: 240 },
147 },
148 });
149 expect(result.frame).toEqual({ x: 1260, y: 351, width: 240, height: 365 });
150 expect(result.overlapsDetailsCard).toBe(false);
151 expect(result.chat).toEqual({ left: 424, width: 736, insetStart: 0, insetEnd: 0 });
152 });
153 it("keeps a dragged or resized preview below the open find bar", () => {
154 const findBar = { left: 1292, right: 1572, bottom: 48 };
155 const layout = (lastInteraction: "drag" | "resize") =>
156 resolveChatCanvasLayout({
157 container: { width: 1584, height: 988 },
158 maxChatWidth: 736,
159 composerHeight: 180,
160 findBar,
161 preview: {
162 ...preview,
163 width: 240,
164 source: { width: 240, height: 365 },
165 position: { x: 1332, y: 12 },
166 lastInteraction,
167 },
168 });
169 for (const lastInteraction of ["drag", "resize"] as const) {
170 const result = layout(lastInteraction);
171 expect(result.frame).toEqual({ x: 1332, y: 60, width: 240, height: 365 });
172 expect(result.overlapsDetailsCard).toBe(false);
173 expect(result.chat).toEqual({ left: 424, width: 736, insetStart: 0, insetEnd: 0 });
174 }
175 });
176 it("keeps a preview clear of a find bar wider than the inline details card", () => {
177 const result = resolveChatCanvasLayout({
178 container: { width: 1800, height: 1000 },
179 findBar: { left: 1362, right: 1782, bottom: 72 },
180 detailsCard: { left: 1508, right: 1788, bottom: 400 },
181 preview: {
182 ...preview,
183 width: 240,
184 source: { width: 240, height: 365 },
185 position: { x: 1200, y: 12 },
186 lastInteraction: "drag",
187 },
188 });
189 const frame = result.frame!;
190 expect(frame.x + frame.width <= 1362 || frame.y >= 84).toBe(true);
191 expect(result.overlapsDetailsCard).toBe(false);
192 });
193 it("uses space beside the full card if a tall preview cannot fit below it", () => {
194 const result = resolveChatCanvasLayout({
195 container: { width: 1584, height: 988 },
196 maxChatWidth: 736,
197 composerHeight: 180,
198 detailsCard: { left: 1260, right: 1572, bottom: 339 },
199 preview: {
200 ...preview,
201 width: 240,
202 source: { width: 240, height: 700 },
203 position: { x: 1260, y: 100 },
204 },
205 });
206 expect(result.frame).toEqual({ x: 1008, y: 100, width: 240, height: 700 });
207 expect(result.overlapsDetailsCard).toBe(false);
208 expectClear(result);
209 });
210 it("keeps a large player when clearing the full card would leave no readable chat", () => {
211 const container = { width: 1584, height: 988 };
212 const source = { width: 1000, height: 1523 };
213 const layout = (width: number) => {
214 const resized = resizePreviewMiniPlayer({
215 start: { x: 1332, y: 610, width: 240, height: 366 },
216 direction: "northwest",
217 delta: { x: 240 - width, y: ((240 - width) * source.height) / source.width },
218 container,
219 source,
220 });
221 return resolveChatCanvasLayout({
222 container,
223 maxChatWidth: 736,
224 composerHeight: 206,
225 detailsCard: { left: 1260, right: 1572, bottom: 339 },
226 preview: { ...preview, width: resized.width, position: resized, source },
227 });
228 };
229 const shifted = layout(460);
230 expect(shifted.frame!.width).toBe(460);
231 expect(shifted.chat.left).toBeLessThan(424);
232 expect(shifted.overlapsDetailsCard).toBe(false);
233 expectClear(shifted);
234
235 const narrowed = layout(532);
236 expect(narrowed.frame!.width).toBe(532);
237 expect(narrowed.chat.width).toBe(656);
238 expect(narrowed.overlapsDetailsCard).toBe(false);
239 expectClear(narrowed);
240
241 const grown = layout(600);
242 expect(grown.frame!.width).toBe(600);
243 expect(grown.overlapsDetailsCard).toBe(true);
244 expect(grown.overlapsChat).toBe(false);
245 expectClear(grown);
246 expect(layout(532)).toEqual(narrowed);
247 });
248 it.each(["west", "north"] as const)(
249 "folds the card before moving a %s resize beside it",
250 (direction) => {
251 const container = { width: 1584, height: 988 };
252 const source = { width: 1000, height: 1523 };
253 const layout = (width: number) => {
254 const resized = resizePreviewMiniPlayer({
255 start: { x: 1332, y: 611, width: 240, height: 365 },
256 direction,
257 delta: { x: 240 - width, y: ((240 - width) * source.height) / source.width },
258 container,
259 source,
260 });
261 return resolveChatCanvasLayout({
262 container,
263 maxChatWidth: 736,
264 composerHeight: 206,
265 detailsCard: { left: 1260, right: 1572, bottom: 339 },
266 preview: {
267 ...preview,
268 width: resized.width,
269 position: resized,
270 source,
271 lastInteraction: "resize",
272 },
273 });
274 };
275 const density = (result: ReturnType<typeof layout>) => {
276 const card = resolveThreadDetailsCardLayout({
277 container,
278 lane: { padding: 48, minChatWidth: 640 },
279 frame: result.frame,
280 overlapsDetailsCard: result.overlapsDetailsCard,
281 });
282 return card
283 ? resolveThreadDetailsCardDensity(card.height, { full: 327, compact: 182 })
284 : "hidden";
285 };
286 expect(density(layout(400))).toBe("full");
287 const compact = layout(460);
288 expect(compact.frame!.x + compact.frame!.width).toBe(1572);
289 expect(compact.frame!.width).toBe(460);
290 expect(compact.chat.left).toBe(364);
291 expect(compact.overlapsDetailsCard).toBe(true);
292 expect(density(compact)).toBe("compact");
293 expectClear(compact);
294 expect(density(layout(500))).toBe("compact");
295 expect(density(layout(520))).toBe("essential");
296 expect(density(layout(540))).toBe("hidden");
297 expect(layout(600).frame!.width).toBe(600);
298 expect(layout(460)).toEqual(compact);
299 expect(density(layout(400))).toBe("full");
300 },
301 );
302 it("requests card folding only when no full-card slot fits and keeps the composer clear", () => {
303 const result = resolveChatCanvasLayout({
304 container: { width: 1584, height: 988 },
305 maxChatWidth: 736,
306 composerHeight: 180,
307 detailsCard: { left: 1260, right: 1572, bottom: 600 },
308 preview: {
309 ...preview,
310 width: 1300,
311 source: { width: 1300, height: 400 },
312 position: { x: 12, y: 12 },
313 },
314 });
315 expect(result.overlapsDetailsCard).toBe(true);
316 expect(result.overlapsChat).toBe(true);
317 expect(result.frame).toEqual({ x: 12, y: 396, width: 1300, height: 400 });
318 });
319});
320
321describe("workspace card beside chat", () => {
322 const withCard = (width: number, player: ChatCanvasPreview | null = null, maxChatWidth = 736) =>
323 resolveChatCanvasLayout({
324 container: { width, height: 900 },
325 maxChatWidth,
326 composerHeight: 180,
327 detailsCard: { left: width - 292, right: width - 12, bottom: 400 },
328 preview: player,
329 });
330 it("keeps chat centered while the card fits beside it", () => {
331 expect(withCard(1384).chat).toEqual({ left: 324, width: 736, insetStart: 0, insetEnd: 0 });
332 });
333 it("moves chat left only as far as the card requires", () => {
334 expect(withCard(1383).chat).toEqual({ left: 323, width: 736, insetStart: 0, insetEnd: 1 });
335 expect(withCard(1147).chat).toEqual({ left: 87, width: 736, insetStart: 0, insetEnd: 237 });
336 });
337 it("narrows chat only after it reaches the left padding", () => {
338 expect(withCard(1108).chat).toMatchObject({ left: 48, width: 736 });
339 expect(withCard(1028).chat).toEqual({ left: 48, width: 656, insetStart: 0, insetEnd: 276 });
340 expect(withCard(1012).chat).toMatchObject({ left: 48, width: 640 });
341 });
342 it("reserves the marker gutter across widths where the details card fits", () => {
343 for (let width = 1012; width <= 1440; width++) {
344 const container = { width, height: 900 };
345 const card = resolveThreadDetailsCardLayout({
346 container,
347 lane: { padding: 48, minChatWidth: 640 },
348 frame: null,
349 })!;
350 const chat = withCard(width).chat;
351 expect(chat.left).toBeGreaterThanOrEqual(48);
352 expect(chat.width).toBeGreaterThanOrEqual(640);
353 expect(card.x - chat.left - chat.width).toBeGreaterThanOrEqual(32);
354 }
355 });
356 it("keeps a full-width chat clear of the card", () => {
357 expect(withCard(1147, null, 10_000).chat).toEqual({
358 left: 48,
359 width: 775,
360 insetStart: 0,
361 insetEnd: 276,
362 });
363 });
364 it("keeps centered chat in place when a new preview opens below the card", () => {
365 expect(withCard(1440, preview)).toEqual({
366 chat: { left: 352, width: 736, insetStart: 0, insetEnd: 0 },
367 frame: { x: 1108, y: 688, width: 320, height: 200 },
368 overlapsChat: false,
369 overlapsDetailsCard: false,
370 });
371 });
372});