mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
fix: make card resource recovery observable
Issue: #462 User-Visible: yes
This commit is contained in:
@@ -443,5 +443,6 @@ test('#438 CLI действительно применяет потолок, а
|
||||
// становится потолок, а не бюджет — но и бюджет обязан уметь падать.
|
||||
const overBudget = runBudgetCli(INITIAL_VIEW_GZIP_BUDGET + 1);
|
||||
assert.equal(overBudget.status, 1, overBudget.output);
|
||||
assert.match(overBudget.output, /exceeds 300000 B budget/);
|
||||
assert.match(overBudget.output,
|
||||
new RegExp(`exceeds ${INITIAL_VIEW_GZIP_BUDGET} B budget`));
|
||||
});
|
||||
|
||||
@@ -136,6 +136,29 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 462 has reviewed desktop, narrow-touch and attempted-kiosk notices', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((scenario) =>
|
||||
scenario.id.startsWith('version-mismatch-'));
|
||||
assert.deepEqual(scenarios.map((scenario) => scenario.id), [
|
||||
'version-mismatch-desktop-dark-en',
|
||||
'version-mismatch-touch-light-ru',
|
||||
'version-mismatch-kiosk-attempted-dark-de',
|
||||
]);
|
||||
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.language)),
|
||||
new Set(['en', 'ru', 'de']));
|
||||
assert.equal(scenarios.every((scenario) => scenario.mode === 'view'
|
||||
&& scenario.capture === 'page'
|
||||
&& scenario.integrationVersion === '0.0.0-golden-backend'), true);
|
||||
const touch = scenarios.find((scenario) => scenario.touchViewport);
|
||||
assert.deepEqual(touch?.viewport, { width: 390, height: 760 });
|
||||
const kiosk = scenarios.find((scenario) => scenario.versionRecoveryAttempted);
|
||||
assert.equal(kiosk?.kiosk, true);
|
||||
assert.equal(kiosk?.theme, 'dark');
|
||||
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
|
||||
assert.match(harness, /sessionStorage\.setItem\(attemptKey, attemptedTarget\)/);
|
||||
assert.match(harness, /integration_version: scenario\.integrationVersion/);
|
||||
});
|
||||
|
||||
test('golden matrix covers required geometry, rendering and adaptive surfaces', () => {
|
||||
const ids = GOLDEN_SCENARIOS.map((scenario) => scenario.id).join(' ');
|
||||
for (const token of ['geometry', 'diagonal-45-opening', 'openings', 'openings-hidden',
|
||||
@@ -375,7 +398,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 54);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 55);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
|
||||
@@ -470,6 +470,7 @@ test('i18n: German values equal to English are explicitly reviewed', () => {
|
||||
'vac.diag_platform',
|
||||
'vac.diag_status',
|
||||
'vac.diag_position',
|
||||
'version_mismatch.backend',
|
||||
]);
|
||||
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
|
||||
assert.deepEqual(new Set(equalKeys), allowed);
|
||||
|
||||
@@ -81,7 +81,10 @@ test('empty render keeps create/import affordances without spatial layers', () =
|
||||
assert.match(wrapper, /if \(body === nothing\) return body;/);
|
||||
assert.match(wrapper, /return this\._renderRoot\(body\);/);
|
||||
const shell = methodBody('_renderRoot');
|
||||
assert.match(shell, /return html`\$\{body\}\$\{this\._renderDangerConfirm\(\)\}`;/);
|
||||
assert.match(shell,
|
||||
/return html`\$\{body\}\$\{this\._renderVersionBanner\(\)\}\$\{this\._renderDangerConfirm\(\)\}`;/);
|
||||
assert.equal(render.includes('_renderVersionBanner'), false,
|
||||
'version recovery must remain available beside every non-empty body branch');
|
||||
assert.equal(render.includes('_renderDangerConfirm'), false,
|
||||
'подтверждение не должно возвращаться внутрь ветки — это и есть дефект #402');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const card = read('src/houseplan-card.ts');
|
||||
const adapter = read('src/version-recovery-card.ts');
|
||||
const editor = read('src/houseplan-editor-runtime.ts');
|
||||
const onboarding = read('src/houseplan-onboarding-runtime.ts');
|
||||
const controller = read('src/version-recovery.ts');
|
||||
const styles = read('src/styles/base.styles.ts');
|
||||
|
||||
const occurrences = (source, fragment) => source.split(fragment).length - 1;
|
||||
|
||||
test('one host seam owns every full-card config/get request', () => {
|
||||
const direct = "callWS({ type: 'houseplan/config/get'";
|
||||
assert.equal(occurrences(adapter, direct), 1, 'the adapter is the only WS request owner');
|
||||
assert.equal(occurrences(card, direct), 0, 'card flows must use the authoritative seam');
|
||||
assert.equal(occurrences(editor, direct), 0, 'lazy editor flows must use the host seam');
|
||||
assert.equal(occurrences(onboarding, direct), 0, 'onboarding flows must use the host seam');
|
||||
|
||||
assert.match(card, /private _getAuthoritativeConfig\(\): Promise<any>/);
|
||||
assert.equal(occurrences(card, 'this._getAuthoritativeConfig()'), 2,
|
||||
'initial load and config-only reload both use the seam');
|
||||
assert.equal(occurrences(editor, 'this.host._getAuthoritativeConfig()'), 3,
|
||||
'delete, optimization undo and backup import use the seam');
|
||||
assert.equal(occurrences(onboarding, 'this.host._getAuthoritativeConfig()'), 1,
|
||||
'empty-install space deletion uses the seam');
|
||||
});
|
||||
|
||||
test('config fulfillment adopts capabilities before aggregate consumers continue', () => {
|
||||
const responseAt = controller.indexOf('const response = await request();');
|
||||
const adoptAt = controller.indexOf('adopt(response);', responseAt);
|
||||
const returnAt = controller.indexOf('return response;', adoptAt);
|
||||
assert.ok(responseAt >= 0 && responseAt < adoptAt && adoptAt < returnAt);
|
||||
assert.match(adapter,
|
||||
/return fetchAuthoritativeConfig\([\s\S]*adoptCardConfigCapabilities\(host, response\)/);
|
||||
assert.match(adapter,
|
||||
/host\._haIntegrationVersion = normalizeRuntimeVersion\(capabilities\.integration_version\)/);
|
||||
assert.match(adapter, /host\._haSupportApi = [\s\S]*\? supportApi : null;/);
|
||||
assert.match(adapter, /host\._haDecorAssetsApi = [\s\S]*: null;/);
|
||||
});
|
||||
|
||||
test('banner insets are symmetric and cannot escape the full-card host', () => {
|
||||
assert.match(styles,
|
||||
/:host\(houseplan-card\) \{\s*display: block;\s*position: relative;\s*\}/);
|
||||
const sharedHost = styles.slice(styles.indexOf(' :host {'),
|
||||
styles.indexOf(' :host(houseplan-card)'));
|
||||
assert.doesNotMatch(sharedHost, /\b(?:display|position):/,
|
||||
'space-card shares variables, not the full-card positioning contract');
|
||||
const rule = styles.slice(
|
||||
styles.indexOf(' .version-recovery {'),
|
||||
styles.indexOf(' .version-recovery.phase-visible'),
|
||||
);
|
||||
assert.match(rule, /left: max\(var\(--sp-5\), env\(safe-area-inset-left\)\);/);
|
||||
assert.match(rule, /right: max\(var\(--sp-5\), env\(safe-area-inset-right\)\);/);
|
||||
assert.match(rule, /width: auto;/);
|
||||
assert.match(rule, /max-width: 430px;/);
|
||||
assert.match(rule, /margin-left: auto;/);
|
||||
assert.doesNotMatch(rule, /calc\(100%/);
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
VERSION_RECOVERY_CHECK_MS,
|
||||
VERSION_RELOAD_ATTEMPT_KEY,
|
||||
VersionRecoveryController,
|
||||
compareRuntimeVersions,
|
||||
fetchAuthoritativeConfig,
|
||||
isVersionReloadSafe,
|
||||
normalizeRuntimeVersion,
|
||||
} from '../test-build/version-recovery.js';
|
||||
|
||||
class FakeClock {
|
||||
at = 10_000;
|
||||
nextId = 1;
|
||||
timers = new Map();
|
||||
setTimeout = (callback, delay) => {
|
||||
const id = this.nextId++;
|
||||
this.timers.set(id, { at: this.at + delay, callback });
|
||||
return id;
|
||||
};
|
||||
clearTimeout = (id) => this.timers.delete(id);
|
||||
advance(ms) {
|
||||
const end = this.at + ms;
|
||||
while (true) {
|
||||
const due = [...this.timers.entries()]
|
||||
.filter(([, timer]) => timer.at <= end)
|
||||
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
|
||||
if (!due) break;
|
||||
this.at = due[1].at;
|
||||
this.timers.delete(due[0]);
|
||||
due[1].callback();
|
||||
}
|
||||
this.at = end;
|
||||
}
|
||||
}
|
||||
|
||||
const allSafe = () => ({
|
||||
connected: true,
|
||||
initialFrameSettled: true,
|
||||
viewOnly: true,
|
||||
surfacesIdle: true,
|
||||
configWritesIdle: true,
|
||||
physicalWritesIdle: true,
|
||||
layoutWritesIdle: true,
|
||||
gesturesIdle: true,
|
||||
interactionPauseElapsed: true,
|
||||
baseZoom: true,
|
||||
});
|
||||
|
||||
function memoryStorage(initial = null) {
|
||||
const values = new Map();
|
||||
if (initial !== null) values.set(VERSION_RELOAD_ATTEMPT_KEY, initial);
|
||||
return {
|
||||
values,
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
function harness({ storage = memoryStorage(), safety = allSafe() } = {}) {
|
||||
const clock = new FakeClock();
|
||||
const events = [];
|
||||
let snapshot = safety;
|
||||
const controller = new VersionRecoveryController({
|
||||
clock,
|
||||
storage: () => storage,
|
||||
safety: () => snapshot,
|
||||
reload: () => events.push({ kind: 'reload', stored: storage?.values?.get(VERSION_RELOAD_ATTEMPT_KEY) }),
|
||||
changed: () => events.push({ kind: 'changed' }),
|
||||
});
|
||||
return {
|
||||
clock,
|
||||
storage,
|
||||
events,
|
||||
controller,
|
||||
setSafety: (value) => { snapshot = value; },
|
||||
};
|
||||
}
|
||||
|
||||
const update = (controller, overrides = {}) => controller.update({
|
||||
frontendVersion: '1.72.0',
|
||||
backendVersion: '1.73.0',
|
||||
kiosk: false,
|
||||
reducedMotion: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('versions are trimmed, exact and symmetric; malformed values stay unknown', () => {
|
||||
assert.equal(normalizeRuntimeVersion(' 1.72.0 '), '1.72.0');
|
||||
for (const value of [undefined, null, 1, '', ' ']) {
|
||||
assert.equal(normalizeRuntimeVersion(value), null);
|
||||
}
|
||||
assert.deepEqual(compareRuntimeVersions(' A ', 'A'), {
|
||||
kind: 'equal', frontend: 'A', backend: 'A',
|
||||
});
|
||||
assert.deepEqual(compareRuntimeVersions('A', 'B'), {
|
||||
kind: 'mismatch', frontend: 'A', backend: 'B',
|
||||
});
|
||||
assert.deepEqual(compareRuntimeVersions('B', 'A'), {
|
||||
kind: 'mismatch', frontend: 'B', backend: 'A',
|
||||
});
|
||||
assert.deepEqual(compareRuntimeVersions('A', ' '), { kind: 'unknown' });
|
||||
});
|
||||
|
||||
test('a fulfilled config is adopted before a sibling request or later preparation fails', async () => {
|
||||
for (const response of [
|
||||
{ integration_version: 'new-target' },
|
||||
{},
|
||||
]) {
|
||||
let rejectSibling;
|
||||
let adoptedVersion = 'stale-target';
|
||||
const sibling = new Promise((_, reject) => { rejectSibling = reject; });
|
||||
const config = fetchAuthoritativeConfig(
|
||||
() => Promise.resolve(response),
|
||||
(value) => { adoptedVersion = normalizeRuntimeVersion(value.integration_version); },
|
||||
);
|
||||
const aggregate = Promise.all([config, sibling]);
|
||||
await config;
|
||||
const expected = response.integration_version || null;
|
||||
assert.equal(adoptedVersion, expected,
|
||||
'the individual config fulfillment sets or clears the runtime version');
|
||||
rejectSibling(new Error('layout or asset preparation failed'));
|
||||
await assert.rejects(aggregate, /layout or asset preparation failed/);
|
||||
assert.equal(adoptedVersion, expected,
|
||||
'an aggregate failure cannot restore a stale runtime version');
|
||||
}
|
||||
});
|
||||
|
||||
test('every named kiosk safety category independently blocks reload', () => {
|
||||
const safe = allSafe();
|
||||
assert.equal(isVersionReloadSafe(safe), true);
|
||||
for (const key of Object.keys(safe)) {
|
||||
assert.equal(isVersionReloadSafe({ ...safe, [key]: false }), false, key);
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary mode always shows the manual notice and never reloads itself', () => {
|
||||
const h = harness();
|
||||
update(h.controller);
|
||||
h.controller.connect();
|
||||
assert.equal(h.controller.relation.kind, 'mismatch');
|
||||
assert.equal(h.controller.banner?.phase, 'visible');
|
||||
assert.equal(h.controller.hasCurrentMismatchNotice, true);
|
||||
assert.equal(h.clock.timers.size, 0);
|
||||
h.clock.advance(VERSION_RECOVERY_CHECK_MS * 100);
|
||||
assert.equal(h.events.some((event) => event.kind === 'reload'), false);
|
||||
assert.equal(h.storage.values.size, 0, 'normal mode does not consume the kiosk attempt');
|
||||
});
|
||||
|
||||
test('kiosk waits for a safe frame, marks the exact target before one reload, then falls back to banner', () => {
|
||||
const h = harness({ safety: { ...allSafe(), surfacesIdle: false } });
|
||||
update(h.controller, { kiosk: true });
|
||||
h.controller.connect();
|
||||
assert.equal(h.controller.banner, null, 'fresh kiosk mismatch stays quiet');
|
||||
assert.equal(h.clock.timers.size, 1);
|
||||
h.clock.advance(VERSION_RECOVERY_CHECK_MS * 3);
|
||||
assert.equal(h.events.some((event) => event.kind === 'reload'), false);
|
||||
assert.equal(h.clock.timers.size, 1, 'polling owns at most one timer');
|
||||
|
||||
h.setSafety(allSafe());
|
||||
h.clock.advance(VERSION_RECOVERY_CHECK_MS);
|
||||
const reloads = h.events.filter((event) => event.kind === 'reload');
|
||||
assert.deepEqual(reloads, [{ kind: 'reload', stored: '1.73.0' }]);
|
||||
assert.equal(h.storage.values.get(VERSION_RELOAD_ATTEMPT_KEY), '1.73.0');
|
||||
assert.equal(h.controller.banner?.phase, 'visible');
|
||||
assert.equal(h.clock.timers.size, 0);
|
||||
h.clock.advance(VERSION_RECOVERY_CHECK_MS * 10);
|
||||
assert.equal(h.events.filter((event) => event.kind === 'reload').length, 1);
|
||||
});
|
||||
|
||||
test('the same target is once per tab across cards and alternating frontend versions', () => {
|
||||
const storage = memoryStorage();
|
||||
const first = harness({ storage });
|
||||
update(first.controller, { kiosk: true, frontendVersion: 'old-A', backendVersion: 'target-B' });
|
||||
first.controller.connect();
|
||||
first.clock.advance(VERSION_RECOVERY_CHECK_MS);
|
||||
assert.equal(first.events.filter((event) => event.kind === 'reload').length, 1);
|
||||
|
||||
const second = harness({ storage });
|
||||
update(second.controller, { kiosk: true, frontendVersion: 'old-C', backendVersion: 'target-B' });
|
||||
second.controller.connect();
|
||||
assert.equal(second.controller.banner?.backend, 'target-B');
|
||||
assert.equal(second.clock.timers.size, 0);
|
||||
second.clock.advance(VERSION_RECOVERY_CHECK_MS * 5);
|
||||
assert.equal(second.events.some((event) => event.kind === 'reload'), false);
|
||||
|
||||
update(second.controller, { kiosk: true, frontendVersion: 'old-C', backendVersion: 'target-D' });
|
||||
assert.equal(second.controller.banner, null, 'a new backend target gets a fresh quiet attempt');
|
||||
second.clock.advance(VERSION_RECOVERY_CHECK_MS);
|
||||
assert.equal(second.events.filter((event) => event.kind === 'reload').length, 1);
|
||||
assert.equal(storage.values.get(VERSION_RELOAD_ATTEMPT_KEY), 'target-D');
|
||||
});
|
||||
|
||||
test('a second card losing the claim observes the stored target and does not reload', () => {
|
||||
const storage = memoryStorage();
|
||||
const h = harness({ storage });
|
||||
update(h.controller, { kiosk: true });
|
||||
h.controller.connect();
|
||||
// Another controller/tab card wins between the initial read and safe tick.
|
||||
storage.setItem(VERSION_RELOAD_ATTEMPT_KEY, '1.73.0');
|
||||
h.clock.advance(VERSION_RECOVERY_CHECK_MS);
|
||||
assert.equal(h.events.some((event) => event.kind === 'reload'), false);
|
||||
assert.equal(h.controller.banner?.phase, 'visible');
|
||||
assert.equal(h.clock.timers.size, 0);
|
||||
});
|
||||
|
||||
test('session storage provider/get/set failures are manual-only and never loop', () => {
|
||||
const cases = [
|
||||
() => { throw new Error('getter denied'); },
|
||||
() => ({ getItem: () => { throw new Error('read denied'); }, setItem: () => undefined }),
|
||||
() => ({ getItem: () => null, setItem: () => { throw new Error('write denied'); } }),
|
||||
];
|
||||
for (const provider of cases) {
|
||||
const clock = new FakeClock();
|
||||
let reloads = 0;
|
||||
const controller = new VersionRecoveryController({
|
||||
clock,
|
||||
storage: provider,
|
||||
safety: allSafe,
|
||||
reload: () => reloads++,
|
||||
changed: () => undefined,
|
||||
});
|
||||
update(controller, { kiosk: true });
|
||||
controller.connect();
|
||||
if (!controller.banner) clock.advance(VERSION_RECOVERY_CHECK_MS);
|
||||
assert.equal(reloads, 0);
|
||||
assert.equal(controller.banner?.phase, 'visible');
|
||||
assert.equal(clock.timers.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a synchronous navigation failure keeps the claimed target manual-only', () => {
|
||||
const clock = new FakeClock();
|
||||
const storage = memoryStorage();
|
||||
const controller = new VersionRecoveryController({
|
||||
clock,
|
||||
storage: () => storage,
|
||||
safety: allSafe,
|
||||
reload: () => { throw new Error('navigation denied'); },
|
||||
changed: () => undefined,
|
||||
});
|
||||
update(controller, { kiosk: true });
|
||||
controller.connect();
|
||||
assert.doesNotThrow(() => clock.advance(VERSION_RECOVERY_CHECK_MS));
|
||||
assert.equal(storage.values.get(VERSION_RELOAD_ATTEMPT_KEY), '1.73.0');
|
||||
assert.equal(controller.banner?.phase, 'visible');
|
||||
assert.equal(clock.timers.size, 0);
|
||||
});
|
||||
|
||||
test('equal/unknown and disconnect cancel timers; reconnect re-reads shared session state', () => {
|
||||
const h = harness();
|
||||
update(h.controller, { kiosk: true });
|
||||
h.controller.connect();
|
||||
assert.equal(h.clock.timers.size, 1);
|
||||
update(h.controller, { kiosk: true, backendVersion: '1.72.0' });
|
||||
assert.equal(h.controller.relation.kind, 'equal');
|
||||
assert.equal(h.clock.timers.size, 0);
|
||||
|
||||
update(h.controller, { kiosk: true, backendVersion: ' ' });
|
||||
assert.equal(h.controller.relation.kind, 'unknown');
|
||||
assert.equal(h.clock.timers.size, 0);
|
||||
|
||||
update(h.controller, { kiosk: true });
|
||||
assert.equal(h.clock.timers.size, 1);
|
||||
h.controller.disconnect();
|
||||
assert.equal(h.clock.timers.size, 0);
|
||||
h.storage.setItem(VERSION_RELOAD_ATTEMPT_KEY, '1.73.0');
|
||||
h.controller.connect();
|
||||
assert.equal(h.controller.banner?.phase, 'visible');
|
||||
assert.equal(h.clock.timers.size, 0);
|
||||
});
|
||||
|
||||
test('banner exit is token-safe and reduced motion removes it immediately', () => {
|
||||
const h = harness();
|
||||
update(h.controller, { reducedMotion: false });
|
||||
h.controller.connect();
|
||||
const shown = h.controller.banner;
|
||||
assert.equal(shown?.phase, 'visible');
|
||||
update(h.controller, { backendVersion: '1.72.0', reducedMotion: false });
|
||||
const leaving = h.controller.banner;
|
||||
assert.equal(leaving?.phase, 'leaving');
|
||||
h.controller.finishBannerExit(shown.token);
|
||||
assert.equal(h.controller.banner?.phase, 'leaving', 'stale animation cannot remove a newer phase');
|
||||
h.controller.finishBannerExit(leaving.token);
|
||||
assert.equal(h.controller.banner, null);
|
||||
|
||||
update(h.controller, { backendVersion: '1.73.0', reducedMotion: true });
|
||||
assert.equal(h.controller.banner?.phase, 'visible');
|
||||
update(h.controller, { backendVersion: '1.72.0', reducedMotion: true });
|
||||
assert.equal(h.controller.banner, null);
|
||||
});
|
||||
Reference in New Issue
Block a user