fix: «Как у пространства» забывает свой цвет комнаты (#581)

Режим заливки комнаты и её цвет — два поля конфига под одним переключателем.
«Как у пространства» снимало только режим; цвет оставался и продолжал
применяться, потому что `roomCustomFillOf` отдавал цвет комнаты независимо от
того, чей режим `custom` действует. Так возникало безымянное состояние «режим
наследую, цвет свой» — Cabinet на даче.

Теперь цвет комнаты участвует в раскраске только вместе с её собственным
`fill_mode: 'custom'` (одна функция — все поверхности: карточка, space-card,
PDF, черновик диалога). Диалог загружает цвет в черновик только при своём
режиме, обнуляет его при уходе с «Свой цвет» и показывает строку цвета только
под этим радио; сохранение пишет `custom_fill` только с `fill_mode: 'custom'`,
иначе удаляет — включая сироту от прежнего редактора. Чтение конфиг не
переписывает: застрявшие комнаты выздоравливают обновлением.

- `test/logic.test.mjs`: AC1 — сирота и любой чужой режим → цвет пространства
- `demo/smoke_room_settings.mjs` шаг 7: свой цвет → «Как у пространства» →
  ни режима, ни цвета, во View цвет пространства; сирота открывается как
  наследование, сохранение её удаляет (проверено красным на базе: 9 фактов)
- `demo/smoke_space_settings.mjs`: override с собственным режимом + сирота
- `demo/golden/harness.mjs`: `roomCustomFill` ставит комнате её режим —
  кадры `lighting-custom-glow-*` не меняются
- мутант `room-orphan-colour-wins-again`
- docs: ARCHITECTURE (#56), CONFIG-COMPATIBILITY, USER-GUIDE ru/en, TESTING;
  отпечаток скриншотов принят попиксельно (11 кадров)

Issue: #581
User-Visible: yes
This commit is contained in:
Claude
2026-09-15 09:48:37 +03:00
parent bae6afaae7
commit 2bec03e6fa
58 changed files with 616 additions and 447 deletions
+25 -5
View File
@@ -617,16 +617,36 @@ test('custom fill projection is safe and follows room -> space -> default inheri
}
assert.deepEqual(customFillOf({ c: '#abcdef', a: -2 }), { c: '#abcdef', a: 0 });
const space = { c: '#112233', a: 0.25 };
const own = (custom_fill) => ({ settings: { fill_mode: 'custom', custom_fill } });
assert.deepEqual(roomCustomFillOf(space, {}), space);
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: null } }), space);
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { c: '#445566' } } }),
{ c: '#445566', a: 0.25 });
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { a: 0.7 } } }),
assert.deepEqual(roomCustomFillOf(space, own(null)), space, 'own mode without a colour paints the space colour');
assert.deepEqual(roomCustomFillOf(space, own({ c: '#445566' })),
{ c: '#445566', a: 0.25 }, 'per-field fallback keeps working under the own mode');
assert.deepEqual(roomCustomFillOf(space, own({ a: 0.7 })),
{ c: '#112233', a: 0.7 });
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { c: '#445566', a: 0.7 } } }),
assert.deepEqual(roomCustomFillOf(space, own({ c: '#445566', a: 0.7 })),
{ c: '#445566', a: 0.7 });
});
// #581 AC1: the room colour counts only with the room's OWN `custom` mode. A
// colour stored without it — the state "as the space" used to leave behind —
// is an orphan and paints the space colour; reading it changes nothing.
test('#581 AC1: an orphan room colour (no own custom mode) yields the space colour', () => {
const space = { c: '#112233', a: 0.25 };
const orphan = { c: '#445566', a: 0.7 };
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: orphan } }), space,
'Cabinet on the dacha: colour without fill_mode');
for (const mode of ['lqi', 'light', 'temp', 'none', 'glow', '', null, undefined]) {
assert.deepEqual(roomCustomFillOf(space, { settings: { fill_mode: mode, custom_fill: orphan } }), space,
`mode ${String(mode)} does not carry the room colour`);
}
assert.deepEqual(roomCustomFillOf(space, { settings: { fill_mode: 'custom', custom_fill: orphan } }), orphan,
'the own custom mode is the only carrier');
const stored = { settings: { custom_fill: orphan, name_scale: 1.35 } };
roomCustomFillOf(space, stored);
assert.deepEqual(stored, { settings: { custom_fill: orphan, name_scale: 1.35 } }, 'reading never rewrites');
});
test('room temperature range inherits per side and normalises the effective pair (#487)', () => {
assert.deepEqual(roomTempRangeOf(20, 25, null), { min: 20, max: 25 });
assert.deepEqual(roomTempRangeOf(20, 25, { settings: { temp_min: 19, temp_max: 24 } }),