import { describe, expect, it } from "vite-plus/test"; import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, COMPOSER_RESTING_EXPANSION_MIN_PX, getRestingComposerImagePreviewCounts, resolveComposerTimelineInset, resolveScrollToEndClearance, resolveRestingComposerControlsLayout, resolveRestingComposerControlsNaturalWidth, shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, shouldUseRestingComposerLayout, } from "./composerFooterLayout"; describe("getRestingComposerImagePreviewCounts", () => { it("shows at most three thumbnails and counts the remainder", () => { expect(getRestingComposerImagePreviewCounts(0)).toEqual({ visibleCount: 0, overflowCount: 0, }); expect(getRestingComposerImagePreviewCounts(3)).toEqual({ visibleCount: 3, overflowCount: 0, }); expect(getRestingComposerImagePreviewCounts(7)).toEqual({ visibleCount: 3, overflowCount: 4, }); }); }); describe("shouldUseCompactComposerFooter", () => { it("stays expanded without a measured width", () => { expect(shouldUseCompactComposerFooter(null)).toBe(false); }); it("switches to compact mode below the breakpoint", () => { expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX - 1)).toBe(true); }); it("stays expanded at and above the breakpoint", () => { expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX)).toBe(false); expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(false); }); it("uses a higher breakpoint for wide action states", () => { expect( shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1, { hasWideActions: true, }), ).toBe(true); expect( shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, { hasWideActions: true, }), ).toBe(false); }); }); describe("shouldUseCompactComposerPrimaryActions", () => { it("matches the wide footer breakpoint", () => { expect( shouldUseCompactComposerPrimaryActions( COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1, { hasWideActions: true }, ), ).toBe(true); expect( shouldUseCompactComposerPrimaryActions(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, { hasWideActions: true, }), ).toBe(false); }); }); describe("resolveComposerTimelineInset", () => { it("follows the expanded overlay height", () => { expect( resolveComposerTimelineInset({ currentInset: 160, overlayHeight: 140, isResting: false }), ).toBe(140); }); it("keeps a larger expanded reservation while resting", () => { expect( resolveComposerTimelineInset({ currentInset: 200, overlayHeight: 60, isResting: true }), ).toBe(200); }); it("reserves the empty expansion when no larger height is known", () => { expect( resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 60, isResting: true }), ).toBe(60 + COMPOSER_RESTING_EXPANSION_MIN_PX); }); }); describe("shouldUseRestingComposerLayout", () => { const resting = { isExistingThread: true, isMobileViewport: false, isScrollCollapsed: true, hasExpandedChrome: false, hasMultilinePrompt: false, timelineOverflows: true, }; it("uses the resting layout after a timeline scroll", () => { expect(shouldUseRestingComposerLayout(resting)).toBe(true); }); it("keeps the composer expanded until the timeline is scrolled", () => { expect(shouldUseRestingComposerLayout({ ...resting, isScrollCollapsed: false })).toBe(false); }); it("keeps the composer expanded while the timeline fits above it", () => { expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false); }); it("keeps new-thread composers expanded", () => { expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); }); it("leaves responsive mobile on its existing collapse path", () => { expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false); }); it("keeps drawers and composer-owned menus expanded", () => { expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false); }); it.each([false, true])( "keeps multiline drafts expanded when scroll collapsed is %s", (isScrollCollapsed) => { expect( shouldUseRestingComposerLayout({ ...resting, hasMultilinePrompt: true, isScrollCollapsed, }), ).toBe(false); }, ); }); describe("shouldAnimateComposerRestingTransition", () => { it("does not animate layout measurements that settle during initial mount", () => { expect( shouldAnimateComposerRestingTransition({ hasCompletedInitialLayout: false, stateChanged: true, hasInterruptedAnimation: false, }), ).toBe(false); }); it("animates later resting-state changes and interrupted transitions", () => { expect( shouldAnimateComposerRestingTransition({ hasCompletedInitialLayout: true, stateChanged: true, hasInterruptedAnimation: false, }), ).toBe(true); expect( shouldAnimateComposerRestingTransition({ hasCompletedInitialLayout: true, stateChanged: false, hasInterruptedAnimation: true, }), ).toBe(true); }); }); describe("resolveRestingComposerControlsLayout", () => { // Picker 140 natural / 96 minimum, plus a 9px separator. Traits 60, // mode 140, overflow 24, gap 4. const base = { gap: 4, naturalFixedWidth: 149, minimumFixedWidth: 105, blockWidths: [60, 140], overflowWidth: 24, }; it("shows everything when the host has room", () => { // 149 + 60 + 140 + 4 * 2 = 357 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({ hiddenCount: 0, visible: true, }); }); it("moves trailing blocks into the overflow menu until the rest fits", () => { // 149 + 60 + 24 + 4 * 2 = 241 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 356 })).toEqual({ hiddenCount: 1, visible: true, }); expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({ hiddenCount: 1, visible: true, }); // 149 + 24 + 4 = 177 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({ hiddenCount: 2, visible: true, }); }); it("shrinks the picker after moving every trailing block into overflow", () => { expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 176 })).toEqual({ hiddenCount: 2, visible: true, }); // 105 + 24 + 4 = 133 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 133 })).toEqual({ hiddenCount: 2, visible: true, }); }); it("hides the whole cluster below the picker's minimum readable width", () => { expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 132 })).toEqual({ hiddenCount: 2, visible: false, }); expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 0 })).toEqual({ hiddenCount: 2, visible: false, }); }); it("uses the same thresholds while shrinking and growing", () => { expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({ hiddenCount: 2, visible: true, }); expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({ hiddenCount: 1, visible: true, }); }); it("supports a single leading control without overflow blocks", () => { expect( resolveRestingComposerControlsLayout({ ...base, naturalFixedWidth: 140, minimumFixedWidth: 140, blockWidths: [], overflowWidth: 0, hostWidth: 139, }), ).toEqual({ hiddenCount: 0, visible: false }); }); }); describe("context strip labels and resting composer controls", () => { // Widths captured from a desktop renderer that crashed with React error // 185. The strip is 724px wide. Its expanded labels need 327px, and the // rest of its chrome needs 125px. The composer controls sit in the host // that takes whatever is left. const stripWidth = 724; const labelWidth = 327; const chromeWidth = 125; const measurement = { gap: 4, naturalFixedWidth: 96.3828125 + 5 + 4, minimumFixedWidth: 52 + 5 + 4, blockWidths: [130.6953125, 119.671875], overflowWidth: 28, }; const naturalWidth = resolveRestingComposerControlsNaturalWidth(measurement); function hostWidth(compact: boolean): number { return stripWidth - chromeWidth - (compact ? 0 : labelWidth); } it("keeps the labels compact when the full controls only fit beside compact labels", () => { // Compact labels leave 599px, so the composer shows every block. const layout = resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(true), }); expect(layout).toEqual({ hiddenCount: 0, visible: true }); // The strip reserves the natural controls width, so expanding the // labels is off the table: 125 + 327 + 364 > 724. const compact = resolveContextStripLabelsCompact({ compact: true, neededWidth: chromeWidth + labelWidth + naturalWidth, availableWidth: stripWidth, }); expect(compact).toBe(true); // The next pass sees the same inputs and lands on the same answer. expect( resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(compact) }), ).toEqual(layout); }); it("does not settle when the strip only reserves the visible controls", () => { // Regression guard for the alternating layout. Reserving only the // controls left visible after two blocks moved into overflow makes the // strip expand its labels, which shrinks the host below what the full // controls need, which hides the blocks again. const hiddenControlsWidth = 137; const expands = !resolveContextStripLabelsCompact({ compact: true, neededWidth: chromeWidth + labelWidth + hiddenControlsWidth, availableWidth: stripWidth, }); expect(expands).toBe(true); expect( resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(false) }), ).toEqual({ hiddenCount: 2, visible: true }); }); }); describe("resolveRestingComposerControlsLayout hysteresis", () => { // Same cluster as above: picker 140 natural / 96 minimum plus a 9px // separator, traits 60, mode 140, overflow 24, gap 4. const base = { gap: 4, naturalFixedWidth: 149, minimumFixedWidth: 105, blockWidths: [60, 140], overflowWidth: 24, }; it("keeps a block in overflow when re-showing it would leave no slack", () => { // 149 + 60 + 140 + 4 * 2 = 357 fills the host exactly. expect( resolveRestingComposerControlsLayout({ ...base, hostWidth: 357, previous: { hiddenCount: 1, visible: true }, }), ).toEqual({ hiddenCount: 1, visible: true }); }); it("re-shows a block once the host clears the slack margin", () => { expect( resolveRestingComposerControlsLayout({ ...base, hostWidth: 358, previous: { hiddenCount: 1, visible: true }, }), ).toEqual({ hiddenCount: 0, visible: true }); }); it("restores the blocks that fit when the full cluster has no slack", () => { const partlyRestored = resolveRestingComposerControlsLayout({ ...base, hostWidth: 357, previous: { hiddenCount: 2, visible: true }, }); expect(partlyRestored).toEqual({ hiddenCount: 1, visible: true }); expect( resolveRestingComposerControlsLayout({ ...base, hostWidth: 357, previous: partlyRestored }), ).toEqual(partlyRestored); expect( resolveRestingComposerControlsLayout({ ...base, hostWidth: 358, previous: partlyRestored }), ).toEqual({ hiddenCount: 0, visible: true }); }); it("requires slack before partially restoring a cluster", () => { // One inline block, the picker, and overflow need 149 + 60 + 24 + 8 = 241px. const previous = { hiddenCount: 2, visible: true }; expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241, previous })).toEqual( previous, ); expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 242, previous })).toEqual({ hiddenCount: 1, visible: true, }); }); it("still resolves from scratch when there is no previous layout", () => { expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({ hiddenCount: 0, visible: true, }); }); it("settles when the measured picker width jitters below a pixel", () => { // Regression guard for React error #185 ("Maximum update depth // exceeded"). The composer re-measures on every render, so the picker's // recovered natural width can land a fraction of a pixel apart between // renders. Without hysteresis that flips hiddenCount forever. let layout = { hiddenCount: 0, visible: true }; const settled: string[] = []; for (let index = 0; index < 10; index += 1) { layout = resolveRestingComposerControlsLayout({ ...base, // The picker's recovered natural width lands a half pixel apart // between renders. naturalFixedWidth: index % 2 === 0 ? 149 : 149.5, hostWidth: 357, previous: layout, }); if (index >= 2) settled.push(`${layout.hiddenCount}:${layout.visible}`); } expect(new Set(settled).size).toBe(1); }); it("keeps the cluster hidden until its minimum width clears the slack", () => { // 105 + 24 + 4 = 133 is the exact minimum for the hidden cluster. expect( resolveRestingComposerControlsLayout({ ...base, hostWidth: 133, previous: { hiddenCount: 2, visible: false }, }), ).toEqual({ hiddenCount: 2, visible: false }); expect( resolveRestingComposerControlsLayout({ ...base, hostWidth: 134, previous: { hiddenCount: 2, visible: false }, }), ).toEqual({ hiddenCount: 2, visible: true }); }); }); describe("resolveScrollToEndClearance", () => { it("removes the side tab gap in both composer states while clearing overlapping attachments", () => { for (const overlayHeight of [120, 214]) { const layout = { overlayHeight, mainSurfaceTop: 534, button: { left: 340, right: 460 }, attachments: [{ top: 500, left: 600, right: 700 }], }; expect(resolveScrollToEndClearance(layout)).toBe(overlayHeight - 34); expect(resolveScrollToEndClearance({ ...layout, attachments: [] })).toBe(overlayHeight); expect( resolveScrollToEndClearance({ ...layout, attachments: [...layout.attachments, { top: 500, left: 100, right: 700 }], }), ).toBe(overlayHeight); expect(resolveScrollToEndClearance({ ...layout, button: { left: 590, right: 710 } })).toBe( overlayHeight, ); } }); });