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.
- package/CHANGELOG.md +27 -0
- package/README.md +16 -9
- package/ROADMAP.md +70 -0
- package/client.js +2468 -237
- package/client.source.js +514 -227
- package/companion-app.js +1431 -0
- package/companion-image.js +59 -0
- package/companion-state.js +424 -0
- package/docs/RELEASE-1.2.2.md +20 -0
- package/icon.svg +28 -1
- package/index.js +57 -6
- package/package.json +8 -2
- package/pet-interactions.js +41 -8
- package/pet-surfaces.js +1 -1
- package/pet-view.js +2 -0
- package/settings-api.js +126 -0
- package/tests/companion-state.test.mjs +267 -0
- package/tests/host.test.mjs +21 -14
- package/tests/pet-interactions.test.mjs +19 -0
- package/tests/settings-api.test.mjs +130 -0
- package/tests/ui.test.mjs +4 -1
- package/tools/build.mjs +7 -1
- package/tools/check-package.mjs +4 -0
package/pet-interactions.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
// Ephemeral UI navigation only. Business data stays in its existing stores.
|
|
2
|
-
export const PET_EDITOR_SCREENS = [
|
|
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
|
-
:
|
|
36
|
-
?
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
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
|
|
package/settings-api.js
ADDED
|
@@ -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
|
+
});
|
package/tests/host.test.mjs
CHANGED
|
@@ -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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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({
|