diff --git a/scripts/model-invariants.mjs b/scripts/model-invariants.mjs index f08579a3..788b88d6 100644 --- a/scripts/model-invariants.mjs +++ b/scripts/model-invariants.mjs @@ -212,21 +212,35 @@ export function checkReferences({ config, layout = {} } = {}, { notes = [] } = { // пользователя. Значит конфиг, только что прошедший Optimize, законно // содержит такие записи, и объявлять их нарушением — врать про модель. // - // Судим тем же правилом: нарушение только там, где владелец ОТСУТСТВУЕТ - // по самой конфигурации. Остальное — наблюдение, как и у ветки - // `unknown_owner` ниже: проверка с ложными срабатываниями умирает первой. + // Судим тем же правилом и ТЕМИ ЖЕ ТРЕМЯ состояниями, что продукт: `absent` + // (нарушение), `live` и `unverified` (наблюдения). Разница между двумя + // последними — не педантизм: сказать «владелец жив» про ключ, который ни + // во что не резолвится, значит заявить доказанность там, где её нет, и + // следующий разбор поверит записи вместо проверки (r1 Medium). const roomOwner = key.startsWith('rl_') ? key.slice(3) : ''; const areaOwner = key.startsWith('grp_') ? key.slice(4) : ''; - const ownerGone = roomOwner ? !allRoomIds.has(roomOwner) - : areaOwner ? !allAreas.has(areaOwner) - : removedMarkerIds.has(key); - if (ownerGone) { + const owner = roomOwner + ? (allRoomIds.has(roomOwner) ? 'live' : 'absent') + : areaOwner + ? (allAreas.has(areaOwner) ? 'live' : 'absent') + : removedMarkerIds.has(key) ? 'absent' + : activeMarkerIds.has(key) ? 'live' + : 'unverified'; + if (owner === 'absent') { add('layout_space', key, space, 'пространства не существует, и владельца позиции тоже нет'); - } else { + } else if (owner === 'live') { notes.push({ invariant: 'references', kind: 'stale_layout_space', owner: key, reference: space, detail: 'пространства не существует, владелец жив — Optimize хранит позицию намеренно' }); + } else { + // Тот же факт, что у ветки `unknown_owner` ниже, и та же формулировка: + // владельца в конфигурации не видно, отличить устройство HA от мусора + // по одному конфигу нельзя. Продукт в этом состоянии тоже ничего не + // удаляет — но не потому, что владелец жив, а потому, что не знает. + notes.push({ invariant: 'references', kind: 'unknown_owner', owner: key, + reference: key, + detail: 'владелец не найден в конфигурации (возможно устройство HA); пространства тоже нет' }); } continue; } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index fcb441f3..490aecf6 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -80,8 +80,20 @@ const MUTANT_DEFINITIONS = [ + 'после Optimize — и следующий разбор начнётся с ложного сигнала', patches: [{ file: 'scripts/model-invariants.mjs', - find: ' const ownerGone = roomOwner ? !allRoomIds.has(roomOwner)', - replace: ' const ownerGone = true || roomOwner ? !allRoomIds.has(roomOwner)', + find: " if (owner === 'absent') {", + replace: " if (owner === 'absent' || true) {", + }], + }, + { + id: 'invariants-claim-proof-for-an-unknown-owner', + guard: 'node --test --test-name-pattern="#566" test/model-invariants.test.mjs', + because: '#566 r1: ключ, который ни во что не резолвится, и живой маркер — разные ' + + 'состояния; общая формулировка «владелец жив» заявляет доказанность там, где её нет, ' + + 'и следующий разбор поверит записи вместо проверки', + patches: [{ + file: 'scripts/model-invariants.mjs', + find: " : activeMarkerIds.has(key) ? 'live'\n : 'unverified';", + replace: " : 'live';", }], }, { @@ -92,8 +104,10 @@ const MUTANT_DEFINITIONS = [ + 'отключение проверки', patches: [{ file: 'scripts/model-invariants.mjs', - find: ' : removedMarkerIds.has(key);', - replace: ' : false;', + // Файл на JS, поэтому литеральная ложь здесь безопасна: сужения типов, + // которые ломает статически мёртвая ветка в .ts (#568), тут нет. + find: " : removedMarkerIds.has(key) ? 'absent'", + replace: " : false ? 'absent'", }], }, { diff --git a/test/geometry-corpus.test.mjs b/test/geometry-corpus.test.mjs index 67d55abd..8c7cf2de 100644 --- a/test/geometry-corpus.test.mjs +++ b/test/geometry-corpus.test.mjs @@ -250,7 +250,10 @@ const CORPUS = [ // хранит их намеренно, а инварианты сообщают о них НАБЛЮДЕНИЕМ. Нарушением // была бы только запись, чей владелец отсутствует по самой конфигурации. expectedViolations: [], - expectedNotes: ['references/stale_layout_space', 'references/stale_layout_space'], + // Владельцев этих двух ключей по конфигурации не видно, поэтому наблюдение + // именно `unknown_owner`: «владелец жив» здесь было бы заявлением о + // доказанности, которой нет (r1 Medium). + expectedNotes: ['references/unknown_owner', 'references/unknown_owner'], condition(space, layout) { const known = new Set([space.id]); const orphans = Object.values(layout).filter((entry) => !known.has(entry.s)); diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index afc5d47b..82d3064f 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -150,14 +150,22 @@ test('#566: на удалённом пространстве нарушение 'layout_space:m_gone', 'layout_space:rl_room_never_existed', ]); + // Три состояния, а не два: продукт различает `live` и `unverified`, и + // проверка обязана различать их тоже. Сказать «владелец жив» про ключ, + // который ни во что не резолвится, — заявить доказанность там, где её нет + // (находка r1). Поэтому сверяется и вид наблюдения, и ТЕКСТ причины: вид без + // текста эту асимметрию пропускает — именно так она и проехала. assert.deepEqual(notes.filter((n) => n.kind === 'stale_layout_space') - .map((n) => n.owner).sort(), [ - '980f1446c4ec1a3a9fa9ff5f6d93caed', 'grp_kitchen', 'm1', 'rl_r1', - ]); - // Наблюдение обязано называть причину, иначе читатель решит, что проверка - // просто ослабла. - assert.match(notes.find((n) => n.kind === 'stale_layout_space').detail, - /Optimize хранит позицию намеренно/); + .map((n) => n.owner).sort(), ['grp_kitchen', 'm1', 'rl_r1']); + assert.deepEqual(notes.filter((n) => n.kind === 'unknown_owner') + .map((n) => n.owner), ['980f1446c4ec1a3a9fa9ff5f6d93caed']); + for (const note of notes.filter((n) => n.kind === 'stale_layout_space')) { + assert.match(note.detail, /владелец жив — Optimize хранит позицию намеренно/); + } + for (const note of notes.filter((n) => n.kind === 'unknown_owner')) { + assert.match(note.detail, /владелец не найден в конфигурации/); + assert.doesNotMatch(note.detail, /владелец жив/); + } }); test('позиция устройства без записи маркера — наблюдение, а не нарушение (#254)', () => {