constraints.client.spec.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. /**
  2. * Interaction limits: the four-pane cap, the five dock regions, and divider
  3. * clamping. The model itself stays unbounded; these rules gate dispatch.
  4. */
  5. import { describe, expect, it } from 'vitest'
  6. import { applyOp } from '../src/engine/operations.ts'
  7. import {
  8. canSplit, clampSizes, DOCK_ZONES, dockPaneCount, MAX_DOCK_PANES, MIN_PANE_FRACTION, zoneAt, zoneSplit,
  9. } from '../src/engine/constraints.ts'
  10. import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
  11. import { seedTab } from './fixtures.client.ts'
  12. import { getPane } from '../src/engine/tree.ts'
  13. import type { LayoutState } from '../src/contract/types.ts'
  14. /** Split the root pane repeatedly until the docked grid holds `count` panes. */
  15. function grid(count: number): LayoutState {
  16. const minter = createIdMinter()
  17. let state = createInitialState(minter, seedTab)
  18. while (dockPaneCount(state) < count) {
  19. state = applyOp(state, {
  20. type: 'split',
  21. paneId: state.activePaneId,
  22. axis: 'row',
  23. direction: 'after',
  24. newPaneId: minter.next('pane'),
  25. newSplitId: minter.next('split'),
  26. }).state
  27. }
  28. return state
  29. }
  30. describe('pane cap', () => {
  31. it('allows splitting up to four docked panes and no further', () => {
  32. expect(MAX_DOCK_PANES).toBe(4)
  33. expect(canSplit(createInitialState(createIdMinter(), seedTab))).toBe(true)
  34. expect(dockPaneCount(grid(4))).toBe(4)
  35. expect(canSplit(grid(4))).toBe(false)
  36. expect(canSplit(grid(3))).toBe(true)
  37. })
  38. it('ignores floating panes when counting the grid', () => {
  39. const minter = createIdMinter()
  40. const state = createInitialState(minter, seedTab)
  41. const guideTabId = getPane(state, state.rootId).tabs[0]
  42. if (guideTabId === undefined) throw new Error('fixture: no guide tab')
  43. const floated = applyOp(state, {
  44. type: 'float', tabId: guideTabId, newPaneId: minter.next('float'), rect: { x: 0, y: 0, width: 10, height: 10 },
  45. }).state
  46. expect(floated.floats).toHaveLength(1)
  47. expect(dockPaneCount(floated)).toBe(1)
  48. expect(canSplit(floated)).toBe(true)
  49. })
  50. })
  51. describe('dock regions', () => {
  52. it('offers five regions', () => {
  53. expect([...DOCK_ZONES].sort()).toEqual(['bottom', 'center', 'left', 'right', 'top'])
  54. })
  55. it('reads the centre of a pane as a move, not a split', () => {
  56. expect(zoneAt(0.5, 0.5)).toBe('center')
  57. expect(zoneSplit('center')).toBeUndefined()
  58. })
  59. it('reads each edge band as its own region', () => {
  60. expect(zoneAt(0.05, 0.5)).toBe('left')
  61. expect(zoneAt(0.95, 0.5)).toBe('right')
  62. expect(zoneAt(0.5, 0.05)).toBe('top')
  63. expect(zoneAt(0.5, 0.95)).toBe('bottom')
  64. })
  65. it('takes the closest edge in a corner', () => {
  66. expect(zoneAt(0.02, 0.10)).toBe('left')
  67. expect(zoneAt(0.10, 0.02)).toBe('top')
  68. })
  69. it('maps regions to the split they create', () => {
  70. expect(zoneSplit('left')).toEqual({ axis: 'row', direction: 'before' })
  71. expect(zoneSplit('right')).toEqual({ axis: 'row', direction: 'after' })
  72. expect(zoneSplit('top')).toEqual({ axis: 'column', direction: 'before' })
  73. expect(zoneSplit('bottom')).toEqual({ axis: 'column', direction: 'after' })
  74. })
  75. })
  76. describe('divider clamping', () => {
  77. it('honours an embedder\'s twenty-percent minimum', () => {
  78. expect(clampSizes([0.01, 0.99], 0.2)).toEqual([0.2, 0.8])
  79. expect(clampSizes([0.99, 0.01], 0.2)).toEqual([0.8, 0.2])
  80. expect(clampSizes([0.5, 0.5], 0.2)).toEqual([0.5, 0.5])
  81. })
  82. it('keeps sizes summing to one', () => {
  83. const sizes = clampSizes([0.6, 0.4])
  84. expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
  85. expect(sizes[0]).toBeCloseTo(0.6)
  86. })
  87. it('lifts a pane dragged under the minimum', () => {
  88. const sizes = clampSizes([0.01, 0.99])
  89. expect(sizes[0]).toBeGreaterThanOrEqual(MIN_PANE_FRACTION * 0.9)
  90. expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
  91. })
  92. it('accepts unnormalized pixel-like input', () => {
  93. const sizes = clampSizes([300, 100])
  94. expect(sizes[0]).toBeCloseTo(0.75)
  95. expect(sizes[1]).toBeCloseTo(0.25)
  96. })
  97. it('treats a negative size as zero and shares an all-zero input equally', () => {
  98. expect(clampSizes([])).toEqual([])
  99. const lifted = clampSizes([-1, 1])
  100. expect(lifted[0]).toBeCloseTo(MIN_PANE_FRACTION)
  101. expect(lifted[1]).toBeCloseTo(1 - MIN_PANE_FRACTION)
  102. expect(clampSizes([0, 0])).toEqual([0.5, 0.5])
  103. })
  104. it('pins a share that drops under the minimum only after the first pass', () => {
  105. // 0.13 clears the floor until the first pin takes its 0.11 from everyone.
  106. const sizes = clampSizes([0.01, 0.13, 0.86])
  107. expect(sizes[0]).toBeCloseTo(MIN_PANE_FRACTION)
  108. expect(sizes[1]).toBeCloseTo(MIN_PANE_FRACTION)
  109. expect(sizes[2]).toBeCloseTo(1 - 2 * MIN_PANE_FRACTION)
  110. })
  111. it('never lifts a pane above an equal share when the minimum would not fit them all', () => {
  112. const sizes = clampSizes(Array.from({ length: 10 }, (_, index) => (index === 0 ? 0.001 : 1)))
  113. expect(sizes[0]).toBeCloseTo(0.1)
  114. expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
  115. })
  116. })