| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; |
| 3 | import { resizePreviewMiniPlayer } from "../preview/previewMiniPlayerLayout"; |
| 4 | import { |
| 5 | resolveThreadDetailsCardDensity, |
| 6 | resolveThreadDetailsCardLayout, |
| 7 | } from "./threadDetailsCardLayout"; |
| 8 | |
| 9 | const preview: ChatCanvasPreview = { |
| 10 | key: "browser:one", |
| 11 | width: 320, |
| 12 | position: null, |
| 13 | source: { width: 1600, height: 1000 }, |
| 14 | }; |
| 15 | const resolve = (width: number, player: ChatCanvasPreview | null = preview, height = 900) => |
| 16 | resolveChatCanvasLayout({ container: { width, height }, preview: player, composerHeight: 180 }); |
| 17 | const 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 | |
| 25 | describe("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 | |
| 321 | describe("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 | }); |