fix: keep room labels aligned across modes

Issue: #200
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 17:12:02 +03:00
parent bf83246b7b
commit cd029a0415
14 changed files with 345 additions and 77 deletions
File diff suppressed because one or more lines are too long
+26
View File
@@ -163,6 +163,24 @@ export function prepareGoldenFixture(scenario) {
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
return space;
};
if (scenario.roomLabelParity) {
const space = requireSpace();
if (space.id !== 'golden-lighting' || space.rooms.length !== 2)
throw new Error(`golden roomLabelParity requires the two-room lighting fixture: ${space.id}`);
space.settings = {
...(space.settings || {}),
show_names: true,
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
};
fixture.layout = {
...(fixture.layout || {}),
'rl_light-left': { s: space.id, x: 0.28, y: 0.24 },
'rl_light-right': { s: space.id, x: 0.72, y: 0.24 },
};
}
if (scenario.deviceName) {
if (!scenario.deviceId || !fixture.devices?.[scenario.deviceId])
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
@@ -399,6 +417,14 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await settleMode(card);
}
if (scenario.roomLabelParity) {
const labels = [...card.renderRoot.querySelectorAll('.roomlabel')];
if (labels.length !== 2
|| labels.some((label) => !label.querySelector('.rlgo') || !label.querySelector('.rlmetrics'))
|| labels.some((label) => !label.querySelector('.rlmetrics')?.textContent?.trim())) {
throw new Error(`golden room-label parity core is incomplete: ${scenario.id}`);
}
}
if (scenario.projection === 'iso' && typeof card._setProjection === 'function') {
card._setProjection('iso');
await card.updateComplete;
+9 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 30;
export const GOLDEN_MATRIX_VERSION = 31;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -44,6 +44,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'room-label-parity-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
roomLabelParity: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'room-label-parity-plan-dark', fixture: 'visual', space: 'golden-lighting', mode: 'plan',
roomLabelParity: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'room-label-parity-view-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
roomLabelParity: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'room-label-parity-plan-light', fixture: 'visual', space: 'golden-lighting', mode: 'plan',
roomLabelParity: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'washer-active-cycle-dark', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', mode: 'view',
stateOverrides: { 'sensor.golden_washer_status': { state: 'start' } },
+184 -37
View File
@@ -1,38 +1,185 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// включить имена комнат
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
// 1) комната не кликабельна: cursor default, обработчика клика нет
const room = sr().querySelector('.room');
out.roomCursor = getComputedStyle(room).cursor === 'default';
// 2) значок ссылки у комнат с зоной, кликабелен, ведёт в зону
const links = sr().querySelectorAll('.rlgo');
out.linksShown = links.length > 0;
const withArea = c._spaceModel().rooms.filter((r) => r.name && r.area).length;
out.linkPerArearoom = links.length === withArea;
const icon = links[0];
out.linkCursor = getComputedStyle(icon).cursor === 'pointer';
out.linkClickable = getComputedStyle(icon).pointerEvents === 'auto';
// навигация: перехват history
let navTo = null;
const orig = history.pushState.bind(history);
history.pushState = (a, b, url) => { navTo = url; };
icon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
history.pushState = orig;
out.navigates = typeof navTo === 'string' && navTo.includes('/config/areas/area/');
// 3) комнаты без зоны — без значка (проверено соотношением выше); в разметке значков нет
c._setMode('plan'); await c.updateComplete;
out.noneInPlan = sr().querySelectorAll('.rlgo').length === 0;
c._setMode('view'); await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
const checks = {};
const geometry = [];
for (const scale of [1, 2]) {
const { page, browser } = await launch({ width: 820, height: 760 }, scale);
for (const dark of [false, true]) {
const key = `dpr${scale}-${dark ? 'dark' : 'light'}`;
const setup = await page.evaluate(async ({ dark }) => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settle = async () => {
c.requestUpdate();
await c.updateComplete;
const deadline = performance.now() + 2000;
while (c._modeTransitionBusy && performance.now() < deadline)
await new Promise((resolve) => setTimeout(resolve, 20));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const labelById = (id) => [...sr().querySelectorAll('.roomlabel')]
.find((label) => label.dataset.id === id);
const relativeBox = (label, selector) => {
const anchorBox = label.getBoundingClientRect();
const box = label.querySelector(selector)?.getBoundingClientRect();
if (!box) return null;
const anchorX = anchorBox.left + anchorBox.width / 2;
const anchorY = anchorBox.top + anchorBox.height / 2;
return {
left: box.left - anchorX,
top: box.top - anchorY,
width: box.width,
height: box.height,
};
};
const snapshot = (label) => ({
name: relativeBox(label, '.rlname'),
metrics: relativeBox(label, '.rlmetrics'),
metricsText: label.querySelector('.rlmetrics')?.textContent?.replace(/\s+/g, ' ').trim() || '',
});
c.hass = {
...c.hass,
themes: { ...(c.hass.themes || {}), darkMode: dark },
};
c._serverCfg = {
...c._serverCfg,
spaces: c._serverCfg.spaces.map((space) => space.id !== c._space ? space : ({
...space,
settings: {
...(space.settings || {}),
show_names: true,
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
},
})),
};
const targetRoom = c._spaceModel().rooms.find((room) => room.name && room.area);
if (targetRoom?.id) {
c._layout = {
...c._layout,
[`rl_${targetRoom.id}`]: { s: c._space, x: 0.16, y: 0.22 },
};
}
c._setMode('view');
await settle();
const labels = [...sr().querySelectorAll('.roomlabel')];
const label = labels.find((candidate) => candidate.querySelector('.rlgo')
&& candidate.querySelector('.rlmetrics'));
if (!label) return { missingFixture: true };
const roomId = label.dataset.id;
const view = snapshot(label);
const links = sr().querySelectorAll('.rlgo');
const withArea = c._spaceModel().rooms.filter((room) => room.name && room.area).length;
const room = sr().querySelector('.room');
const viewIcon = label.querySelector('.rlgo');
const viewCursor = getComputedStyle(viewIcon).cursor;
const viewPointerEvents = getComputedStyle(viewIcon).pointerEvents;
let viewNav = null;
const viewPushState = history.pushState;
history.pushState = (state, title, url) => { viewNav = url; };
viewIcon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
history.pushState = viewPushState;
c._setMode('plan');
await settle();
const planLabel = labelById(roomId);
const planIcon = planLabel?.querySelector('.rlgo');
const plan = planLabel ? snapshot(planLabel) : null;
let planNav = null;
const planPushState = history.pushState;
history.pushState = (state, title, url) => { planNav = url; };
planIcon?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
history.pushState = planPushState;
const iconBox = planIcon?.getBoundingClientRect();
window.__roomLinkOriginalPushState = history.pushState;
window.__roomLinkDragNav = null;
history.pushState = (state, title, url) => { window.__roomLinkDragNav = url; };
return {
missingFixture: false,
roomId,
view,
plan,
roomCursor: getComputedStyle(room).cursor,
viewLinkCount: links.length,
withArea,
viewCursor,
viewPointerEvents,
viewNav,
planLinkCount: sr().querySelectorAll('.rlgo').length,
planCursor: planIcon ? getComputedStyle(planIcon).cursor : '',
planTitle: planIcon?.getAttribute('title') || '',
planNav,
planHandles: planLabel?.querySelectorAll('.rlhandle').length || 0,
planSettingsButtons: sr().querySelectorAll('.rlgearbtn').length,
beforeLayout: c._layout[`rl_${roomId}`] || null,
dragTarget: iconBox ? { x: iconBox.left + iconBox.width / 2, y: iconBox.top + iconBox.height / 2 } : null,
themeApplied: c.hass.themes.darkMode === dark,
};
}, { dark });
if (setup.missingFixture) {
checks[`${key}:fixture`] = false;
continue;
}
let drag = { changed: false, dragNav: 'not-run', stayedInPlan: false };
if (setup.dragTarget) {
await page.mouse.move(setup.dragTarget.x, setup.dragTarget.y);
await page.mouse.down();
await page.mouse.move(setup.dragTarget.x + 24, setup.dragTarget.y + 16, { steps: 4 });
await page.mouse.up();
drag = await page.evaluate(async ({ roomId, beforeLayout }) => {
const c = window.__card;
await c.updateComplete;
const after = c._layout[`rl_${roomId}`] || null;
const changed = !!after && (!beforeLayout
|| Math.abs(after.x - beforeLayout.x) > 1e-6
|| Math.abs(after.y - beforeLayout.y) > 1e-6);
const dragNav = window.__roomLinkDragNav;
history.pushState = window.__roomLinkOriginalPushState;
delete window.__roomLinkOriginalPushState;
delete window.__roomLinkDragNav;
return { changed, dragNav, stayedInPlan: c._mode === 'plan' };
}, { roomId: setup.roomId, beforeLayout: setup.beforeLayout });
}
const values = ['left', 'top', 'width', 'height'];
const nameDeltas = values.map((field) => Math.abs(setup.view.name[field] - setup.plan.name[field]));
const metricDeltas = values.map((field) => Math.abs(setup.view.metrics[field] - setup.plan.metrics[field]));
geometry.push({ key, nameDeltas, metricDeltas });
checks[`${key}:fixture`] = !setup.missingFixture;
checks[`${key}:room-cursor`] = setup.roomCursor === 'default';
checks[`${key}:view-link-per-area-room`] = setup.viewLinkCount === setup.withArea;
checks[`${key}:view-link-interactive`] = setup.viewCursor === 'pointer'
&& setup.viewPointerEvents === 'auto';
checks[`${key}:view-navigates`] = typeof setup.viewNav === 'string'
&& setup.viewNav.includes('/config/areas/area/');
checks[`${key}:plan-keeps-links`] = setup.planLinkCount === setup.withArea;
checks[`${key}:plan-link-is-drag-affordance`] = setup.planCursor === 'grab'
&& setup.planTitle === '';
checks[`${key}:plan-does-not-navigate`] = setup.planNav == null
&& drag.dragNav == null && drag.stayedInPlan;
checks[`${key}:plan-icon-drags-label`] = drag.changed;
checks[`${key}:core-name-parity`] = nameDeltas.every((delta) => delta <= 0.5);
checks[`${key}:core-metrics-parity`] = metricDeltas.every((delta) => delta <= 0.5)
&& setup.view.metricsText === setup.plan.metricsText;
checks[`${key}:plan-controls-preserved`] = setup.planHandles === 4
&& setup.planSettingsButtons > 0;
checks[`${key}:theme-applied`] = setup.themeApplied;
}
await browser.close();
}
checkAll(checks);
await finish(null, { checks, geometry });
File diff suppressed because one or more lines are too long
+9 -9
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,6 +2,11 @@
## v1.65.0-beta.4 — 2026-08-19
- Room labels in the Plan editor now keep the same name, Home Assistant Area
icon and state-row geometry as View, so switching modes no longer shifts a
carefully positioned label. The icon still opens the Area only in View; in
Plan it remains part of the label's drag surface
([#200](https://github.com/Matysh/houseplan-card/issues/200)).
- New and directly edited openings in independent walls now keep a physical
jamb at each wall end equal to half that wall's thickness. Placement, drag,
rebind and length edits share the same limit and explain a rejected edit,
+6
View File
@@ -8,6 +8,12 @@
## v1.65.0-beta.4 — 2026-08-19
- Подписи комнат в редакторе Плана теперь сохраняют ту же геометрию имени,
иконки зоны Home Assistant и строк состояний, что и в Просмотре, поэтому при
переключении режимов аккуратно выставленная подпись больше не сдвигается.
Иконка по-прежнему открывает зону только в Просмотре, а в Плане остаётся
частью области перетаскивания подписи
([#200](https://github.com/Matysh/houseplan-card/issues/200)).
- Новые и явно редактируемые проёмы в независимых стенах теперь оставляют у
каждого торца физический откос не меньше половины толщины этой стены.
Размещение, перетаскивание, перепривязка и изменение длины используют одну
+7 -4
View File
@@ -929,10 +929,13 @@ separately promised workflows:
popover (icon ×0.5–3, room-card font; localStorage per device);
cycle: N auto-advances spaces with a 60 s pause after any touch;
manual: walk the real wall tablet [auto+manual]
- [ ] Room link icon (v1.40.1): clicking empty room space in View does
nothing (default cursor); an open-in-new icon after the room name (rooms
with an HA area, View only) navigates to the area; no icon in editors or
on area-less rooms [auto: smoke_room_link]
- [ ] Room link icon (v1.40.1, #200 parity): clicking empty room space in View
does nothing (default cursor); an open-in-new icon after the room name
appears for rooms with an HA area in View and Plan. In View it navigates
to the area; in Plan it has no separate action/title and remains part of
the draggable room label. Its name/metrics geometry relative to the
saved label anchor matches View within 0.5 CSS px at DPR 1/2 in light and
dark themes. Area-less rooms have no icon [auto: smoke_room_link]
- [ ] Smart guides (v1.40.0): while drawing (outline, cut, decor shapes) or
dragging (icons, room cards, decor) dashed accent guides appear from the
nearest object sharing the X and/or Y (max two, with a dot at the
+3 -1
View File
@@ -33,7 +33,9 @@ View сейчас добавляет к имени иконку перехода
`.roomlabel` в обоих режимах получает одну позицию от `_labelPos()` и одну
проекцию через `_scenePoint()`. `.rlmetrics` уже вынесен из centering math и
имеет одинаковый состав и положение. Различается `.rlname`: условие
`!this._markup && r.area` рендерит `.rlgo` только в View.
`!this._markup && r.area` скрывает `.rlgo` именно в Plan editor. В остальных
режимах, где `_markup` выключен и подписи видимы, иконка уже входит в DOM; это
уточнение Low из зелёного ревью ТЗ r1.
На demo-карточке Living room подтверждено:
+22
View File
@@ -391,6 +391,28 @@ export const MUTANTS = [
replace: ' if (!replacingRemoved && prevPos && prevPos.s === targetSpaceId && !roomChanged) {',
}],
},
{
id: 'plan-room-area-icon-hidden',
guard: 'node demo/smoke_room_link.mjs',
because: 'Plan обязан сохранять тот же состав name-row, что и View; возврат прежнего '
+ 'условия снова убирает Area icon и сдвигает подпись относительно anchor',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const showAreaLink = !!r.area;',
replace: ' const showAreaLink = !this._markup && !!r.area;',
}],
},
{
id: 'plan-room-area-icon-navigates',
guard: 'node demo/smoke_room_link.mjs',
because: 'Area icon в Plan является частью drag-зоны, а не ссылкой; включение View-handlers '
+ 'обязано одновременно проявиться навигацией и невозможностью начать drag с иконки',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const areaLinkInteractive = !this._markup;',
replace: ' const areaLinkInteractive = true;',
}],
},
];
// --- механика ---------------------------------------------------------------
+31 -5
View File
@@ -5002,6 +5002,24 @@ class HouseplanCard extends LitElement {
return this._view && this._view.w ? this._view : fitView(vb, this._stageAspect());
}
/**
* Room labels must keep their View screen geometry while the Plan toolbar
* borrows height from the stage. Devices continue to scale with the active
* editor viewport; only the label core uses the hypothetical View viewport
* at the same zoom and total card height (#200).
*/
private _roomLabelReferenceViewWidth(view: { w: number }): number {
if (!this._markup) return view.w;
const stage = this._stageEl;
const chrome = this.renderRoot.querySelector('.editorchrome') as HTMLElement | null;
if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return view.w;
const totalHeight = stage.clientHeight + (chrome?.getBoundingClientRect().height || 0);
if (totalHeight <= 0) return view.w;
return this._viewForModeTarget(
this._zoom, undefined, undefined, stage.clientWidth, totalHeight,
).w;
}
/** Screen (sx,sy relative to the scene, px) → vb coordinates per the current view. */
private _screenToVb(sx: number, sy: number): number[] {
const s = this._stageEl;
@@ -15799,7 +15817,7 @@ class HouseplanCard extends LitElement {
every marker to a dot. Same expression as the static
space-card, so the two renderers agree. The per-device
multiplier and the kiosk scales still feed --dev-size. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderVacuums(devs, view)}
${this._renderVacFit(view)}
@@ -17134,6 +17152,10 @@ class HouseplanCard extends LitElement {
}
}
}
// Plan keeps the same name-row geometry as the other modes, but the Area
// icon remains part of the draggable label instead of becoming a link.
const showAreaLink = !!r.area;
const areaLinkInteractive = !this._markup;
return html`<div class="roomlabel ${rows.length ? 'card' : ''}"
data-hp="room-label" data-id=${r.id || nothing} data-area=${r.area || nothing}
style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op};--rl-scale:${k};--rl-space:${disp.cardFontScale};--rl-name:${clampScale(r.settings?.name_scale)};--rl-meta:${clampScale(r.settings?.label_scale)}"
@@ -17141,11 +17163,15 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)}
><span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
><span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${showAreaLink
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
title=${this._t('room.open_area')}
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
@pointerdown=${(e: Event) => e.stopPropagation()}></ha-icon>`
title=${areaLinkInteractive ? this._t('room.open_area') : nothing}
@click=${areaLinkInteractive
? (e: Event) => { e.stopPropagation(); this._clickRoom(r); }
: nothing}
@pointerdown=${areaLinkInteractive
? (e: Event) => e.stopPropagation()
: nothing}></ha-icon>`
: nothing}</span>
${rows.length ? html`<span class="rlmetrics">${rows}</span>` : nothing}
${this._mode === 'plan'
+1 -1
View File
@@ -873,7 +873,7 @@ export const cardStyles = css`
pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute;
transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1) * var(--rl-space, 1));
font-size: calc(var(--rl-icon-size, var(--icon-size, 2.5cqw)) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1) * var(--rl-space, 1));
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
+24 -1
View File
@@ -74,6 +74,11 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
assert.equal(scenario.mode, 'plan', scenario.id);
assert.equal(scenario.capture, 'page', scenario.id);
}
if (scenario.roomLabelParity) {
assert.equal(scenario.space, 'golden-lighting', scenario.id);
assert.equal(['view', 'plan'].includes(scenario.mode), true, scenario.id);
assert.equal(scenario.capture, 'page', scenario.id);
}
if (scenario.wallJunctionPreview) {
assert.equal(scenario.wallJunctions, true, scenario.id);
assert.equal(scenario.mode, 'plan', scenario.id);
@@ -102,6 +107,24 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
});
test('room-label parity goldens pair View and Plan in light and dark themes', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.roomLabelParity);
assert.deepEqual(scenarios.map(({ mode, theme }) => `${mode}-${theme}`).sort(), [
'plan-dark', 'plan-light', 'view-dark', 'view-light',
]);
for (const scenario of scenarios) {
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(space.settings.show_names, true);
assert.equal(space.settings.label_temp, true);
assert.equal(space.settings.label_lqi, true);
assert.equal(space.settings.label_light, true);
assert.deepEqual(Object.keys(fixture.layout).filter((id) => id.startsWith('rl_')).sort(), [
'rl_light-left', 'rl_light-right',
]);
}
});
test('washer lifecycle goldens pair active and idle snapshots of one composite fixture', () => {
const active = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-active-cycle-dark');
const idle = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-idle-cycle-dark');
@@ -211,7 +234,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, 30);
assert.equal(GOLDEN_MATRIX_VERSION, 31);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');