dsh-offwork 1.1.2 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,13 @@
1
1
  // Ephemeral UI navigation only. Business data stays in its existing stores.
2
- export const PET_EDITOR_SCREENS = ['water', 'schedule', 'appearance'];
2
+ export const PET_EDITOR_SCREENS = [
3
+ 'water',
4
+ 'schedule',
5
+ 'appearance',
6
+ 'wrap',
7
+ 'preferences',
8
+ 'data',
9
+ 'reminders',
10
+ ];
3
11
  export const initialPetUI = { screen: null, back: null, menu: null, pending: null };
4
12
  export function petUIReducer(state, action) {
5
13
  switch (action.type) {
@@ -32,11 +40,22 @@ export function petUIReducer(state, action) {
32
40
  };
33
41
  const back = action.root
34
42
  ? null
35
- : PET_EDITOR_SCREENS.includes(action.screen)
36
- ? PET_EDITOR_SCREENS.includes(state.screen)
37
- ? state.back
38
- : state.screen
39
- : state.back;
43
+ : action.screen === state.back
44
+ ? null
45
+ : action.screen === 'data'
46
+ ? 'preferences'
47
+ : action.screen === 'wrap'
48
+ ? 'income'
49
+ : state.screen === 'preferences' &&
50
+ ['schedule', 'water', 'appearance'].includes(action.screen)
51
+ ? 'preferences'
52
+ : action.screen === 'preferences'
53
+ ? state.screen
54
+ : PET_EDITOR_SCREENS.includes(action.screen)
55
+ ? PET_EDITOR_SCREENS.includes(state.screen)
56
+ ? state.back
57
+ : state.screen
58
+ : state.back;
40
59
  return { screen: action.screen, back, menu: null, pending: null };
41
60
  }
42
61
  default:
@@ -54,6 +73,11 @@ export function appearanceValues(view) {
54
73
  }
55
74
  export const PET_INTERACTION_MESSAGES = {
56
75
  zh: {
76
+ companionPreferencesTitle: '设置',
77
+ companionDataTitle: '备份与恢复',
78
+ companionWrapTitle: '今日收尾',
79
+ companionRemindersTitle: '待处理提醒',
80
+ quickMore: '更多',
57
81
  petHome: '喝水',
58
82
  petWaterCard: '喝水',
59
83
  waterCountdown: '喝水倒计时',
@@ -64,6 +88,7 @@ export const PET_INTERACTION_MESSAGES = {
64
88
  timerIdle: '未开始',
65
89
  timerRunning: '计时中',
66
90
  timerPaused: '已暂停',
91
+ timerSnoozed: '稍后提醒',
67
92
  timerDue: '已到时',
68
93
  timerStart: '开始计时',
69
94
  timerPause: '暂停计时',
@@ -85,8 +110,9 @@ export const PET_INTERACTION_MESSAGES = {
85
110
  timerFinished: '已结束',
86
111
  timerUnavailable: '暂不可用',
87
112
  petCountdownCard: '下班倒计时',
113
+ petCountdownBefore: '上班倒计时',
88
114
  petCountdownGroup: '计时概览',
89
- petEarningsCard: '收入',
115
+ petEarningsCard: '收入明细',
90
116
  petWaterAlert: '查看喝水提醒',
91
117
  petPausedHint: '提醒已暂停,手动恢复后重新计时。',
92
118
  petMenu: '宠物菜单',
@@ -112,6 +138,11 @@ export const PET_INTERACTION_MESSAGES = {
112
138
  petDismissReminder: '收起提醒',
113
139
  },
114
140
  en: {
141
+ companionPreferencesTitle: 'Settings',
142
+ companionDataTitle: 'Backup and restore',
143
+ companionWrapTitle: 'Wrap up today',
144
+ companionRemindersTitle: 'Pending reminders',
145
+ quickMore: 'More',
115
146
  petHome: 'Water',
116
147
  petWaterCard: 'Water',
117
148
  waterCountdown: 'Water countdown',
@@ -122,6 +153,7 @@ export const PET_INTERACTION_MESSAGES = {
122
153
  timerIdle: 'Not started',
123
154
  timerRunning: 'Running',
124
155
  timerPaused: 'Paused',
156
+ timerSnoozed: 'Snoozed',
125
157
  timerDue: 'Time is up',
126
158
  timerStart: 'Start timer',
127
159
  timerPause: 'Pause timer',
@@ -144,8 +176,9 @@ export const PET_INTERACTION_MESSAGES = {
144
176
  timerFinished: 'Finished',
145
177
  timerUnavailable: 'Unavailable',
146
178
  petCountdownCard: 'Shift countdown',
179
+ petCountdownBefore: 'Until work starts',
147
180
  petCountdownGroup: 'Timer overview',
148
- petEarningsCard: 'Earnings',
181
+ petEarningsCard: 'Earnings details',
149
182
  petWaterAlert: 'View water reminder',
150
183
  petPausedHint: 'Reminders paused. Resume manually to start a fresh interval.',
151
184
  petMenu: 'Pet menu',
package/pet-surfaces.js CHANGED
@@ -175,7 +175,7 @@ export const PET_SURFACE_STYLE = `
175
175
  .owc-v2 .owc-pet-natural>.owc-form:last-child,.owc-v2 .owc-pet-natural>.owc-water-form:last-child{padding-bottom:0}
176
176
  .owc-v2 .owc-pet-scroll .owc-save{background:var(--owc-surface);padding:12px 0;border-top:1px solid var(--owc-border);gap:8px;flex-wrap:wrap}
177
177
  .owc-v2 .owc-save button{min-width:72px}
178
- .owc-v2 .owc-appearance-preview{height:260px;display:flex;align-items:center;justify-content:center;border-radius:12px;background:var(--owc-subtle);margin-bottom:16px;overflow:hidden}
178
+ .owc-v2 .owc-appearance-preview{height:190px;display:flex;align-items:center;justify-content:center;border-radius:12px;background:var(--owc-subtle);margin-bottom:16px;overflow:hidden}
179
179
  .owc-v2 .owc-appearance-preview .owc-pet-art{flex:none;animation:none}
180
180
  .owc-v2 .owc-appearance-preview .owc-pet-art *{animation:none!important}
181
181
  .owc-v2 .owc-preview-actions{display:flex;gap:6px;flex-wrap:wrap}
package/pet-view.js CHANGED
@@ -33,6 +33,8 @@ export const PET_MODELS = Object.freeze({
33
33
  'xiaoxie-original': { ...PET_MODEL, widthScale: 1, heightScale: 1.1, label: 'petDroplet' },
34
34
  });
35
35
  export function petModel(id) {
36
+ if (id === 'custom-image')
37
+ return { id, renderer: 'image', widthScale: 1.3125, heightScale: 1.75, label: 'petCharacter' };
36
38
  return Object.hasOwn(PET_MODELS, id) ? PET_MODELS[id] : PET_MODELS['whale-dress'];
37
39
  }
38
40
 
@@ -0,0 +1,126 @@
1
+ import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export const SETTINGS_ROUTE = '/offwork-clock/settings';
6
+ export function createSettingsAccess(directory) {
7
+ const file = join(directory, 'agent-access.json');
8
+ let queue = Promise.resolve();
9
+ return {
10
+ async get() {
11
+ try {
12
+ const value = JSON.parse(await readFile(file, 'utf8'));
13
+ if (value.schema !== 1 || typeof value.allowed !== 'boolean')
14
+ throw new Error('Invalid access policy');
15
+ return value.allowed;
16
+ } catch (error) {
17
+ if (error.code === 'ENOENT') return false;
18
+ throw error;
19
+ }
20
+ },
21
+ set(allowed) {
22
+ const result = queue.then(async () => {
23
+ if (typeof allowed !== 'boolean') throw new Error('Invalid access policy');
24
+ await mkdir(directory, { recursive: true, mode: 0o700 });
25
+ const temp = join(directory, `.access-${randomUUID()}.tmp`);
26
+ try {
27
+ await writeFile(temp, JSON.stringify({ schema: 1, allowed }), {
28
+ mode: 0o600,
29
+ flag: 'wx',
30
+ });
31
+ await rename(temp, file);
32
+ } finally {
33
+ await unlink(temp).catch(() => {});
34
+ }
35
+ return allowed;
36
+ });
37
+ queue = result.catch(() => {});
38
+ return result;
39
+ },
40
+ };
41
+ }
42
+
43
+ // Served under the Harness host's existing access boundary. Never enables CORS.
44
+ // A custom header is required even for GET; cross-origin browsers cannot send it
45
+ // without a successful preflight. Same-site subdomains are not trusted.
46
+ export function createSettingsHandler(operate, access, authorize = () => 401) {
47
+ return async (req, res) => {
48
+ const send = (status, body) => {
49
+ if (res.destroyed || res.writableEnded) return;
50
+ res.writeHead(status, {
51
+ 'Content-Type': 'application/json; charset=utf-8',
52
+ 'Cache-Control': 'no-store',
53
+ 'X-Content-Type-Options': 'nosniff',
54
+ });
55
+ res.end(JSON.stringify(body));
56
+ };
57
+ if (!['GET', 'POST'].includes(req.method))
58
+ return send(405, { ok: false, error: 'Method not allowed' });
59
+ const site = req.headers['sec-fetch-site'];
60
+ let originOK = true;
61
+ if (req.headers.origin) {
62
+ try {
63
+ originOK = new URL(req.headers.origin).host === req.headers.host;
64
+ } catch {
65
+ originOK = false;
66
+ }
67
+ }
68
+ if (
69
+ req.headers['x-offwork-client'] !== '1' ||
70
+ (site && !['same-origin', 'none'].includes(site)) ||
71
+ !originOK
72
+ )
73
+ return send(403, { ok: false, error: 'Same-origin client required' });
74
+ const rejection = authorize(req);
75
+ if (rejection) return send(rejection, { ok: false, error: 'Authenticated owner required' });
76
+ try {
77
+ if (req.method === 'GET') {
78
+ const result = await operate('get');
79
+ return send(result.ok ? 200 : 422, { ...result, agentAccess: await access.get() });
80
+ }
81
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] || ''))
82
+ return send(415, { ok: false, error: 'JSON required' });
83
+ const chunks = [];
84
+ let size = 0;
85
+ for await (const chunk of req) {
86
+ size += chunk.length;
87
+ if (size > 16384) return send(413, { ok: false, error: 'Request too large' });
88
+ chunks.push(chunk);
89
+ }
90
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
91
+ if (!body || typeof body !== 'object' || Array.isArray(body))
92
+ throw new Error('Invalid request');
93
+ if (body.action === 'access') {
94
+ if (Object.keys(body).some((k) => !['action', 'allowed'].includes(k)))
95
+ throw new Error('Unknown field');
96
+ return send(200, { ok: true, agentAccess: await access.set(body.allowed) });
97
+ }
98
+ if (
99
+ body.action !== 'set' ||
100
+ Object.keys(body).some((k) => !['action', 'settings'].includes(k))
101
+ )
102
+ throw new Error('Invalid action');
103
+ const result = await operate('set', body.settings);
104
+ send(result.ok ? 200 : 422, result);
105
+ } catch {
106
+ send(400, {
107
+ ok: false,
108
+ error: '无法读取或保存设置,请检查配置和访问权限。 / Unable to read or save settings.',
109
+ });
110
+ }
111
+ };
112
+ }
113
+ export function registerSettingsRoute(ctx, operate, access) {
114
+ ctx.inject?.(['webServer', 'connection'], (webCtx) => {
115
+ const authorize = (request) => {
116
+ if (typeof webCtx.connection?.admit !== 'function') return 503;
117
+ const admission = webCtx.connection.admit(request);
118
+ if ('rejection' in admission) return admission.rejection;
119
+ return admission.peer.principal.kind === 'owner' ? undefined : 403;
120
+ };
121
+ const handler = createSettingsHandler(operate, access, authorize);
122
+ webCtx.effect(() =>
123
+ webCtx.webServer.register({ kind: 'exact', path: SETTINGS_ROUTE, handler }),
124
+ );
125
+ });
126
+ }
@@ -0,0 +1,267 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { IDBFactory } from 'fake-indexeddb';
4
+ import { defaultCountdown, transitionCountdown } from '../countdown.js';
5
+ import { defaultHydration, transitionHydration } from '../hydration.js';
6
+ import { createStateStore } from '../hydration-store.js';
7
+ import {
8
+ defaultCompanionState,
9
+ validateCompanionState,
10
+ companionEvents,
11
+ companionPending,
12
+ companionTransition,
13
+ reconcileCompanion,
14
+ companionDaySummary,
15
+ validateBrowserBackup,
16
+ companionBackupStamp,
17
+ companionStateStamp,
18
+ createCompanionRecoveryStore,
19
+ } from '../companion-state.js';
20
+ const at = Date.parse('2026-11-12T09:00:00Z');
21
+ const event = { id: 'smoking:1', kind: 'smoking', label: 'Smoking', dueAt: at };
22
+ const fresh = () => reconcileCompanion(defaultCompanionState(), [event], at);
23
+
24
+ test('closing, snoozing and quiet mode have distinct persistent semantics', () => {
25
+ let state = fresh();
26
+ state = companionTransition(state, { type: 'dismiss', id: event.id }, at);
27
+ assert.equal(companionPending(state, [event], at).length, 0);
28
+ assert.equal(state.deliveries[event.id].dueAt, at);
29
+ state = companionTransition(state, { type: 'snooze', id: event.id }, at);
30
+ assert.equal(companionPending(state, [event], at + 599999).length, 0);
31
+ assert.equal(companionPending(state, [event], at + 600000).length, 1);
32
+ state = companionTransition(
33
+ state,
34
+ { type: 'preferences', patch: { quietUntil: at + 700000 } },
35
+ at,
36
+ );
37
+ assert.equal(companionPending(state, [event], at + 600000).length, 0);
38
+ assert.equal(companionPending(state, [event], at + 700000).length, 1);
39
+ });
40
+ test('only one tab owns a reminder; lease can recover after a crash', async () => {
41
+ const idb = new IDBFactory();
42
+ const stores = ['a', 'b'].map(() =>
43
+ createStateStore(idb, 'race', defaultCompanionState, validateCompanionState),
44
+ );
45
+ await stores[0].update(() => fresh());
46
+ const winners = [];
47
+ await Promise.all(
48
+ stores.map((s, i) =>
49
+ s.update((value) => {
50
+ const next = companionTransition(
51
+ value,
52
+ { type: 'claim', id: event.id, owner: String(i) },
53
+ at,
54
+ );
55
+ if (next.deliveries[event.id].owner === String(i)) winners.push(i);
56
+ return next;
57
+ }),
58
+ ),
59
+ );
60
+ assert.equal(winners.length, 1);
61
+ const loser = String(1 - winners[0]);
62
+ const next = await stores[1].update((s) =>
63
+ companionTransition(s, { type: 'claim', id: event.id, owner: loser }, at + 8000),
64
+ );
65
+ assert.equal(next.deliveries[event.id].owner, loser);
66
+ stores.forEach((s) => s.close());
67
+ });
68
+ test('notification claims are atomic across tabs and snooze permits one new notification', async () => {
69
+ const idb = new IDBFactory();
70
+ const a = createStateStore(idb, 'notification', defaultCompanionState, validateCompanionState),
71
+ b = createStateStore(idb, 'notification', defaultCompanionState, validateCompanionState);
72
+ await a.update(() => fresh());
73
+ let sent = 0;
74
+ await Promise.all(
75
+ [a, b].map((store) =>
76
+ store.update((s) => {
77
+ if (!s.deliveries[event.id].notifiedAt) sent++;
78
+ return companionTransition(s, { type: 'notify', id: event.id }, at);
79
+ }),
80
+ ),
81
+ );
82
+ assert.equal(sent, 1);
83
+ let state = await a.update((s) => companionTransition(s, { type: 'snooze', id: event.id }, at));
84
+ assert.equal(state.deliveries[event.id].notifiedAt, 0);
85
+ state = companionTransition(state, { type: 'notify', id: event.id }, at + 1);
86
+ assert.equal(state.deliveries[event.id].notifiedAt, 0);
87
+ a.close();
88
+ b.close();
89
+ });
90
+ test('legacy water/smoking clocks are observed without modifying them', () => {
91
+ let smoking = transitionCountdown(defaultCountdown(), { type: 'start' }, at - 3600000);
92
+ let water = transitionHydration(
93
+ defaultHydration(),
94
+ { type: 'preferences', enabled: true, interval: 5 },
95
+ at - 300000,
96
+ 'test',
97
+ );
98
+ const before = JSON.stringify({ smoking, water });
99
+ const events = companionEvents(
100
+ defaultCompanionState(),
101
+ { smoking, water, smokingReady: true, waterReady: true },
102
+ at,
103
+ );
104
+ assert.deepEqual(events.map((e) => e.kind).sort(), ['smoking', 'water']);
105
+ assert.equal(JSON.stringify({ smoking, water }), before);
106
+ water.claim = { owner: 'older-client', until: at + 8000, sequence: water.sequence };
107
+ assert.equal(
108
+ companionEvents(
109
+ defaultCompanionState(),
110
+ { water, waterReady: true, waterOwner: 'new-client' },
111
+ at,
112
+ ).length,
113
+ 0,
114
+ );
115
+ assert.equal(
116
+ companionEvents(
117
+ defaultCompanionState(),
118
+ { water, waterReady: true, waterOwner: 'new-client' },
119
+ at + 8000,
120
+ ).length,
121
+ 1,
122
+ );
123
+ });
124
+ test('wrap-up completion suppresses this shift only and leaves water/custom clocks intact', () => {
125
+ let state = defaultCompanionState();
126
+ state.preferences.wrapEnabled = true;
127
+ const shift = {
128
+ isWorkday: true,
129
+ shiftDate: '2026-11-12',
130
+ shiftStart: at - 3600000,
131
+ shiftEnd: at + 300000,
132
+ };
133
+ const inputs = { shift };
134
+ assert.equal(companionEvents(state, inputs, at).length, 1);
135
+ state = companionTransition(
136
+ state,
137
+ {
138
+ type: 'journal',
139
+ date: shift.shiftDate,
140
+ done: 'One change',
141
+ next: 'Review',
142
+ base: 0,
143
+ end: true,
144
+ },
145
+ at,
146
+ );
147
+ assert.equal(companionEvents(state, inputs, at).length, 0);
148
+ assert.equal(state.preferences.wrapEnabled, true);
149
+ assert.equal(
150
+ companionEvents(state, { shift: { ...shift, shiftDate: '2026-11-13' } }, at).length,
151
+ 1,
152
+ );
153
+ assert.equal(companionEvents(state, { shift: { ...shift, isWorkday: false } }, at).length, 0);
154
+ });
155
+ test('notes reject a stale tab and repeating end does not duplicate history', () => {
156
+ let s = companionTransition(
157
+ defaultCompanionState(),
158
+ { type: 'journal', date: '2026-11-12', done: 'A', next: 'B', base: 0, end: true },
159
+ at,
160
+ );
161
+ assert.throws(
162
+ () =>
163
+ companionTransition(
164
+ s,
165
+ { type: 'journal', date: '2026-11-12', done: 'stale', next: '', base: 0, end: false },
166
+ at + 1,
167
+ ),
168
+ /another tab/,
169
+ );
170
+ s = companionTransition(
171
+ s,
172
+ { type: 'journal', date: '2026-11-12', done: 'A2', next: 'B', base: at, end: true },
173
+ at + 1,
174
+ );
175
+ assert.equal(s.history.length, 1);
176
+ });
177
+ test('custom timers are independent and actions reject stale per-timer revisions', () => {
178
+ let s = defaultCompanionState();
179
+ for (const id of ['tea', 'rest'])
180
+ s = companionTransition(s, { type: 'add-timer', id, name: id, minutes: 5 }, at);
181
+ s = companionTransition(
182
+ s,
183
+ { type: 'timer', id: 'tea', revision: 0, operation: { type: 'start' } },
184
+ at,
185
+ );
186
+ assert.equal(s.timers[1].clock.status, 'idle');
187
+ assert.throws(
188
+ () =>
189
+ companionTransition(
190
+ s,
191
+ { type: 'timer', id: 'tea', revision: 0, operation: { type: 'stop' } },
192
+ at,
193
+ ),
194
+ /changed/,
195
+ );
196
+ assert.equal(companionEvents(s, {}, at + 300000)[0].timerId, 'tea');
197
+ });
198
+ test('restore validates data, requires current revision, and resets permissions and leases', () => {
199
+ let s = fresh();
200
+ s.revision = 8;
201
+ const backup = fresh();
202
+ backup.preferences.sound = true;
203
+ backup.preferences.notifications = true;
204
+ backup.deliveries[event.id].owner = 'old';
205
+ backup.deliveries[event.id].leaseUntil = at + 1000;
206
+ assert.throws(
207
+ () => companionTransition(s, { type: 'restore', value: backup, base: 7 }, at),
208
+ /changed/,
209
+ );
210
+ const restored = companionTransition(s, { type: 'restore', value: backup, base: 8 }, at);
211
+ assert.equal(restored.preferences.sound, false);
212
+ assert.equal(restored.preferences.notifications, false);
213
+ assert.equal(restored.deliveries[event.id].owner, '');
214
+ assert.throws(() => validateCompanionState({ ...s, schema: 999 }));
215
+ assert.throws(() =>
216
+ validateCompanionState({
217
+ ...s,
218
+ preferences: { ...s.preferences, image: 'https://evil.example/image.svg' },
219
+ }),
220
+ );
221
+ });
222
+ test('actual event history is bounded, idempotent and counted in chosen timezone', () => {
223
+ let s = defaultCompanionState();
224
+ const e = {
225
+ id: 'drink:1',
226
+ label: 'Water',
227
+ type: 'water',
228
+ at: Date.parse('2026-11-12T23:00:00Z'),
229
+ };
230
+ s = companionTransition(s, { type: 'record', event: e }, at);
231
+ s = companionTransition(s, { type: 'record', event: e }, at);
232
+ assert.equal(s.history.length, 1);
233
+ assert.equal(companionDaySummary(s, '2026-11-13', 'Asia/Shanghai').water, 1);
234
+ assert.equal(companionDaySummary(s, '2026-11-12', 'Asia/Shanghai').water, 0);
235
+ });
236
+
237
+ test('backup preview ignores leases but detects user changes; recovery survives reopening', async () => {
238
+ const b = validateBrowserBackup({
239
+ format: 'dsh-offwork-browser',
240
+ version: 1,
241
+ createdAt: at,
242
+ companion: fresh(),
243
+ water: defaultHydration(),
244
+ smoking: defaultCountdown(),
245
+ view: {},
246
+ });
247
+ const stamp = companionBackupStamp(b);
248
+ b.water.claim = { owner: 'other-tab', until: at + 8000, sequence: 0 };
249
+ b.companion.revision++;
250
+ assert.equal(companionBackupStamp(b), stamp);
251
+ const restored = companionTransition(
252
+ b.companion,
253
+ { type: 'restore', value: b.companion, base: 0, stamp: companionStateStamp(b.companion) },
254
+ at,
255
+ );
256
+ assert.equal(restored.revision, b.companion.revision);
257
+ b.water.count++;
258
+ assert.notEqual(companionBackupStamp(b), stamp);
259
+ const idb = new IDBFactory();
260
+ let store = createCompanionRecoveryStore(idb, createStateStore);
261
+ await store.update((s) => ({ ...s, bundle: b }));
262
+ store.close();
263
+ store = createCompanionRecoveryStore(idb, createStateStore);
264
+ assert.equal((await store.update((s) => s)).bundle.water.count, 1);
265
+ store.close();
266
+ assert.throws(() => validateBrowserBackup({ ...b, water: { ...b.water, schema: 999 } }));
267
+ });
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, writeFile, stat, readdir, rm } from 'node:fs/promise
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join, resolve, dirname, basename } from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
+ import { createSettingsAccess } from '../settings-api.js';
7
8
  import { createOperations, DEFAULT_SETTINGS, validateSettings } from '../index.js';
8
9
 
9
10
  const source = new URL('../index.js', import.meta.url);
@@ -149,7 +150,7 @@ async function adapters(t) {
149
150
  const moduleFile = join(fixtureData.dir, 'host.mjs');
150
151
  await writeFile(moduleFile, await readFile(source, 'utf8'));
151
152
  await writeFile(join(fixtureData.dir, 'package.json'), '{"type":"module"}');
152
- for (const name of ['media-server.js', 'media-manifest.js'])
153
+ for (const name of ['media-server.js', 'media-manifest.js', 'settings-api.js'])
153
154
  await writeFile(
154
155
  join(fixtureData.dir, name),
155
156
  await readFile(new URL('../' + name, import.meta.url)),
@@ -157,28 +158,34 @@ async function adapters(t) {
157
158
  const module = await import(pathToFileURL(moduleFile).href);
158
159
  let command;
159
160
  let tool;
160
- module.apply({
161
- effect: (effect) => effect(),
162
- commands: {
163
- register(definition) {
164
- command = definition;
165
- return () => {};
161
+ const access = createSettingsAccess(fixtureData.dir);
162
+ await access.set(true);
163
+ module.installOperations(
164
+ {
165
+ effect: (effect) => effect(),
166
+ commands: {
167
+ register(definition) {
168
+ command = definition;
169
+ return () => {};
170
+ },
166
171
  },
167
- },
168
- tools: {
169
- register(definition) {
170
- tool = definition;
171
- return () => {};
172
+ tools: {
173
+ register(definition) {
174
+ tool = definition;
175
+ return () => {};
176
+ },
172
177
  },
173
178
  },
174
- });
179
+ fixtureData.operate,
180
+ access,
181
+ );
175
182
  const runCommand = async (rawInput) => {
176
183
  const response = await command.handler({ rawInput });
177
184
  const result = JSON.parse(response.text);
178
185
  assert.equal(response.kind, result.ok ? 'success' : 'error');
179
186
  return result;
180
187
  };
181
- return { ...fixtureData, tool, command, runCommand };
188
+ return { ...fixtureData, tool, command, runCommand, access };
182
189
  }
183
190
 
184
191
  test('command and tool share results, validation, storage and a write queue', async (t) => {
@@ -65,6 +65,25 @@ test('dirty navigation retains the root destination for explicit save or discard
65
65
  assert.equal(next.screen, 'appearance');
66
66
  });
67
67
 
68
+ test('contextual backup and wrap-up return to their owning cards', () => {
69
+ const settings = go(initialPetUI, 'preferences', { root: true });
70
+ const backup = go(settings, 'data');
71
+ assert.equal(backup.back, 'preferences');
72
+ assert.equal(go(backup, backup.back).screen, 'preferences');
73
+ assert.equal(
74
+ go(backup, backup.back).back,
75
+ null,
76
+ 'back must not bounce between settings and backup',
77
+ );
78
+ const appearance = go(settings, 'appearance');
79
+ assert.equal(appearance.back, 'preferences');
80
+ const wrap = go(go(initialPetUI, 'income'), 'wrap');
81
+ assert.equal(wrap.back, 'income');
82
+ const pending = go(wrap, 'smoking', { dirty: true });
83
+ assert.equal(pending.screen, 'wrap');
84
+ assert.equal(go(pending, pending.pending.screen, { discard: true }).screen, 'smoking');
85
+ });
86
+
68
87
  test('appearance snapshots omit position and salary preferences', () => {
69
88
  assert.deepEqual(
70
89
  appearanceValues({