layout-store.client.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. // @vitest-environment jsdom
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { createLayoutStore } from '../src/client/stores.ts'
  4. import type { MainPanelId } from '../src/client/service.ts'
  5. beforeEach(() => { vi.stubGlobal('innerWidth', 1920) })
  6. afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
  7. describe('createLayoutStore', () => {
  8. it('starts with the default sidebar and no right panel preference', () => {
  9. const { store } = createLayoutStore().create()
  10. expect(store.getSnapshot()).toEqual({
  11. panelInfo: { activePanelId: null },
  12. layoutInfo: {
  13. sidebar: 280,
  14. viewportWidth: 1920,
  15. narrowExpanded: false,
  16. rightbar: null,
  17. rightbarShown: false,
  18. rightbarTrack: false,
  19. rightbarFullscreen: false,
  20. rightbarInstant: false,
  21. },
  22. })
  23. })
  24. it('creates independent instances without browser persistence', () => {
  25. const write = vi.spyOn(Storage.prototype, 'setItem')
  26. const a = createLayoutStore().create()
  27. const b = createLayoutStore().create()
  28. a.actions.setSidebar(400)
  29. a.actions.openRightbar(true, false)
  30. expect(b.store.getSnapshot().layoutInfo.sidebar).toBe(280)
  31. expect(b.store.getSnapshot().layoutInfo.rightbar).toBeNull()
  32. expect(write).not.toHaveBeenCalled()
  33. })
  34. it('clamps the sidebar to 264–420px', () => {
  35. const { store, actions } = createLayoutStore().create()
  36. actions.setSidebar(1)
  37. expect(store.getSnapshot().layoutInfo.sidebar).toBe(264)
  38. actions.setSidebar(9999)
  39. expect(store.getSnapshot().layoutInfo.sidebar).toBe(420)
  40. })
  41. it('toggles the wide sidebar between closed and default width', () => {
  42. const { store, actions } = createLayoutStore().create()
  43. actions.setSidebar(400)
  44. actions.toggleSidebar()
  45. expect(store.getSnapshot().layoutInfo.sidebar).toBe(0)
  46. actions.toggleSidebar()
  47. expect(store.getSnapshot().layoutInfo.sidebar).toBe(280)
  48. })
  49. it('keeps the sidebar preference while toggling its narrow override', () => {
  50. const { store, actions } = createLayoutStore().create()
  51. actions.setSidebar(400)
  52. actions.setViewportWidth(980)
  53. actions.toggleSidebar()
  54. expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true })
  55. actions.toggleSidebar()
  56. expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false })
  57. })
  58. it('clears the manual override only when crossing 1024px', () => {
  59. const { store, actions } = createLayoutStore().create()
  60. actions.setViewportWidth(980)
  61. actions.toggleSidebar()
  62. actions.setViewportWidth(980)
  63. actions.setViewportWidth(1023)
  64. expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true)
  65. actions.setViewportWidth(1024)
  66. expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
  67. actions.setViewportWidth(980)
  68. expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
  69. })
  70. })
  71. describe('main panel selection', () => {
  72. const panelA = 'panel-a' as MainPanelId
  73. const panelB = 'panel-b' as MainPanelId
  74. it('changes only panelInfo when switching panels and returning to the Conversation', () => {
  75. const { store, actions } = createLayoutStore().create()
  76. actions.setSidebar(400)
  77. actions.openRightbar(true, true)
  78. actions.closeRightbar()
  79. const layoutInfo = store.getSnapshot().layoutInfo
  80. for (const activePanelId of [panelA, panelB, null]) {
  81. const previousPanelInfo = store.getSnapshot().panelInfo
  82. actions.selectPanel(activePanelId)
  83. expect(store.getSnapshot().panelInfo).toEqual({ activePanelId })
  84. expect(store.getSnapshot().panelInfo).not.toBe(previousPanelInfo)
  85. expect(store.getSnapshot().layoutInfo).toBe(layoutInfo)
  86. }
  87. })
  88. it('keeps the complete snapshot when selecting the current panel again', () => {
  89. const { store, actions } = createLayoutStore().create()
  90. actions.selectPanel(panelA)
  91. const selected = store.getSnapshot()
  92. actions.selectPanel(panelA)
  93. expect(store.getSnapshot()).toBe(selected)
  94. expect(store.getSnapshot().panelInfo.activePanelId).toBe(panelA)
  95. })
  96. it('returns to the Conversation only when the selected main registration disappears', () => {
  97. const { store, actions } = createLayoutStore().create()
  98. const initial = store.getSnapshot()
  99. actions.retainMainPanels([])
  100. expect(store.getSnapshot()).toBe(initial)
  101. actions.selectPanel(panelA)
  102. const selected = store.getSnapshot()
  103. actions.retainMainPanels(['conversation', panelA, panelB])
  104. expect(store.getSnapshot()).toBe(selected)
  105. actions.retainMainPanels(['conversation', panelB])
  106. expect(store.getSnapshot().panelInfo).toEqual({ activePanelId: null })
  107. expect(store.getSnapshot().layoutInfo).toBe(selected.layoutInfo)
  108. })
  109. it.each(['setSidebar', 'toggleSidebar', 'setViewportWidth', 'setRightbar', 'openRightbar', 'closeRightbar'] as const)(
  110. 'preserves panelInfo identity when %s changes layoutInfo', (action) => {
  111. const { store, actions } = createLayoutStore().create()
  112. actions.selectPanel(panelA)
  113. if (action === 'closeRightbar') actions.openRightbar(true, true)
  114. const previous = store.getSnapshot()
  115. switch (action) {
  116. case 'setSidebar': actions.setSidebar(400); break
  117. case 'toggleSidebar': actions.toggleSidebar(); break
  118. case 'setViewportWidth': actions.setViewportWidth(980); break
  119. case 'setRightbar': actions.setRightbar(500); break
  120. case 'openRightbar': actions.openRightbar(true, true); break
  121. case 'closeRightbar': actions.closeRightbar(); break
  122. }
  123. expect(store.getSnapshot().panelInfo).toBe(previous.panelInfo)
  124. expect(store.getSnapshot().layoutInfo).not.toBe(previous.layoutInfo)
  125. },
  126. )
  127. })
  128. describe('right panel', () => {
  129. it('initializes at 45% of the latest frame only on first opening', () => {
  130. const { store, actions } = createLayoutStore().create()
  131. actions.setViewportWidth(1000)
  132. expect(store.getSnapshot().layoutInfo.rightbar).toBeNull()
  133. actions.openRightbar(true, false)
  134. expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
  135. actions.setViewportWidth(2000)
  136. actions.openRightbar(true, true)
  137. expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
  138. actions.closeRightbar()
  139. actions.openRightbar(true, false)
  140. expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
  141. })
  142. it('keeps track and fullscreen reports independent and clears both on close', () => {
  143. const { store, actions } = createLayoutStore().create()
  144. actions.openRightbar(true, false)
  145. expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false })
  146. actions.openRightbar(true, true)
  147. expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true })
  148. actions.openRightbar(false, true)
  149. expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true })
  150. actions.closeRightbar()
  151. expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false })
  152. })
  153. it('keeps dragged px preferences across resize, close, and reopen', () => {
  154. const { store, actions } = createLayoutStore().create()
  155. actions.openRightbar(true, false)
  156. actions.setRightbar(1100)
  157. actions.setViewportWidth(800)
  158. expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100)
  159. actions.closeRightbar()
  160. actions.openRightbar(false, true)
  161. expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100)
  162. })
  163. it('clamps drag preferences to 300px and 70% of the current frame', () => {
  164. const { store, actions } = createLayoutStore().create()
  165. actions.setViewportWidth(1600)
  166. actions.setRightbar(9999)
  167. expect(store.getSnapshot().layoutInfo.rightbar).toBe(1120)
  168. actions.setViewportWidth(1000)
  169. actions.setRightbar(9999)
  170. expect(store.getSnapshot().layoutInfo.rightbar).toBe(700)
  171. actions.setRightbar(1)
  172. expect(store.getSnapshot().layoutInfo.rightbar).toBe(300)
  173. })
  174. it('retains a minimum normal preference when first opened fullscreen on a phone', () => {
  175. const { store, actions } = createLayoutStore().create()
  176. actions.setViewportWidth(320)
  177. actions.openRightbar(false, true)
  178. expect(store.getSnapshot().layoutInfo.rightbar).toBe(300)
  179. })
  180. it('collapses a manually expanded narrow sidebar on opening, not presentation reports', () => {
  181. const { store, actions } = createLayoutStore().create()
  182. actions.setSidebar(400)
  183. actions.setViewportWidth(800)
  184. actions.toggleSidebar()
  185. actions.openRightbar(true, false)
  186. expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false })
  187. actions.toggleSidebar()
  188. actions.openRightbar(true, true)
  189. expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true)
  190. actions.closeRightbar()
  191. actions.openRightbar(true, false)
  192. expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
  193. })
  194. it('keeps the wide sidebar preference and never opens a closed right panel on resize', () => {
  195. const { store, actions } = createLayoutStore().create()
  196. actions.setSidebar(420)
  197. actions.openRightbar(true, false)
  198. expect(store.getSnapshot().layoutInfo.sidebar).toBe(420)
  199. actions.closeRightbar()
  200. actions.setViewportWidth(3000)
  201. expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false })
  202. })
  203. })
  204. describe('right panel instant geometry', () => {
  205. it.each([true, false])('closes fullscreen with track=%s in one instant update and retains repeated close reports', (track) => {
  206. const { store, actions } = createLayoutStore().create()
  207. actions.openRightbar(track, true)
  208. actions.closeRightbar()
  209. expect(store.getSnapshot().layoutInfo).toMatchObject({
  210. rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false, rightbarInstant: true,
  211. })
  212. const closed = store.getSnapshot()
  213. actions.closeRightbar()
  214. expect(store.getSnapshot()).toBe(closed)
  215. })
  216. it('restores the normal track instantly, retaining the marker on an identical report', () => {
  217. const { store, actions } = createLayoutStore().create()
  218. actions.openRightbar(true, true)
  219. actions.openRightbar(true, false)
  220. expect(store.getSnapshot().layoutInfo).toMatchObject({
  221. rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false, rightbarInstant: true,
  222. })
  223. const restored = store.getSnapshot()
  224. actions.openRightbar(true, false)
  225. expect(store.getSnapshot()).toBe(restored)
  226. actions.openRightbar(false, false)
  227. expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
  228. })
  229. it('allows a normal close to animate, including after restoring from fullscreen', () => {
  230. const { store, actions } = createLayoutStore().create()
  231. actions.openRightbar(true, false)
  232. actions.closeRightbar()
  233. expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
  234. actions.openRightbar(true, true)
  235. actions.openRightbar(true, false)
  236. expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true)
  237. actions.closeRightbar()
  238. expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarTrack: false, rightbarInstant: false })
  239. })
  240. it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'setViewportWidth'] as const)('clears instant geometry on %s', (action) => {
  241. const { store, actions } = createLayoutStore().create()
  242. actions.openRightbar(true, true)
  243. actions.closeRightbar()
  244. expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true)
  245. if (action === 'toggleSidebar') actions.toggleSidebar()
  246. else actions[action](action === 'setViewportWidth' ? 1800 : 350)
  247. expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
  248. actions.closeRightbar()
  249. expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
  250. })
  251. it('does not let an unchanged frame measurement reset the fullscreen-exit marker', () => {
  252. const { store, actions } = createLayoutStore().create()
  253. actions.openRightbar(true, true)
  254. actions.closeRightbar()
  255. const closed = store.getSnapshot()
  256. actions.setViewportWidth(closed.layoutInfo.viewportWidth)
  257. expect(store.getSnapshot()).toBe(closed)
  258. })
  259. it.each([true, false])('clears the exit marker on a fresh opening with fullscreen=%s', (fullscreen) => {
  260. const { store, actions } = createLayoutStore().create()
  261. actions.openRightbar(true, true)
  262. actions.closeRightbar()
  263. actions.openRightbar(true, fullscreen)
  264. expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false })
  265. })
  266. })