| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; |
| 4 | import { |
| 5 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, |
| 6 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, |
| 7 | COMPOSER_RESTING_EXPANSION_MIN_PX, |
| 8 | getRestingComposerImagePreviewCounts, |
| 9 | overlayComposerIsResting, |
| 10 | resolveComposerTimelineInset, |
| 11 | resolveScrollToEndClearance, |
| 12 | resolveRestingComposerControlsLayout, |
| 13 | resolveRestingComposerControlsNaturalWidth, |
| 14 | shouldAnimateComposerRestingTransition, |
| 15 | shouldUseCompactComposerPrimaryActions, |
| 16 | shouldUseCompactComposerFooter, |
| 17 | shouldUseRestingComposerLayout, |
| 18 | } from "./composerFooterLayout"; |
| 19 | |
| 20 | describe("getRestingComposerImagePreviewCounts", () => { |
| 21 | it("shows at most three thumbnails and counts the remainder", () => { |
| 22 | expect(getRestingComposerImagePreviewCounts(0)).toEqual({ |
| 23 | visibleCount: 0, |
| 24 | overflowCount: 0, |
| 25 | }); |
| 26 | expect(getRestingComposerImagePreviewCounts(3)).toEqual({ |
| 27 | visibleCount: 3, |
| 28 | overflowCount: 0, |
| 29 | }); |
| 30 | expect(getRestingComposerImagePreviewCounts(7)).toEqual({ |
| 31 | visibleCount: 3, |
| 32 | overflowCount: 4, |
| 33 | }); |
| 34 | }); |
| 35 | }); |
| 36 | |
| 37 | describe("shouldUseCompactComposerFooter", () => { |
| 38 | it("stays expanded without a measured width", () => { |
| 39 | expect(shouldUseCompactComposerFooter(null)).toBe(false); |
| 40 | }); |
| 41 | |
| 42 | it("switches to compact mode below the breakpoint", () => { |
| 43 | expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX - 1)).toBe(true); |
| 44 | }); |
| 45 | |
| 46 | it("stays expanded at and above the breakpoint", () => { |
| 47 | expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX)).toBe(false); |
| 48 | expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(false); |
| 49 | }); |
| 50 | |
| 51 | it("uses a higher breakpoint for wide action states", () => { |
| 52 | expect( |
| 53 | shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1, { |
| 54 | hasWideActions: true, |
| 55 | }), |
| 56 | ).toBe(true); |
| 57 | expect( |
| 58 | shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, { |
| 59 | hasWideActions: true, |
| 60 | }), |
| 61 | ).toBe(false); |
| 62 | }); |
| 63 | }); |
| 64 | |
| 65 | describe("shouldUseCompactComposerPrimaryActions", () => { |
| 66 | it("matches the wide footer breakpoint", () => { |
| 67 | expect( |
| 68 | shouldUseCompactComposerPrimaryActions( |
| 69 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1, |
| 70 | { hasWideActions: true }, |
| 71 | ), |
| 72 | ).toBe(true); |
| 73 | expect( |
| 74 | shouldUseCompactComposerPrimaryActions(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, { |
| 75 | hasWideActions: true, |
| 76 | }), |
| 77 | ).toBe(false); |
| 78 | }); |
| 79 | }); |
| 80 | |
| 81 | describe("overlayComposerIsResting", () => { |
| 82 | it("drops a resting reservation once a status bar replaces the composer", () => { |
| 83 | // The composer rested on a scroll, then the thread swapped it for the |
| 84 | // subagent bar. The bar's 56px overlay must not keep the resting estimate. |
| 85 | const isResting = overlayComposerIsResting({ |
| 86 | composerMounted: false, |
| 87 | composerReportedResting: true, |
| 88 | }); |
| 89 | expect(isResting).toBe(false); |
| 90 | expect(resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 56, isResting })).toBe( |
| 91 | 56, |
| 92 | ); |
| 93 | expect(overlayComposerIsResting({ composerMounted: true, composerReportedResting: true })).toBe( |
| 94 | true, |
| 95 | ); |
| 96 | }); |
| 97 | }); |
| 98 | |
| 99 | describe("resolveComposerTimelineInset", () => { |
| 100 | it("follows the expanded overlay height", () => { |
| 101 | expect( |
| 102 | resolveComposerTimelineInset({ currentInset: 160, overlayHeight: 140, isResting: false }), |
| 103 | ).toBe(140); |
| 104 | }); |
| 105 | |
| 106 | it("keeps a larger expanded reservation while resting", () => { |
| 107 | expect( |
| 108 | resolveComposerTimelineInset({ currentInset: 200, overlayHeight: 60, isResting: true }), |
| 109 | ).toBe(200); |
| 110 | }); |
| 111 | |
| 112 | it("uses the measured expanded height while the resting strip host is mounting", () => { |
| 113 | expect( |
| 114 | resolveComposerTimelineInset({ currentInset: 172, overlayHeight: 110, isResting: true }), |
| 115 | ).toBe(172); |
| 116 | }); |
| 117 | |
| 118 | it("keeps timeline padding stable when the model-only strip appears on collapse", () => { |
| 119 | const expanded = resolveComposerTimelineInset({ |
| 120 | currentInset: 0, |
| 121 | overlayHeight: 172, |
| 122 | isResting: false, |
| 123 | }); |
| 124 | const collapsed = resolveComposerTimelineInset({ |
| 125 | currentInset: expanded, |
| 126 | overlayHeight: 110, |
| 127 | isResting: true, |
| 128 | restingOnlyHeight: 32, |
| 129 | }); |
| 130 | expect(collapsed).toBe(expanded); |
| 131 | expect( |
| 132 | resolveComposerTimelineInset({ |
| 133 | currentInset: collapsed, |
| 134 | overlayHeight: 172, |
| 135 | isResting: false, |
| 136 | }), |
| 137 | ).toBe(expanded); |
| 138 | }); |
| 139 | |
| 140 | it("reserves the empty expansion when no larger height is known", () => { |
| 141 | expect( |
| 142 | resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 60, isResting: true }), |
| 143 | ).toBe(60 + COMPOSER_RESTING_EXPANSION_MIN_PX); |
| 144 | }); |
| 145 | }); |
| 146 | |
| 147 | describe("shouldUseRestingComposerLayout", () => { |
| 148 | const resting = { |
| 149 | isExistingThread: true, |
| 150 | isMobileViewport: false, |
| 151 | isScrollCollapsed: true, |
| 152 | hasExpandedChrome: false, |
| 153 | hasMultilinePrompt: false, |
| 154 | timelineOverflows: true, |
| 155 | }; |
| 156 | |
| 157 | it("uses the resting layout after a timeline scroll", () => { |
| 158 | expect(shouldUseRestingComposerLayout(resting)).toBe(true); |
| 159 | }); |
| 160 | |
| 161 | it("keeps the composer expanded until the timeline is scrolled", () => { |
| 162 | expect(shouldUseRestingComposerLayout({ ...resting, isScrollCollapsed: false })).toBe(false); |
| 163 | }); |
| 164 | |
| 165 | it("keeps the composer expanded while the timeline fits above it", () => { |
| 166 | expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false); |
| 167 | }); |
| 168 | |
| 169 | it("keeps new-thread composers expanded", () => { |
| 170 | expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); |
| 171 | }); |
| 172 | |
| 173 | it("leaves responsive mobile on its existing collapse path", () => { |
| 174 | expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false); |
| 175 | }); |
| 176 | |
| 177 | it("keeps drawers and composer-owned menus expanded", () => { |
| 178 | expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false); |
| 179 | }); |
| 180 | |
| 181 | it.each([false, true])( |
| 182 | "keeps multiline drafts expanded when scroll collapsed is %s", |
| 183 | (isScrollCollapsed) => { |
| 184 | expect( |
| 185 | shouldUseRestingComposerLayout({ |
| 186 | ...resting, |
| 187 | hasMultilinePrompt: true, |
| 188 | isScrollCollapsed, |
| 189 | }), |
| 190 | ).toBe(false); |
| 191 | }, |
| 192 | ); |
| 193 | }); |
| 194 | |
| 195 | describe("shouldAnimateComposerRestingTransition", () => { |
| 196 | it("does not animate layout measurements that settle during initial mount", () => { |
| 197 | expect( |
| 198 | shouldAnimateComposerRestingTransition({ |
| 199 | hasCompletedInitialLayout: false, |
| 200 | stateChanged: true, |
| 201 | hasInterruptedAnimation: false, |
| 202 | }), |
| 203 | ).toBe(false); |
| 204 | }); |
| 205 | |
| 206 | it("animates later resting-state changes and interrupted transitions", () => { |
| 207 | expect( |
| 208 | shouldAnimateComposerRestingTransition({ |
| 209 | hasCompletedInitialLayout: true, |
| 210 | stateChanged: true, |
| 211 | hasInterruptedAnimation: false, |
| 212 | }), |
| 213 | ).toBe(true); |
| 214 | expect( |
| 215 | shouldAnimateComposerRestingTransition({ |
| 216 | hasCompletedInitialLayout: true, |
| 217 | stateChanged: false, |
| 218 | hasInterruptedAnimation: true, |
| 219 | }), |
| 220 | ).toBe(true); |
| 221 | }); |
| 222 | }); |
| 223 | |
| 224 | describe("resolveRestingComposerControlsLayout", () => { |
| 225 | // Picker 140 natural / 96 minimum, plus a 9px separator. Traits 60, |
| 226 | // mode 140, overflow 24, gap 4. |
| 227 | const base = { |
| 228 | gap: 4, |
| 229 | naturalFixedWidth: 149, |
| 230 | minimumFixedWidth: 105, |
| 231 | blockWidths: [60, 140], |
| 232 | overflowWidth: 24, |
| 233 | }; |
| 234 | |
| 235 | it("shows everything when the host has room", () => { |
| 236 | // 149 + 60 + 140 + 4 * 2 = 357 |
| 237 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({ |
| 238 | hiddenCount: 0, |
| 239 | visible: true, |
| 240 | }); |
| 241 | }); |
| 242 | |
| 243 | it("moves trailing blocks into the overflow menu until the rest fits", () => { |
| 244 | // 149 + 60 + 24 + 4 * 2 = 241 |
| 245 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 356 })).toEqual({ |
| 246 | hiddenCount: 1, |
| 247 | visible: true, |
| 248 | }); |
| 249 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({ |
| 250 | hiddenCount: 1, |
| 251 | visible: true, |
| 252 | }); |
| 253 | // 149 + 24 + 4 = 177 |
| 254 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({ |
| 255 | hiddenCount: 2, |
| 256 | visible: true, |
| 257 | }); |
| 258 | }); |
| 259 | |
| 260 | it("shrinks the picker after moving every trailing block into overflow", () => { |
| 261 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 176 })).toEqual({ |
| 262 | hiddenCount: 2, |
| 263 | visible: true, |
| 264 | }); |
| 265 | // 105 + 24 + 4 = 133 |
| 266 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 133 })).toEqual({ |
| 267 | hiddenCount: 2, |
| 268 | visible: true, |
| 269 | }); |
| 270 | }); |
| 271 | |
| 272 | it("hides the whole cluster below the picker's minimum readable width", () => { |
| 273 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 132 })).toEqual({ |
| 274 | hiddenCount: 2, |
| 275 | visible: false, |
| 276 | }); |
| 277 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 0 })).toEqual({ |
| 278 | hiddenCount: 2, |
| 279 | visible: false, |
| 280 | }); |
| 281 | }); |
| 282 | |
| 283 | it("uses the same thresholds while shrinking and growing", () => { |
| 284 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({ |
| 285 | hiddenCount: 2, |
| 286 | visible: true, |
| 287 | }); |
| 288 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({ |
| 289 | hiddenCount: 1, |
| 290 | visible: true, |
| 291 | }); |
| 292 | }); |
| 293 | |
| 294 | it("supports a single leading control without overflow blocks", () => { |
| 295 | expect( |
| 296 | resolveRestingComposerControlsLayout({ |
| 297 | ...base, |
| 298 | naturalFixedWidth: 140, |
| 299 | minimumFixedWidth: 140, |
| 300 | blockWidths: [], |
| 301 | overflowWidth: 0, |
| 302 | hostWidth: 139, |
| 303 | }), |
| 304 | ).toEqual({ hiddenCount: 0, visible: false }); |
| 305 | }); |
| 306 | }); |
| 307 | |
| 308 | describe("context strip labels and resting composer controls", () => { |
| 309 | // Widths captured from a desktop renderer that crashed with React error |
| 310 | // 185. The strip is 724px wide. Its expanded labels need 327px, and the |
| 311 | // rest of its chrome needs 125px. The composer controls sit in the host |
| 312 | // that takes whatever is left. |
| 313 | const stripWidth = 724; |
| 314 | const labelWidth = 327; |
| 315 | const chromeWidth = 125; |
| 316 | const measurement = { |
| 317 | gap: 4, |
| 318 | naturalFixedWidth: 96.3828125 + 5 + 4, |
| 319 | minimumFixedWidth: 52 + 5 + 4, |
| 320 | blockWidths: [130.6953125, 119.671875], |
| 321 | overflowWidth: 28, |
| 322 | }; |
| 323 | const naturalWidth = resolveRestingComposerControlsNaturalWidth(measurement); |
| 324 | |
| 325 | function hostWidth(compact: boolean): number { |
| 326 | return stripWidth - chromeWidth - (compact ? 0 : labelWidth); |
| 327 | } |
| 328 | |
| 329 | it("keeps the labels compact when the full controls only fit beside compact labels", () => { |
| 330 | // Compact labels leave 599px, so the composer shows every block. |
| 331 | const layout = resolveRestingComposerControlsLayout({ |
| 332 | ...measurement, |
| 333 | hostWidth: hostWidth(true), |
| 334 | }); |
| 335 | expect(layout).toEqual({ hiddenCount: 0, visible: true }); |
| 336 | |
| 337 | // The strip reserves the natural controls width, so expanding the |
| 338 | // labels is off the table: 125 + 327 + 364 > 724. |
| 339 | const compact = resolveContextStripLabelsCompact({ |
| 340 | compact: true, |
| 341 | neededWidth: chromeWidth + labelWidth + naturalWidth, |
| 342 | availableWidth: stripWidth, |
| 343 | }); |
| 344 | expect(compact).toBe(true); |
| 345 | |
| 346 | // The next pass sees the same inputs and lands on the same answer. |
| 347 | expect( |
| 348 | resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(compact) }), |
| 349 | ).toEqual(layout); |
| 350 | }); |
| 351 | |
| 352 | it("does not settle when the strip only reserves the visible controls", () => { |
| 353 | // Regression guard for the alternating layout. Reserving only the |
| 354 | // controls left visible after two blocks moved into overflow makes the |
| 355 | // strip expand its labels, which shrinks the host below what the full |
| 356 | // controls need, which hides the blocks again. |
| 357 | const hiddenControlsWidth = 137; |
| 358 | const expands = !resolveContextStripLabelsCompact({ |
| 359 | compact: true, |
| 360 | neededWidth: chromeWidth + labelWidth + hiddenControlsWidth, |
| 361 | availableWidth: stripWidth, |
| 362 | }); |
| 363 | expect(expands).toBe(true); |
| 364 | expect( |
| 365 | resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(false) }), |
| 366 | ).toEqual({ hiddenCount: 2, visible: true }); |
| 367 | }); |
| 368 | }); |
| 369 | |
| 370 | describe("resolveRestingComposerControlsLayout hysteresis", () => { |
| 371 | // Same cluster as above: picker 140 natural / 96 minimum plus a 9px |
| 372 | // separator, traits 60, mode 140, overflow 24, gap 4. |
| 373 | const base = { |
| 374 | gap: 4, |
| 375 | naturalFixedWidth: 149, |
| 376 | minimumFixedWidth: 105, |
| 377 | blockWidths: [60, 140], |
| 378 | overflowWidth: 24, |
| 379 | }; |
| 380 | |
| 381 | it("keeps a block in overflow when re-showing it would leave no slack", () => { |
| 382 | // 149 + 60 + 140 + 4 * 2 = 357 fills the host exactly. |
| 383 | expect( |
| 384 | resolveRestingComposerControlsLayout({ |
| 385 | ...base, |
| 386 | hostWidth: 357, |
| 387 | previous: { hiddenCount: 1, visible: true }, |
| 388 | }), |
| 389 | ).toEqual({ hiddenCount: 1, visible: true }); |
| 390 | }); |
| 391 | |
| 392 | it("re-shows a block once the host clears the slack margin", () => { |
| 393 | expect( |
| 394 | resolveRestingComposerControlsLayout({ |
| 395 | ...base, |
| 396 | hostWidth: 358, |
| 397 | previous: { hiddenCount: 1, visible: true }, |
| 398 | }), |
| 399 | ).toEqual({ hiddenCount: 0, visible: true }); |
| 400 | }); |
| 401 | |
| 402 | it("restores the blocks that fit when the full cluster has no slack", () => { |
| 403 | const partlyRestored = resolveRestingComposerControlsLayout({ |
| 404 | ...base, |
| 405 | hostWidth: 357, |
| 406 | previous: { hiddenCount: 2, visible: true }, |
| 407 | }); |
| 408 | expect(partlyRestored).toEqual({ hiddenCount: 1, visible: true }); |
| 409 | expect( |
| 410 | resolveRestingComposerControlsLayout({ ...base, hostWidth: 357, previous: partlyRestored }), |
| 411 | ).toEqual(partlyRestored); |
| 412 | expect( |
| 413 | resolveRestingComposerControlsLayout({ ...base, hostWidth: 358, previous: partlyRestored }), |
| 414 | ).toEqual({ hiddenCount: 0, visible: true }); |
| 415 | }); |
| 416 | |
| 417 | it("requires slack before partially restoring a cluster", () => { |
| 418 | // One inline block, the picker, and overflow need 149 + 60 + 24 + 8 = 241px. |
| 419 | const previous = { hiddenCount: 2, visible: true }; |
| 420 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241, previous })).toEqual( |
| 421 | previous, |
| 422 | ); |
| 423 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 242, previous })).toEqual({ |
| 424 | hiddenCount: 1, |
| 425 | visible: true, |
| 426 | }); |
| 427 | }); |
| 428 | |
| 429 | it("still resolves from scratch when there is no previous layout", () => { |
| 430 | expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({ |
| 431 | hiddenCount: 0, |
| 432 | visible: true, |
| 433 | }); |
| 434 | }); |
| 435 | |
| 436 | it("settles when the measured picker width jitters below a pixel", () => { |
| 437 | // Regression guard for React error #185 ("Maximum update depth |
| 438 | // exceeded"). The composer re-measures on every render, so the picker's |
| 439 | // recovered natural width can land a fraction of a pixel apart between |
| 440 | // renders. Without hysteresis that flips hiddenCount forever. |
| 441 | let layout = { hiddenCount: 0, visible: true }; |
| 442 | const settled: string[] = []; |
| 443 | for (let index = 0; index < 10; index += 1) { |
| 444 | layout = resolveRestingComposerControlsLayout({ |
| 445 | ...base, |
| 446 | // The picker's recovered natural width lands a half pixel apart |
| 447 | // between renders. |
| 448 | naturalFixedWidth: index % 2 === 0 ? 149 : 149.5, |
| 449 | hostWidth: 357, |
| 450 | previous: layout, |
| 451 | }); |
| 452 | if (index >= 2) settled.push(`${layout.hiddenCount}:${layout.visible}`); |
| 453 | } |
| 454 | expect(new Set(settled).size).toBe(1); |
| 455 | }); |
| 456 | |
| 457 | it("keeps the cluster hidden until its minimum width clears the slack", () => { |
| 458 | // 105 + 24 + 4 = 133 is the exact minimum for the hidden cluster. |
| 459 | expect( |
| 460 | resolveRestingComposerControlsLayout({ |
| 461 | ...base, |
| 462 | hostWidth: 133, |
| 463 | previous: { hiddenCount: 2, visible: false }, |
| 464 | }), |
| 465 | ).toEqual({ hiddenCount: 2, visible: false }); |
| 466 | expect( |
| 467 | resolveRestingComposerControlsLayout({ |
| 468 | ...base, |
| 469 | hostWidth: 134, |
| 470 | previous: { hiddenCount: 2, visible: false }, |
| 471 | }), |
| 472 | ).toEqual({ hiddenCount: 2, visible: true }); |
| 473 | }); |
| 474 | }); |
| 475 | |
| 476 | describe("resolveScrollToEndClearance", () => { |
| 477 | it("removes the side tab gap in both composer states while clearing overlapping attachments", () => { |
| 478 | for (const overlayHeight of [120, 214]) { |
| 479 | const layout = { |
| 480 | overlayHeight, |
| 481 | mainSurfaceTop: 534, |
| 482 | button: { left: 340, right: 460 }, |
| 483 | attachments: [{ top: 500, left: 600, right: 700 }], |
| 484 | }; |
| 485 | expect(resolveScrollToEndClearance(layout)).toBe(overlayHeight - 34); |
| 486 | expect(resolveScrollToEndClearance({ ...layout, attachments: [] })).toBe(overlayHeight); |
| 487 | expect( |
| 488 | resolveScrollToEndClearance({ |
| 489 | ...layout, |
| 490 | attachments: [...layout.attachments, { top: 500, left: 100, right: 700 }], |
| 491 | }), |
| 492 | ).toBe(overlayHeight); |
| 493 | expect(resolveScrollToEndClearance({ ...layout, button: { left: 590, right: 710 } })).toBe( |
| 494 | overlayHeight, |
| 495 | ); |
| 496 | } |
| 497 | }); |
| 498 | }); |
| 499 | |
| 500 | describe("progressive composer controls", () => { |
| 501 | const measurement = { |
| 502 | gap: 4, |
| 503 | naturalFixedWidth: 140, |
| 504 | minimumFixedWidth: 80, |
| 505 | blockWidths: [80, 140], |
| 506 | iconOnlyBlockWidths: [40, 60], |
| 507 | overflowWidth: 24, |
| 508 | }; |
| 509 | |
| 510 | it("keeps labels while they fit and removes trailing labels before controls", () => { |
| 511 | for (const [hostWidth, iconOnlyCount, hiddenCount] of [ |
| 512 | [368, 0, 0], |
| 513 | [367, 1, 0], |
| 514 | [288, 1, 0], |
| 515 | [287, 2, 0], |
| 516 | [248, 2, 0], |
| 517 | [247, 2, 1], |
| 518 | [211, 2, 2], |
| 519 | ] as const) { |
| 520 | expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth })).toEqual({ |
| 521 | hiddenCount, |
| 522 | iconOnlyCount, |
| 523 | visible: true, |
| 524 | }); |
| 525 | } |
| 526 | }); |
| 527 | |
| 528 | it("requires slack to restore labels and controls", () => { |
| 529 | for (const [hostWidth, previous, promoted] of [ |
| 530 | [ |
| 531 | 368, |
| 532 | { hiddenCount: 0, iconOnlyCount: 1, visible: true }, |
| 533 | { hiddenCount: 0, iconOnlyCount: 0, visible: true }, |
| 534 | ], |
| 535 | [ |
| 536 | 288, |
| 537 | { hiddenCount: 0, iconOnlyCount: 2, visible: true }, |
| 538 | { hiddenCount: 0, iconOnlyCount: 1, visible: true }, |
| 539 | ], |
| 540 | [ |
| 541 | 248, |
| 542 | { hiddenCount: 1, iconOnlyCount: 2, visible: true }, |
| 543 | { hiddenCount: 0, iconOnlyCount: 2, visible: true }, |
| 544 | ], |
| 545 | ] as const) { |
| 546 | expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth, previous })).toEqual( |
| 547 | previous, |
| 548 | ); |
| 549 | expect( |
| 550 | resolveRestingComposerControlsLayout({ |
| 551 | ...measurement, |
| 552 | hostWidth: hostWidth + 1, |
| 553 | previous, |
| 554 | }), |
| 555 | ).toEqual(promoted); |
| 556 | } |
| 557 | }); |
| 558 | |
| 559 | it("settles through fractional label-width changes at each threshold", () => { |
| 560 | for (const hostWidth of [368, 288, 248]) { |
| 561 | let previous = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); |
| 562 | for (let index = 0; index < 10; index += 1) { |
| 563 | const next = resolveRestingComposerControlsLayout({ |
| 564 | ...measurement, |
| 565 | hostWidth, |
| 566 | previous, |
| 567 | naturalFixedWidth: 140 + (index % 2) * 0.5, |
| 568 | }); |
| 569 | if (index > 1) expect(next).toEqual(previous); |
| 570 | previous = next; |
| 571 | } |
| 572 | } |
| 573 | }); |
| 574 | }); |