mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
Release v1.61.0-beta.7 candidate
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { screenRgb, svgScreenBlendSupported } from '../test-build/glow-blend.js';
|
||||
import {
|
||||
resolvedSvgScreenBlend, screenRgb, svgScreenBlendSupported,
|
||||
} from '../test-build/glow-blend.js';
|
||||
|
||||
test('screenRgb implements rounded 8-bit screen blending', () => {
|
||||
assert.deepEqual(screenRgb([0, 0, 0], [12, 34, 56]), [12, 34, 56]);
|
||||
@@ -16,5 +18,7 @@ test('capability result is one cached Promise per document', async () => {
|
||||
const first = svgScreenBlendSupported(document);
|
||||
const second = svgScreenBlendSupported(document);
|
||||
assert.equal(first, second);
|
||||
assert.equal(resolvedSvgScreenBlend(document), undefined);
|
||||
assert.equal(await first, false);
|
||||
assert.equal(resolvedSvgScreenBlend(document), false);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,13 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
|
||||
assert.equal(scenario.viewport.width > 0 && scenario.viewport.height > 0, true, scenario.id);
|
||||
assert.equal(scenario.threshold.maxChannelDelta >= 0 && scenario.threshold.maxChannelDelta <= 32, true, scenario.id);
|
||||
assert.equal(scenario.threshold.maxDiffRatio >= 0 && scenario.threshold.maxDiffRatio <= 0.01, true, scenario.id);
|
||||
if (scenario.warmPixelRegion) {
|
||||
const region = scenario.warmPixelRegion;
|
||||
assert.equal(region.x >= 0 && region.y >= 0 && region.w > 0 && region.h > 0, true, scenario.id);
|
||||
assert.equal(region.x + region.w <= 1 && region.y + region.h <= 1, true, scenario.id);
|
||||
assert.equal(Number.isInteger(region.minPixels) && region.minPixels > 0, true, scenario.id);
|
||||
assert.equal(region.minRedBlueDelta > 0, true, scenario.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,8 +49,10 @@ test('doorway spill golden exposes the opaque-fill failure mode from issue 71',
|
||||
assert.equal(scenario.extraOpenings?.filter((opening) => opening.type === 'door').length, 1);
|
||||
assert.equal(scenario.stateOverrides?.['light.golden_light_one']?.state, 'on');
|
||||
assert.deepEqual(scenario.layoutOverrides?.['golden-light-one'], {
|
||||
s: 'golden-lighting', x: 0.22, y: 0.48,
|
||||
s: 'golden-lighting', x: 0.40, y: 0.48,
|
||||
});
|
||||
assert.equal(scenario.warmPixelRegion?.minPixels >= 2500, true);
|
||||
assert.equal(scenario.warmPixelRegion?.x >= 0.5, true);
|
||||
});
|
||||
|
||||
test('golden harness applies doorway, state and layout overrides to a cloned fixture', () => {
|
||||
@@ -53,12 +62,29 @@ test('golden harness applies doorway, state and layout overrides to a cloned fix
|
||||
assert.equal(space.openings.filter((opening) => opening.type === 'door').length, 2);
|
||||
assert.equal(fixture.states['light.golden_light_one'].state, 'on');
|
||||
assert.deepEqual(fixture.layout['golden-light-one'], {
|
||||
s: 'golden-lighting', x: 0.22, y: 0.48,
|
||||
s: 'golden-lighting', x: 0.40, y: 0.48,
|
||||
});
|
||||
assert.equal(space.settings.sun_rays, false);
|
||||
assert.equal(space.settings.custom_fill.a, 1);
|
||||
});
|
||||
|
||||
test('golden overrides fail closed on misspelled fixture references', () => {
|
||||
const base = {
|
||||
id: 'invalid', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, capture: 'stage',
|
||||
threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 },
|
||||
};
|
||||
assert.throws(() => prepareGoldenFixture({
|
||||
...base, stateOverrides: { 'light.golden_light_onee': { state: 'on' } },
|
||||
}), /missing entity/);
|
||||
assert.throws(() => prepareGoldenFixture({
|
||||
...base, layoutOverrides: { 'golden-light-onee': { x: 0.4, y: 0.5 } },
|
||||
}), /missing item/);
|
||||
assert.throws(() => prepareGoldenFixture({
|
||||
...base, roomGlow: { 'light-rightt': true },
|
||||
}), /missing room/);
|
||||
});
|
||||
|
||||
test('a light source paints exactly one region: the floor it can see', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
// One region per source, and it is the visibility polygon clipped to floor.
|
||||
@@ -75,11 +101,31 @@ test('a light source paints exactly one region: the floor it can see', () => {
|
||||
// Exactly one blur, for the whole layer, sized in screen pixels.
|
||||
assert.equal((glow.match(/<feGaussianBlur/g) || []).length, 1);
|
||||
assert.doesNotMatch(glow, /<mask /);
|
||||
assert.match(source, /GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
|
||||
assert.match(source, /const nextFeather = GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
|
||||
assert.match(source, /const featherEnabled = !this\._pinchStart && !this\._panStart[\s\S]*_glowFeatherSuspendUntil/);
|
||||
assert.match(source, /filter=\$\{featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/,
|
||||
'the expensive whole-layer filter must be bypassed during a viewport gesture/transition');
|
||||
// Barriers are keyed by their own content: `_cfgEpoch` lags behind geometry
|
||||
// edited in place, and a stale barrier set lights straight through a wall.
|
||||
assert.doesNotMatch(glow, /_cfgEpoch/);
|
||||
assert.match(source, /const fingerprint = hash\.toString\(36\)/);
|
||||
assert.match(source, /for \(const point of body\) \{ mix\(point\[0\]\); mix\(point\[1\]\); \}/);
|
||||
assert.match(source, /mix\(wall\.b\?\.\[0\] \?\? 0\); mix\(wall\.b\?\.\[1\] \?\? 0\)/);
|
||||
assert.match(source, /const cacheKey = `\$\{space\.id\}\|\$\{fingerprint\}`/);
|
||||
assert.match(source, /mix\(this\._cellCm\)[\s\S]*mix\(this\._gridPitch\)/);
|
||||
});
|
||||
|
||||
test('all destructive editor dialogs use the shared responsive footer groups', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
for (const method of ['_renderOpeningDialog', '_renderPhysicalDialog', '_renderSpaceDialog']) {
|
||||
const start = source.indexOf(`private ${method}`);
|
||||
assert.notEqual(start, -1, method);
|
||||
const end = source.indexOf('\n private ', start + 10);
|
||||
const body = source.slice(start, end < 0 ? undefined : end);
|
||||
assert.match(body, /dialog-action-footer/, method);
|
||||
assert.match(body, /dialog-action-danger/, method);
|
||||
assert.match(body, /dialog-action-commit/, method);
|
||||
}
|
||||
});
|
||||
|
||||
test('editor tray golden contract covers every adaptive width in English and Russian', () => {
|
||||
|
||||
@@ -66,11 +66,22 @@ test('an occluder outside the radius changes nothing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('an edge through the lamp itself is ignored instead of collapsing the ring', () => {
|
||||
test('a source on an opaque edge is rejected instead of deleting the wall', () => {
|
||||
const source = [0, 0];
|
||||
const ring = visibilityPolygon(source, RAD, [[-50, 0, 50, 0]]);
|
||||
assert.ok(ring.length >= 12);
|
||||
assert.ok(reachAt(ring, source, 90) > RAD * 0.98);
|
||||
assert.deepEqual(ring, []);
|
||||
});
|
||||
|
||||
test('the angular seam at pi does not cut a wedge out of the visible region', () => {
|
||||
const source = [25, 12];
|
||||
const room = [[0, 0], [40, 0], [40, 30], [0, 30]];
|
||||
const wall = [15, 12, 15, 30];
|
||||
const ring = visibilityPolygon(source, 40, [...polygonSegments(room), wall]);
|
||||
const area = Math.abs(ring.reduce((sum, point, index) => {
|
||||
const next = ring[(index + 1) % ring.length];
|
||||
return sum + point[0] * next[1] - next[0] * point[1];
|
||||
}, 0)) / 2;
|
||||
assert.ok(area > 925, `the -x seam must not lose a full angular wedge (${area})`);
|
||||
});
|
||||
|
||||
test('a corner made by two crossing barriers is lit right up to the corner', () => {
|
||||
|
||||
+2
-71
@@ -5,9 +5,9 @@ import {
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
splitRoomPath, polyContainsPoly, islandsOf,
|
||||
kelvinToRgb, glowColorOf, normalizeGlowColorOverride, liveGlowBrightness,
|
||||
resolveGlowValues, resolveGlowAppearance, glowAlpha, doorSector, doorSpillRange, hasRoomBehind,
|
||||
resolveGlowValues, resolveGlowAppearance, glowAlpha,
|
||||
controlsAction, isControllable,
|
||||
sharedBoundary, openZoneOf, distToSegment,
|
||||
sharedBoundary, distToSegment,
|
||||
outlineWithout,
|
||||
alignGuides, segmentAngle, is45,
|
||||
swipeTarget, clampScale,
|
||||
@@ -902,63 +902,6 @@ test('live Glow brightness and perceptual alpha keep the documented bounds', ()
|
||||
assert.ok(glowAlpha(0.5, 0.85) < glowAlpha(1, 0.85));
|
||||
});
|
||||
|
||||
test('doorSector: sector through a door, clamped and guarded', () => {
|
||||
const s = [0, 0];
|
||||
const sec = doorSector(s, [10, -2], [10, 2], 50);
|
||||
assert.ok(sec && sec.length === 10 + 0); // вершина + 9 точек дуги
|
||||
assert.deepEqual(sec[0], [0, 0]);
|
||||
for (const p of sec.slice(1)) {
|
||||
const d = Math.hypot(p[0], p[1]);
|
||||
assert.ok(Math.abs(d - 50) < 1e-6);
|
||||
assert.ok(p[0] > 0); // сектор смотрит в сторону двери
|
||||
}
|
||||
// дверь за радиусом
|
||||
assert.equal(doorSector(s, [60, -2], [60, 2], 50), null);
|
||||
// источник на краю двери
|
||||
assert.equal(doorSector(s, [0, 0], [10, 2], 50), null);
|
||||
});
|
||||
|
||||
test('doorSector: thick doorway is clipped by both tunnel jamb faces', () => {
|
||||
const src = [0, 3];
|
||||
const a = [10, -2];
|
||||
const b = [10, 2];
|
||||
const thin = doorSector(src, a, b, 50);
|
||||
const thick = doorSector(src, a, b, 50, 170, 4);
|
||||
assert.ok(thin && thick);
|
||||
assert.deepEqual(doorSector(src, a, b, 50, 170, 0), thin);
|
||||
|
||||
// The vertical wall occupies x=8..12. Every limiting ray must remain inside
|
||||
// the clear opening y=-2..2 at BOTH faces, not only at the centreline.
|
||||
const yAtX = (p, x) => src[1] + (p[1] - src[1]) * ((x - src[0]) / (p[0] - src[0]));
|
||||
for (const p of [thick[1], thick.at(-1)]) {
|
||||
for (const x of [8, 12]) assert.ok(Math.abs(yAtX(p, x)) <= 2 + 1e-9);
|
||||
}
|
||||
|
||||
const spreadAtFarFace = (sector) => Math.abs(yAtX(sector[1], 12) - yAtX(sector.at(-1), 12));
|
||||
assert.ok(spreadAtFarFace(thick) < spreadAtFarFace(thin));
|
||||
});
|
||||
|
||||
test('doorSpillRange: near and distant apertures retain only bounded post-aperture reach', () => {
|
||||
const near = doorSpillRange(250, 60, 95);
|
||||
assert.deepEqual(near, { reach: 95, outerRadius: 155 });
|
||||
assert.ok(near.reach < 250);
|
||||
|
||||
const distant = doorSpillRange(250, 230, 95);
|
||||
assert.deepEqual(distant, { reach: 20, outerRadius: 250 });
|
||||
assert.ok(distant.reach < 250);
|
||||
assert.equal(doorSpillRange(250, 250, 95), null);
|
||||
assert.equal(doorSpillRange(250, 100, 0), null);
|
||||
});
|
||||
|
||||
test('hasRoomBehind: neighbour room yes, street no', () => {
|
||||
const neighbour = [[10, -5], [20, -5], [20, 5], [10, 5]];
|
||||
// дверь в стене x=10 (стена вертикальна: угол 90°), источник слева в (5,0)
|
||||
assert.ok(hasRoomBehind([10, 0], 90, [5, 0], [neighbour], 1));
|
||||
// за дверью пусто
|
||||
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [], 1));
|
||||
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [[[30, 30], [40, 30], [40, 40], [30, 40]]], 1));
|
||||
});
|
||||
|
||||
test('controlsAction: HA-group semantics', () => {
|
||||
assert.equal(controlsAction(['off', 'off']), 'turn_on');
|
||||
assert.equal(controlsAction(['off', 'on']), 'turn_off');
|
||||
@@ -993,18 +936,6 @@ test('sharedBoundary: exact, partial-collinear and none', () => {
|
||||
assert.equal(sharedBoundary(A, [[2, 2], [3, 2], [3, 3], [2, 3]]).filter((s) => len(s) > 1e-6).length, 0);
|
||||
});
|
||||
|
||||
test('openZoneOf: transitive, either-direction links', () => {
|
||||
const rooms = [
|
||||
{ id: 'a', open_to: ['b'] },
|
||||
{ id: 'b' }, // связь только со стороны a
|
||||
{ id: 'c', open_to: ['b'] }, // b↔c со стороны c
|
||||
{ id: 'd' }, // не связан
|
||||
];
|
||||
const z = openZoneOf('a', rooms);
|
||||
assert.deepEqual([...z].sort(), ['a', 'b', 'c']);
|
||||
assert.deepEqual([...openZoneOf('d', rooms)], ['d']);
|
||||
});
|
||||
|
||||
test('distToSegment', () => {
|
||||
assert.equal(distToSegment([0, 5], [0, 0, 0, 10]), 0);
|
||||
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
|
||||
|
||||
@@ -2,9 +2,12 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
canonicalColumnAngle, columnBody, floorMinusBodies, geometryArea,
|
||||
directionalOccluders, partitionBody, pointInPhysicalBody, radialOccluders,
|
||||
directionalOccluders, intersectionPaths, partitionBody, pointInPhysicalBody,
|
||||
sameColumnPlacement,
|
||||
} from '../test-build/physical-geometry.js';
|
||||
import {
|
||||
polygonSegments, splitAtIntersections, visibilityPolygon,
|
||||
} from '../test-build/light-visibility.js';
|
||||
|
||||
const closeTo = (got, want, tol = 1e-6) =>
|
||||
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
|
||||
@@ -40,25 +43,20 @@ test('physical bodies are removed from clean floor area', () => {
|
||||
closeTo(geometryArea(floorMinusBodies(floor, [obstacle])), 3);
|
||||
});
|
||||
|
||||
test('radial occluder extends a wall away from the source', () => {
|
||||
const body = [[1, -0.1], [1.2, -0.1], [1.2, 0.1], [1, 0.1]];
|
||||
const shadows = radialOccluders([body], [0, 0], 10);
|
||||
assert.ok(Math.max(...shadows.flat().map((p) => p[0])) > 9);
|
||||
});
|
||||
|
||||
test('radial shadow covers the far side of a long nearby partition', () => {
|
||||
const body = partitionBody([1, -3], [1, 3], 10, 5, 0.25);
|
||||
assert.ok(body);
|
||||
const shadows = radialOccluders([body], [0, 0], 10);
|
||||
assert.ok(shadows.some((poly) => pointInPhysicalBody([5, 0], poly)),
|
||||
'the far side of the wall must remain occluded inside the glow radius');
|
||||
});
|
||||
|
||||
test('a source inside a physical body is fully blocked', () => {
|
||||
const body = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
|
||||
const shadows = radialOccluders([body], [0, 0], 10);
|
||||
for (const point of [[5, 0], [-5, 0], [0, 5], [0, -5]])
|
||||
assert.ok(shadows.some((poly) => pointInPhysicalBody(point, poly)));
|
||||
test('intersection failure is fail-dark and never returns the unclipped fan', () => {
|
||||
const rooms = [
|
||||
[[100, 70], [160, 70], [160, 120], [100, 120]],
|
||||
[[10, 150], [60, 150], [60, 170], [10, 170]],
|
||||
[[240, 20], [250, 20], [250, 80], [240, 80]],
|
||||
[[180, 150], [230, 150], [230, 220], [180, 220]],
|
||||
[[180, 110], [240, 110], [240, 170], [180, 170]],
|
||||
];
|
||||
// This exact sweep used to make polyclip throw on nudge-generated decimals.
|
||||
const barriers = splitAtIntersections(rooms.flatMap(polygonSegments));
|
||||
const fan = visibilityPolygon([170, 380], 285, barriers);
|
||||
const paths = intersectionPaths([fan], rooms);
|
||||
assert.deepEqual(paths, [], 'a source outside every room must never return its raw visibility fan');
|
||||
assert.deepEqual(intersectionPaths([fan], []), [], 'missing floor bounds are dark, not unbounded');
|
||||
});
|
||||
|
||||
test('overlapping physical bodies are subtracted from floor only once', () => {
|
||||
|
||||
+32
-7
@@ -163,7 +163,7 @@ test('resign prunes to the referenced set and gives everything a fresh chance',
|
||||
assert.deepEqual(calls[1].paths, [URL_A]);
|
||||
});
|
||||
|
||||
test('dispose(): a late answer neither renders nor throws, start() revives it', async () => {
|
||||
test('dispose(): a late answer warms the shared cache without rendering the old card', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s, updates } = signer();
|
||||
s.display(hass, URL_A);
|
||||
@@ -174,16 +174,41 @@ test('dispose(): a late answer neither renders nor throws, start() revives it',
|
||||
assert.equal(updates(), 0, 'no re-render after teardown');
|
||||
|
||||
s.start(() => hass, () => new Set([URL_A]));
|
||||
s.display(hass, URL_A);
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=LATE');
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'a reconnected card asks again');
|
||||
calls[1].res({ urls: { [URL_A]: URL_A + '?authSig=NEW' } });
|
||||
await tick();
|
||||
assert.equal(updates(), 1);
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=NEW');
|
||||
assert.equal(calls.length, 1, 'a reconnected card reuses the late shared result');
|
||||
assert.equal(updates(), 0);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('two placements dedupe the same authority request and share readiness', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const first = signer();
|
||||
const second = signer();
|
||||
first.s.display(hass, URL_A);
|
||||
second.s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1);
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=SHARED' } });
|
||||
await tick();
|
||||
assert.equal(first.s.display(hass, URL_A), URL_A + '?authSig=SHARED');
|
||||
assert.equal(second.s.display(hass, URL_A), URL_A + '?authSig=SHARED');
|
||||
assert.equal(first.s.isReady(hass, URL_A), false, 'signed is not yet paintable');
|
||||
second.s.markLoaded(hass, URL_A);
|
||||
assert.equal(first.s.isReady(hass, URL_A), true, 'loaded state is authority-scoped');
|
||||
});
|
||||
|
||||
test('prepareImage waits for a protected structural asset before adoption', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
const prepared = s.prepareImage(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1);
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=PREPARED' } });
|
||||
assert.equal(await prepared, true);
|
||||
assert.equal(s.isReady(hass, URL_A), true);
|
||||
});
|
||||
|
||||
test('R5-1: an empty but successful answer still backs off', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
PAINT_BARRIER_MAX_MS,
|
||||
VisualContinuityController,
|
||||
contentFingerprint,
|
||||
visualFrameFingerprint,
|
||||
} from '../test-build/visual-continuity.js';
|
||||
|
||||
class FakeClock {
|
||||
at = 10_000;
|
||||
nextId = 1;
|
||||
timers = new Map();
|
||||
frames = new Map();
|
||||
now = () => this.at;
|
||||
setTimeout = (callback, delay) => {
|
||||
const id = this.nextId++;
|
||||
this.timers.set(id, { at: this.at + delay, callback });
|
||||
return id;
|
||||
};
|
||||
clearTimeout = (id) => this.timers.delete(id);
|
||||
requestAnimationFrame = (callback) => {
|
||||
const id = this.nextId++;
|
||||
this.frames.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
cancelAnimationFrame = (id) => this.frames.delete(id);
|
||||
advance(ms) {
|
||||
const end = this.at + ms;
|
||||
while (true) {
|
||||
const due = [...this.timers.entries()]
|
||||
.filter(([, timer]) => timer.at <= end)
|
||||
.sort((left, right) => left[1].at - right[1].at)[0];
|
||||
if (!due) break;
|
||||
this.at = due[1].at;
|
||||
this.timers.delete(due[0]);
|
||||
due[1].callback();
|
||||
}
|
||||
this.at = end;
|
||||
}
|
||||
frame() {
|
||||
this.at += 16;
|
||||
const callbacks = [...this.frames.values()];
|
||||
this.frames.clear();
|
||||
for (const callback of callbacks) callback(this.at);
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(controller, clock, token, fingerprint = 'next') {
|
||||
assert.equal(controller.candidateReady(token), true);
|
||||
const result = controller.commitAfterPaint(token, {
|
||||
updateComplete: async () => undefined,
|
||||
stageValid: () => true,
|
||||
assetsReady: () => true,
|
||||
frameFingerprint: () => fingerprint,
|
||||
});
|
||||
await Promise.resolve();
|
||||
clock.frame();
|
||||
await Promise.resolve();
|
||||
clock.frame();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('quick return is a strict no-op; long return holds the complete frame', () => {
|
||||
const clock = new FakeClock();
|
||||
let updates = 0;
|
||||
const controller = new VisualContinuityController(() => updates++, clock);
|
||||
controller.markCompleteFrame('complete');
|
||||
const baselineUpdates = updates;
|
||||
const token = controller.visibility({
|
||||
kind: 'visible', token: 1, at: clock.now(), hiddenFor: 2000, long: false,
|
||||
});
|
||||
assert.equal(token, 0);
|
||||
assert.equal(controller.state, 'steady');
|
||||
assert.equal(updates, baselineUpdates, 'quick return schedules no render');
|
||||
|
||||
controller.visibility({
|
||||
kind: 'visible', token: 2, at: clock.now(), hiddenFor: 20_000, long: true,
|
||||
});
|
||||
assert.equal(controller.state, 'holding');
|
||||
clock.advance(1000);
|
||||
assert.equal(controller.overlayVisible, false);
|
||||
assert.equal(controller.frameFingerprint, 'complete');
|
||||
});
|
||||
|
||||
test('state-only frame identity refreshes without scheduling another render', () => {
|
||||
const clock = new FakeClock();
|
||||
let updates = 0;
|
||||
const controller = new VisualContinuityController(() => updates++, clock);
|
||||
controller.markCompleteFrame('state-1');
|
||||
const baselineUpdates = updates;
|
||||
controller.refreshCompleteFrame('state-2');
|
||||
assert.equal(controller.frameFingerprint, 'state-2');
|
||||
assert.equal(updates, baselineUpdates);
|
||||
});
|
||||
|
||||
test('a candidate commits only after two paint opportunities', async () => {
|
||||
const clock = new FakeClock();
|
||||
const controller = new VisualContinuityController(() => undefined, clock);
|
||||
controller.markCompleteFrame('old');
|
||||
const token = controller.beginCandidate('resume');
|
||||
const result = commit(controller, clock, token, 'new');
|
||||
assert.equal(await result, true);
|
||||
assert.equal(controller.state, 'steady');
|
||||
assert.equal(controller.frameFingerprint, 'new');
|
||||
});
|
||||
|
||||
test('overlay is delayed, never covers a complete stale frame, and fades after commit', async () => {
|
||||
const clock = new FakeClock();
|
||||
const controller = new VisualContinuityController(() => undefined, clock);
|
||||
const token = controller.beginCandidate('cold-recovery', 'connection');
|
||||
assert.equal(controller.overlayVisible, false);
|
||||
clock.advance(149);
|
||||
assert.equal(controller.overlayVisible, false);
|
||||
clock.advance(1);
|
||||
assert.equal(controller.overlayPhase, 'entering');
|
||||
clock.frame();
|
||||
assert.equal(controller.overlayPhase, 'fading-in');
|
||||
clock.advance(150);
|
||||
assert.equal(controller.overlayPhase, 'opaque');
|
||||
assert.equal(await commit(controller, clock, token), true);
|
||||
clock.advance(250);
|
||||
assert.equal(controller.overlayPhase, 'leaving');
|
||||
clock.advance(180);
|
||||
assert.equal(controller.state, 'steady');
|
||||
assert.equal(controller.overlayVisible, false);
|
||||
});
|
||||
|
||||
test('a candidate that wins during fade-in cancels the overlay without a minimum hold', async () => {
|
||||
const clock = new FakeClock();
|
||||
const controller = new VisualContinuityController(() => undefined, clock);
|
||||
const token = controller.beginCandidate('cold-recovery');
|
||||
clock.advance(150);
|
||||
clock.frame();
|
||||
assert.equal(controller.overlayPhase, 'fading-in');
|
||||
assert.equal(await commit(controller, clock, token), true);
|
||||
assert.equal(controller.state, 'steady');
|
||||
assert.equal(controller.overlayVisible, false);
|
||||
});
|
||||
|
||||
test('a stale recovery token can never commit', async () => {
|
||||
const clock = new FakeClock();
|
||||
const controller = new VisualContinuityController(() => undefined, clock);
|
||||
controller.markCompleteFrame('old');
|
||||
const stale = controller.beginCandidate('first');
|
||||
controller.beginCandidate('second');
|
||||
assert.equal(controller.candidateReady(stale), false);
|
||||
assert.equal(await controller.commitAfterPaint(stale, {
|
||||
updateComplete: async () => undefined,
|
||||
stageValid: () => true,
|
||||
assetsReady: () => true,
|
||||
frameFingerprint: () => 'stale',
|
||||
}), false);
|
||||
assert.equal(controller.frameFingerprint, 'old');
|
||||
});
|
||||
|
||||
test('a readiness loss rejects recoverably without masquerading as a timeout', async () => {
|
||||
const clock = new FakeClock();
|
||||
const controller = new VisualContinuityController(() => undefined, clock);
|
||||
controller.markCompleteFrame('old');
|
||||
const token = controller.beginCandidate('asset-refresh', 'asset');
|
||||
controller.candidateReady(token);
|
||||
const committed = await controller.commitAfterPaint(token, {
|
||||
updateComplete: async () => undefined,
|
||||
stageValid: () => true,
|
||||
assetsReady: () => false,
|
||||
frameFingerprint: () => 'bad',
|
||||
});
|
||||
assert.equal(committed, false);
|
||||
assert.equal(controller.state, 'holding');
|
||||
assert.equal(controller.frameFingerprint, 'old');
|
||||
assert.equal(controller.trace.at(-1)?.event, 'paint-barrier-rejected');
|
||||
});
|
||||
|
||||
test('a real paint timeout without a complete frame becomes a recoverable error', async () => {
|
||||
const clock = new FakeClock();
|
||||
const controller = new VisualContinuityController(() => undefined, clock);
|
||||
const token = controller.beginCandidate('cold-recovery');
|
||||
controller.candidateReady(token);
|
||||
const result = controller.commitAfterPaint(token, {
|
||||
updateComplete: async () => undefined,
|
||||
stageValid: () => true,
|
||||
assetsReady: () => true,
|
||||
frameFingerprint: () => 'late',
|
||||
});
|
||||
await Promise.resolve();
|
||||
clock.advance(PAINT_BARRIER_MAX_MS);
|
||||
assert.equal(await result, false);
|
||||
assert.equal(controller.state, 'recovery-error');
|
||||
assert.equal(controller.overlayPhase, 'opaque');
|
||||
});
|
||||
|
||||
test('content fingerprints are stable across object key order and frame parts', () => {
|
||||
assert.equal(contentFingerprint({ b: 2, a: 1 }), contentFingerprint({ a: 1, b: 2 }));
|
||||
assert.notEqual(contentFingerprint({ a: 1 }), contentFingerprint({ a: 2 }));
|
||||
assert.equal(visualFrameFingerprint([1, 'x']), visualFrameFingerprint([1, 'x']));
|
||||
});
|
||||
Reference in New Issue
Block a user