settings-scope.client.spec.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. import { Context } from '@deepseek-ai/cordis'
  2. import z from '@deepseek-ai/schemastery'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import type {
  5. SettingsNamespaceView, SettingsPathOpView,
  6. } from '@deepseek-ai/dsh-api-remotes/client'
  7. import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  8. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  9. import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
  10. import { SettingsSchemaService } from '../src/client/schema.ts'
  11. import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts'
  12. import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts'
  13. const settingsSchema = new SettingsSchemaService(new Context())
  14. interface UiTestSettings {
  15. preference: 'light' | 'dark' | 'system'
  16. }
  17. const ENVELOPE = z.object({
  18. preference: z.union(['light', 'dark', 'system']).default('system'),
  19. }).toJSON()
  20. /** What a Remote call answers with: no carrier envelope, and a typed failure. */
  21. type Answer<T> =
  22. | { ok: true; value: T }
  23. | { ok: false; error: RemoteError }
  24. function ok<T>(value: T): Answer<T> {
  25. return { ok: true, value }
  26. }
  27. function rejected<T>(): Answer<T> {
  28. return { ok: false, error: new RemoteError('settings/rejected', 'conflict', { ns: 'ui-test' }) }
  29. }
  30. /** The providing plugin's context, scripted down to the settings namespace. */
  31. function ctxWith(settings: object) {
  32. return { remote: { settings } } as never
  33. }
  34. function view(value: JsonValue, revision = 0): SettingsNamespaceView {
  35. return {
  36. ns: 'ui-test',
  37. // `toJSON()` already produced the wire envelope; its declared type is the
  38. // schema builder's, so one cast names what the Host actually sends.
  39. schema: ENVELOPE as unknown as JsonValue,
  40. value,
  41. applies: 'live',
  42. secrets: [],
  43. revision,
  44. }
  45. }
  46. function described(value: JsonValue, revision = 0) {
  47. return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
  48. }
  49. function deferred<T>() {
  50. let resolve!: (value: T) => void
  51. let reject!: (reason: unknown) => void
  52. const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
  53. return { promise, resolve, reject }
  54. }
  55. /** A host-mode mirror plus a controller derived from it, over one scripted context. */
  56. function derivedScope(
  57. api: { describe?: ReturnType<typeof vi.fn>; mutate?: ReturnType<typeof vi.fn> },
  58. spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' },
  59. ) {
  60. const ctx = ctxWith(api)
  61. const mirror = new SettingsDescribeMirror(ctx)
  62. const scope = new SettingsScopeController<UiTestSettings>(ctx, spec, mirror, 'host', settingsSchema)
  63. return { mirror, scope }
  64. }
  65. /** Record each distinct published section, starting from the current one. */
  66. function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
  67. const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
  68. scope.subscribe(() => {
  69. const value = scope.getSnapshot().value
  70. if (value !== seen[seen.length - 1]) seen.push(value)
  71. })
  72. return seen
  73. }
  74. describe('SettingsScopeController', () => {
  75. it('starts loading and derives a schema-valid section with revision and writability', async () => {
  76. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  77. const { mirror, scope } = derivedScope({ describe: describeCall })
  78. expect(scope.getSnapshot()).toEqual({
  79. status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
  80. })
  81. await mirror.load()
  82. expect(scope.getSnapshot()).toEqual({
  83. status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
  84. })
  85. })
  86. it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
  87. const describeCall = vi.fn()
  88. .mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  89. .mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
  90. .mockResolvedValueOnce(described(null, 5))
  91. .mockResolvedValueOnce(described('scalar', 6))
  92. .mockResolvedValueOnce(described(['queue'], 7))
  93. .mockResolvedValueOnce(rejected())
  94. .mockRejectedValueOnce(new Error('offline'))
  95. const { mirror, scope } = derivedScope({ describe: describeCall })
  96. const good = trackValues(scope)
  97. for (let i = 0; i < 7; i++) await mirror.load()
  98. expect(scope.getSnapshot()).toMatchObject({
  99. status: 'ready', value: { preference: 'dark' }, revision: 7,
  100. })
  101. expect(good).toEqual([undefined, { preference: 'dark' }])
  102. })
  103. it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
  104. const broken = { ...view({ preference: 'dark' }, 2), schema: null }
  105. const describeCall = vi.fn()
  106. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
  107. const { mirror, scope } = derivedScope({ describe: describeCall })
  108. await mirror.load()
  109. expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
  110. })
  111. it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
  112. const describeCall = vi.fn()
  113. .mockResolvedValueOnce(described({ preference: 'light' }, 1))
  114. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
  115. .mockResolvedValueOnce(described({ preference: 'system' }, 2))
  116. const { mirror, scope } = derivedScope({ describe: describeCall })
  117. await mirror.load()
  118. expect(scope.getSnapshot().status).toBe('ready')
  119. await mirror.load()
  120. expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
  121. await mirror.load()
  122. expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
  123. })
  124. it('applies a custom decode override in place of the wire schema', async () => {
  125. const describeCall = vi.fn()
  126. .mockResolvedValueOnce(described({ preference: 'light' }, 1))
  127. .mockResolvedValueOnce(described({ preference: 'dark' }, 2))
  128. const { mirror, scope } = derivedScope({ describe: describeCall }, {
  129. namespace: 'ui-test',
  130. decode: section => (section as UiTestSettings).preference === 'dark'
  131. ? section as UiTestSettings
  132. : undefined,
  133. })
  134. await mirror.load()
  135. expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
  136. await mirror.load()
  137. expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
  138. })
  139. it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
  140. const first = deferred<Answer<SettingsNamespaceView>>()
  141. const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
  142. const mutate = vi.fn()
  143. .mockReturnValueOnce(first.promise)
  144. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
  145. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  146. const published = trackValues(scope)
  147. await mirror.load()
  148. const dark = scope.set('preference', 'dark')
  149. const light = scope.set('preference', 'light')
  150. await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  151. first.resolve(ok(view({ preference: 'dark' }, 5)))
  152. await Promise.all([dark, light])
  153. expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
  154. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
  155. expect(mutate).toHaveBeenNthCalledWith(1,
  156. 'ui-test',
  157. [{ op: 'set', path: ['preference'], value: 'dark' }],
  158. 4,
  159. )
  160. expect(mutate).toHaveBeenNthCalledWith(2,
  161. 'ui-test',
  162. [{ op: 'set', path: ['preference'], value: 'light' }],
  163. 5,
  164. )
  165. })
  166. it('sends one copied multi-field mutation behind one revision fence', async () => {
  167. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 7))
  168. const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 8)))
  169. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  170. await mirror.load()
  171. const ops: SettingsPathOpView[] = [
  172. { op: 'set', path: ['enabled'], value: true },
  173. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  174. ]
  175. const write = scope.mutate(ops)
  176. ops[0] = { op: 'unset', path: ['enabled'] }
  177. ;(ops[1] as unknown as { value: Array<{ model: string }> }).value[0]!.model = 'changed'
  178. await write
  179. expect(mutate).toHaveBeenCalledWith(
  180. 'ui-test',
  181. [
  182. { op: 'set', path: ['enabled'], value: true },
  183. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  184. ],
  185. 7,
  186. )
  187. })
  188. it('preserves an editor-owned revision fence behind earlier queued writes', async () => {
  189. const first = deferred<Answer<SettingsNamespaceView>>()
  190. const describeCall = vi.fn()
  191. .mockResolvedValueOnce(described({ preference: 'system' }, 7))
  192. .mockResolvedValueOnce(described({ preference: 'dark' }, 8))
  193. const mutate = vi.fn()
  194. .mockReturnValueOnce(first.promise)
  195. .mockResolvedValueOnce(rejected())
  196. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  197. await mirror.load()
  198. const earlier = scope.set('preference', 'dark')
  199. const fenced = scope.mutate([{ op: 'set', path: ['preference'], value: 'light' }], 7)
  200. first.resolve(ok(view({ preference: 'dark' }, 8)))
  201. await Promise.all([earlier, fenced])
  202. expect(mutate).toHaveBeenNthCalledWith(
  203. 2,
  204. 'ui-test',
  205. [{ op: 'set', path: ['preference'], value: 'light' }],
  206. 7,
  207. )
  208. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 })
  209. })
  210. it('folds the latest write answer into the mirror so a sibling scope sees it', async () => {
  211. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4))
  212. const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5)))
  213. const ctx = ctxWith({ describe: describeCall, mutate })
  214. const mirror = new SettingsDescribeMirror(ctx)
  215. const writer = new SettingsScopeController<UiTestSettings>(ctx, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
  216. const sibling = new SettingsScopeController<UiTestSettings>(ctx, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
  217. await mirror.load()
  218. await writer.set('preference', 'dark')
  219. expect(describeCall).toHaveBeenCalledTimes(1)
  220. expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 })
  221. })
  222. it('re-reads after a revisionless first write lands during the initial read', async () => {
  223. const initial = deferred<ReturnType<typeof described>>()
  224. const describeCall = vi.fn()
  225. .mockReturnValueOnce(initial.promise)
  226. .mockResolvedValueOnce(described({ preference: 'dark' }, 2))
  227. const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
  228. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  229. const loading = mirror.load()
  230. await Promise.resolve()
  231. await scope.set('preference', 'dark')
  232. initial.resolve(described({ preference: 'system' }, 1))
  233. await loading
  234. expect(mutate).toHaveBeenCalledWith(
  235. 'ui-test',
  236. [{ op: 'set', path: ['preference'], value: 'dark' }],
  237. undefined,
  238. )
  239. expect(describeCall).toHaveBeenCalledTimes(2)
  240. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 })
  241. })
  242. it('recovers the latest refused write from Host state', async () => {
  243. const describeCall = vi.fn()
  244. .mockResolvedValueOnce(described({ preference: 'system' }, 2))
  245. .mockResolvedValueOnce(described({ preference: 'light' }, 3))
  246. const mutate = vi.fn()
  247. .mockResolvedValueOnce(rejected())
  248. .mockResolvedValueOnce(rejected())
  249. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  250. const published = trackValues(scope)
  251. await mirror.load()
  252. await scope.set('preference', 'dark')
  253. await scope.set('preference', 'system')
  254. expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
  255. })
  256. it('does not recover superseded refused writes', async () => {
  257. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2))
  258. const mutate = vi.fn()
  259. .mockResolvedValueOnce(rejected())
  260. .mockResolvedValueOnce(rejected())
  261. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
  262. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  263. const published = trackValues(scope)
  264. await mirror.load()
  265. await Promise.all([
  266. scope.set('preference', 'dark'),
  267. scope.set('preference', 'system'),
  268. scope.set('preference', 'light'),
  269. ])
  270. expect(describeCall).toHaveBeenCalledTimes(1)
  271. expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
  272. })
  273. it('keeps the write queue usable when a subscriber throws', async () => {
  274. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  275. const describeCall = vi.fn()
  276. .mockResolvedValueOnce(described({ preference: 'dark' }, 1))
  277. .mockResolvedValueOnce(described({ preference: 'light' }, 2))
  278. const { mirror, scope } = derivedScope({ describe: describeCall })
  279. let thrown = false
  280. scope.subscribe(() => {
  281. if (thrown) return
  282. thrown = true
  283. throw new Error('subscriber failed')
  284. })
  285. await expect(mirror.load()).resolves.toBeUndefined()
  286. await expect(mirror.load()).resolves.toBeUndefined()
  287. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
  288. expect(report).toHaveBeenCalledWith('[client-store] subscriber failed:', expect.objectContaining({
  289. message: 'subscriber failed',
  290. }))
  291. report.mockRestore()
  292. })
  293. it('keeps the write queue usable when a write publication listener throws', async () => {
  294. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  295. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1))
  296. const mutate = vi.fn()
  297. .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
  298. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
  299. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  300. await mirror.load()
  301. let shouldThrow = true
  302. mirror.subscribe(() => {
  303. if (!shouldThrow) return
  304. shouldThrow = false
  305. throw new Error('write subscriber failed')
  306. })
  307. await expect(scope.set('preference', 'dark')).resolves.toBeUndefined()
  308. await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
  309. expect(mutate).toHaveBeenCalledTimes(2)
  310. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
  311. expect(report).toHaveBeenCalledWith('[client-store] subscriber failed:', expect.objectContaining({
  312. message: 'write subscriber failed',
  313. }))
  314. report.mockRestore()
  315. })
  316. it('keeps the write queue usable after a failed mirror fold', async () => {
  317. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1))
  318. const mutate = vi.fn()
  319. .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
  320. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
  321. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  322. await mirror.load()
  323. vi.spyOn(mirror, 'acceptView').mockImplementationOnce(() => {
  324. throw new Error('mirror fold failed')
  325. })
  326. await expect(scope.set('preference', 'dark')).rejects.toThrow('mirror fold failed')
  327. await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
  328. expect(mutate).toHaveBeenCalledTimes(2)
  329. expect(mutate).toHaveBeenNthCalledWith(2,
  330. 'ui-test',
  331. [{ op: 'set', path: ['preference'], value: 'light' }],
  332. 1,
  333. )
  334. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
  335. })
  336. it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
  337. const first = deferred<Answer<SettingsNamespaceView>>()
  338. const mutate = vi.fn().mockReturnValue(first.promise)
  339. const describeCall = vi.fn()
  340. const { scope } = derivedScope({ describe: describeCall, mutate })
  341. const published = trackValues(scope)
  342. const dark = scope.set('preference', 'dark')
  343. await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  344. const light = scope.set('preference', 'light')
  345. let stopped = false
  346. const stop = scope.dispose().then(() => { stopped = true })
  347. await Promise.resolve()
  348. expect(stopped).toBe(false)
  349. first.resolve(ok(view({ preference: 'dark' }, 1)))
  350. await Promise.all([dark, light, stop])
  351. await scope.set('preference', 'system')
  352. expect(mutate).toHaveBeenCalledOnce()
  353. expect(describeCall).not.toHaveBeenCalled()
  354. expect(published).toEqual([undefined])
  355. })
  356. it('stops deriving from the mirror after dispose', async () => {
  357. const describeCall = vi.fn()
  358. .mockResolvedValueOnce(described({ preference: 'dark' }, 1))
  359. .mockResolvedValueOnce(described({ preference: 'light' }, 2))
  360. const { mirror, scope } = derivedScope({ describe: describeCall })
  361. await mirror.load()
  362. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' } })
  363. await scope.dispose()
  364. await mirror.load()
  365. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
  366. })
  367. it('ignores a mirror notification already queued when disposal starts', async () => {
  368. let notify = (): void => {}
  369. let snapshot = {
  370. status: 'ready' as const,
  371. view: {
  372. writable: true, hasDocument: true,
  373. namespaces: [view({ preference: 'dark' }, 1)],
  374. },
  375. error: null,
  376. }
  377. const mirror = {
  378. getSnapshot: () => snapshot,
  379. subscribe: (listener: () => void) => {
  380. notify = listener
  381. return () => {}
  382. },
  383. } as never
  384. const scope = new SettingsScopeController<UiTestSettings>(
  385. ctxWith({}), { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
  386. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
  387. await scope.dispose()
  388. snapshot = {
  389. ...snapshot,
  390. view: { ...snapshot.view, namespaces: [view({ preference: 'light' }, 2)] },
  391. }
  392. notify()
  393. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
  394. })
  395. it('keeps a remote browser in memory mode without Host calls', async () => {
  396. const describeCall = vi.fn()
  397. const mutate = vi.fn()
  398. const ctx = ctxWith({ describe: describeCall, mutate })
  399. const mirror = new SettingsDescribeMirror(ctx, 'memory')
  400. const scope = new SettingsScopeController<UiTestSettings>(
  401. ctx, { namespace: 'ui-test' }, mirror, 'memory', settingsSchema)
  402. expect(scope.getSnapshot()).toEqual({
  403. status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
  404. })
  405. await mirror.load()
  406. await scope.set('preference', 'dark')
  407. await scope.dispose()
  408. expect(describeCall).not.toHaveBeenCalled()
  409. expect(mutate).not.toHaveBeenCalled()
  410. })
  411. it('carries the composition base and the user layer into the snapshot', async () => {
  412. const layered: SettingsNamespaceView = {
  413. ...view({ preference: 'dark' }, 3),
  414. base: { preference: 'system' },
  415. user: { preference: 'dark' },
  416. }
  417. const describeCall = vi.fn()
  418. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] }))
  419. const { mirror, scope } = derivedScope({ describe: describeCall })
  420. await mirror.load()
  421. expect(scope.getSnapshot()).toMatchObject({
  422. value: { preference: 'dark' },
  423. base: { preference: 'system' },
  424. user: { preference: 'dark' },
  425. })
  426. })
  427. it('reports an inherited field as absent from the user layer', async () => {
  428. const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } }
  429. const describeCall = vi.fn()
  430. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] }))
  431. const { mirror, scope } = derivedScope({ describe: describeCall })
  432. await mirror.load()
  433. expect(scope.getSnapshot().user).toBeUndefined()
  434. })
  435. it('clears one field through an unset op fenced by the held revision', async () => {
  436. const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4)))
  437. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  438. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  439. await mirror.load()
  440. await scope.unset('preference')
  441. expect(mutate).toHaveBeenCalledWith(
  442. 'ui-test',
  443. [{ op: 'unset', path: ['preference'] }],
  444. 3,
  445. )
  446. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 })
  447. })
  448. it('recovers the Host state when the latest clear is refused', async () => {
  449. const mutate = vi.fn().mockResolvedValueOnce(rejected())
  450. const describeCall = vi.fn()
  451. .mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  452. .mockResolvedValueOnce(described({ preference: 'light' }, 5))
  453. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  454. await mirror.load()
  455. await scope.unset('preference')
  456. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 })
  457. })
  458. })
  459. describe('SettingsScopeBinder.bind', () => {
  460. it('shares one mirror read across bound scopes and disposes each with its fiber', async () => {
  461. const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1))
  462. const mirror = new SettingsDescribeMirror(ctxWith({ describe: describeCall }))
  463. const ctx = new Context()
  464. let theme!: SettingsScope<UiTestSettings>
  465. let locale!: SettingsScope<UiTestSettings>
  466. new TestRemote(ctx, { settings: { describe: describeCall } })
  467. await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, persistence: 'host' }).await()
  468. expect(ctx.settingsScope.describe()).toBe(mirror)
  469. const fiber = ctx.plugin({
  470. inject: ['remote', 'settingsScope'],
  471. apply: (plugin: Context) => {
  472. theme = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
  473. locale = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
  474. },
  475. })
  476. await fiber.await()
  477. await vi.waitFor(() => {
  478. expect(theme.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
  479. expect(locale.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
  480. })
  481. expect(describeCall).toHaveBeenCalledTimes(1)
  482. await fiber.dispose()
  483. await mirror.load()
  484. expect(theme.getSnapshot()).toMatchObject({ revision: 1 })
  485. })
  486. it('binds a remote browser in memory mode without starting a settings read', async () => {
  487. const describeCall = vi.fn()
  488. const mirror = new SettingsDescribeMirror(ctxWith({ describe: describeCall }), 'memory')
  489. const ctx = new Context()
  490. let scope!: SettingsScope<UiTestSettings>
  491. new TestRemote(ctx, { settings: { describe: describeCall } })
  492. await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, persistence: 'memory' }).await()
  493. const fiber = ctx.plugin({
  494. inject: ['remote', 'settingsScope'],
  495. apply: (plugin: Context) => {
  496. scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
  497. },
  498. })
  499. await fiber.await()
  500. expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
  501. await fiber.dispose()
  502. expect(describeCall).not.toHaveBeenCalled()
  503. })
  504. })