feat(client-runtime): carry the layered view and a field reset through the settings scope

A form needs two things the snapshot did not carry. The `user` layer tells it
which fields the user overrode — presence, not value equality, because an
override equal to the composition default is still an override — and `base`
is what a cleared field reverts to. `unset` is that clear, sharing `set`'s
queue, revision fence, and rejected-write recovery through one write path.
This commit is contained in:
Yichen Jiang
2026-08-10 18:27:01 +08:00
parent bd0563bff6
commit 8a3c5daad7
7 changed files with 150 additions and 7 deletions
@@ -293,6 +293,77 @@ describe('SettingsScopeController', () => {
expect(describeCall).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
it('carries the composition base and the user layer into the snapshot', async () => {
const layered: SettingsNamespaceView = {
...view({ preference: 'dark' }, 3),
base: { preference: 'system' },
user: { preference: 'dark' },
}
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({
value: { preference: 'dark' },
base: { preference: 'system' },
user: { preference: 'dark' },
})
})
it('reports an inherited field as absent from the user layer', async () => {
const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } }
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot().user).toBeUndefined()
})
it('clears one field through an unset op fenced by the held revision', async () => {
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4)))
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
await scope.unset('preference')
expect(mutate).toHaveBeenCalledWith({
ns: 'ui-test',
ops: [{ op: 'unset', path: ['preference'] }],
expectedRevision: 3,
})
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 })
})
it('recovers the Host state when the latest clear is refused', async () => {
const mutate = vi.fn().mockResolvedValueOnce(rejected())
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
.mockResolvedValueOnce(described({ preference: 'light' }, 5))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
await scope.unset('preference')
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 })
})
})
describe('bindSettingsScope', () => {