feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ts = require('typescript');
|
||||
const source = readFileSync(process.env.API_CLIENT_TEST_SOURCE || new URL('../src/api/client.ts', import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
function loadClient(fetchImpl = fetch) {
|
||||
const timers = new Map();
|
||||
const delays = [];
|
||||
const events = [];
|
||||
let sequence = 0;
|
||||
let notifyHeaders;
|
||||
const headers = new Promise(resolve => { notifyHeaders = resolve; });
|
||||
const exports = {};
|
||||
vm.runInNewContext(compiled, {
|
||||
exports,
|
||||
require: name => {
|
||||
assert.equal(name, '../auth/portal');
|
||||
return { isOperatorPortal: () => false, phoneCSRFKey: () => 'test-csrf' };
|
||||
},
|
||||
AbortController, DOMException, Event, URLSearchParams,
|
||||
sessionStorage: { getItem: () => null },
|
||||
window: {
|
||||
setTimeout: (callback, delay) => { const id = ++sequence; timers.set(id, callback); delays.push(delay); return id; },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
dispatchEvent: event => { events.push(event.type); return true; }
|
||||
},
|
||||
fetch: async (...args) => { const response = await fetchImpl(...args); notifyHeaders(); return response; }
|
||||
});
|
||||
return {
|
||||
...exports, timers, delays, events, headers,
|
||||
expire: () => {
|
||||
assert.equal(timers.size, 1, 'deadline must remain active after headers');
|
||||
timers.values().next().value();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function stalledResponse(t, status = 200) {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
response.flushHeaders();
|
||||
response.write('{');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => {
|
||||
server.closeAllConnections();
|
||||
return new Promise(resolve => server.close(resolve));
|
||||
});
|
||||
return `http://127.0.0.1:${server.address().port}/api/v1/subjects/test`;
|
||||
}
|
||||
|
||||
async function waitForBody(api) {
|
||||
await api.headers;
|
||||
// Let fetch's header continuation settle while the server keeps the body open.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
|
||||
test('a saved mutation whose response body stalls is bounded and is not retried', { timeout: 5000 }, async t => {
|
||||
const url = await stalledResponse(t);
|
||||
let requests = 0;
|
||||
const api = loadClient((...args) => { requests++; return fetch(...args); });
|
||||
const outcome = api.authenticatedJSON('PATCH', url, '', undefined, { preferred_name: 'synthetic' }).catch(error => error);
|
||||
await waitForBody(api);
|
||||
api.expire();
|
||||
const error = await outcome;
|
||||
assert.ok(error instanceof api.APIRequestError);
|
||||
assert.equal(error.code, 'REQUEST_TIMEOUT');
|
||||
assert.equal(error.status, 0);
|
||||
assert.equal(error.message, '保存结果尚未确认,请先核验服务器记录');
|
||||
assert.equal(requests, 1);
|
||||
assert.deepEqual(api.delays, [15000]);
|
||||
assert.equal(api.timers.size, 0);
|
||||
assert.deepEqual(api.events, []);
|
||||
});
|
||||
|
||||
test('GET body timeout retains read-only recovery guidance', { timeout: 5000 }, async t => {
|
||||
const api = loadClient();
|
||||
const outcome = api.authenticatedJSON('GET', await stalledResponse(t), '').catch(error => error);
|
||||
await waitForBody(api);
|
||||
api.expire();
|
||||
const error = await outcome;
|
||||
assert.equal(error.code, 'REQUEST_TIMEOUT');
|
||||
assert.match(error.message, /请求超时/);
|
||||
assert.doesNotMatch(error.message, /保存结果/);
|
||||
});
|
||||
|
||||
test('a stalled HTTP error body is bounded without revoking authentication', { timeout: 5000 }, async t => {
|
||||
const api = loadClient();
|
||||
const outcome = api.authenticatedJSON('PATCH', await stalledResponse(t, 502), '').catch(error => error);
|
||||
await waitForBody(api);
|
||||
api.expire();
|
||||
assert.equal((await outcome).code, 'REQUEST_TIMEOUT');
|
||||
assert.deepEqual(api.events, []);
|
||||
});
|
||||
|
||||
test('caller cancellation while reading an error body is not a timeout or auth failure', { timeout: 5000 }, async t => {
|
||||
const api = loadClient();
|
||||
const caller = new AbortController();
|
||||
const outcome = api.authenticatedJSON('GET', await stalledResponse(t, 401), '', caller.signal).catch(error => error);
|
||||
await waitForBody(api);
|
||||
caller.abort();
|
||||
const error = await outcome;
|
||||
assert.equal(error.name, 'AbortError');
|
||||
assert.equal(error.code, DOMException.ABORT_ERR);
|
||||
assert.equal(api.timers.size, 0);
|
||||
assert.deepEqual(api.events, []);
|
||||
});
|
||||
|
||||
test('successful JSON responses clear their timers and preserve creation timeout', async () => {
|
||||
const api = loadClient(async () => Response.json({ data: { id: 'synthetic' } }));
|
||||
const result = await api.authenticatedJSON('POST', '/api/v1/subjects', '', undefined, { preferred_name: 'synthetic' });
|
||||
assert.equal(result.data.id, 'synthetic');
|
||||
assert.deepEqual(api.delays, [55000]);
|
||||
assert.equal(api.timers.size, 0);
|
||||
});
|
||||
|
||||
test('completed 502 HTML preserves the HTTP failure without authentication events', async () => {
|
||||
const api = loadClient(async () => new Response('<h1>Bad Gateway</h1>', { status: 502 }));
|
||||
const error = await api.authenticatedJSON('PATCH', '/api/v1/subjects/test', '').catch(value => value);
|
||||
assert.ok(error instanceof api.APIRequestError);
|
||||
assert.equal(error.status, 502);
|
||||
assert.deepEqual(api.events, []);
|
||||
assert.equal(api.timers.size, 0);
|
||||
});
|
||||
|
||||
test('completed 401 JSON still requests the existing authentication check', async () => {
|
||||
const api = loadClient(async () => Response.json({ error: { code: 'UNAUTHORIZED', message: 'synthetic' } }, { status: 401 }));
|
||||
const error = await api.authenticatedJSON('GET', '/api/v1/me', '').catch(value => value);
|
||||
assert.equal(error.status, 401);
|
||||
assert.equal(error.code, 'UNAUTHORIZED');
|
||||
assert.deepEqual(api.events, [api.AUTHENTICATION_REQUIRED_EVENT]);
|
||||
});
|
||||
|
||||
test('invalid successful JSON remains an unconfirmed response rather than success', async () => {
|
||||
const api = loadClient(async () => new Response('{', { status: 200 }));
|
||||
const error = await api.authenticatedJSON('PATCH', '/api/v1/subjects/test', '').catch(value => value);
|
||||
assert.equal(error.name, 'SyntaxError');
|
||||
assert.equal(api.timers.size, 0);
|
||||
assert.deepEqual(api.events, []);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BEIJING_TIMEZONE, formatBeijingDateTime, beijingDateKey, beijingDateTimeInput, parseBeijingDateTimeInput, beijingDateBoundary, beijingDayStart, beijingWeekStart, beijingCalendarRange } from "../src/lib/beijing-time.ts";
|
||||
|
||||
test("Beijing display, date grouping and form round-trip ignore the browser timezone", () => {
|
||||
const previous = process.env.TZ;
|
||||
try {
|
||||
for (const zone of ["Etc/UTC", "America/Los_Angeles", "America/New_York", "Asia/Tokyo"]) {
|
||||
process.env.TZ = zone;
|
||||
const at = new Date("2026-10-05T12:16:08Z");
|
||||
assert.equal(BEIJING_TIMEZONE, "Asia/Shanghai");
|
||||
assert.match(formatBeijingDateTime(at), /20:16:08/);
|
||||
assert.equal(formatBeijingDateTime(at, { hour: "2-digit", minute: "2-digit", timeZone: zone }), "20:16");
|
||||
assert.equal(beijingDateKey("2026-10-04T16:30:00Z"), "2026-10-05");
|
||||
assert.equal(beijingDateTimeInput(at), "2026-10-05T20:16");
|
||||
assert.equal(parseBeijingDateTimeInput("2026-10-05T20:16"), "2026-10-05T12:16:00.000Z");
|
||||
assert.equal(parseBeijingDateTimeInput("2026-10-05T20:16:08.123"), "2026-10-05T12:16:08.123Z");
|
||||
assert.equal(beijingDayStart(at).toISOString(), "2026-10-04T16:00:00.000Z");
|
||||
assert.equal(beijingDateBoundary("2026-10-05", 1).toISOString(), "2026-10-05T16:00:00.000Z");
|
||||
assert.deepEqual(beijingCalendarRange(30, new Date("2026-10-05T16:30:00Z")), {
|
||||
from: "2026-09-06T16:00:00.000Z", to: "2026-10-05T16:30:00.000Z", timezone: "Asia/Shanghai",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("Monday Beijing midnight resets the week, Tuesday midnight does not", () => {
|
||||
assert.equal(beijingWeekStart(new Date("2026-10-04T15:59:59Z")).toISOString(), "2026-09-27T16:00:00.000Z");
|
||||
assert.equal(beijingWeekStart(new Date("2026-10-04T16:00:00Z")).toISOString(), "2026-10-04T16:00:00.000Z");
|
||||
assert.equal(beijingWeekStart(new Date("2026-10-05T16:00:00Z")).toISOString(), "2026-10-04T16:00:00.000Z");
|
||||
assert.equal(beijingDateBoundary("2025-12-31", 1).toISOString(), "2025-12-31T16:00:00.000Z");
|
||||
assert.equal(beijingDateBoundary("2028-02-28", 1).toISOString(), "2028-02-28T16:00:00.000Z");
|
||||
});
|
||||
|
||||
test("invalid dates do not become a shifted date or an invented valid time", () => {
|
||||
for (const value of [null, undefined, "", "not-a-date"]) {
|
||||
assert.equal(formatBeijingDateTime(value), "—");
|
||||
assert.equal(beijingDateKey(value), "");
|
||||
assert.equal(beijingDateTimeInput(value), "");
|
||||
}
|
||||
for (const value of ["2026-02-29T10:00", "2026-10-05T24:00", "2026-10-05T20:99", "2026-10-05T20:16:60", "2026-10-05", "2026-10-05T20:16Z"]) {
|
||||
assert.throws(() => parseBeijingDateTimeInput(value), RangeError);
|
||||
}
|
||||
assert.throws(() => beijingDateBoundary("2026-02-30"), RangeError);
|
||||
assert.throws(() => beijingCalendarRange(0), RangeError);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = readFileSync(new URL('../src/ble/client.ts', import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText;
|
||||
const { BLEClient, BLE_SERVICE, BLEDeviceSelectionCancelledError } = await import('data:text/javascript;base64,' + Buffer.from(compiled).toString('base64'));
|
||||
globalThis.window = { isSecureContext: true };
|
||||
|
||||
for (const name of ['NotFoundError', 'AbortError']) test(`localizes ${name} from the device chooser and allows retry`, async () => {
|
||||
Object.defineProperty(globalThis.navigator, 'bluetooth', { configurable: true, value: {
|
||||
async requestDevice() { throw new DOMException('User cancelled the requestDevice() chooser.', name); },
|
||||
} });
|
||||
const client = new BLEClient();
|
||||
await assert.rejects(client.connect(), error => error instanceof BLEDeviceSelectionCancelledError && error.message === '已取消选择设备,可重新搜索。');
|
||||
const { gatt } = fake();
|
||||
assert.equal((await client.connect()).protocol_version, 2);
|
||||
client.disconnect();
|
||||
assert.equal(gatt.connected, false);
|
||||
});
|
||||
|
||||
test('does not mistake a missing GATT service for cancelling the chooser', async () => {
|
||||
const { gatt } = fake();
|
||||
const missing = new DOMException('Missing GATT service', 'NotFoundError');
|
||||
gatt.getPrimaryService = async () => { throw missing; };
|
||||
await assert.rejects(new BLEClient().connect(), error => error === missing && !(error instanceof BLEDeviceSelectionCancelledError));
|
||||
assert.equal(gatt.connected, false);
|
||||
});
|
||||
|
||||
function fake(mode = 'normal') {
|
||||
const tx = new EventTarget();
|
||||
tx.startNotifications = async () => tx;
|
||||
const writes = [];
|
||||
const commands = [];
|
||||
let input = Buffer.alloc(0);
|
||||
const gatt = {
|
||||
connected: false,
|
||||
async connect() { this.connected = true; return this; },
|
||||
disconnect() { this.connected = false; },
|
||||
async getPrimaryService(id) {
|
||||
assert.equal(id, BLE_SERVICE);
|
||||
return { getCharacteristic: async id => id.includes('2001-') ? rx : tx };
|
||||
},
|
||||
};
|
||||
const rx = { async writeValueWithResponse(bytes) {
|
||||
assert(bytes.length <= 20);
|
||||
writes.push(bytes);
|
||||
input = Buffer.concat([input, bytes]);
|
||||
if (input.length < 2 || input.length < 2 + input.readUInt16LE(0)) return;
|
||||
assert.equal(input.length, 2 + input.readUInt16LE(0));
|
||||
const command = JSON.parse(input.subarray(2).toString('utf8'));
|
||||
commands.push(command);
|
||||
let response = command.cmd === 'get_info'
|
||||
? { device_id: 'KP01-B43A451634A8', model: 'ECHOEAR-S3', protocol_version: mode === 'bad-protocol' ? 1 : 2 }
|
||||
: { status: 'received' };
|
||||
const body = Buffer.from(JSON.stringify(response));
|
||||
const frame = Buffer.concat([Buffer.from([body.length & 255, body.length >> 8]), body]);
|
||||
input = Buffer.alloc(0);
|
||||
if (mode === 'oversize') {
|
||||
tx.value = new DataView(new Uint8Array(21).buffer);
|
||||
tx.dispatchEvent(new Event('characteristicvaluechanged'));
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < frame.length; i += 7) {
|
||||
const chunk = Uint8Array.from(frame.subarray(i, i + 7));
|
||||
tx.value = new DataView(chunk.buffer);
|
||||
tx.dispatchEvent(new Event('characteristicvaluechanged'));
|
||||
}
|
||||
} };
|
||||
const device = Object.assign(new EventTarget(), { gatt });
|
||||
Object.defineProperty(globalThis.navigator, 'bluetooth', { configurable: true, value: {
|
||||
async requestDevice({ filters }) {
|
||||
assert.deepEqual(filters, [{ services: [BLE_SERVICE] }]);
|
||||
return device;
|
||||
},
|
||||
} });
|
||||
return { writes, commands, gatt };
|
||||
}
|
||||
|
||||
test('discovers by service and uses the same plaintext v2 frames as the mini program', async () => {
|
||||
const { writes, commands, gatt } = fake();
|
||||
const client = new BLEClient();
|
||||
const info = await client.connect();
|
||||
assert.deepEqual(info, { device_id: 'KP01-B43A451634A8', model: 'ECHOEAR-S3', protocol_version: 2 });
|
||||
assert.deepEqual(commands[0], { cmd: 'get_info' });
|
||||
assert.equal(writes[0][0] | writes[0][1] << 8, Buffer.byteLength(JSON.stringify({ cmd: 'get_info' })));
|
||||
assert.deepEqual(await client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: 'x'.repeat(43) }), { status: 'received' });
|
||||
assert.deepEqual(commands[1], { cmd: 'configure', wifi_ssid: 'HomeWiFi', wifi_password: 'test-only-password', bind_token: 'x'.repeat(43) });
|
||||
client.disconnect();
|
||||
assert.equal(gatt.connected, false);
|
||||
});
|
||||
|
||||
for (const mode of ['oversize', 'bad-protocol']) test(`rejects ${mode} response and disconnects`, async () => {
|
||||
const { gatt } = fake(mode);
|
||||
const client = new BLEClient();
|
||||
await assert.rejects(client.connect());
|
||||
assert.equal(gatt.connected, false);
|
||||
});
|
||||
|
||||
test('rejects invalid Wi-Fi data before sending it to the device', async () => {
|
||||
const { commands } = fake();
|
||||
const client = new BLEClient();
|
||||
await client.connect();
|
||||
await assert.rejects(client.configure({ ssid: '', password: 'short', token: 'bad' }), /格式不正确/);
|
||||
assert.equal(commands.length, 1);
|
||||
});
|
||||
|
||||
test('Wi-Fi-only reconfiguration sends an empty token, never a new ownership claim', async () => {
|
||||
const { commands } = fake();
|
||||
const client = new BLEClient();
|
||||
await client.connect();
|
||||
await client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: '' });
|
||||
assert.deepEqual(commands[1], { cmd: 'configure', wifi_ssid: 'HomeWiFi', wifi_password: 'test-only-password', bind_token: '' });
|
||||
await assert.rejects(client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: 'malformed' }));
|
||||
assert.equal(commands.length, 2);
|
||||
client.disconnect();
|
||||
});
|
||||
|
||||
test('Factory reset uses a distinct BLE command with no binding token and validates Wi-Fi', async () => {
|
||||
const {commands}=fake();const client=new BLEClient();await client.connect();
|
||||
await client.factoryReset({ssid:'Home',password:'TestOnly123'});
|
||||
assert.deepEqual(commands[1],{cmd:'factory_reset',wifi_ssid:'Home',wifi_password:'TestOnly123',bind_token:''});
|
||||
await assert.rejects(client.factoryReset({ssid:'',password:'short'}));assert.equal(commands.length,2);client.disconnect();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Explicit opt-in real-hardware smoke check. Does not submit Wi-Fi or bind an account.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
const label = JSON.parse(readFileSync(process.argv[2], 'utf8'));
|
||||
const compiled = ts.transpileModule(readFileSync(new URL('../src/ble/security2.ts', import.meta.url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText;
|
||||
const { Security2 } = await import('data:text/javascript;base64,' + Buffer.from(compiled).toString('base64'));
|
||||
const python = process.env.BLE_PYTHON;
|
||||
if (!python) throw new Error('Set BLE_PYTHON to the ESP-IDF Python executable');
|
||||
const child = spawn(python, ['../.tmp-ble-release/ble_bridge.py', 'EchoEar-' + label.device_id.slice(-6)], { stdio: ['pipe','pipe','inherit'] });
|
||||
const lines = createInterface({ input: child.stdout })[Symbol.asyncIterator]();
|
||||
async function receive() { const line = await lines.next(); if (line.done) throw new Error('BLE bridge ended'); const result = JSON.parse(line.value); if (result.error) throw new Error(result.error); return result; }
|
||||
try {
|
||||
const connected = await receive(); console.log(JSON.stringify(connected));
|
||||
const security = new Security2();
|
||||
async function exchange(type, data) {
|
||||
const header = Buffer.from([type, data.length & 255, data.length >> 8]);
|
||||
child.stdin.write(JSON.stringify({ frame: Buffer.concat([header, data]).toString('base64') }) + '\n');
|
||||
const frame = Buffer.from((await receive()).frame,'base64');
|
||||
if (frame[0] !== type || frame.readUInt16LE(1) + 3 !== frame.length) throw new Error('Invalid device reply');
|
||||
return frame.subarray(3);
|
||||
}
|
||||
await security.handshake(label.device_id, label.setup_password, data => exchange(0, data));
|
||||
console.log('Real ESP-IDF Security2 handshake verified');
|
||||
for (const cmd of ['get_info','get_status','get_info','get_status']) {
|
||||
const raw = Buffer.from(JSON.stringify({ cmd }));
|
||||
const response = JSON.parse(Buffer.from(await security.decrypt(await exchange(1, await security.encrypt(raw)))).toString());
|
||||
if (cmd === 'get_info' && response.device_id !== label.device_id) throw new Error('Wrong physical device');
|
||||
console.log(JSON.stringify({ cmd, response }));
|
||||
}
|
||||
security.clear();
|
||||
} finally { child.stdin.end(JSON.stringify({ close: true }) + '\n'); await new Promise(resolve => child.on('exit', resolve)); }
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createHash, createCipheriv, createDecipheriv } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
const compiled = ts.transpileModule(readFileSync(new URL('../src/ble/security2.ts', import.meta.url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText;
|
||||
const { Security2 } = await import('data:text/javascript;base64,' + Buffer.from(compiled).toString('base64'));
|
||||
// Independent server side, based on ESP-IDF esp_srp.c and RFC 3526 group 15.
|
||||
const { getDiffieHellman } = await import('node:crypto');
|
||||
const N = BigInt('0x' + getDiffieHellman('modp15').getPrime('hex'));
|
||||
const b = 0xa1386433d535c175643fb90493801529327587213648974398127398478937289477n;
|
||||
const buf = (n, width = 0) => { let h = n.toString(16); if (h.length % 2) h = '0' + h; return Buffer.from(h.padStart(width * 2, '0'), 'hex'); };
|
||||
const int = b => BigInt('0x' + Buffer.from(b).toString('hex'));
|
||||
const hash = (...args) => createHash('sha512').update(Buffer.concat(args.map(x => Buffer.from(x)))).digest();
|
||||
function pow(a, b) { let r = 1n; a = (a % N + N) % N; for (; b; b >>= 1n, a = a * a % N) if (b & 1n) r = r * a % N; return r; }
|
||||
const varint = n => { const a = []; do { a.push(n & 127 | (n > 127 ? 128 : 0)); n >>>= 7; } while (n); return Buffer.from(a); };
|
||||
const field = (id, x) => typeof x === 'number' ? Buffer.concat([varint(id * 8), varint(x)]) : Buffer.concat([varint(id * 8 + 2), varint(x.length), x]);
|
||||
function parse(raw) { let i = 0; const o = {}; const read = () => { let n = 0, s = 0, x; do { x = raw[i++]; n |= (x & 127) << s; s += 7; } while (x & 128); return n; }; while (i < raw.length) { const tag = read(); if ((tag & 7) === 0) o[tag >> 3] = read(); else { const len = read(); o[tag >> 3] = raw.slice(i, i += len); } } return o; }
|
||||
const packet = (kind, id, data) => Buffer.concat([field(2, 2), field(12, Buffer.concat([field(1, kind), field(id, data)]))]);
|
||||
function server(username, password, { badProof = false, salt = Buffer.alloc(16, 3) } = {}) {
|
||||
const v = pow(5n, int(hash(salt, hash(Buffer.from(username + ':' + password)))));
|
||||
const k = int(hash(buf(N, 384), buf(5n, 384)));
|
||||
const B = (k * v + pow(5n, b)) % N;
|
||||
let key, Araw, proof;
|
||||
const iv = Buffer.from('010203040506070800000001', 'hex');
|
||||
return {
|
||||
async exchange(input) {
|
||||
const payload = parse(parse(input)[12]);
|
||||
if ((payload[1] ?? 0) === 0) {
|
||||
const command = parse(payload[20]); Araw = command[2];
|
||||
assert.equal(Buffer.from(command[1]).toString(), username); assert.equal(Araw.length, 384);
|
||||
const A = int(Araw), u = int(hash(Araw, buf(B, 384)));
|
||||
key = hash(buf(pow(A * pow(v, u), b)));
|
||||
const hn = hash(buf(N)), hg = hash(buf(5n, 384));
|
||||
proof = hash(hn.map((v, i) => v ^ hg[i]), hash(Buffer.from(username)), salt, Araw, buf(B), key);
|
||||
return packet(1, 21, Buffer.concat([field(2, buf(B)), field(3, salt)]));
|
||||
}
|
||||
assert.equal(payload[1], 2); assert.deepEqual(Buffer.from(parse(payload[22])[1]), proof);
|
||||
const serverProof = hash(Araw, proof, key); if (badProof) serverProof[0] ^= 1;
|
||||
return packet(3, 23, Buffer.concat([field(2, serverProof), field(3, iv)]));
|
||||
},
|
||||
decrypt(data) { const d = createDecipheriv('aes-256-gcm', key.subarray(0, 32), iv); d.setAuthTag(data.slice(-16)); const raw = Buffer.concat([d.update(data.slice(0, -16)), d.final()]); iv.writeUInt32BE(iv.readUInt32BE(8) + 1, 8); return raw; },
|
||||
encrypt(data) { const e = createCipheriv('aes-256-gcm', key.subarray(0, 32), iv); const raw = Buffer.concat([e.update(data), e.final(), e.getAuthTag()]); iv.writeUInt32BE(iv.readUInt32BE(8) + 1, 8); return raw; },
|
||||
};
|
||||
}
|
||||
test('Security2 matches the C server and shares an ordered GCM counter', async () => {
|
||||
const s = server('KP01-B43A451634A8', 'test-setup-only'), c = new Security2();
|
||||
await c.handshake('KP01-B43A451634A8', 'test-setup-only', s.exchange);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
assert.equal(s.decrypt(await c.encrypt(Buffer.from('request-' + i))).toString(), 'request-' + i);
|
||||
assert.equal(Buffer.from(await c.decrypt(s.encrypt(Buffer.from('response-' + i)))).toString(), 'response-' + i);
|
||||
}
|
||||
});
|
||||
test('leading zero salt/digest bytes are preserved (C firmware compatibility)', async () => {
|
||||
const username = 'KP01-B43A451634A8'; let password = '', i = 0;
|
||||
do { password = 'leading-zero-test-' + i++; } while (hash(Buffer.from(username + ':' + password))[0] !== 0);
|
||||
const s = server(username, password, { salt: Buffer.alloc(16) }), c = new Security2();
|
||||
await c.handshake(username, password, s.exchange);
|
||||
assert.equal(s.decrypt(await c.encrypt(Buffer.from('verified'))).toString(), 'verified');
|
||||
});
|
||||
test('wrong device proof and damaged ciphertext fail closed', async () => {
|
||||
const c = new Security2(), bad = server('device', 'password', { badProof: true });
|
||||
await assert.rejects(c.handshake('device', 'password', bad.exchange));
|
||||
await assert.rejects(c.encrypt(Buffer.from('secret')));
|
||||
const s = server('device', 'password'); await c.handshake('device', 'password', s.exchange);
|
||||
s.decrypt(await c.encrypt(Buffer.from('hello')));
|
||||
const damaged = s.encrypt(Buffer.from('reply')); damaged[0] ^= 1;
|
||||
await assert.rejects(c.decrypt(damaged)); await assert.rejects(c.encrypt(Buffer.from('secret')));
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { consentGrantNotice, hasConsent, resolveConsentPolicy } from '../src/components/consent-model.ts';
|
||||
const now = Date.parse('2026-10-05T10:00:00Z');
|
||||
const consent = { type: 'transcript_storage', effective: true, granted_at: '2026-10-01T10:00:00Z' };
|
||||
|
||||
test('consent status rejects withdrawn, expired, future and ineffective grants', () => {
|
||||
assert.equal(hasConsent([consent], 'transcript_storage', now), true);
|
||||
for (const patch of [{ effective: false }, { withdrawn_at: '2026-10-04T10:00:00Z' }, { expires_at: '2026-10-05T10:00:00Z' }, { granted_at: '2026-10-06T10:00:00Z' }]) {
|
||||
assert.equal(hasConsent([{ ...consent, ...patch }], 'transcript_storage', now), false);
|
||||
}
|
||||
assert.equal(hasConsent([{ ...consent, expires_at: '2026-10-05T10:00:01Z' }], 'transcript_storage', now), true);
|
||||
});
|
||||
|
||||
test('historical withdrawal does not hide a later valid grant or grant a different purpose', () => {
|
||||
const withdrawn = { ...consent, withdrawn_at: '2026-10-04T10:00:00Z' };
|
||||
assert.equal(hasConsent([withdrawn, consent], 'transcript_storage', now), true);
|
||||
assert.equal(hasConsent([withdrawn, consent], 'raw_audio_storage', now), false);
|
||||
assert.equal(hasConsent([], 'transcript_storage', now), false);
|
||||
});
|
||||
|
||||
test('a policy version alone never counts as a readable policy', () => {
|
||||
for (const version of ['', 'local-v1', 'published-v2']) {
|
||||
assert.equal(resolveConsentPolicy(version).state, 'blocked');
|
||||
assert.equal(consentGrantNotice('personalized_memory', '测试对象', version), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('blank or mismatched policy documents block confirmation', () => {
|
||||
for (const document of [
|
||||
{ version: 'v1', title: '测试文档', content: ' ' },
|
||||
{ version: 'v1', title: '', content: '仅供自动化测试的内容' },
|
||||
{ version: 'v0', title: '测试文档', content: '仅供自动化测试的内容' },
|
||||
]) {
|
||||
assert.equal(resolveConsentPolicy('v1', document).state, 'blocked');
|
||||
assert.equal(consentGrantNotice('personalized_memory', '测试对象', 'v1', document), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('grant evidence includes the displayed document and exact purpose', () => {
|
||||
const document = { version: 'v1', title: '测试文档', content: '仅供自动化测试的内容\n保留换行' };
|
||||
assert.equal(resolveConsentPolicy('v1', document).state, 'ready');
|
||||
const notice = consentGrantNotice('personalized_memory', '测试对象', 'v1', document);
|
||||
assert.ok(notice.includes(document.content));
|
||||
assert.ok(notice.includes('测试对象'));
|
||||
assert.ok(notice.includes('个性化记忆'));
|
||||
assert.notEqual(notice, consentGrantNotice('personalized_memory', '测试对象', 'v1', { ...document, content: `${document.content}\n已修订` }));
|
||||
assert.notEqual(notice, consentGrantNotice('raw_audio_storage', '测试对象', 'v1', document));
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const Module = require('node:module');
|
||||
const ts = require('typescript');
|
||||
const React = require('react');
|
||||
const { renderToStaticMarkup } = require('react-dom/server');
|
||||
|
||||
// Render the actual consent components without a browser or network connection.
|
||||
// Ant Design's portal is replaced with a visible dialog for server rendering.
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function(request, parent, isMain) {
|
||||
if (request.endsWith('.css')) return {};
|
||||
if (request === '@phosphor-icons/react') return new Proxy({}, { get: () => () => null });
|
||||
if (request === '../api/client') return {
|
||||
APIRequestError: class extends Error {},
|
||||
platformApi: new Proxy({}, { get() { return () => { throw new Error('Unexpected API request'); }; } }),
|
||||
};
|
||||
if (request === 'antd') return {
|
||||
Modal: ({ children, okButtonProps, okText }) => React.createElement('div', null, children, React.createElement('button', { disabled: okButtonProps.disabled }, okText)),
|
||||
Alert: ({ title }) => React.createElement('p', null, title),
|
||||
Checkbox: ({ children, ...props }) => React.createElement('label', null, React.createElement('input', { type: 'checkbox', ...props }), children),
|
||||
};
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
for (const extension of ['.ts', '.tsx']) {
|
||||
require.extensions[extension] = (module, file) => module._compile(ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }, fileName: file,
|
||||
}).outputText, file);
|
||||
}
|
||||
const { SubjectConsentDialog } = require('../src/components/SubjectConsentDialog.tsx');
|
||||
const { CreationConsentDialog } = require('../src/components/CreationConsentDialog.tsx');
|
||||
const { ConsentGrantDialog } = require('../src/components/ConsentGuidance.tsx');
|
||||
const noop = () => {};
|
||||
const render = (component, props) => renderToStaticMarkup(React.createElement(component, props));
|
||||
const status = { state: 'ready', items: [], refresh: noop };
|
||||
const common = { subjectID: 'subject-test', subjectName: '测试对象', accessToken: '', policyVersion: 'local-v1', status, onClose: noop };
|
||||
const inputFor = (html, label) => html.match(new RegExp(`<input[^>]*aria-label="${label}"[^>]*>`))?.[0];
|
||||
|
||||
test('existing-subject authorization is blocked by a version-only policy, but active consent can be unchecked', () => {
|
||||
const html = render(SubjectConsentDialog, { ...common, canManage: true, onSaved: noop,
|
||||
status: { ...status, items: [{ type: 'transcript_storage', effective: true, granted_at: '2020-01-01T00:00:00Z' }] },
|
||||
});
|
||||
assert.match(html, /政策正文不可用/);
|
||||
assert.match(html, /local-v1/);
|
||||
assert.match(inputFor(html, '个性化记忆'), /disabled/);
|
||||
assert.doesNotMatch(inputFor(html, '保存聊天文字'), /disabled/);
|
||||
assert.match(inputFor(html, '保存聊天文字'), /checked/);
|
||||
assert.doesNotMatch(html, /我已阅读/);
|
||||
});
|
||||
|
||||
test('creation without policy text keeps optional authorization blocked and allows creating without it', () => {
|
||||
const html = render(CreationConsentDialog, { name: '测试对象', selected: [], policyVersion: 'local-v1', onChange: noop, onBack: noop, onClose: noop, onConfirm: noop });
|
||||
assert.match(html, /<fieldset[^>]*disabled/);
|
||||
assert.match(html, /<button[^>]*disabled[^>]*>同意所选并创建/);
|
||||
const skip = html.match(/<button[^>]*>暂不开通,完成创建<\/button>/)?.[0];
|
||||
assert.ok(skip);
|
||||
assert.doesNotMatch(skip, /disabled/);
|
||||
assert.doesNotMatch(html, /checked=""/);
|
||||
});
|
||||
|
||||
test('quick grant cannot confirm merely because local-v1 is present', () => {
|
||||
const html = render(ConsentGrantDialog, { ...common, type: 'personalized_memory', canGrant: true, onGranted: noop });
|
||||
assert.match(html, /尚未提供可阅读的正式政策正文/);
|
||||
assert.match(html, /<input[^>]*disabled/);
|
||||
assert.match(html, /<button[^>]*disabled[^>]*>确认授权/);
|
||||
assert.doesNotMatch(html, /checked=""/);
|
||||
});
|
||||
|
||||
test('an available matching document is readable and never preconfirms a grant', () => {
|
||||
const document = { version: 'v2', title: '测试文档', content: '仅供自动化测试的正文' };
|
||||
const html = render(CreationConsentDialog, { name: '测试对象', selected: ['personalized_memory'], policyVersion: 'v2', policyDocument: document, onChange: noop, onBack: noop, onClose: noop, onConfirm: noop });
|
||||
assert.match(html, /隐私与授权政策正文/);
|
||||
assert.match(html, /仅供自动化测试的正文/);
|
||||
assert.match(html, /<button[^>]*disabled[^>]*>同意所选并创建/);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
const source = fs.readFileSync(new URL('../src/components/device-control-model.ts', import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } });
|
||||
const module = { exports: {} };
|
||||
new Function('exports', compiled.outputText)(module.exports);
|
||||
const { speakerVolume, volumeFeedback, powerPending, deviceStateLabel } = module.exports;
|
||||
const twin = (changes = {}) => ({ desired_revision: 4, reported_revision: 4, desired_config: { volume: { speaker: 0 }, power: { mode: 'active' } }, reported_config: { volume: { speaker: 0 }, power: { mode: 'active' } }, ...changes });
|
||||
|
||||
test('zero volume is a valid confirmed setting; malformed hardware values stay unknown', () => {
|
||||
assert.equal(speakerVolume({ volume: { speaker: 0 } }), 0);
|
||||
for (const value of ['60', -1, 101, 2.5, NaN]) assert.equal(speakerVolume({ volume: { speaker: value } }), undefined);
|
||||
assert.deepEqual(volumeFeedback(twin(), true), { state: 'applied', text: '已生效' });
|
||||
});
|
||||
test('saving a target is not an acknowledgment, even if the old reported value happens to match', () => {
|
||||
assert.equal(volumeFeedback(twin({ reported_revision: 3 }), true).state, 'pending');
|
||||
assert.equal(volumeFeedback(twin({ reported_config: { volume: { speaker: 20 } } }), true).state, 'pending');
|
||||
assert.equal(volumeFeedback(twin({ reported_revision: 3 }), false).text, '已保存,等待设备连接');
|
||||
assert.equal(volumeFeedback(twin(), false).text, '上次已生效');
|
||||
});
|
||||
test('missing reports and read-only state never imply readiness or successful application', () => {
|
||||
assert.equal(volumeFeedback(undefined).state, 'loading');
|
||||
assert.equal(volumeFeedback(twin({ desired_config: {}, reported_config: {} }), true).state, 'unknown');
|
||||
assert.equal(deviceStateLabel(twin({ reported_config: {} }), true), '在线 · 状态待确认');
|
||||
assert.equal(deviceStateLabel(twin(), undefined), '正在确认连接');
|
||||
assert.equal(deviceStateLabel(twin(), false), '设备离线');
|
||||
});
|
||||
test('standby uses the reported mode, and a queued request remains pending until the revision is acknowledged', () => {
|
||||
const waiting = twin({ desired_revision: 5, desired_config: { power: { mode: 'standby' } } });
|
||||
assert.equal(powerPending(waiting), true);
|
||||
assert.equal(deviceStateLabel(waiting, true), '在线 · 使用中');
|
||||
const applied = twin({ desired_config: { power: { mode: 'standby' } }, reported_config: { power: { mode: 'standby' } } });
|
||||
assert.equal(powerPending(applied), false);
|
||||
assert.equal(deviceStateLabel(applied, true), '在线 · 已待机');
|
||||
assert.equal(powerPending({ ...applied, reported_revision: 3 }), true);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = fs.readFileSync(new URL('../src/components/device-volume-state.ts', import.meta.url), 'utf8');
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText)(module, module.exports);
|
||||
const { volumeSyncMessage, volumeSyncStatus } = module.exports;
|
||||
const twin = (desired, actual, desiredRevision = 2, reportedRevision = 2) => ({
|
||||
desired_revision: desiredRevision, reported_revision: reportedRevision,
|
||||
desired_config: desired === undefined ? {} : { volume: { speaker: desired } },
|
||||
reported_config: actual === undefined ? {} : { volume: { speaker: actual } },
|
||||
});
|
||||
|
||||
test('volume status distinguishes a read in progress from an unreported volume', () => {
|
||||
assert.equal(volumeSyncStatus(undefined), '正在读取音量状态');
|
||||
assert.equal(volumeSyncStatus(twin(undefined, undefined)), '等待设备上报音量');
|
||||
assert.equal(volumeSyncStatus(twin(undefined, 0)), '已收到设备音量');
|
||||
});
|
||||
|
||||
test('synchronized volume requires both the actual value and revision confirmation', () => {
|
||||
assert.equal(volumeSyncStatus(twin(0, 0)), '音量已同步');
|
||||
assert.match(volumeSyncStatus(twin(60, 60, 2, 1)), /等待设备确认/);
|
||||
assert.match(volumeSyncStatus(twin(60, 40)), /等待设备确认/);
|
||||
assert.match(volumeSyncStatus(twin(60, undefined)), /等待设备确认/);
|
||||
});
|
||||
|
||||
test('offline changes remain pending, while last confirmed values remain confirmed', () => {
|
||||
assert.equal(volumeSyncStatus(twin(60, 40), false), '音量设置已保存,设备上线后会自动同步');
|
||||
assert.equal(volumeSyncStatus(twin(60, 60), false), '音量已同步');
|
||||
});
|
||||
|
||||
test('action confirmation retains the existing actual-volume information', () => {
|
||||
assert.equal(volumeSyncMessage(twin(0, 0)), '音量已生效,设备实际音量 0%');
|
||||
assert.equal(volumeSyncMessage(twin(undefined, 40)), '设备当前音量 40%');
|
||||
assert.equal(volumeSyncMessage(twin(60, 40), false), '音量设置已保存,设备上线后会自动同步');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { familyControlMode, familyControlReady, familyControlDangerous } from '../src/pages/family-control-policy.ts';
|
||||
|
||||
const draft = kind => ({ kind, acknowledged: false, confirmation: '' });
|
||||
|
||||
test('ordinary saves and authorized media access can execute without a second acknowledgement', () => {
|
||||
for (const kind of ['household_create', 'fact_create', 'subject_update', 'device_twin_update', 'invitation_create', 'recording_play_address', 'recording_download_address', 'privacy_access_request', 'privacy_export_download', 'firmware_release_resume']) {
|
||||
assert.equal(familyControlMode(kind), 'direct');
|
||||
assert.equal(familyControlReady(draft(kind)), true);
|
||||
assert.equal(familyControlDangerous(kind), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('permission and device-impact actions still require a review surface', () => {
|
||||
for (const kind of ['invitation_accept', 'recording_access_grant', 'sharing_grant_create', 'privacy_export_request', 'member_remove', 'consent_withdraw', 'firmware_key_register', 'firmware_key_revoke', 'firmware_rollout_start', 'firmware_release_retire', 'firmware_pause', 'firmware_rollback', 'device_diagnostics']) {
|
||||
assert.equal(familyControlMode(kind), 'review', kind);
|
||||
}
|
||||
});
|
||||
|
||||
test('creating a subject never silently accepts data consent', () => {
|
||||
assert.equal(familyControlMode('subject_create'), 'consent');
|
||||
assert.equal(familyControlReady(draft('subject_create')), false);
|
||||
assert.equal(familyControlReady({ ...draft('subject_create'), acknowledged: true }), true);
|
||||
});
|
||||
|
||||
test('ownership transfer requires the exact named recipient, while removal needs a single review', () => {
|
||||
const transfer = { ...draft('ownership_transfer'), item: { display_name: '测试家人' } };
|
||||
assert.equal(familyControlMode(transfer.kind), 'typed');
|
||||
for (const confirmation of ['', '测试', '另一位家人']) assert.equal(familyControlReady({ ...transfer, confirmation }), false);
|
||||
assert.equal(familyControlReady({ ...transfer, confirmation: '测试家人' }), true);
|
||||
assert.equal(familyControlReady({ ...transfer, confirmation: ' 测试家人 ' }), true);
|
||||
assert.equal(familyControlReady({ ...draft('member_remove'), item: transfer.item }), true);
|
||||
});
|
||||
|
||||
test('unchanged roles cannot be submitted even after confirming', () => {
|
||||
const role = { ...draft('member_role_change'), item: { role: 'viewer' }, targetRole: 'viewer', acknowledged: true };
|
||||
assert.equal(familyControlReady(role), false);
|
||||
assert.equal(familyControlReady({ ...role, targetRole: 'editor', acknowledged: false }), true);
|
||||
});
|
||||
|
||||
test('danger styling distinguishes granting access from losing it', () => {
|
||||
assert.equal(familyControlDangerous('recording_access_grant'), false);
|
||||
assert.equal(familyControlDangerous('recording_access_revoke'), true);
|
||||
assert.equal(familyControlDangerous('ownership_transfer'), true);
|
||||
assert.equal(familyControlDangerous('firmware_release_retire'), true);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { homeWeek, homeDeviceStatus } from "../src/components/home/home-model.ts";
|
||||
const now = new Date("2026-09-20T12:00:00+08:00");
|
||||
const call = (id, created_at, extras = {}) => ({ id, subject_id: "elder-a", status: "COMPLETED", duration_ms: 60000, created_at, ...extras });
|
||||
test("empty days never become steady mood observations", () => {
|
||||
const week = homeWeek([], "elder-a", {}, now);
|
||||
assert.equal(week.count, 0); assert.equal(week.minutes, 0); assert.equal(week.days.length, 7);
|
||||
assert.equal(week.range, "9月14日—9月20日");
|
||||
assert(week.days.every(day => day.moodLabel === undefined));
|
||||
});
|
||||
test("counts conversations, not days with activity", () => {
|
||||
const week = homeWeek([call("one", "2026-09-19T10:00:00+08:00"), call("two", "2026-09-19T11:00:00+08:00", { duration_ms: 120000 })], "elder-a", {}, now);
|
||||
assert.equal(week.count, 2); assert.equal(week.minutes, 3); assert.equal(week.days[5].count, 2);
|
||||
});
|
||||
test("scopes to selected elder and completed calls within week; excludes future records", () => {
|
||||
const week = homeWeek([
|
||||
call("other", "2026-09-19T10:00:00+08:00", { subject_id: "elder-b" }),
|
||||
call("failed", "2026-09-19T10:00:00+08:00", { status: "FAILED" }),
|
||||
call("previous", "2026-09-13T23:59:59+08:00"), call("future", "2026-09-20T13:00:00+08:00"),
|
||||
call("current", "2026-09-14T00:00:00+08:00")
|
||||
], "elder-a", {}, now);
|
||||
assert.equal(week.count, 1); assert.equal(week.latest.id, "current");
|
||||
});
|
||||
test("uses Beijing at UTC date boundaries even with a historical profile timezone", () => {
|
||||
assert.equal(homeWeek([call("boundary", "2026-09-13T16:05:00Z")], "elder-a", {}, now, "Asia/Shanghai").days[0].count, 1);
|
||||
assert.equal(homeWeek([call("boundary", "2026-09-13T16:05:00Z")], "elder-a", {}, now, "America/Los_Angeles").days[0].count, 1);
|
||||
assert.equal(homeWeek([], "elder-a", {}, new Date("2026-10-04T16:00:00Z"), "America/Los_Angeles").range, "10月5日—10月11日");
|
||||
});
|
||||
test("mood needs both a record and supporting evidence", () => {
|
||||
const conversations = [call("one", "2026-09-19T11:00:00+08:00")];
|
||||
assert.equal(homeWeek(conversations, "elder-a", { one: { moodLabel: "平稳" } }, now).days[5].moodLabel, undefined);
|
||||
assert.equal(homeWeek(conversations, "elder-a", { one: { moodLabel: "愉快", evidence: "聊天摘要中的原始表达" } }, now).days[5].moodLabel, "愉快");
|
||||
});
|
||||
test("missing presence is unknown; any online device counts as connected", () => {
|
||||
const devices = [{ id: "one", subject_id: "elder-a" }, { id: "two", subject_id: "elder-a" }];
|
||||
assert.equal(homeDeviceStatus(devices, {}, "elder-a"), "unknown");
|
||||
assert.equal(homeDeviceStatus(devices, { one: { online: false }, two: { online: false } }, "elder-a"), "offline");
|
||||
assert.equal(homeDeviceStatus(devices, { one: { online: true } }, "elder-a"), "online");
|
||||
assert.equal(homeDeviceStatus(devices, {}, "elder-b"), "unbound");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { homeOverviewWeek, homeMinutes, homeDuration, homeSummary } from '../src/components/home/home-model.ts';
|
||||
|
||||
const overview = {
|
||||
subject_id: 'elder-a', timezone: 'Asia/Shanghai', week_start: '2026-09-28', week_end: '2026-10-05',
|
||||
conversation_count: 35, total_duration_ms: 98000,
|
||||
daily: Array.from({ length: 7 }, (_, index) => ({ date: new Date(Date.UTC(2026, 8, 28 + index)).toISOString().slice(0, 10), conversation_count: index === 3 ? 35 : 0, duration_ms: index === 3 ? 98000 : 0 })),
|
||||
latest_conversation: { id: 'latest', source: 'call_memory', status: 'FAILED', started_at: '2026-10-01T14:00:11Z', ended_at: '2026-10-01T14:01:49Z', duration_ms: 98000, summary: '本次聊天的记忆摘要', summary_state: 'READY', summary_kind: 'memory' },
|
||||
};
|
||||
|
||||
test('home uses the full server aggregate and retains real failed direct conversations', () => {
|
||||
const week = homeOverviewWeek(overview);
|
||||
assert.equal(week.count, 35);
|
||||
assert.equal(week.totalDurationMS, 98000);
|
||||
assert.equal(week.latest.id, 'latest');
|
||||
assert.equal(week.latest.status, 'FAILED');
|
||||
assert.equal(week.days[3].count, 35);
|
||||
assert.equal(week.range, '9月28日—10月4日');
|
||||
});
|
||||
|
||||
test('latest conversation remains visible outside the current week', () => {
|
||||
const week = homeOverviewWeek({ ...overview, conversation_count: 0, total_duration_ms: 0, week_start: '2026-10-05', week_end: '2026-10-12', latest_conversation: overview.latest_conversation });
|
||||
assert.equal(week.count, 0);
|
||||
assert.equal(week.latest.created_at, '2026-10-01T14:00:11Z');
|
||||
});
|
||||
|
||||
test('milliseconds are displayed without rounding short chats down to zero', () => {
|
||||
assert.equal(homeMinutes(12000), '<1');
|
||||
assert.equal(homeMinutes(98000), '1.6');
|
||||
assert.equal(homeDuration(98000), '1分38秒');
|
||||
assert.equal(homeDuration(12000), '12秒');
|
||||
assert.equal(homeDuration(100), '不足1秒');
|
||||
assert.equal(homeMinutes(0), '0');
|
||||
assert.equal(homeMinutes(NaN), '0');
|
||||
});
|
||||
|
||||
test('summary state does not imply every ended call is still being processed', () => {
|
||||
assert.equal(homeSummary(overview.latest_conversation), '本次聊天的记忆摘要');
|
||||
for (const [state, pattern] of [['PROCESSING', /正在整理/], ['INSUFFICIENT_TRANSCRIPT', /文字不足/], ['NO_SUMMARY', /暂无/], ['SYNC_FAILED', /异常/], ['UNAVAILABLE', /没有可展示/]]) {
|
||||
assert.match(homeSummary({ ...overview.latest_conversation, summary: undefined, summary_state: state }), pattern);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const webRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
// Allows the copied source to use installed dependencies without copying them
|
||||
// or writing to the original checkout. Normal repo tests need no override.
|
||||
const dependencyRoot = process.env.HOME_TEST_WEB_ROOT || webRoot;
|
||||
const requireDependency = createRequire(path.join(dependencyRoot, 'package.json'));
|
||||
const ts = requireDependency('typescript');
|
||||
const React = requireDependency('react');
|
||||
const { renderToStaticMarkup } = requireDependency('react-dom/server');
|
||||
const modules = new Map();
|
||||
function loadSource(file) {
|
||||
if (modules.has(file)) return modules.get(file).exports;
|
||||
const module = { exports: {} };
|
||||
modules.set(file, module);
|
||||
const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022, esModuleInterop: true } });
|
||||
const requireSource = name => {
|
||||
if (name.endsWith('.css') || name.startsWith('@fontsource')) return {};
|
||||
// Icon drawings do not affect these state/copy assertions. Avoid loading
|
||||
// the vendor's browser-oriented CJS entry in Node's ESM test runner.
|
||||
if (name === '@phosphor-icons/react') return new Proxy({}, { get: () => () => React.createElement('svg', { 'aria-hidden': true }) });
|
||||
if (!name.startsWith('.')) return requireDependency(name);
|
||||
const relative = path.resolve(path.dirname(file), name);
|
||||
const fallback = path.join(dependencyRoot, path.relative(webRoot, relative));
|
||||
const target = [relative + '.ts', relative + '.tsx', fallback + '.ts', fallback + '.tsx'].find(candidate => fs.existsSync(candidate));
|
||||
if (!target) throw new Error(`Missing local test source: ${name}`);
|
||||
return loadSource(target);
|
||||
};
|
||||
new Function('require', 'module', 'exports', compiled.outputText)(requireSource, module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
const { CustomerHomePage } = loadSource(path.join(webRoot, 'src/components/home/CustomerHomePage.tsx'));
|
||||
const check = (code, status, blocking, title, detail, action) => ({ code, status, blocking, title, detail, action });
|
||||
const ready = { subject_id: 'elder-a', ready: true, evaluated_at: '2026-10-05T00:00:00Z', checks: [check('SUBJECT_ACTIVE', 'ready', true, '长者档案已启用', '档案可用于通话'), check('VOICE_CONSENT', 'ready', true, '语音通话授权已具备', '语音授权已具备')], devices: [], start_time_checks: [] };
|
||||
const props = {
|
||||
households: [{ id: 'household-a', name: '测试家庭' }], householdID: 'household-a',
|
||||
subjects: [{ id: 'elder-a', household_id: 'household-a', preferred_name: '测试长者', timezone: 'Asia/Shanghai' }],
|
||||
subject: { id: 'elder-a', household_id: 'household-a', preferred_name: '测试长者', timezone: 'Asia/Shanghai' },
|
||||
devices: [{ id: 'device-a', subject_id: 'elder-a' }], presence: { 'device-a': { online: true } },
|
||||
conversations: [], insights: {}, stories: [], privacyRequests: [], readiness: ready,
|
||||
now: new Date('2026-10-05T00:00:00Z'), onHousehold() {}, onSubject() {}, onNavigate() {}, onRetry() {}, onLogout() {},
|
||||
};
|
||||
const render = extra => renderToStaticMarkup(React.createElement(CustomerHomePage, { ...props, ...extra })).replace(/<[^>]+>/g, '');
|
||||
|
||||
test('home shows the actual missing consent and completed checks, not online equals ready', () => {
|
||||
const checks = [ready.checks[0], check('VOICE_CONSENT', 'blocked', true, '缺少语音通话授权', '核对语音通话授权凭证', 'MANAGE_CONSENTS')];
|
||||
const text = render({ readiness: { ...ready, ready: false, checks } });
|
||||
assert.match(text, /当前下一步缺少语音通话授权/);
|
||||
assert.match(text, /核对语音通话授权凭证/);
|
||||
assert.match(text, /已完成 1 项 · 待完成 1 项/);
|
||||
assert.doesNotMatch(text, /通话准备项已完成/);
|
||||
});
|
||||
|
||||
test('a readiness 502 leaves the navigation available and does not claim missing setup', () => {
|
||||
const text = render({ readinessState: 'error', readinessError: '准备服务暂时不可用(HTTP 502)' });
|
||||
assert.match(text, /陪伴准备状态待确认/);
|
||||
assert.match(text, /重新核对准备状态/);
|
||||
assert.match(text, /设置与权限/);
|
||||
assert.doesNotMatch(text, /查看准备清单|通话准备项已完成|待完成 \d+ 项/);
|
||||
});
|
||||
|
||||
test('overview failure stays local and is not presented as zero chats or no history', () => {
|
||||
const text = render({ useOverview: true, overviewError: '陪伴近况暂时不可用(HTTP 502)' });
|
||||
assert.match(text, /通话准备项已完成/);
|
||||
assert.match(text, /陪伴近况暂时未能读取/);
|
||||
assert.match(text, /重新加载陪伴近况/);
|
||||
assert.doesNotMatch(text, /0 次聊天|还没有聊天记录|还没有新的家庭动态/);
|
||||
});
|
||||
|
||||
test('overview loading is distinct from an empty conversation history', () => {
|
||||
const text = render({ useOverview: true, overviewLoading: true });
|
||||
assert.match(text, /正在载入陪伴近况/);
|
||||
assert.doesNotMatch(text, /0 次聊天|还没有聊天记录/);
|
||||
});
|
||||
|
||||
test('changing elder does not show the previous elder data requests or preparation', () => {
|
||||
const text = render({
|
||||
subject: { ...props.subject, id: 'elder-b', preferred_name: '另一位长者' },
|
||||
useOverview: true, overviewLoading: true,
|
||||
privacyRequests: [{ id: 'request-a', subject_id: 'elder-a', status: 'EXECUTING', request_type: 'ACCESS' }],
|
||||
});
|
||||
assert.match(text, /另一位长者的陪伴近况/);
|
||||
assert.match(text, /陪伴准备状态待确认/);
|
||||
assert.doesNotMatch(text, /通话准备项已完成|1项数据申请正在处理中/);
|
||||
});
|
||||
|
||||
test('nonblocking suggestions say they do not block conversation', () => {
|
||||
const text = render({ readiness: { ...ready, checks: [...ready.checks, check('CONFIG_SYNC', 'attention', false, '设备配置待同步', '设备可在下次连接时同步', 'OPEN_DEVICES')] } });
|
||||
assert.match(text, /建议检查 · 不阻止通话/);
|
||||
assert.match(text, /已完成 2 项 · 待完成 0 项 · 建议 1 项/);
|
||||
});
|
||||
|
||||
test('home displays latest time in Beijing despite browser or historical profile timezone', () => {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = 'America/Los_Angeles';
|
||||
try {
|
||||
const text = render({
|
||||
subject: { ...props.subject, timezone: 'America/Los_Angeles' }, useOverview: true,
|
||||
overview: {
|
||||
subject_id: 'elder-a', timezone: 'America/Los_Angeles', week_start: '2026-10-05', week_end: '2026-10-12',
|
||||
conversation_count: 1, total_duration_ms: 12000,
|
||||
daily: [{ date: '2026-10-05', conversation_count: 1, duration_ms: 12000 }],
|
||||
latest_conversation: { id: 'synthetic-call', source: 'call_memory', status: 'FAILED', started_at: '2026-10-05T12:16:08Z', ended_at: '2026-10-05T12:16:20Z', duration_ms: 12000, summary_state: 'NO_SUMMARY' },
|
||||
},
|
||||
});
|
||||
assert.match(text, /20:16(北京时间)/);
|
||||
assert.match(text, /12秒/);
|
||||
assert.match(text, /设备通话须已结束并有有效文字/);
|
||||
assert.match(text, /服务用量还包含其他会话记录/);
|
||||
assert.doesNotMatch(text, /05:16/);
|
||||
assert.match(text, /通话准备项已完成/);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { homePreparation } from '../src/components/home/home-model.ts';
|
||||
|
||||
const check = (code, status, blocking = true, action) => ({ code, status, blocking, action, title: code, detail: `${code} details` });
|
||||
const readiness = (checks, extra = {}) => ({ subject_id: 'elder-a', ready: !checks.some(item => item.blocking && item.status !== 'ready'), checks, devices: [], evaluated_at: '2026-10-05T00:00:00Z', start_time_checks: ['usage_quota', 'runtime_routing', 'no_active_conversation'], ...extra });
|
||||
|
||||
test('an elder is required before subject-scoped preparation can be checked', () => {
|
||||
const result = homePreparation(undefined, readiness([check('VOICE_CONSENT', 'ready')]));
|
||||
assert.equal(result.status, 'needs_subject');
|
||||
assert.equal(result.destination, 'subjects');
|
||||
assert.equal(result.checks.length, 0);
|
||||
});
|
||||
|
||||
test('missing, failed and refreshing data never become unfinished setup', () => {
|
||||
const previous = readiness([check('VOICE_CONSENT', 'blocked', true, 'MANAGE_CONSENTS')]);
|
||||
for (const [data, state] of [[undefined, undefined], [previous, 'error'], [previous, 'loading']]) {
|
||||
const result = homePreparation('elder-a', data, state);
|
||||
assert.equal(result.status, 'unknown');
|
||||
assert.equal(result.pending, 0);
|
||||
assert.equal(result.completed, 0);
|
||||
assert.equal(result.destination, 'retry');
|
||||
}
|
||||
});
|
||||
|
||||
test('changing elder does not reuse another elder preparation', () => {
|
||||
const result = homePreparation('elder-b', readiness([check('SUBJECT_ACTIVE', 'ready')]));
|
||||
assert.equal(result.status, 'unknown');
|
||||
assert.equal(result.checks.length, 0);
|
||||
});
|
||||
|
||||
test('voice authorization has a direct privacy destination even with a ready device', () => {
|
||||
const result = homePreparation('elder-a', readiness([
|
||||
check('DEVICE_BOUND', 'ready'), check('DEVICE_RUNTIME', 'ready'),
|
||||
check('VOICE_CONSENT', 'blocked', true, 'MANAGE_CONSENTS'),
|
||||
]));
|
||||
assert.equal(result.destination, 'privacy');
|
||||
assert.equal(result.next.code, 'VOICE_CONSENT');
|
||||
assert.equal(result.pending, 1);
|
||||
assert.equal(result.completed, 2);
|
||||
});
|
||||
|
||||
test('next action follows remaining server blockers instead of a fixed device-first sequence', () => {
|
||||
const profile = check('PROFILE_BACKGROUND', 'blocked', true, 'EDIT_PROFILE');
|
||||
const device = check('DEVICE_BOUND', 'blocked', true, 'BIND_DEVICE');
|
||||
assert.equal(homePreparation('elder-a', readiness([profile, device])).destination, 'subjects');
|
||||
assert.equal(homePreparation('elder-a', readiness([device, profile])).destination, 'devices');
|
||||
const result = homePreparation('elder-a', readiness([device, profile]));
|
||||
assert.deepEqual(result.checks.map(item => item.destination), ['devices', 'subjects']);
|
||||
});
|
||||
|
||||
test('web binding guidance matches the existing Bluetooth add and assignment flow', () => {
|
||||
const result = homePreparation('elder-a', readiness([{ ...check('DEVICE_BOUND', 'blocked', true, 'BIND_DEVICE'), detail: '请使用设备屏幕显示的一次性绑定码完成绑定。' }]));
|
||||
assert.match(result.next.detail, /蓝牙/);
|
||||
assert.match(result.next.detail, /已添加/);
|
||||
assert.doesNotMatch(result.next.detail, /绑定码|小程序/);
|
||||
assert.equal(result.destination, 'devices');
|
||||
});
|
||||
|
||||
test('attention checks remain optional and do not count as blockers', () => {
|
||||
const result = homePreparation('elder-a', readiness([
|
||||
check('SUBJECT_ACTIVE', 'ready'), check('VOICE_CONSENT', 'ready'),
|
||||
check('CONFIG_SYNC', 'attention', false, 'OPEN_DEVICES'),
|
||||
check('RECENT_CONTACT', 'attention', false, 'OPEN_DEVICES'),
|
||||
]));
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.pending, 0);
|
||||
assert.equal(result.suggestions, 2);
|
||||
assert.equal(result.next.blocking, false);
|
||||
assert.equal(result.destination, 'devices');
|
||||
});
|
||||
|
||||
test('a blocker is handled before an earlier optional suggestion', () => {
|
||||
const result = homePreparation('elder-a', readiness([
|
||||
check('CONFIG_SYNC', 'attention', false, 'OPEN_DEVICES'),
|
||||
check('CONVERSATION_GOAL', 'blocked', true, 'EDIT_PROFILE'),
|
||||
]));
|
||||
assert.equal(result.next.code, 'CONVERSATION_GOAL');
|
||||
assert.equal(result.destination, 'subjects');
|
||||
});
|
||||
|
||||
test('completed checks lead to records without inventing memory or recording consent', () => {
|
||||
const result = homePreparation('elder-a', readiness([check('VOICE_CONSENT', 'ready'), check('DEVICE_BOUND', 'ready')]));
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.destination, 'conversations');
|
||||
assert.equal(result.next, undefined);
|
||||
assert.equal(result.checks.length, 2);
|
||||
});
|
||||
|
||||
test('empty or contradictory payloads do not claim completion', () => {
|
||||
for (const data of [readiness([]), readiness([check('VOICE_CONSENT', 'ready')], { ready: false }), readiness([check('VOICE_CONSENT', 'blocked')], { ready: true })]) {
|
||||
assert.equal(homePreparation('elder-a', data).status, 'unknown');
|
||||
}
|
||||
});
|
||||
|
||||
test('an unknown blocking action does not guess a settings destination', () => {
|
||||
const result = homePreparation('elder-a', readiness([check('VOICE_CONSENT', 'blocked', true)]));
|
||||
assert.equal(result.status, 'blocked');
|
||||
assert.equal(result.destination, 'retry');
|
||||
assert.equal(result.next.detail, 'VOICE_CONSENT details');
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { memoirTimeline, readMemoirMode, saveMemoirMode } from '../src/pages/memoir-reading.ts';
|
||||
|
||||
const chapter = (event_time, story_id) => ({ event_time, story_id, title: story_id, paragraphs: ['正文'], caption: '' });
|
||||
|
||||
test('timeline uses explicit years, keeps chapter indices and groups multiple stories without changing the book', () => {
|
||||
const chapters = [chapter('1982年', 'a'), chapter('约1964年', 'b'), chapter('1968年夏天', 'c'), chapter('小时候', 'd')];
|
||||
const original = structuredClone(chapters);
|
||||
assert.deepEqual(memoirTimeline(chapters).map(group => [group.label, group.indices]), [['1960 年代', [1, 2]], ['1980 年代', [0]], ['时间未明确', [3]]]);
|
||||
assert.deepEqual(chapters, original);
|
||||
});
|
||||
|
||||
test('cross-decade ranges and unknown dates never become invented exact years or life stages', () => {
|
||||
const groups = memoirTimeline([chapter('1968年至1982年', 'a'), chapter('年轻的时候', 'b'), chapter('', 'c'), chapter('1972—1976年', 'd')]);
|
||||
assert.deepEqual(groups.map(group => [group.label, group.indices]), [['1970 年代', [3]], ['跨年代往事', [0]], ['时间未明确', [1, 2]]]);
|
||||
assert.deepEqual(memoirTimeline([]), []);
|
||||
});
|
||||
|
||||
test('mode preferences are scoped by elder and invalid stored values fall back to book', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
const values = new Map();
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value) } });
|
||||
try {
|
||||
saveMemoirMode('elder-a', 'timeline');
|
||||
assert.equal(readMemoirMode('elder-a'), 'timeline');
|
||||
assert.equal(readMemoirMode('elder-b'), 'book');
|
||||
values.set('memoir-reading-mode:v1:elder-a', 'bad-mode');
|
||||
assert.equal(readMemoirMode('elder-a'), 'book');
|
||||
assert.deepEqual([...values.keys()], ['memoir-reading-mode:v1:elder-a']);
|
||||
} finally { if (previous) Object.defineProperty(globalThis, 'localStorage', previous); else delete globalThis.localStorage; }
|
||||
});
|
||||
|
||||
test('unavailable browser storage does not block reading or switching', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, get: () => { throw new Error('blocked'); } });
|
||||
try { assert.equal(readMemoirMode('elder'), 'book'); assert.doesNotThrow(() => saveMemoirMode('elder', 'scroll')); }
|
||||
finally { if (previous) Object.defineProperty(globalThis, 'localStorage', previous); else delete globalThis.localStorage; }
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { memoryStatus } from '../src/pages/memory-status.ts';
|
||||
|
||||
const synced = {
|
||||
memory_revision: 27, viking_visible_revision: 27, privacy_epoch: 0,
|
||||
sync_state: 'IN_SYNC', critical_backlog: false, pending_count: 0, blocked_count: 0,
|
||||
upload_pending_count: 0, extracting_count: 0, synced_call_count: 27, insufficient_transcript_count: 0,
|
||||
};
|
||||
const ready = { hasRecords: true, readState: 'ready', statusFailed: false };
|
||||
|
||||
test('uploads and extraction remain visible even after memory projection has completed', () => {
|
||||
const result = memoryStatus({ ...synced, sync_state: 'LAGGING_NORMAL', upload_pending_count: 2, extracting_count: 3, insufficient_transcript_count: 4 }, ready);
|
||||
assert.match(result.label, /正在上传 2 通聊天/);
|
||||
assert.match(result.label, /等待提取 3 通聊天/);
|
||||
assert.doesNotMatch(result.label, /已同步/);
|
||||
assert.equal(result.note, '4通聊天文字不足,未生成总结');
|
||||
});
|
||||
|
||||
test('pending projections and lagging states never appear as synchronized', () => {
|
||||
assert.match(memoryStatus({ ...synced, pending_count: 2 }, ready).label, /正在同步 2 项记忆/);
|
||||
assert.match(memoryStatus({ ...synced, sync_state: 'LAGGING_NORMAL' }, ready).label, /仍在同步/);
|
||||
for (const patch of [{ sync_state: 'LAGGING_CRITICAL' }, { critical_backlog: true }, { blocked_count: 1 }, { error_code: 'VIKING_RETRIEVAL_FAILED' }]) {
|
||||
assert.match(memoryStatus({ ...synced, ...patch }, ready).label, /同步异常/);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed reads override stale successful status', () => {
|
||||
assert.match(memoryStatus(synced, { ...ready, statusFailed: true }).label, /状态读取失败/);
|
||||
assert.match(memoryStatus(synced, { ...ready, readState: 'error' }).label, /记忆读取失败/);
|
||||
assert.match(memoryStatus(undefined, { ...ready, readState: 'loading' }).label, /正在读取/);
|
||||
assert.match(memoryStatus(synced, { ...ready, readState: 'loading' }).label, /正在读取/);
|
||||
});
|
||||
|
||||
test('empty records are distinct from synchronized records and unknown status', () => {
|
||||
assert.equal(memoryStatus(synced, ready).label, '✓ 记忆已同步');
|
||||
assert.equal(memoryStatus({ ...synced, sync_state: 'EMPTY', insufficient_transcript_count: 2 }, { ...ready, hasRecords: false }).label, '暂无记忆');
|
||||
assert.match(memoryStatus({ ...synced, sync_state: 'UNRECOGNIZED' }, ready).label, /待确认/);
|
||||
assert.equal(memoryStatus({ ...synced, insufficient_transcript_count: 2 }, ready).note, '2通聊天文字不足,未生成总结');
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createServer } from 'vite';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
|
||||
const playwright = require(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const evidence = path.resolve(process.env.QA_OUTPUT ?? path.join(root, '../.tmp-memory-ui'));
|
||||
await fs.mkdir(path.join(evidence, 'empty-env'), { recursive: true });
|
||||
const server = await createServer({ configFile: false, root, envDir: path.join(evidence, 'empty-env'), cacheDir: path.join(evidence, 'vite-cache'), esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0, strictPort: false } });
|
||||
await server.listen();
|
||||
const chrome = process.env.CHROME_EXECUTABLE ?? process.env.CHROME_PATH;
|
||||
const browser = await playwright.chromium.launch({ headless: true, ...(chrome ? { executablePath: chrome } : {}) });
|
||||
const page = await browser.newPage({ viewport: { width: 1165, height: 1050 }, timezoneId: 'America/Los_Angeles' });
|
||||
const errors = [], writes = [], calls = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
const record = { memory_id: '11111111-1111-4111-8111-111111111111', record_version: 7, content: '合成测试:喜欢在院子里散步', memory_type: 'EVENT', status: 'ACTIVE', proactive_policy: 'ALLOWED', sensitivity: 'normal', entity_key: 'existing-stable-key', source_refs: [], created_at: '2026-10-05T12:16:08Z', updated_at: '2026-10-05T12:16:08Z' };
|
||||
let failWrite = false, denyWrite = false;
|
||||
await page.route('**/*', async route => {
|
||||
const req = route.request();
|
||||
if (new URL(req.url()).hostname !== '127.0.0.1') throw new Error('Blocked non-local request');
|
||||
if (!new URL(req.url()).pathname.startsWith('/api/')) return route.continue();
|
||||
const body = req.postDataJSON();
|
||||
calls.push({ url: req.url(), body });
|
||||
let data;
|
||||
if (req.url().endsWith('/query')) data = { items: body.elder_id === 'test-elder-a' ? [record] : [], mutations: [], next_cursor: '', can_write: true, can_manage_access: false };
|
||||
else if (req.url().endsWith('/sync/status')) data = { sync_state: 'SYNCED', pending_count: 0, blocked_count: 0, upload_pending_count: 0, extracting_count: 0, synced_call_count: 1, insufficient_transcript_count: 0 };
|
||||
else if (req.url().endsWith('/mutations')) {
|
||||
writes.push(body);
|
||||
if (denyWrite) return route.fulfill({ status: 403, contentType: 'application/json', body: JSON.stringify({ error: { code: 'MEMORY_CONSENT_REQUIRED', message: '需要有效的个性化记忆授权。' } }) });
|
||||
if (failWrite) return route.fulfill({ status: 502, contentType: 'application/json', body: JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: '合成测试失败' } }) });
|
||||
data = { mutation_id: body.mutation_id };
|
||||
} else throw new Error(`Unexpected local endpoint ${req.url()}`);
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data }) });
|
||||
});
|
||||
try {
|
||||
await page.goto(`${server.resolvedUrls.local[0]}test/memory-ui/`);
|
||||
await page.getByRole('button', { name: '+ 添加记忆' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('类型', { exact: true }).waitFor();
|
||||
await page.waitForTimeout(350);
|
||||
const style = await dialog.getByLabel('类型', { exact: true }).evaluate(input => {
|
||||
const css = getComputedStyle(input);
|
||||
return { className: input.className, backgroundColor: css.backgroundColor, minHeight: css.minHeight, width: input.getBoundingClientRect().width, opacity: css.opacity, parent: input.parentElement.outerHTML };
|
||||
});
|
||||
await fs.writeFile(path.join(evidence, process.env.BASELINE ? 'before-style.json' : 'after-style.json'), JSON.stringify(style, null, 2));
|
||||
await page.screenshot({ path: path.join(evidence, process.env.BASELINE ? 'before.png' : 'after.png') });
|
||||
if (process.env.BASELINE) { console.log(JSON.stringify({ baseline: true, style })); }
|
||||
else {
|
||||
assert.equal(style.backgroundColor, 'rgba(0, 0, 0, 0)');
|
||||
assert.equal(style.minHeight, '0px');
|
||||
assert.equal(await dialog.locator('.ant-select-content').filter({ hasText: '经历事件' }).count(), 1);
|
||||
assert.equal(await dialog.locator('.ant-select-content').filter({ hasText: '普通' }).count(), 1);
|
||||
assert.equal(await dialog.locator('.ant-select-content').filter({ hasText: '可以主动提起' }).count(), 1);
|
||||
assert.equal(await dialog.getByLabel('事项或事实标识', { exact: false }).count(), 0);
|
||||
await dialog.getByLabel('类型', { exact: true }).focus();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
for (const label of ['敏感级别', '使用范围']) {
|
||||
await dialog.getByLabel(label, { exact: true }).focus();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await dialog.getByLabel('记忆内容').fill('合成测试:键盘添加内容');
|
||||
await dialog.getByRole('button', { name: '添加记忆', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
assert.equal(writes[0].action, 'ADD');
|
||||
assert.notEqual(writes[0].patch.memory_type, 'EVENT');
|
||||
assert.notEqual(writes[0].patch.sensitivity, 'normal');
|
||||
assert.notEqual(writes[0].patch.proactive_policy, 'ALLOWED');
|
||||
assert.match(writes[0].patch.entity_key, /^manual:[0-9a-f-]{36}$/);
|
||||
await page.getByRole('button', { name: '编辑记忆', exact: true }).click();
|
||||
await dialog.getByLabel('记忆内容').fill('合成测试:修改后仍保留稳定标识');
|
||||
failWrite = true;
|
||||
await dialog.getByRole('button', { name: '保存记忆', exact: true }).click();
|
||||
await dialog.getByRole('alert').waitFor();
|
||||
failWrite = false;
|
||||
// Ant Design's invisible leaving spinner may remain in the accessible name
|
||||
// after loading has ended. Wait for the actual button state and tolerate
|
||||
// that decorative prefix, while continuing to use its accessible role.
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector('.cp-memory-editor .ant-modal-footer .ant-btn-primary');
|
||||
return button && !button.classList.contains('ant-btn-loading') && !button.disabled;
|
||||
});
|
||||
await fs.writeFile(path.join(evidence, 'retry-accessibility.txt'), await page.locator('.cp-memory-editor').ariaSnapshot());
|
||||
await fs.writeFile(path.join(evidence, 'retry-controls.json'), JSON.stringify(await page.locator('.cp-memory-editor button').evaluateAll(els => els.map(el => ({ html: el.outerHTML, hidden: el.closest('[aria-hidden="true"]')?.outerHTML.substring(0, 200), inert: el.closest('[inert]')?.outerHTML.substring(0, 200) }))), null, 2));
|
||||
await dialog.getByRole('button', { name: /^(?:loading )?保存记忆$/ }).click();
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
assert.equal(writes[1].patch.entity_key, 'existing-stable-key');
|
||||
assert.equal(writes[1].expected_record_versions[record.memory_id], 7);
|
||||
assert.equal(writes[1].mutation_id, writes[2].mutation_id);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.getByRole('button', { name: '+ 添加记忆' }).click();
|
||||
await dialog.getByLabel('记忆内容').fill('合成测试:取消草稿');
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
await page.getByRole('button', { name: '+ 添加记忆' }).waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: '+ 添加记忆' }).evaluate(el => el === document.activeElement), true);
|
||||
}
|
||||
const count = writes.length;
|
||||
await page.getByRole('button', { name: '模拟无授权' }).click();
|
||||
assert.equal(await page.getByRole('button', { name: '+ 添加记忆' }).isDisabled(), true);
|
||||
assert.equal(await page.getByRole('button', { name: '编辑记忆', exact: true }).isDisabled(), true);
|
||||
assert.equal(await page.getByRole('button', { name: '调整使用范围' }).isDisabled(), true);
|
||||
await page.getByText('更多操作', { exact: true }).click();
|
||||
assert.equal(await page.getByRole('button', { name: '删除记忆', exact: true }).isEnabled(), true);
|
||||
await page.getByRole('button', { name: '删除记忆', exact: true }).click();
|
||||
assert.equal(await dialog.getByRole('button', { name: '永久删除', exact: true }).isDisabled(), true);
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
assert.equal(writes.length, count);
|
||||
await page.getByRole('button', { name: '模拟授权加载' }).click();
|
||||
assert.equal(await page.getByRole('button', { name: '+ 添加记忆' }).isDisabled(), true);
|
||||
await page.getByRole('button', { name: '模拟有效授权' }).click();
|
||||
await page.getByRole('button', { name: '+ 添加记忆' }).click();
|
||||
await dialog.getByLabel('记忆内容').fill('合成测试:编辑过程中授权变化');
|
||||
await page.getByRole('button', { name: '模拟无授权' }).evaluate(el => el.click());
|
||||
assert.equal(await dialog.getByRole('button', { name: '添加记忆', exact: true }).isDisabled(), true);
|
||||
assert.equal(writes.length, count);
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
await page.getByRole('button', { name: '模拟有效授权' }).click();
|
||||
await page.getByRole('button', { name: '+ 添加记忆' }).click();
|
||||
await page.waitForTimeout(350);
|
||||
await dialog.getByLabel('记忆内容').fill('合成测试:后端拒绝');
|
||||
denyWrite = true;
|
||||
await dialog.getByRole('button', { name: '添加记忆', exact: true }).click();
|
||||
await dialog.getByRole('alert').waitFor();
|
||||
assert.equal(await dialog.getByLabel('记忆内容').inputValue(), '合成测试:后端拒绝');
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
denyWrite = false;
|
||||
await page.getByRole('button', { name: '+ 添加记忆' }).click();
|
||||
await dialog.getByLabel('记忆内容').fill('合成测试:不能泄露到其他家庭');
|
||||
// Simulate the parent changing its selected elder while a dialog is open.
|
||||
await page.getByRole('button', { name: '切换合成家庭' }).evaluate(el => el.click());
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
assert.equal(await page.getByText(record.content, { exact: true }).count(), 0);
|
||||
assert.equal(writes.length, count + 1);
|
||||
assert.equal(errors.length, 0, errors.join('\n'));
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByRole('button', { name: '+ 添加记忆' }).click();
|
||||
await dialog.getByLabel('记忆内容').waitFor();
|
||||
await page.waitForTimeout(350);
|
||||
assert.equal(await dialog.locator('.ant-select-content').filter({ hasText: '经历事件' }).isVisible(), true);
|
||||
const mobileBounds = await dialog.boundingBox();
|
||||
assert.ok(mobileBounds.x >= 0 && mobileBounds.x + mobileBounds.width <= 390);
|
||||
await page.screenshot({ path: path.join(evidence, 'after-mobile.png') });
|
||||
const result = { passed: true, checks: ['selected labels visible', 'all three selects keyboard selection', 'ADD generated entity key', 'edit preserved key and version', 'retry idempotency', 'repeat Escape and focus', 'missing/loading consent blocks non-delete', 'consent change during edit', 'delete confirmation retained without sending', 'server consent rejection preserves draft', 'elder isolation', 'mobile render'], writes: writes.length, unexpectedRequests: calls.filter(call => !call.url.includes('/memories/')).length };
|
||||
await fs.writeFile(path.join(evidence, 'result.json'), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(evidence, 'failure.png') });
|
||||
await fs.writeFile(path.join(evidence, 'failure-dom.txt'), await page.locator('body').innerText());
|
||||
console.log(JSON.stringify({ writes, errors }));
|
||||
throw error;
|
||||
} finally { await browser.close(); await server.close(); }
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { MemoryWorkspace } from '../../src/pages/MemoryWorkspace';
|
||||
import '../../src/styles.css';
|
||||
import '../../src/cal-theme.css';
|
||||
import '../../src/customer-responsive.css';
|
||||
import '../../src/maintenance-console.css';
|
||||
import '../../src/admin-redesign.css';
|
||||
import '../../src/components/home/customer-portal.css';
|
||||
|
||||
function Fixture() {
|
||||
const [elder, setElder] = useState('test-elder-a');
|
||||
const [consent, setConsent] = useState<'granted' | 'missing' | 'loading'>('granted');
|
||||
return <ConfigProvider locale={zhCN} theme={{ token: { controlHeight: 40 } }}>
|
||||
<button onClick={() => setElder(value => value === 'test-elder-a' ? 'test-elder-b' : 'test-elder-a')}>切换合成家庭</button>
|
||||
<button onClick={() => setConsent('missing')}>模拟无授权</button>
|
||||
<button onClick={() => setConsent('granted')}>模拟有效授权</button>
|
||||
<button onClick={() => setConsent('loading')}>模拟授权加载</button>
|
||||
<main style={{ padding: 24 }}><MemoryWorkspace elderID={elder} accessToken="local-synthetic-token" section="daily" {...({ memoryConsent: consent } as object)} /></main>
|
||||
</ConfigProvider>;
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<Fixture />);
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Memory local test</title></head><body><div id="root"></div><script type="module" src="./fixture.tsx"></script></body></html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { volumeSyncMessage } from '../src/components/device-volume-state.ts';
|
||||
import { memorySection } from '../src/pages/memory-section.ts';
|
||||
const twin = (revision, actual) => ({ desired_revision: 19, reported_revision: revision, desired_config: { volume: { speaker: 56 } }, reported_config: { volume: { speaker: actual } } });
|
||||
test('volume confirms only the matching actual value and revision', () => {
|
||||
assert.match(volumeSyncMessage(twin(19, 56), true), /已生效/);
|
||||
assert.match(volumeSyncMessage(twin(18, 56), true), /等待设备确认/);
|
||||
assert.match(volumeSyncMessage(twin(19, 55), true), /等待设备确认/);
|
||||
assert.match(volumeSyncMessage(twin(18, 55), false), /上线后/);
|
||||
assert.match(volumeSyncMessage(undefined), /读取/);
|
||||
assert.match(volumeSyncMessage({ ...twin(19, 0), desired_config: { volume: { speaker: 0 } } }), /已生效/);
|
||||
});
|
||||
test('daily and long-term memory categories cover all supported types', () => {
|
||||
for (const type of ['EVENT', 'OPEN_ITEM', 'INTERACTION']) assert.equal(memorySection(type), 'daily');
|
||||
for (const type of ['PROFILE', 'PREFERENCE', 'RELATIONSHIP', 'STORY']) assert.equal(memorySection(type), 'memory');
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { householdSelectionKey, readHouseholdSelection, writeHouseholdSelection, resolveHouseholdSelection, timezoneError, parseContextList, contextListError, privacyStatusLabel, privacyRequestStatus, isPrivacyRequestProcessing } from '../src/pages/portal-validation.ts';
|
||||
|
||||
test('family choice survives reload and only resolves accessible families', () => {
|
||||
const values = new Map();
|
||||
globalThis.window = { localStorage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) } };
|
||||
const accounts = [{ tenant_id: 'tenant-a', user_id: 'user-a' }, { tenant_id: 'tenant-b', user_id: 'user-b' }];
|
||||
const key = householdSelectionKey(accounts);
|
||||
assert.equal(key, householdSelectionKey([...accounts].reverse()));
|
||||
writeHouseholdSelection(key, 'A');
|
||||
assert.equal(resolveHouseholdSelection([{ id: 'B' }, { id: 'A' }], readHouseholdSelection(key)), 'A');
|
||||
assert.equal(resolveHouseholdSelection([{ id: 'B' }], readHouseholdSelection(key)), 'B');
|
||||
assert.equal(readHouseholdSelection(householdSelectionKey([{ tenant_id: 'tenant-a', user_id: 'other-user' }])), undefined);
|
||||
assert.equal(resolveHouseholdSelection([], 'A'), undefined);
|
||||
writeHouseholdSelection(key);
|
||||
assert.equal(readHouseholdSelection(key), undefined);
|
||||
globalThis.window = { get localStorage() { throw new Error('Storage unavailable'); } };
|
||||
assert.doesNotThrow(() => writeHouseholdSelection(key, 'A'));
|
||||
assert.equal(readHouseholdSelection(key), undefined);
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
test('invalid timezones fail before confirmation', () => {
|
||||
assert.equal(timezoneError('Asia/Shanghai'), '');
|
||||
assert.equal(timezoneError('America/New_York'), '');
|
||||
assert.equal(timezoneError('UTC'), '');
|
||||
assert.ok(timezoneError('Invalid/Zone'));
|
||||
assert.ok(timezoneError(''));
|
||||
});
|
||||
|
||||
test('topic parsing preserves excess entries for explicit validation', () => {
|
||||
const items = parseContextList('1、2,3,4、5、6、7、8、9');
|
||||
assert.equal(items.length, 9);
|
||||
assert.ok(contextListError(items));
|
||||
assert.equal(contextListError(items.slice(0, 8)), '');
|
||||
assert.ok(contextListError(['很'.repeat(51)]));
|
||||
});
|
||||
|
||||
test('every privacy request state has a Chinese label', () => {
|
||||
for (const state of ['SUBMITTED', 'VERIFYING', 'EXECUTING', 'COMPLETED', 'PARTIAL_FAILED']) {
|
||||
assert.notEqual(privacyStatusLabel(state), state);
|
||||
assert.equal(privacyStatusLabel(state.toLowerCase()), privacyStatusLabel(state));
|
||||
}
|
||||
});
|
||||
|
||||
test('failed deletion subtasks override a stale executing status until reconciliation completes', () => {
|
||||
const request = { status: 'EXECUTING', subtasks: [{ status: 'COMPLETED' }, { status: 'FAILED' }] };
|
||||
assert.equal(privacyRequestStatus(request), 'PARTIAL_FAILED');
|
||||
assert.equal(isPrivacyRequestProcessing(request), false);
|
||||
assert.equal(isPrivacyRequestProcessing({ ...request, subtasks: [{ status: 'RUNNING' }] }), true);
|
||||
assert.equal(privacyRequestStatus({ status: 'COMPLETED', subtasks: [{ status: 'COMPLETED' }] }), 'COMPLETED');
|
||||
assert.equal(isPrivacyRequestProcessing({ status: 'COMPLETED' }), false);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { privacyDeletionProgress } from '../src/pages/privacy-detail.ts';
|
||||
|
||||
const webRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const requireDependency = createRequire(path.join(webRoot, 'package.json'));
|
||||
const ts = requireDependency('typescript');
|
||||
const React = requireDependency('react');
|
||||
const { renderToStaticMarkup } = requireDependency('react-dom/server');
|
||||
const source = fs.readFileSync(process.env.PRIVACY_CARD_SOURCE || path.join(webRoot, 'src/pages/SaaSWorkspace.tsx'), 'utf8');
|
||||
// Exercise the production card without importing the entire workspace or its
|
||||
// unrelated browser-only integrations. Its display helpers are small stubs.
|
||||
const cardSource = source.slice(source.indexOf('function PrivacyRequestCard('), source.indexOf('function Info('));
|
||||
assert.ok(cardSource, 'the privacy card must exist');
|
||||
const compiled = ts.transpileModule(cardSource + '\nexports.PrivacyRequestCard = PrivacyRequestCard;', { compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 } });
|
||||
const module = { exports: {} };
|
||||
new Function('require', 'exports', 'useState', 'useRef', 'privacyDeletionProgress', 'formatDate', 'privacyRequestTypeLabel', 'privacyRequestStatus', 'privacyStatusLabel', 'privacyCategoryLabel', 'privacyRequestSummary', 'privacySubsystemLabel', 'privacySubtaskStatusLabel', 'Info', compiled.outputText)(
|
||||
requireDependency, module.exports, React.useState, React.useRef, privacyDeletionProgress,
|
||||
value => value, () => '删除数据', item => item.status, value => value, value => value, () => '', value => value, value => value,
|
||||
({ label, value }) => React.createElement('div', {}, label, value),
|
||||
);
|
||||
const tasks = ['COMPLETED', 'SKIPPED', 'COMPLETED', 'COMPLETED', 'FAILED', 'PENDING'].map((status, index) => ({ id: `private-task-${index}`, subsystem: ['redis', 'export_files', 'object_storage', 'pgvector', 'provider_index', 'postgresql'][index], status, attempts: status === 'FAILED' ? 8 : 0, last_error: status === 'FAILED' ? 'HTTP 429 synthetic-stack' : undefined, next_attempt_at: status === 'FAILED' ? '2026-10-04T14:48:45Z' : undefined }));
|
||||
const request = { id: 'private-request-id', subject_id: 'private-subject-id', request_type: 'DELETE', status: 'EXECUTING', version: 2, created_at: '2026-10-04T10:00:00Z', updated_at: '2026-10-04T14:48:15Z', subtasks: tasks, result_summary: {} };
|
||||
const render = overrides => renderToStaticMarkup(React.createElement(module.exports.PrivacyRequestCard, { request, subjectName: '测试长者', selected: false, loading: false, onRefresh() {}, ...overrides }));
|
||||
const visibleSummary = markup => markup.slice(0, markup.indexOf('<details')).replace(/<[^>]*>/g, '');
|
||||
|
||||
test('normal view keeps evidence count and controls while technical data stays collapsed', () => {
|
||||
const markup = render({ readAt: '2026-10-06T00:00:00Z' });
|
||||
const summary = visibleSummary(markup);
|
||||
assert.match(summary, /尚未全部清除/);
|
||||
assert.match(summary, /已核对 4\/6/);
|
||||
assert.match(summary, /最近核验:2026-10-06T00:00:00Z/);
|
||||
assert.match(summary, /刷新/);
|
||||
assert.match(summary, /复制请求编号/);
|
||||
assert.doesNotMatch(summary, /private-|HTTP 429|provider_index|v2|已尝试/);
|
||||
assert.match(markup, /<details class="cp-details"><summary>诊断详情/);
|
||||
assert.doesNotMatch(markup, /<details[^>]* open/);
|
||||
assert.match(markup, /HTTP 429 synthetic-stack/);
|
||||
assert.match(markup, /2026-10-04T14:48:45Z/);
|
||||
assert.match(markup, /不代表仍会自动重试/);
|
||||
});
|
||||
|
||||
test('list-only state does not misrepresent an empty array as verified progress', () => {
|
||||
const summary = visibleSummary(render({ request: { ...request, subtasks: [] } }));
|
||||
assert.match(summary, /详情尚未读取或已更新/);
|
||||
assert.match(summary, /最近核验:尚未读取详情/);
|
||||
assert.doesNotMatch(summary, /0\/0|2026-10-04T14:48:15Z/);
|
||||
});
|
||||
|
||||
test('failed refresh keeps its original verification time and hides the technical exception', () => {
|
||||
const markup = render({ readAt: '2026-10-05T00:00:00Z', detailError: 'synthetic-detail-502' });
|
||||
assert.match(visibleSummary(markup), /最近核验:2026-10-05T00:00:00Z(最新刷新失败,保留上次结果)/);
|
||||
assert.doesNotMatch(visibleSummary(markup), /synthetic-detail-502/);
|
||||
assert.match(markup, /最近读取异常:synthetic-detail-502/);
|
||||
});
|
||||
|
||||
test('loading refresh disables the read action without offering deletion retry', () => {
|
||||
const markup = render({ loading: true });
|
||||
assert.match(markup, /disabled="">正在核验/);
|
||||
assert.doesNotMatch(visibleSummary(markup), /重新删除|重试删除|重新入队/);
|
||||
});
|
||||
|
||||
test('a completed parent with failed children stays incomplete in both badge and summary', () => {
|
||||
const summary = visibleSummary(render({ request: { ...request, status: 'COMPLETED' } }));
|
||||
assert.match(summary, /尚未全部清除/);
|
||||
assert.match(summary, /核验信息不一致/);
|
||||
assert.doesNotMatch(summary, /已核对完成/);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { privacyRequestsWithDetail } from '../src/pages/privacy-detail.ts';
|
||||
|
||||
const summary = { id: 'synthetic-request', version: 2, updated_at: '2026-10-05T00:00:00Z', subtasks: [] };
|
||||
const detail = { ...summary, subtasks: [{ id: 'synthetic-subtask', status: 'FAILED' }] };
|
||||
test('list polling does not erase loaded subtasks for the same revision', () => {
|
||||
assert.equal(privacyRequestsWithDetail([summary], detail)[0].subtasks.length, 1);
|
||||
assert.equal(summary.subtasks.length, 0);
|
||||
});
|
||||
test('a newer list revision invalidates old detail evidence', () => {
|
||||
assert.equal(privacyRequestsWithDetail([{ ...summary, version: 3 }], detail)[0].subtasks.length, 0);
|
||||
assert.equal(privacyRequestsWithDetail([{ ...summary, updated_at: '2026-10-05T00:01:00Z' }], detail)[0].subtasks.length, 0);
|
||||
});
|
||||
test('a successfully fetched request is visible even when list loading failed', () => {
|
||||
assert.deepEqual(privacyRequestsWithDetail([], detail), [detail]);
|
||||
});
|
||||
test('subtask failure projected without a parent revision invalidates the old status', () => {
|
||||
const failed = { ...summary, status: 'PARTIAL_FAILED' };
|
||||
assert.equal(privacyRequestsWithDetail([failed], { ...detail, status: 'EXECUTING' })[0], failed);
|
||||
});
|
||||
test('a more recent detail read can disclose a failure missing from the older list', () => {
|
||||
const failed = { ...detail, status: 'PARTIAL_FAILED' };
|
||||
assert.equal(privacyRequestsWithDetail([{ ...summary, status: 'EXECUTING' }], failed, true)[0], failed);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { privacyDeletionProgress, privacyRequestsWithDetail } from '../src/pages/privacy-detail.ts';
|
||||
|
||||
const request = { id: 'request-a', request_type: 'DELETE', status: 'EXECUTING', legal_hold: false, version: 2, updated_at: '2026-10-05T00:00:00Z', subtasks: [] };
|
||||
const tasks = statuses => statuses.map((status, index) => ({ id: `task-${index}`, status }));
|
||||
|
||||
test('list-only evidence never claims 0/0 or completion', () => {
|
||||
const progress = privacyDeletionProgress({ ...request, status: 'COMPLETED' });
|
||||
assert.equal(progress.complete, false);
|
||||
assert.equal(progress.label, '尚未全部清除');
|
||||
assert.doesNotMatch(progress.progress, /0\/0/);
|
||||
assert.match(progress.summary, /暂不能确认/);
|
||||
});
|
||||
|
||||
test('four checked, one failed and one waiting stays incomplete', () => {
|
||||
const progress = privacyDeletionProgress({ ...request, subtasks: tasks(['COMPLETED', 'SKIPPED', 'COMPLETED', 'COMPLETED', 'FAILED', 'PENDING']) });
|
||||
assert.equal(progress.progress, '已核对 4/6');
|
||||
assert.equal(progress.complete, false);
|
||||
assert.doesNotMatch(progress.summary, /自动重试|正在重试|已全部清除/);
|
||||
});
|
||||
|
||||
test('historical completed parent with a failed child cannot claim completion', () => {
|
||||
const progress = privacyDeletionProgress({ ...request, status: 'COMPLETED', subtasks: tasks(['COMPLETED', 'FAILED']) });
|
||||
assert.equal(progress.complete, false);
|
||||
assert.match(progress.summary, /信息不一致/);
|
||||
});
|
||||
|
||||
test('all checked subtasks still require final parent confirmation and no hold', () => {
|
||||
const subtasks = tasks(['COMPLETED', 'SKIPPED']);
|
||||
assert.equal(privacyDeletionProgress({ ...request, subtasks }).complete, false);
|
||||
assert.equal(privacyDeletionProgress({ ...request, status: 'COMPLETED', legal_hold: true, subtasks }).complete, false);
|
||||
assert.equal(privacyDeletionProgress({ ...request, status: 'COMPLETED', subtasks }).complete, true);
|
||||
});
|
||||
|
||||
test('a newer list invalidates both the progress evidence and its read timestamp identity', () => {
|
||||
const detail = { ...request, subtasks: tasks(['COMPLETED', 'FAILED']) };
|
||||
const summary = { ...request, version: 3 };
|
||||
const displayed = privacyRequestsWithDetail([summary], detail, true)[0];
|
||||
assert.notEqual(displayed, detail);
|
||||
assert.equal(privacyDeletionProgress(displayed).total, 0);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const webRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const requireBrowser = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM || import.meta.url);
|
||||
let chromium;
|
||||
try { ({ chromium } = requireBrowser('playwright')); } catch { /* Optional browser test dependency. */ }
|
||||
|
||||
// Runs real React handlers against synthetic profile data. No API request is
|
||||
// allowed, including localhost API calls. The override tests a private copy
|
||||
// without changing the shared checkout; normal CI leaves it unset.
|
||||
test('profile entry targets and simple save preserve the draft and historical timezone', {
|
||||
skip: !chromium && 'Set PLAYWRIGHT_REQUIRE_FROM to a local Playwright installation.',
|
||||
}, async t => {
|
||||
const { createServer } = await import(new URL('../node_modules/vite/dist/node/index.js', import.meta.url));
|
||||
const work = await mkdtemp(path.join(tmpdir(), 'story-profile-test-'));
|
||||
await mkdir(path.join(work, 'empty-env'));
|
||||
const sourceFile = path.join(webRoot, 'src/pages/SaaSWorkspace.tsx');
|
||||
const source = await readFile(process.env.PROFILE_TEST_SOURCE || sourceFile, 'utf8');
|
||||
const target = sourceFile.replaceAll('\\', '/');
|
||||
const server = await createServer({
|
||||
configFile: false, root: webRoot, envDir: path.join(work, 'empty-env'), cacheDir: path.join(work, 'vite-cache'),
|
||||
oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'synthetic-profile-tests', enforce: 'pre',
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use('/__profile-test', (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end('<!doctype html><meta charset="UTF-8"><div id="root"></div><script type="module" src="/@id/__profile-test.tsx"></script>');
|
||||
});
|
||||
},
|
||||
resolveId(id) { if (id === '__profile-test.tsx') return '\0__profile-test.tsx'; },
|
||||
load(id) {
|
||||
if (id.split('?')[0].replaceAll('\\', '/') === target) return source + '\nexport { SubjectPanel };\n';
|
||||
if (id !== '\0__profile-test.tsx') return;
|
||||
return `
|
||||
import React from 'react'; import { createRoot } from 'react-dom/client';
|
||||
import { SubjectPanel } from '/src/pages/SaaSWorkspace.tsx';
|
||||
const original = { id:'synthetic-elder', household_id:'synthetic-home', preferred_name:'测试长者',
|
||||
language:'zh-CN', timezone:'America/Los_Angeles', version:4, status:'active',
|
||||
profile_data:{background:'合成背景', interests:['养花']},
|
||||
conversation_preferences:{pace:'slow',conversation_goal:'日常陪伴',preferred_topics:[],avoided_topics:[]} };
|
||||
window.profileSaves=[]; window.profileVerifies=0;
|
||||
function App() {
|
||||
const [subject,setSubject]=React.useState(original), [attempt,setAttempt]=React.useState();
|
||||
const save=(baseline,input)=>{
|
||||
window.profileSaves.push({baseline,input});
|
||||
if (new URL(location.href).searchParams.get('outcome') === 'uncertain') {
|
||||
setAttempt({status:'uncertain',householdID:baseline.household_id,baseVersion:baseline.version,input,message:'合成未知结果'});
|
||||
} else {
|
||||
setSubject(current=>({...current,...input,version:current.version+1}));
|
||||
setAttempt({status:'saved',householdID:baseline.household_id,baseVersion:baseline.version,input});
|
||||
}
|
||||
};
|
||||
return React.createElement(SubjectPanel, { subjects:[subject],selectedID:subject.id,onSelect:()=>{},subject,
|
||||
facts:[],consents:[],consentState:'ready',loading:false,canWrite:true,actionBusy:false,
|
||||
onAdd:()=>{},onUpdateSubject:save,onConfirmFact:async()=>{},onWithdrawConsent:async()=>{},
|
||||
onOpenConsent:()=>{},saveAttempt:attempt,onVerifySave:async()=>{window.profileVerifies++},
|
||||
onUseLatest:()=>setAttempt(undefined) });
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(React.createElement(App));
|
||||
`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) });
|
||||
const errors = [], blocked = [];
|
||||
const newPage = async (query = '') => {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, serviceWorkers: 'block' });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== origin || url.pathname.startsWith('/api/')) {
|
||||
blocked.push(url.origin + url.pathname);
|
||||
return route.abort('blockedbyclient');
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto(origin + '/__profile-test' + query);
|
||||
await page.locator('.cp-preparation').waitFor();
|
||||
return page;
|
||||
};
|
||||
const nameField = page => page.locator('.cp-profile-fields label').filter({ has: page.locator('strong', { hasText: /^称呼$/ }) }).locator('input');
|
||||
const profileHeading = page => page.locator('.cp-profile-fields > h2');
|
||||
|
||||
await t.test('basic and preference entries, repeated opening, and close/reopen select the requested page', async () => {
|
||||
const page = await newPage();
|
||||
await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).click();
|
||||
assert.equal(await profileHeading(page).innerText(), '基本资料');
|
||||
await nameField(page).fill('未保存称呼');
|
||||
await page.locator('.cp-profile-form .cp-tabs').getByRole('button', { name: '陪伴偏好', exact: true }).click();
|
||||
assert.equal(await profileHeading(page).innerText(), '陪伴偏好');
|
||||
// A repeat navigation event must work even while this editor is mounted.
|
||||
await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).dispatchEvent('click');
|
||||
await nameField(page).waitFor();
|
||||
assert.equal(await profileHeading(page).innerText(), '基本资料');
|
||||
assert.equal(await nameField(page).inputValue(), '未保存称呼');
|
||||
await page.getByRole('button', { name: '关闭资料详情', exact: true }).click();
|
||||
await page.locator('.cp-preparation button').filter({ hasText: '交流偏好' }).click();
|
||||
assert.equal(await profileHeading(page).innerText(), '陪伴偏好');
|
||||
await page.getByRole('button', { name: '关闭资料详情', exact: true }).click();
|
||||
await page.locator('.cp-identity button').click();
|
||||
assert.equal(await profileHeading(page).innerText(), '基本资料');
|
||||
assert.equal(await page.getByText('所在时区', { exact: true }).count(), 0);
|
||||
assert.doesNotMatch(await page.locator('body').innerText(), /America\/Los_Angeles|Asia\/Shanghai/);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
await t.test('save submits once without a confirmation and keeps the original timezone and baseline', async () => {
|
||||
const page = await newPage();
|
||||
await page.locator('.cp-identity button').click();
|
||||
await nameField(page).fill('更新后的称呼');
|
||||
await page.getByRole('button', { name: '保存资料', exact: true }).click();
|
||||
await page.locator('.cp-profile-actions').getByText('服务器已确认保存', { exact: true }).waitFor();
|
||||
const saves = await page.evaluate(() => window.profileSaves);
|
||||
assert.equal(saves.length, 1);
|
||||
assert.equal(saves[0].baseline.version, 4);
|
||||
assert.equal(saves[0].input.preferred_name, '更新后的称呼');
|
||||
assert.equal(saves[0].input.timezone, 'America/Los_Angeles');
|
||||
assert.equal(await page.locator('.cp-profile-form input[type=checkbox]').count(), 0);
|
||||
assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true);
|
||||
assert.doesNotMatch(await page.locator('.cp-profile-form').innerText(), /synthetic-elder|v4|v5/);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
await t.test('an unknown result keeps its draft, blocks another submit, and offers only read verification', async () => {
|
||||
const page = await newPage('?outcome=uncertain');
|
||||
await page.locator('.cp-identity button').click();
|
||||
await nameField(page).fill('未知结果草稿');
|
||||
await page.getByRole('button', { name: '保存资料', exact: true }).click();
|
||||
const verify = page.getByRole('button', { name: '核验服务器资料(只读)', exact: true });
|
||||
await verify.waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true);
|
||||
assert.equal(await nameField(page).inputValue(), '未知结果草稿');
|
||||
await verify.click();
|
||||
assert.deepEqual(await page.evaluate(() => ({ saves: window.profileSaves.length, verifies: window.profileVerifies })), { saves: 1, verifies: 1 });
|
||||
await page.getByRole('button', { name: '关闭资料详情', exact: true }).click();
|
||||
await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).click();
|
||||
assert.equal(await nameField(page).inputValue(), '未知结果草稿');
|
||||
assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true);
|
||||
await page.close();
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(blocked, []);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
// This temporary directory was created by this test and is always in tmpdir.
|
||||
if (path.dirname(work) !== path.resolve(tmpdir())) throw new Error('Unsafe temporary cleanup path');
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mutationOutcomeUnknown, needsSaveVerification, subjectSaveLabel, subjectMatchesUpdate, verifiedSaveStatus } from '../src/pages/subject-save.ts';
|
||||
|
||||
const original = { id: 'synthetic-elder', household_id: 'synthetic-home', version: 4, preferred_name: '合成长者', profile_data: { interests: ['养花'], background: '合成背景' } };
|
||||
const input = { preferred_name: '合成长者(已编辑)', profile_data: { background: '合成背景', interests: ['养花'] } };
|
||||
const attempt = { householdID: original.household_id, baseVersion: 4, input, status: 'uncertain' };
|
||||
|
||||
test('lost and gateway responses are unknown, while explicit rejection is distinct', () => {
|
||||
for (const status of [0, 408, 500, 502, 503, 504]) assert.equal(mutationOutcomeUnknown({ status }), true);
|
||||
for (const status of [400, 401, 403, 409, 412, 422, 429]) assert.equal(mutationOutcomeUnknown({ status }), false);
|
||||
assert.equal(mutationOutcomeUnknown(new TypeError('network failure')), true);
|
||||
assert.equal(mutationOutcomeUnknown(new SyntaxError('invalid JSON')), true);
|
||||
});
|
||||
|
||||
test('a background refresh does not convert an uncertain save into a success label', () => {
|
||||
assert.match(subjectSaveLabel(attempt, false), /尚未确认/);
|
||||
assert.equal(needsSaveVerification(attempt), true);
|
||||
assert.doesNotMatch(subjectSaveLabel(undefined, false), /已保存|最新版本/);
|
||||
});
|
||||
|
||||
test('read-only verification requires matching all submitted fields and a non-stale version', () => {
|
||||
const committed = { ...original, ...input, version: 5 };
|
||||
assert.equal(subjectMatchesUpdate(committed, input), true);
|
||||
assert.equal(verifiedSaveStatus(committed, attempt), 'verified');
|
||||
assert.equal(verifiedSaveStatus(original, attempt), 'uncertain');
|
||||
assert.equal(verifiedSaveStatus({ ...committed, version: 3 }, attempt), 'uncertain');
|
||||
assert.equal(verifiedSaveStatus({ ...committed, preferred_name: '另一项并发修改' }, attempt), 'conflict');
|
||||
assert.equal(verifiedSaveStatus({ ...committed, profile_data: { ...input.profile_data, interests: [] } }, attempt), 'conflict');
|
||||
});
|
||||
|
||||
test('editing after an acknowledged save has a draft label again', () => {
|
||||
assert.equal(subjectSaveLabel({ ...attempt, status: 'saved' }, true), '修改尚未保存');
|
||||
assert.equal(subjectSaveLabel({ ...attempt, status: 'saved' }, false), '服务器已确认保存');
|
||||
assert.equal(needsSaveVerification({ ...attempt, status: 'saved' }), false);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { usageDateRange, usageMinutes, usageConversationContext, usageConversationURL } from "../src/components/usage-model.ts";
|
||||
|
||||
test("usage range begins at Beijing midnight independent of browser timezone", () => {
|
||||
const previous = process.env.TZ;
|
||||
try {
|
||||
for (const zone of ["Etc/UTC", "America/Los_Angeles", "Asia/Tokyo"]) {
|
||||
process.env.TZ = zone;
|
||||
assert.deepEqual(usageDateRange(30, new Date("2026-10-05T16:30:00Z")), {
|
||||
from: "2026-09-06T16:00:00.000Z", to: "2026-10-05T16:30:00.000Z", timezone: "Asia/Shanghai",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("usage range includes the current local day and handles year rollover", () => {
|
||||
assert.equal(usageDateRange(1, new Date("2026-01-01T00:05:00+08:00")).from, "2025-12-31T16:00:00.000Z");
|
||||
assert.equal(usageDateRange(7, new Date("2026-01-01T00:05:00+08:00")).from, "2025-12-25T16:00:00.000Z");
|
||||
for (const days of [0, -1, 367, NaN, 1.5]) assert.throws(() => usageDateRange(days), RangeError);
|
||||
});
|
||||
|
||||
test('30/90/365 days are inclusive Beijing calendar days across midnight, with an exact request-time end', () => {
|
||||
const cases = [
|
||||
['2026-10-05T15:59:59.999Z', {30:'2026-09-05T16:00:00.000Z',90:'2026-07-07T16:00:00.000Z',365:'2025-10-05T16:00:00.000Z'}],
|
||||
['2026-10-05T16:00:00.000Z', {30:'2026-09-06T16:00:00.000Z',90:'2026-07-08T16:00:00.000Z',365:'2025-10-06T16:00:00.000Z'}],
|
||||
['2026-10-05T16:00:00.001Z', {30:'2026-09-06T16:00:00.000Z',90:'2026-07-08T16:00:00.000Z',365:'2025-10-06T16:00:00.000Z'}],
|
||||
];
|
||||
for(const [at,starts] of cases) for(const days of [30,90,365]) {
|
||||
assert.deepEqual(usageDateRange(days,new Date(at)), {from:starts[days],to:at,timezone:'Asia/Shanghai'});
|
||||
}
|
||||
assert.equal(usageDateRange(365,new Date('2028-03-01T00:00:00+08:00')).from,'2027-03-02T16:00:00.000Z',
|
||||
'the year option means 365 inclusive days, rather than the same date twelve months earlier');
|
||||
});
|
||||
|
||||
test("short completed chats do not display zero minutes", () => {
|
||||
assert.equal(usageMinutes(0), "0");
|
||||
assert.equal(usageMinutes(1), "<1");
|
||||
assert.equal(usageMinutes(29000), "<1");
|
||||
assert.equal(usageMinutes(60000), "1");
|
||||
assert.equal(usageMinutes(98000), "1.6");
|
||||
});
|
||||
|
||||
const roster = [{ id: 'a-first', household_id: 'a' }, { id: 'a-second', household_id: 'a' }, { id: 'b-first', household_id: 'b' }];
|
||||
const fleet = [{ id: 'a-device', household_id: 'a', subject_id: 'a-second' }, { id: 'b-device', household_id: 'b', subject_id: 'b-first' }, { id: 'unassigned', household_id: 'a' }, { id: 'stale-owner', household_id: 'a', subject_id: 'b-first' }];
|
||||
test('a selected device selects its verified elder even when another elder is already current', () => {
|
||||
for (const current of [undefined, 'a-first', 'a-second']) assert.deepEqual(usageConversationContext('a', fleet, roster, current, 'a-device'), { householdID: 'a', deviceID: 'a-device', subjectID: 'a-second' });
|
||||
});
|
||||
test('all devices use the current valid elder or the existing family default', () => {
|
||||
assert.equal(usageConversationContext('a', fleet, roster, 'a-second').subjectID, 'a-second');
|
||||
assert.deepEqual(usageConversationContext('a', fleet, roster, 'b-first'), { householdID: 'a', deviceID: undefined, subjectID: 'a-first' });
|
||||
});
|
||||
test('all devices with a single verified owner select that elder instead of an unrelated default', () => {
|
||||
const singleOwnerFleet = [{ id: 'one', household_id: 'a', subject_id: 'a-second' }, { id: 'two', household_id: 'a', subject_id: 'a-second' }, fleet[1]];
|
||||
assert.equal(usageConversationContext('a', singleOwnerFleet, roster, 'a-first').subjectID, 'a-second');
|
||||
assert.equal(usageConversationContext('a', [...singleOwnerFleet, fleet[2]], roster, 'a-first').subjectID, 'a-first', 'incomplete ownership uses the existing default');
|
||||
});
|
||||
test('foreign or removed devices and invalid owners never cross the household boundary', () => {
|
||||
for (const device of ['b-device', 'removed']) assert.deepEqual(usageConversationContext('a', fleet, roster, 'b-first', device), { householdID: 'a', deviceID: undefined, subjectID: 'a-first' });
|
||||
for (const device of ['unassigned', 'stale-owner']) assert.equal(usageConversationContext('a', fleet, roster, 'b-first', device).subjectID, 'a-first');
|
||||
assert.equal(usageConversationContext('empty', fleet, roster, 'a-first', 'a-device').subjectID, undefined);
|
||||
});
|
||||
test('the all-records URL carries verified context without promising date or device filtering', () => {
|
||||
const context = usageConversationContext('a', fleet, roster, 'a-first', 'a-device');
|
||||
const url = new URL(usageConversationURL(context), 'https://synthetic.invalid');
|
||||
assert.deepEqual(Object.fromEntries(url.searchParams), { section: 'conversations', household_id: 'a', usage_device_id: 'a-device', subject_id: 'a-second' });
|
||||
assert(!url.searchParams.has('device_id') && !url.searchParams.has('from') && !url.searchParams.has('to'));
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createServer } from 'vite';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
|
||||
const { chromium } = require('playwright');
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const evidence = path.resolve(process.env.QA_OUTPUT);
|
||||
await fs.mkdir(path.join(evidence, 'empty-env'), { recursive: true });
|
||||
const server = await createServer({ configFile: false, root, envDir: path.join(evidence, 'empty-env'), cacheDir: path.join(evidence, 'vite-cache'), esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0 } });
|
||||
await server.listen();
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE });
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 1000 }, timezoneId: 'America/Los_Angeles' });
|
||||
const errors = [], writes = [], queries = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
const record = { id: 'synthetic-event', kind: 'event', time_ms: Date.parse('2026-10-06T12:00:00Z'), session_id: 'synthetic-session', content: '合成记忆:长者年轻时在杭州工作,喜欢在院子里喝茶。完整正文保留。', memory_info: { summary: '合成记忆正文', original_messages: ['合成聊天原文'] }, digest: 'a'.repeat(64) };
|
||||
let deleted = false, failDelete = true, failRead = false, canWrite = true, slow = false;
|
||||
await page.route('**/*', async route => {
|
||||
const req = route.request(), url = new URL(req.url());
|
||||
if (url.hostname !== '127.0.0.1') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
const body = req.postDataJSON();
|
||||
if (url.pathname.endsWith('/viking/query')) {
|
||||
queries.push(body);
|
||||
if (slow) await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (failRead) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'VIKING_QUERY_FAILED' }) });
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ data: { items: body.elder_id === 'elder-a' && !deleted ? [{ ...record, kind: body.kind }] : [], can_write: canWrite, limited: false, limit: body.limit } }) });
|
||||
}
|
||||
if (url.pathname.endsWith('/viking/delete')) {
|
||||
writes.push(body);
|
||||
if (failDelete) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'VIKING_DELETE_UNCONFIRMED' }) });
|
||||
deleted = true;
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ data: { deleted: true } }) });
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${url.pathname}`);
|
||||
});
|
||||
try {
|
||||
await page.goto(`${server.resolvedUrls.local[0]}test/viking-ui/`);
|
||||
await page.getByRole('button', { name: '删除这条火山记忆' }).waitFor();
|
||||
await page.getByText('2026/10/06 20:00:00', { exact: false }).first().waitFor();
|
||||
await page.screenshot({ path: path.join(evidence, 'desktop.png'), fullPage: true });
|
||||
await page.getByLabel('搜索火山记忆', { exact: true }).fill('年轻时的工作');
|
||||
await page.getByLabel('记录开始日期').fill('2026-10-01');
|
||||
await page.getByLabel('记录结束日期').fill('2026-10-07');
|
||||
await page.getByRole('button', { name: '查询火山库' }).click();
|
||||
await page.getByRole('button', { name: '删除这条火山记忆' }).waitFor({ state: 'visible' });
|
||||
assert.equal(queries.at(-1).query, '年轻时的工作');
|
||||
assert.equal(queries.at(-1).start_time_ms, Date.parse('2026-10-01T00:00:00+08:00'));
|
||||
assert.equal(queries.at(-1).end_time_ms, Date.parse('2026-10-07T23:59:59.999+08:00'));
|
||||
await page.getByRole('button', { name: '删除这条火山记忆' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
assert(await dialog.getByRole('button', { name: '确认删除', exact: true }).isDisabled());
|
||||
await dialog.getByLabel('删除原因', { exact: true }).fill('合成数据清理');
|
||||
await dialog.getByRole('checkbox').check();
|
||||
await dialog.getByRole('button', { name: '确认删除', exact: true }).click();
|
||||
await dialog.getByText('还未确认火山端删除成功。', { exact: false }).waitFor();
|
||||
await page.screenshot({ path: path.join(evidence, 'delete-unconfirmed.png'), fullPage: true });
|
||||
failDelete = false;
|
||||
await dialog.getByRole('button', { name: /确认删除/ }).click();
|
||||
await page.getByText('已核验:这条记忆已从火山 Viking 库删除。').waitFor();
|
||||
assert.equal(writes.length, 2); assert.equal(writes[0].operation_id, writes[1].operation_id);
|
||||
assert.equal(writes[0].digest, record.digest); assert.equal(writes[0].elder_id, 'elder-a');
|
||||
deleted = false; canWrite = false;
|
||||
await page.getByRole('button', { name: '刷新火山记忆' }).click();
|
||||
await page.getByText('当前账号可查看,删除需要记忆修改权限。', { exact: false }).waitFor();
|
||||
assert(await page.getByRole('button', { name: '删除这条火山记忆' }).isDisabled());
|
||||
failRead = true;
|
||||
await page.getByRole('button', { name: '刷新火山记忆' }).click();
|
||||
await page.getByText('火山记忆库暂时无法读取,请稍后重试。').waitFor();
|
||||
assert.equal(await page.getByText('火山库没有返回符合条件的聊天记忆').count(), 0);
|
||||
failRead = false; canWrite = true;
|
||||
await page.getByRole('button', { name: '刷新火山记忆' }).click();
|
||||
await page.getByRole('button', { name: '删除这条火山记忆' }).waitFor();
|
||||
slow = true;
|
||||
await page.getByRole('button', { name: '刷新火山记忆' }).click();
|
||||
await page.getByRole('button', { name: '切换合成长者' }).click();
|
||||
await page.getByText('火山库没有返回符合条件的聊天记忆').waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: '删除这条火山记忆' }).count(), 0);
|
||||
slow = false;
|
||||
await page.getByRole('button', { name: '切换合成长者' }).click();
|
||||
await page.getByRole('button', { name: '删除这条火山记忆' }).waitFor();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ path: path.join(evidence, 'mobile.png'), fullPage: true });
|
||||
assert.deepEqual(errors, []);
|
||||
await fs.writeFile(path.join(evidence, 'result.json'), JSON.stringify({ passed: true, synthetic_only: true, checked: ['search', 'Beijing-date-range', 'confirmation', 'failure-feedback', 'idempotent-retry', 'read-only', 'read-failure', 'elder-switch-race', 'mobile'], writes: writes.length }, null, 2));
|
||||
console.log('PASS: Viking memory browser flow (synthetic API only)');
|
||||
} catch (e) { await page.screenshot({ path: path.join(evidence, 'failed.png'), fullPage: true }); console.log(JSON.stringify({ writes, buttons: await page.getByRole('button').allTextContents(), dialogs: await page.getByRole('dialog').count() })); throw e; } finally { await browser.close(); await server.close(); }
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { VikingMemoryWorkspace } from '../../src/pages/VikingMemoryWorkspace';
|
||||
import '../../src/styles.css';
|
||||
import '../../src/cal-theme.css';
|
||||
import '../../src/customer-responsive.css';
|
||||
import '../../src/components/home/customer-portal.css';
|
||||
function Fixture() {
|
||||
const [elder, setElder] = useState('elder-a');
|
||||
return <ConfigProvider locale={zhCN} theme={{ token: { controlHeight: 40 } }}><main className="companion-portal" style={{ padding: 24, maxWidth: 1200, margin: 'auto' }}><h1>故事与记忆 · 合成长者</h1><button onClick={() => setElder(value => value === 'elder-a' ? 'elder-b' : 'elder-a')}>切换合成长者</button><VikingMemoryWorkspace elderID={elder} accessToken="synthetic-token" /></main></ConfigProvider>;
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<Fixture />);
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Viking 合成记忆验证</title></head><body><div id="root"></div><script type="module" src="./fixture.tsx"></script></body></html>
|
||||
Reference in New Issue
Block a user