131 lines
5.0 KiB
TypeScript
131 lines
5.0 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
$composerPopoutZones,
|
|
clampPopoutPosition,
|
|
getComposerPopoutZone,
|
|
POPOUT_WIDTH_REM,
|
|
type PopoutBounds,
|
|
pruneComposerPopoutZones,
|
|
setComposerPopoutPosition,
|
|
setComposerPoppedOut
|
|
} from './composer-popout'
|
|
|
|
// jsdom's window is 1024x768; every expectation below is relative to that.
|
|
const VW = 1024
|
|
const VH = 768
|
|
|
|
const BOX = { height: 56, width: 320 }
|
|
|
|
// A split layout: chat on the right half, under a 40px header.
|
|
const RIGHT_HALF: PopoutBounds = { bottom: VH, left: VW / 2, right: VW, top: 40 }
|
|
// The same drag intent, viewed from the left half.
|
|
const LEFT_HALF: PopoutBounds = { bottom: VH, left: 0, right: VW / 2, top: 40 }
|
|
|
|
const LEFT_ZONE = 'g-left'
|
|
const RIGHT_ZONE = 'g-right'
|
|
|
|
describe('clampPopoutPosition', () => {
|
|
it('keeps the whole box inside its surface, not just its corner', () => {
|
|
const { bottom, right } = clampPopoutPosition({ bottom: 24, right: 24 }, BOX, RIGHT_HALF)
|
|
|
|
// Insets are viewport-relative, so convert back to an absolute rect.
|
|
const boxRight = VW - right
|
|
const boxLeft = boxRight - BOX.width
|
|
const boxBottom = VH - bottom
|
|
const boxTop = boxBottom - BOX.height
|
|
|
|
expect(boxLeft).toBeGreaterThanOrEqual(RIGHT_HALF.left)
|
|
expect(boxRight).toBeLessThanOrEqual(RIGHT_HALF.right)
|
|
expect(boxTop).toBeGreaterThanOrEqual(RIGHT_HALF.top)
|
|
expect(boxBottom).toBeLessThanOrEqual(RIGHT_HALF.bottom)
|
|
})
|
|
|
|
it('pulls an out-of-bounds intent back into the surface', () => {
|
|
// Dragged far left of this surface (a position that belongs to the other
|
|
// half of the split) — it must land inside, not off the edge.
|
|
const { right } = clampPopoutPosition({ bottom: 24, right: 900 }, BOX, RIGHT_HALF)
|
|
|
|
expect(VW - right - BOX.width).toBeGreaterThanOrEqual(RIGHT_HALF.left)
|
|
})
|
|
|
|
it('is pure — the same intent yields a different placement per surface', () => {
|
|
const intent = { bottom: 24, right: 24 }
|
|
|
|
const onRight = clampPopoutPosition(intent, BOX, RIGHT_HALF)
|
|
const onLeft = clampPopoutPosition(intent, BOX, LEFT_HALF)
|
|
|
|
// Right half honors the intent (it fits); left half has to push the box
|
|
// inward. Two surfaces, two placements, one unchanged intent — this is what
|
|
// lets keep-alive tabs share a zone's position without overwriting it.
|
|
expect(onRight).not.toEqual(onLeft)
|
|
expect(clampPopoutPosition(intent, BOX, RIGHT_HALF)).toEqual(onRight)
|
|
})
|
|
|
|
it('falls back to the full window when the surface has no measured area', () => {
|
|
const { bottom, right } = clampPopoutPosition({ bottom: 24, right: 24 }, BOX, undefined)
|
|
|
|
expect(bottom).toBe(24)
|
|
expect(right).toBe(24)
|
|
})
|
|
|
|
it('assumes the compact float width when the box is unmeasured', () => {
|
|
// Pre-layout (peel-off, restore) there is no rect yet; the fallback must
|
|
// still leave the box grabbable rather than clamping it to a zero-width slot.
|
|
const { right } = clampPopoutPosition({ bottom: 24, right: 5000 }, undefined, RIGHT_HALF)
|
|
|
|
expect(VW - right - POPOUT_WIDTH_REM * 16).toBeGreaterThanOrEqual(RIGHT_HALF.left)
|
|
})
|
|
})
|
|
|
|
describe('pop-out state is scoped to a layout zone', () => {
|
|
beforeEach(() => {
|
|
$composerPopoutZones.set({})
|
|
})
|
|
|
|
it('floats one zone without touching its neighbor', () => {
|
|
setComposerPoppedOut(LEFT_ZONE, true)
|
|
|
|
// Tabs in the left zone float; the right split stays docked. This is the
|
|
// whole point of keying by zone: popping out on the left must not fling a
|
|
// composer out of every other pane.
|
|
expect(getComposerPopoutZone(LEFT_ZONE).poppedOut).toBe(true)
|
|
expect(getComposerPopoutZone(RIGHT_ZONE).poppedOut).toBe(false)
|
|
})
|
|
|
|
it('keeps each zone position independent', () => {
|
|
setComposerPopoutPosition(LEFT_ZONE, { bottom: 100, right: 100 })
|
|
setComposerPopoutPosition(RIGHT_ZONE, { bottom: 24, right: 24 })
|
|
|
|
expect(getComposerPopoutZone(LEFT_ZONE).position).toEqual({ bottom: 100, right: 100 })
|
|
expect(getComposerPopoutZone(RIGHT_ZONE).position).toEqual({ bottom: 24, right: 24 })
|
|
})
|
|
|
|
it('reports the default for a zone nobody has touched', () => {
|
|
// Every tab in a fresh zone reads the same default, so a new split starts
|
|
// docked at the default corner rather than inheriting a neighbor's drag.
|
|
expect(getComposerPopoutZone('g-unseen')).toEqual({ poppedOut: false, position: { bottom: 24, right: 24 } })
|
|
})
|
|
|
|
it('does not let one zone float seed the zones split after it', () => {
|
|
setComposerPoppedOut(LEFT_ZONE, true)
|
|
setComposerPopoutPosition(LEFT_ZONE, { bottom: 200, right: 200 })
|
|
|
|
// A zone created later is its own thing — it must not inherit the float
|
|
// state of whichever zone happened to be touched first.
|
|
expect(getComposerPopoutZone('g-split-later')).toEqual({
|
|
poppedOut: false,
|
|
position: { bottom: 24, right: 24 }
|
|
})
|
|
})
|
|
|
|
it('drops state for zones that no longer exist', () => {
|
|
setComposerPoppedOut(LEFT_ZONE, true)
|
|
setComposerPoppedOut(RIGHT_ZONE, true)
|
|
|
|
pruneComposerPopoutZones([LEFT_ZONE])
|
|
|
|
expect(Object.keys($composerPopoutZones.get())).toEqual([LEFT_ZONE])
|
|
})
|
|
})
|