Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba87b3ff7 | ||
|
|
ab296302d1 | ||
|
|
8d5bade656 | ||
|
|
9edef041a9 | ||
|
|
95175a7616 | ||
|
|
19e421b3cf | ||
|
|
3f3583ac58 | ||
|
|
9a3af808d9 | ||
|
|
deebcfb095 | ||
|
|
0715321fdc | ||
|
|
53585b451b | ||
|
|
fa7ac02c0f | ||
|
|
548e7a8563 | ||
|
|
9592092b8e | ||
|
|
c50bc725c8 | ||
|
|
b2346eaac3 | ||
|
|
a325294d7a | ||
|
|
3066fec17e | ||
|
|
462b56453c | ||
|
|
49a02637ca | ||
|
|
a625871538 | ||
|
|
e181b08f2b | ||
|
|
50fa7f9be2 | ||
|
|
b49721a0bd | ||
|
|
ad2f9afd2b | ||
|
|
27ea23c1ec | ||
|
|
3d43980568 | ||
|
|
37ef970dfc | ||
|
|
914e84028d | ||
|
|
d5575f6393 | ||
|
|
1950df608f | ||
|
|
94ea4694f4 | ||
|
|
be6d57e96f | ||
|
|
5acf04fa33 | ||
|
|
93c6c7551b | ||
|
|
bac63dbcaf | ||
|
|
31c2583e46 | ||
|
|
2f41c0b29e | ||
|
|
a3e7ac1fc6 | ||
|
|
7194fb2df0 | ||
|
|
a44fbd373d | ||
|
|
c21e9de32b | ||
|
|
9bbba66d11 | ||
|
|
b4a2d5b952 | ||
|
|
b6a66a1bac | ||
|
|
88eb382403 | ||
|
|
f6eac1d971 | ||
|
|
67c780a742 | ||
|
|
2783ceff94 | ||
|
|
0c74828240 | ||
|
|
9b00002a5b | ||
|
|
19b14d3976 | ||
|
|
172d9d5a00 | ||
|
|
588ef42546 | ||
|
|
e2bb1e5390 | ||
|
|
398281a93f | ||
|
|
9d375dcb40 | ||
|
|
57f38fedba | ||
|
|
d204bf5d77 | ||
|
|
156835c048 | ||
|
|
e20560afa8 | ||
|
|
6b31e94501 | ||
|
|
a824acc1b1 | ||
|
|
441d3cc3de | ||
|
|
9293109ba1 | ||
|
|
96bfd94313 | ||
|
|
3ad5d0baea | ||
|
|
440460255b | ||
|
|
0ce5e4eed3 | ||
|
|
ed6d1d76db | ||
|
|
a8a9b7d99e | ||
|
|
39571e20d6 | ||
|
|
0796a01571 | ||
|
|
57b19f9914 | ||
|
|
c50458a098 | ||
|
|
d20ef60037 | ||
|
|
8f037a74dc | ||
|
|
ad2858a80c | ||
|
|
5a1cddeaf0 | ||
|
|
3c1b4ebee2 | ||
|
|
be93291065 | ||
|
|
6ac513caa0 | ||
|
|
e38beed796 | ||
|
|
6b592c13c0 | ||
|
|
72764cff90 | ||
|
|
f02af9330b | ||
|
|
fa01aa00bf | ||
|
|
0b9acd6382 | ||
|
|
f5fb7a0f8a | ||
|
|
61905bacdc | ||
|
|
9b65d4bcf7 | ||
|
|
ffe2ec51c0 | ||
|
|
b7d8b9e6ee | ||
|
|
e5a62f7ffd | ||
|
|
7674b78c33 | ||
|
|
3f402a3481 | ||
|
|
278d9257eb | ||
|
|
33cb131b3a | ||
|
|
615181050b | ||
|
|
8acbd333d8 | ||
|
|
00130e08aa | ||
|
|
f6e64ba985 | ||
|
|
cbece6324f | ||
|
|
97dfa457a4 | ||
|
|
34c5c2d134 | ||
|
|
73d6e2eeeb | ||
|
|
24cd39fca5 | ||
|
|
65526a6a7f | ||
|
|
b593489041 | ||
|
|
a91ba16a5c | ||
|
|
1063156f12 | ||
|
|
2b8b7b2aa7 | ||
|
|
eed7239aa3 | ||
|
|
9d8f89d260 | ||
|
|
590b3a7e67 | ||
|
|
6842b69eed | ||
|
|
ceab345215 | ||
|
|
d72cb704f3 | ||
|
|
913e3187f1 | ||
|
|
43809178ee | ||
|
|
581d165bda | ||
|
|
a9d0cab2dd |
@@ -5,11 +5,12 @@ name: Мутационный гейт
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
|
||||
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
|
||||
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
|
||||
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
|
||||
# test/mutation-gate.test.mjs.
|
||||
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
|
||||
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
|
||||
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
|
||||
# Мутанты, задетые диффом, конвейер ревью гоняет отдельно на кандидате (#510).
|
||||
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
|
||||
# обычными юнитами: test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
@@ -25,14 +26,15 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
|
||||
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
|
||||
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
|
||||
# запуск (отладка гейта) отменял идущий по расписанию — так 24.08 погиб
|
||||
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
|
||||
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
|
||||
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
|
||||
@@ -111,8 +113,8 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
|
||||
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
|
||||
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
|
||||
# для отладки самого гейта, его результат смотрят в прогоне — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
|
||||
@@ -321,20 +321,10 @@ jobs:
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
echo "name=$branch" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
|
||||
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
|
||||
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
|
||||
# ветку, и спрашивать его поздно.
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
|
||||
# где-нибудь достижим. Файлов может не быть (инфраструктурная
|
||||
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
|
||||
# длиннее одного.
|
||||
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
|
||||
| awk '{print $2" "$4}' | tr '\n' ';')
|
||||
echo "specs=$specs" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ
|
||||
# ребейза (#515): снятые здесь, они после force-push приведённой
|
||||
# ветки указывали на осиротевший коммит, и ни reuse (#499), ни
|
||||
# страховка #414 не находили дерева в свежем клоне.
|
||||
else
|
||||
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -413,9 +403,46 @@ jobs:
|
||||
- name: Зафиксировать SHA материала ревью
|
||||
id: material
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "материал ревью: $(git rev-parse --short HEAD)"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
|
||||
# хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая
|
||||
# копия равна тому, что ревьюер прочтёт, и коммит с этим деревом
|
||||
# уже запушен в ветку — следующий прогон найдёт его в свежем клоне.
|
||||
# В шаге публикации дерево уже сброшено на целевую ветку, и
|
||||
# спрашивать его поздно.
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
|
||||
# где-нибудь достижим. Файлов может не быть (инфраструктурная
|
||||
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
|
||||
# длиннее одного.
|
||||
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
|
||||
| awk '{print $2" "$4}' | tr '\n' ';')
|
||||
echo "specs=$specs" >> "$GITHUB_OUTPUT"
|
||||
# Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это
|
||||
# единственный якорь, доказывающий «вердикт вынесен на этом тексте».
|
||||
# Читается здесь, а не из github.event.issue.body: между событием
|
||||
# метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание
|
||||
# Validate), и снимок события описывал бы не тот текст.
|
||||
body=$(mktemp)
|
||||
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
|
||||
digest=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
|
||||
});
|
||||
' "$body")
|
||||
echo "issue_body=$digest" >> "$GITHUB_OUTPUT"
|
||||
echo "тело issue: ${digest:0:12}"
|
||||
else
|
||||
echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт"
|
||||
fi
|
||||
echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')"
|
||||
|
||||
# Повторное применение зелёного вердикта без вызова модели (#499). Сценарий
|
||||
# #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же
|
||||
@@ -431,8 +458,13 @@ jobs:
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
# Правка ТЗ между раундами обязана отменять повторное применение
|
||||
# зелёного вердикта: иначе вызов модели пропускается и находку
|
||||
# «ТЗ менялось» некому напечатать (#517 AC6).
|
||||
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
run: |
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD)
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||||
--issue-body="${ISSUE_BODY}")
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
if printf '%s\n' "$out" | grep -qx 'reuse=true'; then
|
||||
@@ -474,6 +506,64 @@ jobs:
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
|
||||
# запускает Validate с мутантами на материале и ждёт его. Красный или
|
||||
# пропавший прогон возвращает задачу автору без ревью — цикл не
|
||||
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
|
||||
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
|
||||
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
|
||||
# слияние само дожидается Validate на кандидате.
|
||||
- name: Validate с мутантами на материале
|
||||
id: gate
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
run: |
|
||||
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
|
||||
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
|
||||
{ echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then
|
||||
echo 'proceed=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'proceed=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Validate красный — вернуть автору без ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
RESULT: ${{ steps.gate.outputs.result }}
|
||||
NOTE: ${{ steps.gate.outputs.note }}
|
||||
URL: ${{ steps.gate.outputs.url }}
|
||||
run: |
|
||||
short=$(git rev-parse --short "$SHA")
|
||||
cat > /tmp/gate.md <<EOF
|
||||
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||||
|
||||
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
|
||||
2. дождаться зелёного дешёвого Validate на пуше;
|
||||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
|
||||
|
||||
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
|
||||
|
||||
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
|
||||
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
|
||||
# ревью тратится на повторение CI вместо чтения кода.
|
||||
@@ -483,7 +573,7 @@ jobs:
|
||||
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
|
||||
- name: Зелёные гейты на этом SHA
|
||||
id: validated
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
@@ -512,21 +602,21 @@ jobs:
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
@@ -542,7 +632,7 @@ jobs:
|
||||
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
|
||||
- name: Установить Claude Code детерминированно
|
||||
id: claude_bin
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
run: |
|
||||
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
|
||||
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
@@ -557,9 +647,43 @@ jobs:
|
||||
"$bin" --version
|
||||
echo "path=$bin" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ТЗ живёт в теле issue (#517), а GitHub хранит его правки без diff и без
|
||||
# доступной чтением истории. Единственное, что можно доказать, — «текст
|
||||
# изменился после того, как ревью ТЗ признало его зелёным». Это не отказ:
|
||||
# автор вправе уточнять ТЗ. Это находка, доставляемая ревьюеру кода в
|
||||
# промпт и владельцу в комментарий вердикта: разбор AC в таком заходе
|
||||
# обязан быть полным, а не по дельте.
|
||||
- name: "ТЗ менялось после зелёного ревью ТЗ (#517)"
|
||||
id: spec_body
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
DIGEST: ${{ steps.material.outputs.issue_body }}
|
||||
run: |
|
||||
if [ -z "$DIGEST" ]; then
|
||||
echo "хеша тела нет — сравнивать не с чем"
|
||||
exit 0
|
||||
fi
|
||||
out=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const [num, digest] = process.argv.slice(1);
|
||||
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
|
||||
const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n")
|
||||
.filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name));
|
||||
const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) }));
|
||||
const changed = m.issueBodyChanged(docs, digest);
|
||||
if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`);
|
||||
else process.stdout.write("changed=false\n");
|
||||
});
|
||||
' "$NUM" "$DIGEST")
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
@@ -586,6 +710,8 @@ jobs:
|
||||
|
||||
${{ steps.rebase.outputs.note }}
|
||||
|
||||
${{ steps.spec_body.outputs.changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', steps.spec_body.outputs.doc, steps.spec_body.outputs.recorded) || '' }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
@@ -631,8 +757,9 @@ jobs:
|
||||
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
|
||||
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
|
||||
|
||||
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
|
||||
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
|
||||
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
|
||||
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
|
||||
есть у старой задачи, он и есть материал, новые не создаются.
|
||||
Проверь обязательные разделы §7.1, однозначность каждого AC и
|
||||
указание способа доказательства. Отдельно проверь, что автор не
|
||||
выдал догадку за решение: утверждение о поведении, которого нет ни
|
||||
@@ -783,7 +910,7 @@ jobs:
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
@@ -791,9 +918,11 @@ jobs:
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
|
||||
# После ребейза конвейером — якоря приведённого материала (#515).
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
# Вердикт из structured_output попадает в блок якорей (#499): по нему
|
||||
# следующий заход решает, можно ли применить зелёный вердикт повторно.
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
@@ -855,6 +984,7 @@ jobs:
|
||||
node scripts/review-doc-guard.mjs --anchor="$doc" \
|
||||
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
|
||||
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
|
||||
--issue-body="$MATERIAL_ISSUE_BODY" \
|
||||
--verdict="$verdict" --high="$high"
|
||||
else
|
||||
echo "::warning::$SOURCE не найден — документа для публикации нет"
|
||||
@@ -945,7 +1075,7 @@ jobs:
|
||||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||||
# пользы нет — он достанет только то, что есть на origin.
|
||||
- name: "Материал раунда воспроизводим (#413)"
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -962,7 +1092,7 @@ jobs:
|
||||
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -1017,7 +1147,7 @@ jobs:
|
||||
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
|
||||
# случится (#364).
|
||||
- name: dev ушёл вперёд, пока шло ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
if: steps.gate.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
@@ -1063,7 +1193,7 @@ jobs:
|
||||
--issue="$NUM" --repo="${{ github.repository }}"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
env:
|
||||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
|
||||
@@ -50,6 +50,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
|
||||
# #514: the only check on a real Home Assistant. houseplan-e2e installs
|
||||
# the release's houseplan.zip — the bytes HACS ships — into HA in docker
|
||||
# and walks the sidebar page, dashboards, roles, PDF, restart and the
|
||||
# stable→tag upgrade. A red, missing or cancelled run withholds the
|
||||
# assets exactly like Full Performance. Cross-repository dispatch needs a
|
||||
# token with Actions: write on houseplan-e2e; HP_PROCESS_TOKEN (classic,
|
||||
# repo scope) has it, E2E_DISPATCH_TOKEN is the fallback for a
|
||||
# fine-grained token.
|
||||
- name: Require green E2E on a real Home Assistant for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: node scripts/e2e-gate.mjs --tag="$TAG"
|
||||
build:
|
||||
name: Сборка бандла и загрузка ассетов
|
||||
needs: gate
|
||||
|
||||
@@ -18,9 +18,15 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full:
|
||||
description: 'Полный набор: смоки, golden, performance_smoke'
|
||||
description: 'Полный набор: смоки, golden, performance_smoke, мутанты по диффу'
|
||||
type: boolean
|
||||
default: true
|
||||
# Мутанты по диффу бегут по запросу (#510): ревью-конвейер и слияние
|
||||
# кандидата запускают `-f full=false -f mutants=true` на материале.
|
||||
mutants:
|
||||
description: 'Мутанты по диффу на этом SHA (кандидат ревью)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
# Exact-SHA release gates never depend on an obsolete commit.
|
||||
@@ -218,10 +224,10 @@ jobs:
|
||||
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
|
||||
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
|
||||
heavy: ${{ steps.heavy.outputs.heavy }}
|
||||
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого судить
|
||||
# диапазон на dev (#388): общее имя — чужая база у потребителя.
|
||||
# `base` — до какого коммита классифицировать файлы ветки (#387),
|
||||
# `range_base` — от какого судить диапазон на dev (#388).
|
||||
range_base: ${{ steps.base.outputs.range_base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
@@ -241,6 +247,7 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
MUTANTS_INPUT: ${{ inputs.mutants }}
|
||||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -476,14 +483,21 @@ jobs:
|
||||
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
|
||||
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
|
||||
# детерминированных непересекающихся шарда вместо ослабления проверки.
|
||||
# С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и
|
||||
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
|
||||
# материале, ночной прогон/PR/кандидат беты берут её сами. За 08–09.09 на
|
||||
# промежуточных пушах она стоила 48 из 56 часов job-минут и в основном
|
||||
# отменялась следующим пушем. Когда мутанты запрошены, job бежит даже при
|
||||
# диффе без входов (отбор пустой, минута на checkout): гейт ревью читает
|
||||
# её исход по job, и skipped был бы неотличим от «не запрашивали».
|
||||
changed_mutants:
|
||||
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
|
||||
name: "Мутанты по диффу (${{ matrix.shard }}/6): затронутые свидетели краснеют"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
|
||||
if: needs.changes.outputs.mutants_requested == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -494,26 +508,13 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Backend test dependencies
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
|
||||
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
|
||||
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
|
||||
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||||
# (файлы патча и гарда, без строки версии) повторно не гоняется.
|
||||
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
|
||||
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
|
||||
# пропуск станет известен только после установки окружения.
|
||||
- name: Журнал свидетелей (последний по шарду)
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
@@ -524,7 +525,13 @@ jobs:
|
||||
restore-keys: |
|
||||
mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-
|
||||
mutation-ledger-${{ matrix.shard }}-
|
||||
- name: Затронутые мутанты ловятся
|
||||
# #518: план шарда считается до установки окружения. npm ci, Python с
|
||||
# зависимостями бэкенда и Chromium — около трёх минут на шард, и раньше
|
||||
# их платили даже тогда, когда гонять было нечего: правка задела чужой
|
||||
# конец хост-файла или журнал уже держит всех отобранных. Плану хватает
|
||||
# git и node из образа.
|
||||
- name: План шарда
|
||||
id: plan
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
@@ -546,11 +553,45 @@ jobs:
|
||||
fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
|
||||
echo "count=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "диапазон: $base..$HEAD_SHA"
|
||||
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||||
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
|
||||
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
|
||||
echo "к прогону в этом шарде: ${count:-0}"
|
||||
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
|
||||
- if: steps.plan.outputs.count != '0'
|
||||
run: npm ci
|
||||
- if: steps.plan.outputs.count != '0'
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Backend test dependencies
|
||||
if: steps.plan.outputs.count != '0'
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.plan.outputs.count != '0'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Затронутые мутанты ловятся
|
||||
if: steps.plan.outputs.count != '0'
|
||||
env:
|
||||
BASE: ${{ steps.plan.outputs.base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
|
||||
node scripts/mutation-gate.mjs --changed="$BASE..$HEAD_SHA" --shard="$SHARD/6" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
|
||||
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
|
||||
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
# Без слэша: с ним шаблон не покрывает СИМВОЛИЧЕСКУЮ ССЫЛКУ с этим именем,
|
||||
# и `git add -A` в чужом worktree затягивает её в коммит (#520, прежде #429).
|
||||
node_modules
|
||||
tsout/
|
||||
test-build/
|
||||
*.log
|
||||
|
||||
@@ -305,10 +305,14 @@ does not meet.
|
||||
|
||||
## Specs
|
||||
|
||||
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
|
||||
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
|
||||
on which surface, at what moment; and what the person sees before and after, in one
|
||||
sentence without implementation terms.
|
||||
The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision
|
||||
2026-09-10, #517); `docs/specs/` is an archive of specs written before that date
|
||||
and takes no new files. Required sections are in `PROCESS.md` §7.1, plus two
|
||||
product ones: which persona meets this, on which surface, at what moment; and what
|
||||
the person sees before and after, in one sentence without implementation terms.
|
||||
Proof that a verdict was passed on a given text is the pipeline's job: it writes
|
||||
the `sha256` of the normalised body into the review document's anchor block, and
|
||||
an edit made after a green spec review reaches the code reviewer as a finding.
|
||||
|
||||
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
|
||||
fact is the worst kind of defect: it passes review because it looks like a decision.
|
||||
@@ -387,6 +391,18 @@ demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
|
||||
locally it costs a minute. Precedent: on #89 a fixture error lived through a
|
||||
whole review round that a local run would have caught immediately.
|
||||
|
||||
**One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues`
|
||||
locally with `gh` available before pushing (without `gh` the hook cannot check the
|
||||
issue status and stays silent). After `S7-code-review` do not push to the branch
|
||||
until the verdict or the return arrives: a push on top of a running review cancels
|
||||
it (10–20 runner minutes) and, after the material is fixed, also the merge (#312).
|
||||
Set `S7` once per round, not after every CI fix: the pipeline now runs Validate
|
||||
with the diff mutants on the material itself and returns a red one to `S6` without
|
||||
spending a review cycle. Mutants by diff no longer run on ordinary pushes — only
|
||||
on the review candidate, the merge candidate, the beta candidate, PRs and the
|
||||
nightly run — so a routine push costs ~3 minutes; 08–09.09 they cost 48 of 56
|
||||
Validate job-hours and were mostly cancelled by the next push.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
@@ -409,7 +425,14 @@ simply the only one we have). The deliberate override is
|
||||
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||
manifest. Baselines are still accepted only via
|
||||
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||
index records the platform next to the Chromium build.
|
||||
index records the platform next to the Chromium build. The one local
|
||||
shortcut is `npm run docs:accept -- --identical` (#512): it re-captures on this
|
||||
machine, compares decoded pixels with the committed frames and, only when every
|
||||
frame is identical, refreshes the manifest fingerprints — frames that differ go
|
||||
through the artefact as before. The displayed card version reaches the DOM
|
||||
through `displayVersion()` (`src/card-version.ts`); the global
|
||||
`__HP_VERSION_OVERRIDE__` behind it is for harnesses only and the product never
|
||||
sets it.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
@@ -445,7 +468,10 @@ an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
went red after the #113 merge. A stable release additionally waits for Full
|
||||
Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`,
|
||||
dispatched on the tag by `release.yml`, #514); betas and the development cycle
|
||||
never run E2E.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
|
||||
@@ -122,9 +122,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
### 2.3 ТЗ в работе — написание ТЗ
|
||||
|
||||
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
|
||||
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
|
||||
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
|
||||
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
|
||||
- **Артефакт:** **тело issue**, раздел `## ТЗ` (решение владельца 2026-09-10,
|
||||
#517). Файл в `docs/specs/` не создаётся ни на одном треке: каталог — архив
|
||||
ТЗ до этой даты, и задачи, у которых файл уже есть, доживают по старой схеме.
|
||||
Доказуемость («вердикт вынесен на этом тексте») держит конвейер: в блок якорей
|
||||
документа ревью пишется `sha256` нормализованного тела, и правка ТЗ после
|
||||
зелёного ревью ТЗ приходит ревьюеру кода находкой, а не тишиной.
|
||||
- **Выход:** полная первая редакция по §7.
|
||||
|
||||
### 2.4 ТЗ на ревью
|
||||
@@ -155,8 +158,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
|
||||
- влияние на производительность и бюджеты названо (или явно «нет»);
|
||||
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
|
||||
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
|
||||
документация, golden/скриншоты, performance/security);
|
||||
- release-артефакты названы: changelog RU+EN, документация, golden/скриншоты,
|
||||
performance/security — либо явное «нет»;
|
||||
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
|
||||
- открытых продуктовых вопросов нет; риски перечислены.
|
||||
|
||||
@@ -428,8 +431,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
**Что упрощается:**
|
||||
|
||||
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
|
||||
доказательством · откат. Файл в `docs/specs/` не создаётся;
|
||||
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
|
||||
(в теле issue, как и на полном треке с 2026-09-10);
|
||||
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
|
||||
- лимит ревью ТЗ — 2 цикла.
|
||||
|
||||
@@ -488,7 +491,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
| Роль | Делает | Не имеет права |
|
||||
|---|---|---|
|
||||
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
|
||||
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
|
||||
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
|
||||
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
|
||||
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
|
||||
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
|
||||
@@ -520,7 +523,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
```
|
||||
issue #NN
|
||||
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
|
||||
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
|
||||
↔ ветка issue/NN-slug
|
||||
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
|
||||
@@ -593,10 +596,9 @@ issue #NN
|
||||
|
||||
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
|
||||
|
||||
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
|
||||
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
|
||||
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
|
||||
таблицу «issue ↔ ТЗ».
|
||||
1. ✅ **Статус ТЗ дублировал статус issue.** Закрыто 2026-09-10 (#517): ТЗ живёт
|
||||
в теле issue, `docs/specs/` — архив, индекс с колонкой «Статус ТЗ» удалён
|
||||
вместе с самой таблицей. Статус задачи — только метка `S*`.
|
||||
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
|
||||
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
|
||||
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
|
||||
@@ -653,6 +655,13 @@ python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:c
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
|
||||
человек.
|
||||
|
||||
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
|
||||
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
|
||||
декодирует оба набора в Chromium и при нуле отличающихся пикселей во всех
|
||||
кадрах обновляет только отпечаток исходников в `screenshots.json`; байты
|
||||
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
|
||||
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
|
||||
|
||||
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
|
||||
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
|
||||
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
|
||||
@@ -694,7 +703,9 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
|
||||
|
||||
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
|
||||
Performance зелёные на точном SHA; статусов issue не касается.
|
||||
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant:
|
||||
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на теге и ждёт его
|
||||
зелёного (#514); статусов issue не касается.
|
||||
|
||||
---
|
||||
|
||||
@@ -811,9 +822,11 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
|
||||
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
|
||||
2. имя ветки `issue/NN-slug` соответствует трейлерам;
|
||||
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
|
||||
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
|
||||
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
|
||||
3. у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1` в теле issue —
|
||||
либо архивный `docs/specs/NN-*.md` у задачи до 2026-09-10. Офлайн тела нет,
|
||||
и проверка молчит; с `--issues` — предупреждение (настоящий рубеж — ревью ТЗ).
|
||||
Добавление нового файла в `docs/specs/**` тоже предупреждение: каталог
|
||||
заморожен (#517);
|
||||
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
|
||||
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
|
||||
`Baseline-Reviewed: <ссылка на прогон CI>`;
|
||||
@@ -897,6 +910,34 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
|
||||
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или
|
||||
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
|
||||
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
|
||||
только по запросу: на кандидате ревью, кандидате слияния (#492), кандидате беты,
|
||||
в ночном прогоне и на PR; обычный push обходится дешёвыми гейтами (~3 минуты).
|
||||
За 08–09.09 мутанты на каждом промежуточном пуше стоили 48 из 56 часов
|
||||
job-минут Validate и в основном отменялись следующим пушем.
|
||||
|
||||
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
|
||||
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
|
||||
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
|
||||
диффу, который читает ханки `git diff --unified=0`. Сторона гарда осталась
|
||||
файловой: у гарда якоря нет. Неоднозначный якорь и непрочитанные ханки дают
|
||||
прежний широкий ответ — незнание не доказательство. Приближение того же класса,
|
||||
что и сам отбор по диффу; нижняя граница — ночной полный гейт (#513). Шард
|
||||
считает свой план до установки окружения и при пустом плане не платит за
|
||||
npm ci, Python и Chromium, оставаясь исполненной job: доказательство гейта
|
||||
требует успешной job, а не пропущенной.
|
||||
|
||||
**Один хендофф — один пуш.** Перед пушем — локальный `node scripts/process-gate.mjs
|
||||
--issues` при доступном `gh` (хук без `gh` статус issue не проверяет и молчит);
|
||||
после `S7-code-review` в ветку не пушить, пока не пришёл вердикт или возврат: пуш
|
||||
поверх идущего ревью отменяет его и стоит 10–20 минут раннера, а после фиксации
|
||||
материала — ещё и слияние (#312). `S7` ставится один раз на заход, не после
|
||||
каждого фикса CI: красный Validate конвейер вернёт сам.
|
||||
|
||||
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
|
||||
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
|
||||
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
|
||||
@@ -945,8 +986,9 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
|
||||
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
|
||||
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
|
||||
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, ожидание
|
||||
зелёного Validate **на этом SHA** и только затем push в `dev` с lease на ту
|
||||
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, запуск
|
||||
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
|
||||
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
|
||||
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
|
||||
— новая попытка; после третьей — `S6-in-progress` с комментарием;
|
||||
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
|
||||
@@ -1083,9 +1125,9 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
|
||||
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
|
||||
одна `S*`-метка, инварианты чистые.
|
||||
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
|
||||
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
|
||||
код, которого уже нет.
|
||||
2. ✅ **Колонка «Статус ТЗ» убрана** — вместе со всем индексом: `docs/specs/`
|
||||
стал архивом, ТЗ переехало в тело issue (#517, 2026-09-10). Перенос старых
|
||||
документов ревью в `docs/reviews/` отменён: они описывают код, которого уже нет.
|
||||
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
|
||||
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
|
||||
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
|
||||
|
||||
@@ -57,7 +57,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.73.0"
|
||||
VERSION = "1.74.0-beta.1"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
SUMMARY_PANEL_API_VERSION = 1
|
||||
|
||||
|
||||
@@ -1,309 +1,309 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74",
|
||||
"fingerprint": "7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 298891,
|
||||
"initialViewGzipBytes": 300059,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-card.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 301193,
|
||||
"initialPanelGzipBytes": 302365,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 2302,
|
||||
"initialPanelOnlyGzipBytes": 2306,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/de-HksnN-9C.js",
|
||||
"houseplan-assets/editor-CSFqqHwT.js",
|
||||
"houseplan-assets/fr-lBEFwYGK.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
|
||||
"houseplan-assets/iso-scene-render-RhSO-S9j.js",
|
||||
"houseplan-assets/live-interaction-runtime-C_klxsXE.js",
|
||||
"houseplan-assets/pdf-export-CsCJxU0E.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
|
||||
],
|
||||
"lazyGzipBytes": 417329,
|
||||
"lazyGzipBytes": 418179,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/editor-CSFqqHwT.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 205633,
|
||||
"lazyEditorGzipBytes": 205726,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14148,
|
||||
"lazyOnboardingGzipBytes": 14187,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js"
|
||||
"houseplan-assets/de-HksnN-9C.js",
|
||||
"houseplan-assets/fr-lBEFwYGK.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 54843,
|
||||
"lazyLocaleGzipBytes": 54854,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js"
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/iso-scene-render-RhSO-S9j.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 13452,
|
||||
"lazyIsometricGzipBytes": 13460,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 10253,
|
||||
"lazyFurnitureArtGzipBytes": 10255,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/pdf-export-CsCJxU0E.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 120367,
|
||||
"lazyPdfGzipBytes": 120372,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"sha256": "a4abe8807d8c404a7ce9f65b0a72ca3d8f8d4d5bf38fa8220915818f9238c0db",
|
||||
"path": "houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"sha256": "e76da9cb2c5c8a32b2798c6f8785819bd7ff6ee624987feb8ff2c89522f87fef",
|
||||
"rawBytes": 21134,
|
||||
"gzipBytes": 7144,
|
||||
"gzipBytes": 7148,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-IBWzSVOj.js",
|
||||
"sha256": "cf031fb380b4b056c116e14a8cb26729ae4d0b275991b07989917f884f8841ff",
|
||||
"path": "houseplan-assets/de-HksnN-9C.js",
|
||||
"sha256": "3a1ada3b04ef3eb7e9c6856c700d589e90e962a6753087b59f89b481c68a9499",
|
||||
"rawBytes": 91441,
|
||||
"gzipBytes": 27705,
|
||||
"gzipBytes": 27710,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-DKd1NGbT.js",
|
||||
"sha256": "e62e388db3b39eaa5f0e8f9b9676b7a6e30c453f84d22d530740f9be093512d1",
|
||||
"rawBytes": 3837,
|
||||
"path": "houseplan-assets/editor-CSFqqHwT.js",
|
||||
"sha256": "a5adebb3a438356deb0e5a35fa218bcb4ab6a8be23d6e535b6cd350170775926",
|
||||
"rawBytes": 3832,
|
||||
"gzipBytes": 1585,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-T10CV6-a.js",
|
||||
"sha256": "b602e1137a8395f963be744ed43d2054e472093502f687e40e6bd3e7a25a8794",
|
||||
"path": "houseplan-assets/fr-lBEFwYGK.js",
|
||||
"sha256": "de104eb96b8e4fbb2895ab861300878eb3f27dc616e4737bc2e9e818286c9d88",
|
||||
"rawBytes": 93719,
|
||||
"gzipBytes": 27138,
|
||||
"gzipBytes": 27144,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"sha256": "1e26cad8d04b459f4b1140e705a02a1d380b8c49a84f92b8ea2ce3e684544800",
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"sha256": "9b63fd1d6479a45b433b323468a56b46ab0e0e3d2a5bfb82b6349d475e59eee8",
|
||||
"rawBytes": 33655,
|
||||
"gzipBytes": 10253,
|
||||
"gzipBytes": 10255,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/guard-7S6iRB0N.js",
|
||||
"sha256": "67f95421a21516a66157d8fdc0129c2d1280cee31a3621a81039c88835d1c0f8",
|
||||
"path": "houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"sha256": "b98da3eb22fde2449b43c37851d7e2cbbcb14103b20b7c6b417fa4bab2502416",
|
||||
"rawBytes": 518,
|
||||
"gzipBytes": 368,
|
||||
"gzipBytes": 371,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"sha256": "dea85ab6dfeebcb263bd672270041e34473d40b9db24e69259e55d42e5ff44d4",
|
||||
"rawBytes": 1076387,
|
||||
"gzipBytes": 298088,
|
||||
"path": "houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"sha256": "e7bdf9f5d1b699c27b57f25c369b137eceae0cf6f3ec7f2d364ef6c2bd552887",
|
||||
"rawBytes": 1079369,
|
||||
"gzipBytes": 299254,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
|
||||
"houseplan-assets/de-HksnN-9C.js",
|
||||
"houseplan-assets/editor-CSFqqHwT.js",
|
||||
"houseplan-assets/fr-lBEFwYGK.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
|
||||
"houseplan-assets/iso-scene-render-RhSO-S9j.js",
|
||||
"houseplan-assets/live-interaction-runtime-C_klxsXE.js",
|
||||
"houseplan-assets/pdf-export-CsCJxU0E.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"sha256": "bc5163222924f68e6279b412b16a53f28a76b5b4d80d4a03e7fe28c4cce77a39",
|
||||
"rawBytes": 671972,
|
||||
"gzipBytes": 178859,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
|
||||
"sha256": "00be6a1cdb443cd875e736984baf86b77e63d96474ec72d4d66bf2baa9f1a2ba",
|
||||
"rawBytes": 672339,
|
||||
"gzipBytes": 178936,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"sha256": "31ea22267928606938b34c47f1ac8ecc2041048bc8c045a411d7108d353de849",
|
||||
"rawBytes": 28237,
|
||||
"gzipBytes": 7004,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
|
||||
"sha256": "f81b58bde08ff74bab903803cc709e18449900dc91a3f64106db737bbabd55e3",
|
||||
"rawBytes": 28287,
|
||||
"gzipBytes": 7039,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"sha256": "0e18deb183cb473c2a96fb689c91c68a3729b8ad56fb8907b01c30810585dbec",
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
|
||||
"sha256": "968345cc4eaaf6cc46accbdc30b4fff83e3fc40a5a7b7a0f611229f0e53ec6b9",
|
||||
"rawBytes": 11034,
|
||||
"gzipBytes": 3731,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"sha256": "00fd101b4e5469f6d289b77eab3056bedf98082520af9139a1afff5823fcb025",
|
||||
"path": "houseplan-assets/iso-scene-render-RhSO-S9j.js",
|
||||
"sha256": "b5c630c05c0c2648c96006ab51cf271ae2ca585742df4bd9ad456ce9dac86646",
|
||||
"rawBytes": 38562,
|
||||
"gzipBytes": 13084,
|
||||
"gzipBytes": 13089,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"sha256": "631b277aae71d9977fabb3f330c4a9cdf38e47ec51259584540b747d9c9444df",
|
||||
"path": "houseplan-assets/live-interaction-runtime-C_klxsXE.js",
|
||||
"sha256": "319a2a94cf85acd20b27522ae71b81e25ee1f2d69f1688256c5eeed4dddec3d5",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2124,
|
||||
"gzipBytes": 2129,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"sha256": "48b07b9ecfbbf2c1a8de53edce8bc32b1217e2c2f20eb5ceb24af75999870d8a",
|
||||
"path": "houseplan-assets/pdf-export-CsCJxU0E.js",
|
||||
"sha256": "eccac69e0d54ceeff8fceb8db69a0f20f5339443cb7961f8916f9a850bb1ae6c",
|
||||
"rawBytes": 237254,
|
||||
"gzipBytes": 110114,
|
||||
"gzipBytes": 110117,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-DOpNDtK_.js",
|
||||
"sha256": "4ed28cc81b7f0ccac46e0fb6ac37856ddbdf7813040585447089bf4a0a61805f",
|
||||
"rawBytes": 14811,
|
||||
"gzipBytes": 3390,
|
||||
"path": "houseplan-assets/summary-panel-editor-BC_vG9Hn.js",
|
||||
"sha256": "5c7fd338b94152ae5e1194f07c6353863a9e22c4f2e99424f5795484da989a64",
|
||||
"rawBytes": 14824,
|
||||
"gzipBytes": 3401,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-DXF83oAy.js",
|
||||
"sha256": "51953d004b76561a842f67b9455985cceac2663c52499d1e1acc3a3cdfdaded9",
|
||||
"rawBytes": 2195,
|
||||
"gzipBytes": 1121,
|
||||
"path": "houseplan-assets/summary-panel-metrics-C2M32Xkf.js",
|
||||
"sha256": "bca21f0d66af64c7c1ef501a8cd38e04e2a74ee7196e004f6f60397d9e9a72f1",
|
||||
"rawBytes": 2610,
|
||||
"gzipBytes": 1308,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
|
||||
"sha256": "7598bcc5d26d8d2f9a15cc154c32a0b836ba0ac53b7b5f9b65f3c2c2bc0287d1",
|
||||
"rawBytes": 69530,
|
||||
"gzipBytes": 19034,
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js",
|
||||
"sha256": "54c791fdd19e77c24ac142794c4207345eaec03d2ab15dfdf3b991afa32bb812",
|
||||
"rawBytes": 72272,
|
||||
"gzipBytes": 19729,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-DOpNDtK_.js",
|
||||
"houseplan-assets/summary-panel-metrics-DXF83oAy.js"
|
||||
"houseplan-assets/summary-panel-editor-BC_vG9Hn.js",
|
||||
"houseplan-assets/summary-panel-metrics-C2M32Xkf.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"sha256": "0176688590ae76a8766ee735de892e37f29211a042c27f5a2992eda0cc8ef8d9",
|
||||
"path": "houseplan-assets/zigbee-topology-LI-FVdpo.js",
|
||||
"sha256": "d01ec49d20c5f3dc028a59107f1f9060634ab93d6d112e03a7c2b85792159440",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7424,
|
||||
"gzipBytes": 7431,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js",
|
||||
"sha256": "6b43a567d3686249519d43ef6f04bb9596529d99d04f745944c0e561c0e3034e",
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js",
|
||||
"sha256": "3a0fad7d479bbeb161434e498a3c8ac7f88da62ef37c173b66f95057098bcc97",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1762,
|
||||
"gzipBytes": 1765,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "b8095427f52e7413e84d3202953d15765fbd43d4536226beff01ba7ff692ae2e",
|
||||
"sha256": "21da7b3390ea9d4992e33bf4d6fdf684145b53f407e253a599e70a459585298d",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 803,
|
||||
"gzipBytes": 805,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "f0a1709a58213029c08e73da2ed81b217827c8f5cdbf96e082385cfb608eabcc",
|
||||
"sha256": "a664721695cf5c85903eea440d05a295cb6d6d2cd1e3669fd5c65c9752534492",
|
||||
"rawBytes": 6124,
|
||||
"gzipBytes": 2302,
|
||||
"gzipBytes": 2306,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-card.js"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{m as e,l as o,t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-DkiI2BOK.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{t as e,l as o,x as t,e4 as s,A as a,e5 as i,e6 as l,e7 as n,dV as r,b as c}from"./houseplan-card-Bj6mVEeM.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{dT as r,dU as e,dV as t}from"./houseplan-card-Bj6mVEeM.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{dS as r,dT as t,dU as e}from"./houseplan-card-DkiI2BOK.js";const s={},a=r(class extends t{constructor(){super(...arguments),this.ot=s}render(r,t){return t()}update(r,[t,s]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return e}else if(this.ot===t)return e;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,s)}});export{a as i};
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{b as a,A as e}from"./houseplan-card-Bj6mVEeM.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-2X-IAkWu.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{b as a,A as e}from"./houseplan-card-DkiI2BOK.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-DQS834xA.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
@@ -6,7 +6,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c46
|
||||
?disabled=${d.busy} aria-label=${b("summary.show_local")}
|
||||
aria-describedby="summary-local-show-hint"
|
||||
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
|
||||
.title=${b("summary.settings")} wide
|
||||
.title=${b("summary.settings")} wide flex-content
|
||||
dismiss-on-scrim aria-busy=${String(d.busy)}
|
||||
@hp-close=${k}>
|
||||
<div class="body summary-editor" @click=${()=>m.closeSource()}>
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{e9 as e,L as t,a2 as n,J as i,an as o,N as r,bo as s,bn as a,a1 as c,a0 as l,ax as d,ea as u,eb as f,bq as m,ec as g}from"./houseplan-card-DkiI2BOK.js";function v(e){if(!e.registry.authoritative)return null;const t=new Set,n=d(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*h(c,l,d=_){try{let u=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=d(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,o,r,g.roomGeom,g.multiWallNodes)||a;v=y(v,s(c,m.physicalBodies)),yield}const _=m.cellCm/o;u+=a(v)*_*_/1e4}return u}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function b(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:m(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function S(e,t){const n=e?.states?.[t];if(!n)return null;const i=u(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{az as e,ea as t,eb as n,bs as i,ec as r,P as a,a5 as s,M as o,aq as c,N as l,bq as d,bp as u,ed as f}from"./houseplan-card-Bj6mVEeM.js";function g(t){if(!t.registry.authoritative)return null;const n=new Set,i=e(t.markers);for(const e of Object.values(t.registry.devices||{}))e?.id&&"service"!==e.entry_type&&!i.devices.has(e.id)&&e.area_id&&t.areaToSpace[e.area_id]&&n.add(e.id);for(const e of t.markers||[]){if(e.removed||"virtual"===e.binding)continue;const r=String(e.binding||"").indexOf(":");if(r<1)continue;const a=e.binding.slice(0,r),s=e.binding.slice(r+1),o="device"===a?t.registry.devices?.[s]:null,c="entity"===a?t.registry.entities?.[s]:null,l="device"===a?s:c?.device_id,d="device"===a?o?.area_id:c?.area_id||l&&t.registry.devices?.[l]?.area_id,u="string"==typeof e.room_id&&e.room_id.length>0&&null===e.area?"":e.area||d||"",f=u&&t.areaToSpace[u]||e.space||t.firstSpaceId;l&&t.registry.devices?.[l]&&t.spaceIds.has(f)&&(!i.devices.has(l)||"entity"===a&&i.liveEntities.has(s))&&n.add(l)}return n}function m(e,t){return e?.length?t?.length?f(e,t):e:t}function v(e,t){try{let n=0;for(const i of t){const t=e.spaces.find(e=>String(e?.id)===i.id);if(!t)continue;const f=r(t,i);let g=null;for(const e of i.rooms){if(!e.id)continue;const t=a(e);if(!t)continue;const n=s(i.rooms,e.id,f.walls,f.openCuts,o,f.cellCm,c,l)||t;g=m(g,d(n,f.physicalBodies))}const v=f.cellCm/c;n+=u(g)*v*v/1e4}return n}catch{return null}}function y(e,t,n,r){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:i(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(r||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function _(e,i){const r=e?.states?.[i];if(!r)return null;const a=t(e,i);return a?n(a,String(r.attributes?.unit_of_measurement||"")):null}export{g as representedHaDeviceIds,_ as summaryEntityValue,y as summarySystemValue,v as totalCleanFloorAreaM2};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{n as t,b as n}from"./zigbee-topology-DOkBU6gZ.js";import{F as e}from"./houseplan-card-Bj6mVEeM.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{n as t,b as n}from"./zigbee-topology-LI-FVdpo.js";import{B as e}from"./houseplan-card-DkiI2BOK.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";try{await import("./houseplan-assets/houseplan-card-Bj6mVEeM.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";try{await import("./houseplan-assets/houseplan-card-DkiI2BOK.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.73.0"
|
||||
"version": "1.74.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -294,6 +294,41 @@ try {
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
// #520 diagnostics: where the extra half second of model readiness goes.
|
||||
// Counts Lit update cycles and model builds; printed, never budgeted.
|
||||
card.__diag = { updates: 0, updateMs: 0, models: 0, adopts: 0, epochs: [] };
|
||||
// Every `_cfgEpoch` bump with the frame that made it: the extra epoch is
|
||||
// what rebuilds the model a second time (#520).
|
||||
let diagEpoch = 0;
|
||||
Object.defineProperty(card, '_cfgEpoch', {
|
||||
configurable: true,
|
||||
get: () => diagEpoch,
|
||||
set: (next) => {
|
||||
if (next !== diagEpoch) {
|
||||
const frames = (new Error().stack || '').split('\n').slice(1, 4)
|
||||
.map((line) => line.trim().replace(/^at\s+/, '').replace(/\s*\(.*$/, ''));
|
||||
card.__diag.epochs.push(`${diagEpoch}->${next}@${frames.join('<')}`);
|
||||
}
|
||||
diagEpoch = next;
|
||||
},
|
||||
});
|
||||
const diagPerform = card.performUpdate.bind(card);
|
||||
card.performUpdate = function () {
|
||||
const started = performance.now();
|
||||
const result = diagPerform();
|
||||
card.__diag.updates += 1;
|
||||
card.__diag.updateMs += performance.now() - started;
|
||||
return result;
|
||||
};
|
||||
const diagBuild = card._buildModel.bind(card);
|
||||
card._buildModel = function () { card.__diag.models += 1; return diagBuild(); };
|
||||
if (typeof card._adoptStructuralResponses === 'function') {
|
||||
const legacy = card._adoptStructuralResponses.bind(card);
|
||||
card._adoptStructuralResponses = function (...args) { card.__diag.adopts += 1; return legacy(...args); };
|
||||
} else if (typeof card._adoptAuthoritative === 'function') {
|
||||
const gated = card._adoptAuthoritative.bind(card);
|
||||
card._adoptAuthoritative = function (...args) { card.__diag.adopts += 1; return gated(...args); };
|
||||
}
|
||||
card.setConfig({
|
||||
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
|
||||
});
|
||||
@@ -363,6 +398,14 @@ try {
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const bootDiag = {
|
||||
updates: card.__diag.updates,
|
||||
updateMs: Number(card.__diag.updateMs.toFixed(1)),
|
||||
models: card.__diag.models,
|
||||
adopts: card.__diag.adopts,
|
||||
cfgEpoch: card._cfgEpoch,
|
||||
epochs: card.__diag.epochs.slice(0, 8),
|
||||
};
|
||||
const initialProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && initialProjection !== 'iso')
|
||||
@@ -1096,6 +1139,7 @@ try {
|
||||
|
||||
const result = {
|
||||
sample,
|
||||
bootDiag,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
@@ -1165,7 +1209,16 @@ try {
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
// #520: диагностика печатается в лог прогона и в запись не попадает.
|
||||
const { bootDiag, ...measured } = row;
|
||||
if (bootDiag) {
|
||||
console.log(`#520 diag sample ${row.sample}: updates=${bootDiag.updates}`
|
||||
+ ` updateMs=${bootDiag.updateMs} models=${bootDiag.models}`
|
||||
+ ` adopts=${bootDiag.adopts} cfgEpoch=${bootDiag.cfgEpoch}`
|
||||
+ ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]`
|
||||
+ ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`);
|
||||
}
|
||||
if (measuredSample >= 0) rows.push(measured);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -43,6 +43,17 @@ radial spokes visible instead of hiding them under a translucent room fill.
|
||||
CI turns green"; the second stops an empty or clipped frame from becoming the
|
||||
contract unseen (#350).
|
||||
|
||||
## Displayed version (#512)
|
||||
|
||||
Frames never show the real `CARD_VERSION`. The harness sets the test seam
|
||||
`window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before any card is created and
|
||||
feeds the same constant wherever it plays the backend (`integration_version`,
|
||||
support facts), so the about dialog, the version-recovery banner and the support
|
||||
and backup previews print `0.0.0-golden` in every baseline. A beta bump therefore
|
||||
changes no golden frame; the version-mismatch scenarios keep their own
|
||||
`0.0.0-golden-backend` and still exercise the frontend≠backend relation. The
|
||||
product never sets the seam (`src/card-version.ts`).
|
||||
|
||||
## Workflow
|
||||
|
||||
Build and copy the exact current source first:
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 61,
|
||||
"acceptedAt": "2026-09-09T07:34:37.356Z",
|
||||
"sourceFingerprint": "4c289ff2fbf0a2df9cd9ca8f6f3c286d9d2c67019a1ced4be717a7d101800575",
|
||||
"acceptedAt": "2026-09-09T15:06:57.368Z",
|
||||
"sourceFingerprint": "b6d96e4120acb757fb31f8003e38b4de3eba014f652ec08da7890d611e50bacf",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"witnesses": {
|
||||
"count": 112,
|
||||
"count": 128,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"panel-wide-view-light-en": "2c93497871bc40f6401f6cb7e65e73bf98d0064bd2755ca2026135975bd2d9f3",
|
||||
"panel-wide-plan-editor-dark-en": "9404d7e3aa84b4b40a8cfeed27eea8831e55e9e6236abbb7cb092c709ca0dfcd",
|
||||
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
|
||||
"version-mismatch-desktop-dark-en": "8a693b1b676276ceef8c30dbd8dbccb375a4ce856690e1f2beb182242503743c",
|
||||
"version-mismatch-touch-light-ru": "6aa0d115da2ec6ba9081a57eb739b7531f860a4ad7a9716ff270bce7e82e803b",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "848b05c62360b532e3105212ea76730bd02320672d341e2cef90875bac7d87fd",
|
||||
"version-mismatch-desktop-dark-en": "b8059b768284208f605329618799d13491655e488e9d739a40b85cdc0eba5888",
|
||||
"version-mismatch-touch-light-ru": "df42d0158025371b82eabd522e0dd7d0b9121a9ba685970db3d7839d97fce50f",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "2e3eaf2fb13d1cca77abe97938374b3e25235fc7db87bb6f7a2e72f1cafe4a7e",
|
||||
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
|
||||
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
|
||||
"coincident-partition-before-dark": "32d6d7e32db1bb579b33c783833ce986d10a81589e0783c5e7fae4b8c1e2755e",
|
||||
@@ -40,9 +40,9 @@
|
||||
"isometric-stage3-no-filter-dark": "b2eeda001fa1d72c74aa0a06a996df205a7398dfc9f729406939a49a4c009a21",
|
||||
"geometry-view-dark-fit": "454da1248f1fe13b8f60c589ae9ebee5e852995e9bc5175d3c5692c445f7cb94",
|
||||
"geometry-view-light-fit": "de5f6a41552447ca909d20471ed6665f558fb6e111c1f333eeba55866517d5a5",
|
||||
"pdf-export-geometry-light": "2e7d0d027f4fc63e8005412e59b08da0114217cbd441bd3195f4ae909d19ff86",
|
||||
"pdf-export-polish-light": "a8f9698513de9730d58fed90cd4c7092fdc8abc49378ee3ad1e58ce0fd607c7b",
|
||||
"pdf-export-stepped-dimensions-light": "d4dffc0b2bab76405a5c4d504e18b86392abaf50ba63940666d165497947b21d",
|
||||
"pdf-export-geometry-light": "03765f043e4dffd0c46d2a63059c580cb20bfdddd3df49ab58cac626b4d0f91a",
|
||||
"pdf-export-polish-light": "30c2cb38ec2cec45aba5721a8d210ba982b51ea30a840fff9d76973c1e1f2724",
|
||||
"pdf-export-stepped-dimensions-light": "5d5d16194fba0ae86b84607eabc4dbda9450162eb6441ad38520e5aaef84b86d",
|
||||
"vacuum-trail-smoothing-dark": "f8eecc726a3e8bdaba414584ad6afd48908ee9d96ea969a1e37758f4e76843e2",
|
||||
"room-label-parity-view-dark": "a188d4db84533d605cb6fe822d20c34d164e3bfc8458a55beaad564854eef169",
|
||||
"room-label-parity-plan-dark": "07e896d4f6dad384bd52dc60f786f042e1cfcd26ddb1f7b12b72d40bb4824324",
|
||||
@@ -162,7 +162,7 @@
|
||||
"general-color-popover-desktop-en": "8dd1aac5beb37d942325c566dd1fedd6303a74d97882202cca92516fc4bd7982",
|
||||
"settings-help-zoom-200-en-light": "d4c0ca1d0a6807707bc5f3366f4da8d8bba13297d09339d36917fd77f9fbe923",
|
||||
"settings-help-zoom-200-ru-dark": "5128090ce7ed3196b3cf81e95634c11c9a42160959cac682792920353f8405c7",
|
||||
"support-desktop-empty-light-en": "a16ebd9246343bdfe363eb1546fab0dfee220cdb746575afb920ffad946648d1",
|
||||
"support-desktop-empty-light-en": "ec60104cb98b646db22089c93f70f78c3a13cdb7001782b2981ce19800ce3cee",
|
||||
"support-desktop-preview-dark-en": "37707b251c117eb381f7ddd6ad13a235be82c139a4d92dc07789a76cc425e946",
|
||||
"support-phone-validation-light-ru": "1699dde634aa583ce6aaa30252f6cc01c9d822ff2718d0d77b179c7c38216ea2",
|
||||
"support-phone-success-dark-en": "21146b3a221010cdc45e1a83c299395772a9f214550a296b78c5ecb72ddab332",
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 50 KiB |
@@ -32,9 +32,13 @@ const spanOverDoorFixture = JSON.parse(readFileSync(
|
||||
const wallUnionIsolationFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const cardVersion = JSON.parse(readFileSync(
|
||||
new URL('../../package.json', import.meta.url), 'utf8',
|
||||
)).version;
|
||||
// The version the frames show (#512). The card's own CARD_VERSION changes with
|
||||
// every beta and used to invalidate every frame that prints it (about, the
|
||||
// version-recovery banner, support and backup previews) although nothing but
|
||||
// the digits changed. The harness pins the displayed version through the
|
||||
// `__HP_VERSION_OVERRIDE__` seam and feeds the same constant wherever it plays
|
||||
// the backend, so a version bump is invisible to golden by construction.
|
||||
const cardVersion = '0.0.0-golden';
|
||||
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
@@ -857,6 +861,8 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion, attemptKey }) => {
|
||||
// #512: the displayed version is pinned before any card is created.
|
||||
window.__HP_VERSION_OVERRIDE__ = cardVersion;
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
|
||||
@@ -121,7 +121,9 @@ one runner. Its raw reports and comparison are uploaded as
|
||||
`full-performance-<profile>`, and the table is written to that GitHub job
|
||||
summary. Stable release assets require both exact-SHA
|
||||
`Validate` and exact-SHA `Full Performance`; prereleases require only
|
||||
`Validate`.
|
||||
`Validate`. The gate (`scripts/release-gate.mjs`, #511) judges by the latest
|
||||
non-cancelled run on the SHA: a re-run or a manual comparison against another
|
||||
baseline refreshes the verdict, and a cancelled twin is invisible.
|
||||
|
||||
This base-vs-candidate design intentionally does not compare timings captured
|
||||
on different machines or different Chromium builds. A runtime/profile mismatch
|
||||
@@ -218,6 +220,17 @@ rationale in the change. Do not loosen a threshold merely to make a single red
|
||||
run pass. A new fixture profile gets a new profile id instead of silently
|
||||
changing the meaning of `large-house-v1`.
|
||||
|
||||
`large-house-isometric-v1` and its Stage 3 dense twin carry
|
||||
`countNoiseAllowance: 5` (the other profiles keep 3). Owner decision 2026-09-09, #507: since v1.73.0-beta.1 the
|
||||
isometric renderer lives in the lazy `iso-scene-render` chunk (#160 Stage 3),
|
||||
so the single v1.72.0 boot task is split into two around that import. The
|
||||
load-phase work is unchanged — timings, `longTask.totalP95Ms` and
|
||||
`longTask.maxSingleMs` keep their unchanged ratios and still catch real
|
||||
growth — but `longTask.countP95` counts the split as +2 tasks and, with
|
||||
runner jitter, sat at 16 → 20 against a 19.2 limit on the v1.73.0 stable
|
||||
comparison. The allowance widens the count check alone to 16 → 21 for that
|
||||
baseline; it is not a licence for more work per task.
|
||||
|
||||
The `cleanFloor` entry ceiling is 100: the reviewed fixture warms exactly 100
|
||||
deterministic room/physical-body entries. An extra 20 means that one complete
|
||||
floor was invalidated and rebuilt, so fixture extensions must recalibrate this
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.2,
|
||||
"countNoiseAllowance": 3,
|
||||
"countNoiseAllowance": 5,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.2,
|
||||
"noiseAllowanceMs": 150
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.2,
|
||||
"countNoiseAllowance": 3,
|
||||
"countNoiseAllowance": 5,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.2,
|
||||
"noiseAllowanceMs": 150
|
||||
|
||||
@@ -18,6 +18,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
methods: Object.freeze([
|
||||
'_baseVb',
|
||||
'_bindingStatus',
|
||||
'_buildModel',
|
||||
'_cancelDecorGesture',
|
||||
'_checkSpacePhysicalGeometry',
|
||||
'_decorBoxOf',
|
||||
@@ -40,6 +41,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_booting',
|
||||
'_bootSoft',
|
||||
'_cameraTransition',
|
||||
'_cfgEpoch',
|
||||
...CACHE_FIELDS,
|
||||
'_continuity',
|
||||
'_cursorPt',
|
||||
@@ -72,13 +74,20 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
// bundles moved it into ResizeController. A comparison target must expose
|
||||
// one of the two explicit shapes; the current member retains its object
|
||||
// type check and is also verified against current production source.
|
||||
// #500 replaced the private structural adoption with a gated public entry
|
||||
// point. The boot diagnostics (#520) count adoptions through whichever of
|
||||
// the two the measured bundle owns; an undeclared rename would silently
|
||||
// report zero adoptions instead of failing.
|
||||
fieldAlternatives: Object.freeze([
|
||||
Object.freeze({ current: '_resize', legacy: '_rszDrag' }),
|
||||
Object.freeze({ current: '_adoptAuthoritative', legacy: '_adoptStructuralResponses' }),
|
||||
]),
|
||||
fieldTypes: Object.freeze({
|
||||
_adoptAuthoritative: 'function',
|
||||
_booting: 'boolean',
|
||||
_bootSoft: 'boolean',
|
||||
_cameraTransition: 'object',
|
||||
_cfgEpoch: 'number',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_continuity: 'object',
|
||||
|
||||
@@ -1,80 +1,327 @@
|
||||
// Направляющие выравнивания — свидетель на НАСТОЯЩЕМ жесте (#521).
|
||||
//
|
||||
// Прежняя редакция этого смока была зелёной на сломанном коде и потому сама
|
||||
// оказалась частью дефекта: она присваивала `_deviceDrag`/`_decorDraft`
|
||||
// целиком и звала `requestUpdate()`. Присвоение с `oldValue == null`
|
||||
// маршрутизацию живого пути не включает, поэтому проверялась обычная осевая
|
||||
// отрисовка — состояние, которого при настоящем жесте не бывает. С #451 всё,
|
||||
// что двигается пальцем, идёт в живого художника, а слой направляющих жил
|
||||
// только в осевшей сцене; на пользователе это выглядело как «направляющих
|
||||
// больше нет» в трёх редакторах сразу.
|
||||
//
|
||||
// Отсюда протокол каждого сценария:
|
||||
// 1. дождаться тишины — сразу после входа в режим `_hdrH` сходится
|
||||
// ступеньками ~200 мс, и каждая ступенька даёт осевой кадр «в подарок»,
|
||||
// на котором сломанный код тоже показывает направляющую;
|
||||
// 2. вести жест настоящими `PointerEvent`/`click` по элементу;
|
||||
// 3. считать осевые циклы за движения — их обязано быть НОЛЬ, иначе смок
|
||||
// снова проверяет не тот путь;
|
||||
// 4. требовать ровно одну группу `.alignguides`: осевая копия слоя на время
|
||||
// жеста подавлена, две линии (одна из них устаревшая) — дефект.
|
||||
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;
|
||||
const g = c._gridPitch;
|
||||
const guides = () => sr().querySelectorAll('.alignline').length;
|
||||
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
|
||||
const poly = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
const v = poly[0];
|
||||
c._path = [[v[0] + g * 10, v[1] + g * 10]];
|
||||
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.drawGuide = guides() >= 1;
|
||||
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
|
||||
// нет выравнивания → нет гидов
|
||||
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGuideOffAxis = guides() === 0;
|
||||
// 2) бейдж угла: 45° красит
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml = sr().querySelector('.measurelabel');
|
||||
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml2 = sr().querySelector('.measurelabel');
|
||||
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
|
||||
c._path = []; c._cursorPt = null;
|
||||
// 3) редактор устройств: drag значка в линию с другим значком
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const devs = c._devices.filter((d) => d.space === c._space);
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
const pb = c._pos(b);
|
||||
c._deviceDrag = {
|
||||
id: b.id, spaceId: b.space, displayName: b.name, pointerId: 74,
|
||||
source: null, sx: 0, sy: 0, ox: pb.x, oy: pb.y, moved: true,
|
||||
before: { s: b.space, x: pb.x / 1000, y: pb.y / 1000 },
|
||||
start: c._devicePlacementForCanvas(b, pb.x, pb.y),
|
||||
// Считается ВИДИМОЕ. Осевая копия слоя на время жеста гасится прозрачностью
|
||||
// и остаётся в DOM — если считать узлы, две направляющие подряд (живая и
|
||||
// осевая, отставшая на шаг) выглядят как одна.
|
||||
const visible = (selector) => [...sr().querySelectorAll(selector)].filter((node) => {
|
||||
const layer = node.closest('.hp-editor-only-layer');
|
||||
return !layer || layer.style.opacity !== '0';
|
||||
});
|
||||
const lines = () => visible('.alignline').length;
|
||||
const dots = () => visible('.aligndot').length;
|
||||
const groups = () => visible('.alignguides').length;
|
||||
/** Кто сейчас рисует видимую направляющую: живой художник или осевшая сцена. */
|
||||
const guideOwner = () => {
|
||||
const group = visible('.alignguides')[0];
|
||||
if (!group) return 'none';
|
||||
return group.closest('[data-hp-live-editor]') ? 'live' : 'settled';
|
||||
};
|
||||
const keptDrag = c._deviceDrag;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
// #400: направляющая обязана идти ОТ ДРУГОГО значка, а не от самого себя.
|
||||
// Прежняя проверка `guides() >= 1` этого не различала: точка всегда
|
||||
// совпадает сама с собой в пределах допуска, поэтому исключение
|
||||
// перетаскиваемого маркера могло не работать вовсе — и не работало,
|
||||
// потому что читало `_drag`, который в этом режиме всегда null.
|
||||
out.devGuideComesFromAnotherMarker = (() => {
|
||||
const key = (list) => list.map((point) => point.join(','));
|
||||
const withDrag = key(c._editorRuntime._alignCandidates());
|
||||
c._deviceDrag = null;
|
||||
const withoutDrag = key(c._editorRuntime._alignCandidates());
|
||||
c._deviceDrag = keptDrag;
|
||||
// Исключение снимает РОВНО один кандидат — перетаскиваемый маркер.
|
||||
// Сравнение по разнице списков, а не по заранее посчитанной точке:
|
||||
// позиция значка живёт вместе с перетаскиванием и к моменту проверки
|
||||
// уже другая.
|
||||
const removed = withoutDrag.filter((point) => !withDrag.includes(point));
|
||||
return withDrag.length === withoutDrag.length - 1 && removed.length === 1;
|
||||
const guideLineX = () => {
|
||||
const line = visible('.alignline')[0];
|
||||
return line ? Number(line.getAttribute('x1')) : NaN;
|
||||
};
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
/** Осевые кадры оседания редактора должны закончиться ДО жеста. */
|
||||
const quiet = async () => {
|
||||
for (let i = 0; i < 8; i++) await frame();
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
};
|
||||
const pe = (type, id, extra) => new PointerEvent(type, {
|
||||
pointerId: id, isPrimary: true, bubbles: true, composed: true, cancelable: true, ...extra,
|
||||
});
|
||||
let settled = 0;
|
||||
const settledWillUpdate = c.willUpdate.bind(c);
|
||||
c.willUpdate = function (changed) { settled += 1; return settledWillUpdate(changed); };
|
||||
const g = c._gridPitch;
|
||||
const stage = () => c._stageEl || sr().querySelector('.stage');
|
||||
const toClient = (x, y) => {
|
||||
const rect = stage().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return {
|
||||
x: rect.left + ((x - v.x) / v.w) * rect.width,
|
||||
y: rect.top + ((y - v.y) / v.h) * rect.height,
|
||||
};
|
||||
};
|
||||
const pxPerUnit = () => {
|
||||
const rect = stage().getBoundingClientRect();
|
||||
return rect.width / c._viewOr(c._baseVb()).w;
|
||||
};
|
||||
|
||||
// ---- 1) редактор устройств: настоящее перетаскивание значка -------------
|
||||
c._setMode('devices');
|
||||
await c.updateComplete;
|
||||
const devs = c._devices.filter((d) => d.space === c._space && !d.virtual
|
||||
&& d.bindingStatus?.kind !== 'ha_disabled');
|
||||
const anchor = devs[0];
|
||||
const dragged = devs[1];
|
||||
// Обе позиции — на узлах сетки, иначе выравнивание жестом недостижимо.
|
||||
// Цель ведёт значок и по X (на ось якоря), и по Y — тогда конец
|
||||
// направляющей отличает живую точку от замороженного снимка.
|
||||
c._layout = {
|
||||
...c._layout,
|
||||
[anchor.id]: { s: c._space, x: (53 * g) / 1000, y: (53 * g) / 1000 },
|
||||
[dragged.id]: { s: c._space, x: (61 * g) / 1000, y: (69 * g) / 1000 },
|
||||
};
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const anchorPos = c._pos(anchor);
|
||||
const marker = [...sr().querySelectorAll('[data-hp="device"]')]
|
||||
.find((element) => element.dataset.id === dragged.id);
|
||||
out.markerIsOnTheStage = !!marker;
|
||||
const live = () => c._livePos(c._devices.find((d) => d.id === dragged.id));
|
||||
await quiet();
|
||||
const box = marker.getBoundingClientRect();
|
||||
const sx = box.x + box.width / 2;
|
||||
const sy = box.y + box.height / 2;
|
||||
marker.dispatchEvent(pe('pointerdown', 77, { clientX: sx, clientY: sy }));
|
||||
await c.updateComplete;
|
||||
out.pointerDownStartsTheDrag = !!c._deviceDrag && c._deviceDrag.moved === false;
|
||||
const settledAtDown = settled;
|
||||
let clientX = sx;
|
||||
let clientY = sy;
|
||||
let oneLayerEveryStep = true;
|
||||
const targetY = anchorPos.y + g * 8;
|
||||
for (let step = 0; step < 10; step++) {
|
||||
const at = live();
|
||||
const dx = anchorPos.x - at.x;
|
||||
const dy = targetY - at.y;
|
||||
if (Math.abs(dx) < g * 0.01 && Math.abs(dy) < g * 0.01) break;
|
||||
const share = step < 4 ? 1 / (4 - step) : 1;
|
||||
clientX += dx * share * pxPerUnit();
|
||||
clientY += dy * share * pxPerUnit();
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
|
||||
await frame();
|
||||
await frame();
|
||||
if (groups() > 1) oneLayerEveryStep = false;
|
||||
}
|
||||
const atGuide = live();
|
||||
out.dragReachedTheAnchorAxis = Math.abs(atGuide.x - anchorPos.x) < 1e-6;
|
||||
out.devGuideFollowsARealDrag = lines() >= 1 && dots() >= 1;
|
||||
out.devSingleGuideLayer = groups() === 1 && oneLayerEveryStep;
|
||||
out.devZeroSettledRendersDuringDrag = settled - settledAtDown === 0;
|
||||
// Конец направляющей считается от точки жеста: `pt[1] ± 1.5 шага`. Живая
|
||||
// точка и замороженный снимок здесь заведомо разные — это и есть проверка,
|
||||
// что направляющую нарисовали от маркера, а не от его прошлого места.
|
||||
const frozen = c._pos(c._devices.find((d) => d.id === dragged.id));
|
||||
const over = g * 1.5;
|
||||
const endFrom = (point) => point.y + Math.sign(point.y - anchorPos.y) * over;
|
||||
const guideLine = sr().querySelector('.alignline');
|
||||
const y2 = guideLine ? Number(guideLine.getAttribute('y2')) : NaN;
|
||||
out.snapshotIsStaleDuringTheDrag = Math.abs(endFrom(frozen) - endFrom(atGuide)) > 1;
|
||||
out.devGuideIsMeasuredFromTheLivePosition = Math.abs(y2 - endFrom(atGuide)) < 0.01;
|
||||
out.alignPointIsTheLivePosition = Math.abs(c._alignPoint[0] - atGuide.x) < 1e-6
|
||||
&& Math.abs(c._alignPoint[1] - atGuide.y) < 1e-6;
|
||||
out.guideIsPaintedByTheLivePainter = guideOwner() === 'live';
|
||||
// #400: направляющая обязана идти ОТ ДРУГОГО значка. Проверяется без
|
||||
// подмены состояния жеста: перетаскиваемого маркера нет среди кандидатов.
|
||||
const candidates = c._editorRuntime._alignCandidates();
|
||||
const key = (x, y) => `${Math.round(x * 1000)}:${Math.round(y * 1000)}`;
|
||||
const candidateKeys = candidates.map((point) => key(point[0], point[1]));
|
||||
out.devCandidatesExcludeTheDraggedMarker = !candidateKeys.includes(key(atGuide.x, atGuide.y))
|
||||
&& candidates.length === devs.length - 1;
|
||||
out.devGuideAnchorIsTheOtherMarker = candidateKeys.includes(key(anchorPos.x, anchorPos.y));
|
||||
// AC6: серия движений внутри одного кадра — один расчёт кандидатов.
|
||||
const runtime = c._editorRuntime;
|
||||
const realCandidates = runtime._alignCandidates.bind(runtime);
|
||||
let candidateCalls = 0;
|
||||
runtime._alignCandidates = function () { candidateCalls += 1; return realCandidates(); };
|
||||
const paintsBefore = c._liveEditorPaintCount;
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX: clientX + i, clientY }));
|
||||
}
|
||||
await frame();
|
||||
await frame();
|
||||
const paints = c._liveEditorPaintCount - paintsBefore;
|
||||
out.candidatesAreComputedOncePerFrame = paints >= 1 && candidateCalls >= 1
|
||||
&& candidateCalls <= paints;
|
||||
runtime._alignCandidates = realCandidates;
|
||||
|
||||
// Передача слоя посреди жеста. Осевой кадр приходит не от жеста — у
|
||||
// владельца это делает наблюдатель высоты шапки (`_hdrH` сходится
|
||||
// ступеньками при любом изменении раскладки), и он же маскировал дефект на
|
||||
// замерах S2. Каждый осевой рендер заканчивается `_commitLiveEditor()`,
|
||||
// который гасит живой слой и возвращает владение сцене: двух групп не
|
||||
// бывает, но точка обязана остаться живой — иначе осевая копия нарисует
|
||||
// направляющую там, где маркер стоял до жеста.
|
||||
// Серия AC6 увела маркер с оси на пару шагов — возвращаем его, иначе
|
||||
// проверять передачу слоя не на чем: без совпадения направляющей нет.
|
||||
for (let step = 0; step < 8; step++) {
|
||||
const at = live();
|
||||
if (Math.abs(at.x - anchorPos.x) < g * 0.01) break;
|
||||
clientX += (anchorPos.x - at.x) * pxPerUnit();
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
|
||||
await frame();
|
||||
await frame();
|
||||
}
|
||||
const atHandover = live();
|
||||
out.markerIsBackOnTheAxis = Math.abs(atHandover.x - anchorPos.x) < 1e-6 && lines() === 1;
|
||||
const settledBeforeHandover = settled;
|
||||
c._hdrH = (c._hdrH || 0) + 0.01;
|
||||
await c.updateComplete;
|
||||
await frame();
|
||||
out.handoverHappened = settled - settledBeforeHandover >= 1;
|
||||
out.handoverGivesTheLayerBackToTheSettledScene = guideOwner() === 'settled'
|
||||
&& sr().querySelector('[data-hp-live-editor]').childElementCount === 0;
|
||||
out.handoverKeepsExactlyOneGuide = groups() === 1 && lines() === 1;
|
||||
out.handoverGuideStaysOnTheLivePoint = Math.abs(guideLineX() - atHandover.x) < 1e-6;
|
||||
// Настоящее движение — и слой снова у живого художника. Шаг вниз по той же
|
||||
// оси: выравнивание сохраняется, а позиция меняется, иначе живой отрисовки
|
||||
// не будет вовсе — менять нечего, и это правильно.
|
||||
clientY += g * pxPerUnit();
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
|
||||
for (let i = 0; i < 10 && guideOwner() !== 'live'; i++) await frame();
|
||||
out.nextMoveTakesTheLayerBack = guideOwner() === 'live' && groups() === 1 && lines() === 1;
|
||||
|
||||
// Съехали с оси — направляющей нет. Смещение выбирается так, чтобы не
|
||||
// попасть на ось ни одного из кандидатов: их девять, и «на глаз» выбранный
|
||||
// отступ однажды уже сел ровно на чужую ось.
|
||||
const away = (() => {
|
||||
const points = c._editorRuntime._alignCandidates();
|
||||
const here = live();
|
||||
for (let step = 3; step <= 24; step++) {
|
||||
const x = here.x + g * step;
|
||||
const y = here.y + g * step;
|
||||
const clear = points.every((point) => Math.abs(point[0] - x) > g * 0.5
|
||||
&& Math.abs(point[1] - y) > g * 0.5);
|
||||
if (clear) return step;
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
c._deviceDrag = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuideGone = guides() === 0;
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
|
||||
// 5) в Просмотре гидов не бывает
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
out.noneInView = guides() === 0;
|
||||
out.foundAPlaceOffEveryAxis = away > 0;
|
||||
clientX += g * away * pxPerUnit();
|
||||
clientY += g * away * pxPerUnit();
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
|
||||
await frame();
|
||||
await frame();
|
||||
out.devNoGuideOffAxis = lines() === 0;
|
||||
marker.dispatchEvent(pe('pointerup', 77, { clientX, clientY }));
|
||||
await c.updateComplete;
|
||||
out.devGuideGoneAfterTheDrop = lines() === 0 && groups() === 0;
|
||||
|
||||
// ---- 2) подложка: настоящее рисование прямоугольника -------------------
|
||||
c._setMode('decor');
|
||||
await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{
|
||||
id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3,
|
||||
}];
|
||||
c._decorTool = 'rect';
|
||||
c._decorStyle = { color: '#00ff00', width: 3, fill: false };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await quiet();
|
||||
{
|
||||
const start = toClient(500, 500);
|
||||
const element = stage();
|
||||
const settledAtStart = settled;
|
||||
element.dispatchEvent(pe('pointerdown', 91, { clientX: start.x, clientY: start.y }));
|
||||
await c.updateComplete;
|
||||
out.decorGestureStartsADraft = !!c._decorDraft;
|
||||
let x = start.x;
|
||||
const y = toClient(0, 600).y;
|
||||
let decorOneLayer = true;
|
||||
for (let step = 0; step < 10; step++) {
|
||||
const b = c._decorDraft?.b;
|
||||
if (!b) break;
|
||||
const remaining = 200 - b[0]; // левый край d1
|
||||
if (Math.abs(remaining) < 0.05) break;
|
||||
x += remaining * pxPerUnit() * (step < 3 ? 0.5 : 1);
|
||||
element.dispatchEvent(pe('pointermove', 91, { clientX: x, clientY: y }));
|
||||
await frame();
|
||||
await frame();
|
||||
if (groups() > 1) decorOneLayer = false;
|
||||
}
|
||||
out.decorDraftReachedTheShapeCorner = Math.abs((c._decorDraft?.b?.[0] ?? -1) - 200) < 0.05;
|
||||
out.decorGuideFollowsARealGesture = lines() >= 1 && dots() >= 1;
|
||||
out.decorSingleGuideLayer = groups() === 1 && decorOneLayer;
|
||||
out.decorZeroSettledRendersDuringGesture = settled - settledAtStart <= 1;
|
||||
element.dispatchEvent(pe('pointerup', 91, { clientX: x, clientY: y }));
|
||||
await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// ---- 3) план: направляющая идёт за курсором БЕЗ клика ------------------
|
||||
c._setMode('plan');
|
||||
c._tool = 'draw';
|
||||
await c.updateComplete;
|
||||
await quiet();
|
||||
{
|
||||
const room = c._spaceModel().rooms[0];
|
||||
const poly = room.poly || [[room.x, room.y], [room.x + room.w, room.y],
|
||||
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h]];
|
||||
const vertex = poly[0];
|
||||
const element = stage();
|
||||
const first = toClient(vertex[0] + g * 10, vertex[1] + g * 10);
|
||||
element.dispatchEvent(new MouseEvent('click', {
|
||||
clientX: first.x, clientY: first.y, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.planClickAddsTheFirstVertex = c._path.length === 1;
|
||||
await quiet();
|
||||
const settledAtHover = settled;
|
||||
let planOneLayer = true;
|
||||
for (let step = 1; step <= 4; step++) {
|
||||
const x = vertex[0] + g * 10 - (g * 10 * step) / 4;
|
||||
const point = toClient(x, vertex[1] + g * 20);
|
||||
element.dispatchEvent(pe('pointermove', 93, { clientX: point.x, clientY: point.y }));
|
||||
await frame();
|
||||
await frame();
|
||||
if (groups() > 1) planOneLayer = false;
|
||||
}
|
||||
out.planHoverReachedTheVertexAxis = Math.abs((c._cursorPt?.[0] ?? -1) - vertex[0]) < 1e-6;
|
||||
out.planGuideFollowsTheCursorWithoutAClick = lines() >= 1 && dots() >= 1;
|
||||
out.planSingleGuideLayer = groups() === 1 && planOneLayer;
|
||||
out.planZeroSettledRendersDuringHover = settled - settledAtHover === 0;
|
||||
// Бейдж угла живёт тем же жестом: 45° красит, иначе — обычный градус.
|
||||
const at45 = toClient(c._path[0][0] + g * 5, c._path[0][1] + g * 5);
|
||||
element.dispatchEvent(pe('pointermove', 93, { clientX: at45.x, clientY: at45.y }));
|
||||
await frame();
|
||||
await frame();
|
||||
const label45 = sr().querySelector('.measurelabel');
|
||||
out.badge45 = !!label45 && label45.classList.contains('on45') && label45.textContent.includes('45');
|
||||
const off45 = toClient(c._path[0][0] + g * 5, c._path[0][1] + g * 2);
|
||||
element.dispatchEvent(pe('pointermove', 93, { clientX: off45.x, clientY: off45.y }));
|
||||
await frame();
|
||||
await frame();
|
||||
const labelOff = sr().querySelector('.measurelabel');
|
||||
out.badgeOff45 = !!labelOff && !labelOff.classList.contains('on45')
|
||||
&& labelOff.textContent.includes('°');
|
||||
c._path = [];
|
||||
c._cursorPt = null;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// ---- 4) в Просмотре направляющих не бывает ----------------------------
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
out.noneInView = lines() === 0 && groups() === 0;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -196,7 +196,8 @@ const out = await page.evaluate(async () => {
|
||||
card._saveConfigDebounced.cancel();
|
||||
card._persistLayout.cancel();
|
||||
card._writeChain = Promise.resolve();
|
||||
card._adoptStructuralResponses = () => ({ configChanged: false, layoutChanged: false });
|
||||
// #500: the delete path re-reads and adopts through the one gated entry; keep it inert here.
|
||||
card._adoptAuthoritative = async () => ({ status: 'adopted', spaceChanged: false });
|
||||
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
|
||||
card._space = 'another-space';
|
||||
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
|
||||
|
||||
@@ -37,7 +37,7 @@ const res = await page.evaluate(async () => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
// #397 AC3: the reconnect path reads BOTH answers, and a config that
|
||||
// differs clears the history for its own reason (`configChanged` in
|
||||
// `_adoptStructuralResponses`). To isolate the layout question the
|
||||
// `adoptStructuralResponses`, #500). To isolate the layout question the
|
||||
// fake server echoes the config the card already holds.
|
||||
return { config: structuredClone(c._serverCfg), rev: c._cfgRev };
|
||||
}
|
||||
@@ -294,7 +294,7 @@ const res = await page.evaluate(async () => {
|
||||
placement: structuredClone(rawProbe) },
|
||||
});
|
||||
// #397 AC3: the reconnect path (`_loadFromServer` →
|
||||
// `_adoptStructuralResponses`) is the OTHER reader of the same value: it
|
||||
// `adoptAuthoritativeGated`, #500) is the OTHER reader of the same value: it
|
||||
// compares the stored `_layoutContentFingerprint` with the server answer
|
||||
// instead of recomputing it. It must be exercised right after a write,
|
||||
// before any reload has had a chance to align the two sides — otherwise
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// #500 AC4: the four post-write adoptions — space/delete from both runtimes,
|
||||
// Optimize Undo and Import apply — pass the same backdrop readiness gate as a
|
||||
// reload before the structure is replaced, and take config/layout revisions
|
||||
// from the re-read `config/get`/`layout/get`, never from the write reply.
|
||||
// A second client changes `plan_url` between our write and the re-read; the
|
||||
// card must prepare THAT backdrop first and end on the re-read revision.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
await card._ensureOnboardingRuntime();
|
||||
const editor = card._editorRuntime;
|
||||
const onboarding = card._onboardingRuntime;
|
||||
const adoption = card._adoption;
|
||||
const result = {};
|
||||
|
||||
const space = (id, title, planUrl) => ({
|
||||
id, title, plan_url: planUrl, view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], partitions: [],
|
||||
});
|
||||
const freshConfig = () => ({
|
||||
model_version: 9,
|
||||
spaces: [space('alpha', 'Alpha', '/local/alpha.png'), space('beta', 'Beta', '/local/beta.png')],
|
||||
markers: [],
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const order = [];
|
||||
const toasts = [];
|
||||
let retries = 0;
|
||||
let assetReady = true;
|
||||
const originalPrepareImage = card._signer.prepareImage.bind(card._signer);
|
||||
card._signer.prepareImage = async (_hass, href) => { order.push(`prepare:${href}`); return assetReady; };
|
||||
card._scheduleLoadRetry = () => { retries += 1; };
|
||||
const originalAdoptResponses = adoption.adoptResponses.bind(adoption);
|
||||
adoption.adoptResponses = (...args) => { order.push('adopt'); return originalAdoptResponses(...args); };
|
||||
card._confirmDanger = async () => true;
|
||||
card._showToast = (message) => { toasts.push(message); };
|
||||
|
||||
// Fake server: our write bumps both revisions; a concurrent client then
|
||||
// swaps the backdrop and bumps the config revision again before the re-read.
|
||||
let server;
|
||||
let concurrentHref = '';
|
||||
const concurrentWrite = () => {
|
||||
for (const s of server.config.spaces) s.plan_url = concurrentHref;
|
||||
server.cfgRev += 1;
|
||||
};
|
||||
const originalHass = card.hass;
|
||||
card.hass = {
|
||||
...originalHass,
|
||||
callWS: async (message) => {
|
||||
switch (message.type) {
|
||||
case 'houseplan/space/delete': {
|
||||
server.config.spaces = server.config.spaces.filter((s) => s.id !== message.space_id);
|
||||
server.cfgRev += 1; server.layRev += 1;
|
||||
const reply = { config_rev: server.cfgRev, layout_rev: server.layRev };
|
||||
concurrentWrite();
|
||||
return reply;
|
||||
}
|
||||
case 'houseplan/plan/optimize_undo': {
|
||||
server.cfgRev += 1; server.layRev += 1;
|
||||
const reply = { config_rev: server.cfgRev, layout_rev: server.layRev };
|
||||
concurrentWrite();
|
||||
return reply;
|
||||
}
|
||||
case 'houseplan/import/apply': {
|
||||
server.cfgRev += 1; server.layRev += 1;
|
||||
const reply = { kind: 'full', config_rev: server.cfgRev, layout_rev: server.layRev, counts: { spaces: 2, rooms: 0, markers: 0 } };
|
||||
concurrentWrite();
|
||||
return reply;
|
||||
}
|
||||
case 'houseplan/config/get':
|
||||
return { config: structuredClone(server.config), rev: server.cfgRev, can_write: true };
|
||||
case 'houseplan/layout/get':
|
||||
return { layout: structuredClone(server.layout), rev: server.layRev };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const reset = async (scenario) => {
|
||||
// Ревью r2 M1: отложенная запись предыдущего сценария иначе доживает до
|
||||
// следующего и флашится безусловным `if (pending) flush()` внутри
|
||||
// `_deleteSpace`; мок отвечает `{}` без `rev`, запасной путь «rev + 1»
|
||||
// двигает ревизию по ЧУЖОМУ телу, и отказная ветка выглядит принявшей.
|
||||
// Сценарии обязаны быть независимы — так же чистится smoke_danger_confirmation.
|
||||
card._saveConfigDebounced.cancel();
|
||||
card._persistLayout.cancel();
|
||||
server = { config: freshConfig(), layout: {}, cfgRev: 10, layRev: 20 };
|
||||
concurrentHref = `media-source://image/${scenario}-500`;
|
||||
adoption.restoreCached({ config: structuredClone(server.config), rev: 10, layout: {}, layout_rev: 20 });
|
||||
card._space = 'beta';
|
||||
card._spaceDialog = null;
|
||||
card._backupImportDialog = null;
|
||||
order.length = 0;
|
||||
toasts.length = 0;
|
||||
retries = 0;
|
||||
await card.updateComplete;
|
||||
};
|
||||
// Review r1 M1: a refused backdrop gate adopts nothing and skips the caller's
|
||||
// tail (no toast, no space switch, dialog released), exactly like every
|
||||
// reload path; the scheduled retry owns the rest.
|
||||
const refusedVerdict = (scenario, extra = {}) => {
|
||||
result[`${scenario}RefusedAdoptsNothing`] = card._cfgRev === 10 && card._layoutRev === 20
|
||||
&& card._serverCfg.spaces.length === 2 && card._serverCfg.spaces.every((s) => s.plan_url !== concurrentHref);
|
||||
result[`${scenario}RefusedSchedulesRetry`] = retries === 1 && !order.includes('adopt');
|
||||
result[`${scenario}RefusedSkipsTail`] = toasts.length === 0 && card._space === 'beta';
|
||||
Object.assign(result, extra);
|
||||
};
|
||||
const verdict = (scenario, extra = {}) => {
|
||||
const prepared = order.indexOf(`prepare:${concurrentHref}`);
|
||||
const adopted = order.indexOf('adopt');
|
||||
result[`${scenario}PreparesConcurrentBackdropBeforeAdoption`] = prepared >= 0 && adopted > prepared;
|
||||
result[`${scenario}RevisionsFromReRead`] = card._cfgRev === server.cfgRev && card._layoutRev === server.layRev;
|
||||
result[`${scenario}AdoptsConcurrentBackdrop`] = card._serverCfg.spaces.every((s) => s.plan_url === concurrentHref);
|
||||
Object.assign(result, extra);
|
||||
};
|
||||
|
||||
const spaceDialog = (spaceId) => ({
|
||||
mode: 'edit', spaceId, title: 'Beta', planUrl: null, planFile: null, source: 'draw',
|
||||
showBorders: true, showNames: true, zeroWallStyle: 'dashed', displayTouched: true,
|
||||
hideDecor: false, hideOpenings: false, roomColor: '#888888', roomOpacity: 1, bgColor: null,
|
||||
bgMode: null, northDeg: null, sunRays: null, fillMode: 'custom', customFill: null,
|
||||
glowEnabled: true, tempMin: 15, tempMax: 30, showLqi: true, cardFontScale: 1,
|
||||
labelTemp: true, labelHum: true, labelLqi: true, labelLight: true, cellCm: 5,
|
||||
busy: false, saved: [],
|
||||
});
|
||||
|
||||
for (const [name, runtime] of [['onboardingDelete', onboarding], ['editorDelete', editor]]) {
|
||||
await reset(name);
|
||||
card._spaceDialog = spaceDialog('beta');
|
||||
await runtime._deleteSpace();
|
||||
// the write reply carried cfgRev 11; the concurrent client moved it to 12
|
||||
verdict(name, {
|
||||
[`${name}RevisionNotFromDeleteReply`]: card._cfgRev === 12 && server.cfgRev === 12,
|
||||
[`${name}LeavesDeletedSpace`]: card._space === 'alpha' && card._serverCfg.spaces.length === 1,
|
||||
[`${name}ClosesDialog`]: card._spaceDialog === null,
|
||||
});
|
||||
}
|
||||
|
||||
await reset('optimizeUndo');
|
||||
card._canOptimizeUndo = true;
|
||||
card._undoKind = 'optimize';
|
||||
await editor._undoPlanOptimization();
|
||||
verdict('optimizeUndo', { optimizeUndoClearsUndo: card._canOptimizeUndo === false && card._undoKind === null });
|
||||
|
||||
await reset('importApply');
|
||||
card._backupImportDialog = {
|
||||
filename: 'plan.json', size: 1, token: 'token-500', preview: { confirmation_required: false, counts: {} },
|
||||
expectedConfigRev: card._cfgRev, expectedLayoutRev: card._layoutRev,
|
||||
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
|
||||
};
|
||||
const fixedFloor = !!card._hasFixedFloor;
|
||||
await editor._applyBackupImport();
|
||||
verdict('importApply', {
|
||||
importApplyClosesDialog: card._backupImportDialog === null,
|
||||
// today's rule, not its absence: a fixed-floor card re-selects through
|
||||
// _adoptInitialSpace, otherwise the previous space is kept
|
||||
importApplyKeepsSpaceRule: fixedFloor ? typeof card._space === 'string' && card._space.length > 0 : card._space === 'beta',
|
||||
});
|
||||
|
||||
assetReady = false;
|
||||
for (const [name, runtime] of [['onboardingDelete', onboarding], ['editorDelete', editor]]) {
|
||||
await reset(`${name}-refused`);
|
||||
card._spaceDialog = spaceDialog('beta');
|
||||
await runtime._deleteSpace();
|
||||
refusedVerdict(name, { [`${name}RefusedReleasesDialog`]: card._spaceDialog?.busy === false });
|
||||
}
|
||||
await reset('optimizeUndo-refused');
|
||||
card._canOptimizeUndo = true;
|
||||
card._undoKind = 'optimize';
|
||||
await editor._undoPlanOptimization();
|
||||
refusedVerdict('optimizeUndo', { optimizeUndoRefusedKeepsUndoFlag: card._canOptimizeUndo === true && card._optimizeUndoBusy === false });
|
||||
await reset('importApply-refused');
|
||||
card._backupImportDialog = {
|
||||
filename: 'plan.json', size: 1, token: 'token-500', preview: { confirmation_required: false, counts: {} },
|
||||
expectedConfigRev: card._cfgRev, expectedLayoutRev: card._layoutRev,
|
||||
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
|
||||
};
|
||||
await editor._applyBackupImport();
|
||||
refusedVerdict('importApply', { importApplyRefusedReleasesDialog: card._backupImportDialog?.busy === false });
|
||||
assetReady = true;
|
||||
|
||||
card.hass = originalHass;
|
||||
card._signer.prepareImage = originalPrepareImage;
|
||||
delete card._scheduleLoadRetry;
|
||||
delete adoption.adoptResponses;
|
||||
await card.updateComplete;
|
||||
return result;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -118,29 +118,31 @@ check('kioskNoVeil', kiosk.kioskNoVeil);
|
||||
check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
|
||||
|
||||
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
|
||||
// The wait lives on the Playwright side: an in-page promise that only a
|
||||
// requestAnimationFrame chain keeps alive was reported as "garbage collected"
|
||||
// on a CI runner (#506 beta.9 candidate) while the same phase passed locally.
|
||||
// Polling from outside cannot be collected, and a destroyed context or a
|
||||
// navigation surfaces as its own error instead of a vanished promise.
|
||||
page.on('framenavigated', (frame) => console.log('diagnostic navigation:', frame.url()));
|
||||
page.on('crash', () => console.log('diagnostic page crash'));
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const reduced = await page.evaluate(async () => {
|
||||
await page.evaluate(() => {
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let anim = 'missed';
|
||||
await new Promise((done) => {
|
||||
const tick = () => {
|
||||
const house = sr().querySelector('.bootveil .boothouse');
|
||||
if (house) { anim = getComputedStyle(house).animationName; done(); return; }
|
||||
if (performance.now() - t0 < 500) requestAnimationFrame(tick);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
c.remove();
|
||||
return { anim };
|
||||
window.__preloaderReduced = { card: c, started: performance.now() };
|
||||
});
|
||||
const reduced = await page.waitForFunction(() => {
|
||||
const probe = window.__preloaderReduced;
|
||||
const root = probe.card.shadowRoot || probe.card.renderRoot;
|
||||
const house = root?.querySelector('.bootveil .boothouse');
|
||||
if (house) return { anim: getComputedStyle(house).animationName };
|
||||
return performance.now() - probe.started > 500 ? { anim: 'missed' } : null;
|
||||
}, null, { polling: 'raf', timeout: 5000 }).then((handle) => handle.jsonValue());
|
||||
await page.evaluate(() => { window.__preloaderReduced.card.remove(); delete window.__preloaderReduced; });
|
||||
check('reducedMotionStaticHouse', reduced.anim, 'none');
|
||||
|
||||
await finish(browser, { ...res, ...kiosk, ...reduced });
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Переключение пространств не доигрывает чужие переходы (#525).
|
||||
//
|
||||
// Lit переиспользует узлы списка по позиции. Пока у створки двери
|
||||
// (`.op-leaf`, `transform`) и у дуги (`.op-arc`, `stroke-dashoffset`) есть
|
||||
// переход в 0.6 с, позиционное переиспользование превращает смену
|
||||
// пространства в анимацию: створка нового этажа доезжает из положения двери,
|
||||
// которая занимала этот слот раньше. То же у оболочки маркера
|
||||
// (`.device-shell-frame`, `box-shadow`).
|
||||
//
|
||||
// Две ловушки, обе стоили бы свидетелю правдивости:
|
||||
// 1. `document.getAnimations()` здесь пуст ДАЖЕ НА СЛОМАННОМ КОДЕ — карточка
|
||||
// живёт в shadow root, и документный вызов туда не заходит. Считаем
|
||||
// поэлементно, обходя вложенные теневые деревья.
|
||||
// 2. В демо-доме проёмов нет вовсе (`space.openings` пуст у обоих
|
||||
// пространств), поэтому дверь в двух пространствах готовит сам смок.
|
||||
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 root = () => c.shadowRoot || c.renderRoot;
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const settle = async (frames = 10, ms = 500) => {
|
||||
for (let i = 0; i < frames; i++) await frame();
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
/** Все бегущие анимации внутри карточки, включая вложенные shadow root. */
|
||||
const runningAnimations = () => {
|
||||
const found = [];
|
||||
const walk = (node) => {
|
||||
for (const element of node.querySelectorAll('*')) {
|
||||
const list = element.getAnimations ? element.getAnimations() : [];
|
||||
for (const animation of list) {
|
||||
if (animation.playState !== 'running') continue;
|
||||
found.push({
|
||||
cls: element.getAttribute?.('class') || element.tagName,
|
||||
property: animation.transitionProperty || animation.animationName || '?',
|
||||
});
|
||||
}
|
||||
if (element.shadowRoot) walk(element.shadowRoot);
|
||||
}
|
||||
};
|
||||
walk(root());
|
||||
return found;
|
||||
};
|
||||
// Вкладка подсвечивается, слайд едет, значения сводной панели проявляются —
|
||||
// это и есть переключение. Всё остальное, что бежит сразу после него,
|
||||
// доигрывает чужие данные.
|
||||
const EXPECTED = (entry) => /(^|\s)tab(\s|$)|zoomwrap/.test(entry.cls)
|
||||
|| /^summary-/.test(entry.property);
|
||||
|
||||
// ---- фикстура: дверь на одном месте в двух пространствах ---------------
|
||||
const ids = c._serverCfg.spaces.map((space) => space.id).slice(0, 2);
|
||||
out.twoSpacesExist = ids.length === 2;
|
||||
const physicalize = (space) => {
|
||||
for (const room of [...(space.rooms || [])]) {
|
||||
const rendered = c._spaceModelById(space.id)?.rooms?.find((item) => item.id === room.id);
|
||||
if (!rendered?.poly?.length) continue;
|
||||
c._wallDialog = {
|
||||
a: rendered.poly[0], b: rendered.poly[1], value: '15', roomId: room.id,
|
||||
source: { kind: 'room' }, sx: 0, sy: 0,
|
||||
};
|
||||
c._wallThickApply(true);
|
||||
}
|
||||
};
|
||||
for (const id of ids) physicalize(c._serverCfg.spaces.find((space) => space.id === id));
|
||||
c._geometryHistory.length = 0;
|
||||
const contactOpen = 'binary_sensor.probe_door_open';
|
||||
const contactShut = 'binary_sensor.probe_door_shut';
|
||||
const withContacts = (openState) => ({
|
||||
...c.hass,
|
||||
states: {
|
||||
...c.hass.states,
|
||||
[contactOpen]: { entity_id: contactOpen, state: openState, attributes: { device_class: 'door' } },
|
||||
[contactShut]: { entity_id: contactShut, state: 'off', attributes: { device_class: 'door' } },
|
||||
},
|
||||
entities: {
|
||||
...(c.hass.entities || {}),
|
||||
[contactOpen]: { entity_id: contactOpen },
|
||||
[contactShut]: { entity_id: contactShut },
|
||||
},
|
||||
});
|
||||
c.hass = withContacts('on');
|
||||
const door = (id, contact) => ({
|
||||
id, type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08, contact,
|
||||
});
|
||||
c._serverCfg.spaces.find((space) => space.id === ids[0]).openings = [door('opA', contactOpen)];
|
||||
c._serverCfg.spaces.find((space) => space.id === ids[1]).openings = [door('opB', contactShut)];
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- 1) штатное переключение не анимирует чужую створку ----------------
|
||||
c._pickSpace(ids[0]);
|
||||
await c.updateComplete;
|
||||
await settle();
|
||||
const leafBefore = root().querySelector('.op-leaf');
|
||||
out.doorIsDrawnInTheFirstSpace = !!leafBefore;
|
||||
out.quietBeforeTheSwitch = runningAnimations().filter((entry) => !EXPECTED(entry)).length === 0;
|
||||
// поворот створки живёт инлайновым стилем, не атрибутом
|
||||
const leafTransform = (leaf) => leaf?.style?.transform || leaf?.getAttribute('transform') || null;
|
||||
const transformBefore = leafTransform(leafBefore);
|
||||
|
||||
c._pickSpace(ids[1]);
|
||||
await c.updateComplete;
|
||||
await frame();
|
||||
const afterSwitch = runningAnimations();
|
||||
const leafAfter = root().querySelector('.op-leaf');
|
||||
out.doorIsDrawnInTheSecondSpace = !!leafAfter;
|
||||
out.theTwoDoorsDifferInState = !!transformBefore && transformBefore !== leafTransform(leafAfter);
|
||||
out.noOpeningTransitionOnSwitch = afterSwitch
|
||||
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
|
||||
out.noMarkerTransitionOnSwitch = afterSwitch
|
||||
.filter((entry) => /device-shell-frame/.test(entry.cls)).length === 0;
|
||||
const unexpected = afterSwitch.filter((entry) => !EXPECTED(entry));
|
||||
out.onlyTheSwitchItselfAnimates = unexpected.length === 0;
|
||||
out.unexpectedAnimations = unexpected.map((entry) => `${entry.cls}:${entry.property}`);
|
||||
// ещё кадр — переход мог стартовать с задержкой в один тик
|
||||
await frame();
|
||||
await frame();
|
||||
const later = runningAnimations().filter((entry) => !EXPECTED(entry));
|
||||
out.stillQuietOneFrameLater = later.length === 0;
|
||||
|
||||
// ---- 2) настоящая смена состояния двери по-прежнему анимируется --------
|
||||
await settle();
|
||||
out.quietBeforeTheRealChange = runningAnimations()
|
||||
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
[contactShut]: { entity_id: contactShut, state: 'on', attributes: { device_class: 'door' } } } };
|
||||
await c.updateComplete;
|
||||
await frame();
|
||||
const onRealChange = runningAnimations();
|
||||
out.aRealDoorOpeningStillAnimates = onRealChange
|
||||
.filter((entry) => /op-leaf/.test(entry.cls)).length >= 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res, { unexpectedAnimations: [] });
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,155 @@
|
||||
// #508: the summary-panel settings dialog scrolls in Home Assistant — wheel and
|
||||
// touch. In HA `hp-dialog` renders `ha-dialog`, whose own `.body` is the
|
||||
// scroller; `.summary-editor` (overflow:auto + overscroll-behavior:contain) was
|
||||
// not height-bound there, and a scroll container that never scrolls stops
|
||||
// Chromium's scroll chaining for wheel and touch alike. `flex-content` binds it.
|
||||
//
|
||||
// The stub below reproduces the HA 2026.9 ha-dialog contract that matters:
|
||||
// a fixed, height-capped `dialog` laid out as a flex column, a `.body` with
|
||||
// overflow:auto, `:host([flexcontent]) .body` as a flex column, and the
|
||||
// full-screen media query for phones. It is registered BEFORE the dialog is
|
||||
// opened, so hp-dialog takes its HA branch. Witness (c) proves the stub
|
||||
// reproduces the bug: without `flexcontent` the editor does not scroll.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const STUB = String.raw`
|
||||
class HaDialogStub extends HTMLElement {
|
||||
static get observedAttributes() { return ['open']; }
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' }).innerHTML = '<style>'
|
||||
+ ':host{display:block}'
|
||||
+ 'dialog{position:fixed;inset:0;margin:auto;width:min(var(--ha-dialog-width-md,580px),95vw);'
|
||||
+ 'max-height:calc(100vh - 80px);padding:0;border:0;display:flex;flex-direction:column;overflow:hidden}'
|
||||
+ '.header{flex:none;min-height:56px;display:flex;align-items:center;padding:0 16px}'
|
||||
+ '.content-wrapper{position:relative;flex:1;display:flex;flex-direction:column;min-height:0}'
|
||||
+ '.body{position:relative;overflow:auto;flex-grow:1}'
|
||||
+ ':host([flexcontent]) .body{max-width:100%;flex:1;display:flex;flex-direction:column}'
|
||||
+ '.footer{flex:none;display:flex;padding:12px 16px 16px}'
|
||||
+ '@media all and (max-width:450px){dialog{width:100vw;max-height:100dvh;min-height:100dvh}}'
|
||||
+ '</style><dialog><div class="header"><slot name="headerTitle"></slot></div>'
|
||||
+ '<div class="content-wrapper"><div class="body"><slot></slot></div></div>'
|
||||
+ '<div class="footer"><slot name="footer"></slot></div></dialog>';
|
||||
}
|
||||
set open(value) { this._open = !!value; this._sync(); }
|
||||
get open() { return !!this._open; }
|
||||
connectedCallback() { this._sync(); queueMicrotask(() => this.dispatchEvent(new Event('opened'))); }
|
||||
_sync() {
|
||||
const dialog = this.shadowRoot.querySelector('dialog');
|
||||
if (!dialog || !this.isConnected) return;
|
||||
if (this._open && !dialog.open) dialog.showModal();
|
||||
if (!this._open && dialog.open) dialog.close();
|
||||
}
|
||||
}
|
||||
customElements.define('ha-dialog', HaDialogStub);
|
||||
`;
|
||||
|
||||
async function scenario(viewport, { flexContent = true } = {}) {
|
||||
const { page, browser } = await launch(viewport, 1, [], { hasTouch: viewport.width < 500 });
|
||||
await page.waitForFunction(() => !!window.__card?._summary);
|
||||
const opened = await page.evaluate(async ({ stub, flexContent }) => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(stub)();
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const settle = async () => { await card.updateComplete; await frame(); await card.updateComplete; };
|
||||
card._config = { ...card._config, language: 'en', kiosk: false };
|
||||
card._serverCanWrite = true;
|
||||
card._haSummaryPanelApi = 1;
|
||||
card.narrow = innerWidth < 500;
|
||||
const config = structuredClone(card._serverCfg);
|
||||
const blocks = [];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
blocks.push({ id: `block-${index}`, title: `Block ${index}`, visible: true, scope: { type: 'all' }, values: [
|
||||
{ id: `devices-${index}`, label: 'Devices', source: { type: 'system', key: 'device_count' } },
|
||||
{ id: `area-${index}`, label: 'Floor area', source: { type: 'system', key: 'total_area' } },
|
||||
{ id: `clock-${index}`, label: 'Date and time', source: { type: 'system', key: 'datetime' } },
|
||||
] });
|
||||
}
|
||||
config.settings = { ...config.settings, summary_panel: { version: 1, title: 'Summary', show_on_mobile: true, blocks } };
|
||||
card._serverCfg = config;
|
||||
card._settings = config.settings;
|
||||
card._sendConfigCandidate = async () => {};
|
||||
card._summary.updated();
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
root().querySelector('.summary-control')?.querySelector('button:first-child')?.click();
|
||||
for (let i = 0; i < 80 && !root().querySelector('hp-dialog[data-kind="summary"]'); i += 1) { await frame(); }
|
||||
const dialog = root().querySelector('hp-dialog[data-kind="summary"]');
|
||||
if (!dialog) return { dialog: false };
|
||||
if (!flexContent) { dialog.flexContent = false; dialog.removeAttribute('flex-content'); }
|
||||
await dialog.updateComplete;
|
||||
await settle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const editor = dialog.querySelector('.summary-editor');
|
||||
const ha = dialog.shadowRoot.querySelector('ha-dialog');
|
||||
const body = ha?.shadowRoot.querySelector('.body');
|
||||
const native = ha?.shadowRoot.querySelector('dialog');
|
||||
const rect = (node) => { const r = node.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; };
|
||||
const footer = dialog.querySelector('[slot="footer"]');
|
||||
window.__508 = { dialog, editor, body };
|
||||
return {
|
||||
dialog: true, haBranch: !!ha, flexContent: ha?.hasAttribute('flexcontent') === true,
|
||||
editor: rect(editor), editorScroll: [editor.scrollHeight, editor.clientHeight],
|
||||
body: rect(body), bodyScroll: [body.scrollHeight, body.clientHeight],
|
||||
dialogRect: rect(native), footer: footer ? rect(footer) : null, inner: [innerWidth, innerHeight],
|
||||
};
|
||||
}, { stub: STUB, flexContent });
|
||||
const readScroll = () => page.evaluate(() => ({ editor: window.__508.editor.scrollTop, body: window.__508.body.scrollTop }));
|
||||
return { page, browser, opened, readScroll };
|
||||
}
|
||||
|
||||
// (a) desktop: the wheel over the content scrolls .summary-editor; header/footer stay
|
||||
{
|
||||
const { page, browser, opened, readScroll } = await scenario({ width: 1280, height: 720 });
|
||||
check('desktop.haBranch', opened.haBranch);
|
||||
check('desktop.flexContent', opened.flexContent);
|
||||
check('desktop.editorIsTheScroller', opened.editorScroll[0] > opened.editorScroll[1] + 50);
|
||||
check('desktop.bodyDoesNotScroll', opened.bodyScroll[0] <= opened.bodyScroll[1] + 1);
|
||||
const footerBefore = opened.footer;
|
||||
await page.mouse.move(opened.editor.x + opened.editor.w / 2, opened.editor.y + Math.min(opened.editor.h / 2, 160));
|
||||
await page.mouse.wheel(0, 240);
|
||||
await page.waitForTimeout(250);
|
||||
const after = await readScroll();
|
||||
check('desktop.wheelScrollsEditor', after.editor >= 100);
|
||||
const footerAfter = await page.evaluate(() => { const r = window.__508.dialog.querySelector('[slot="footer"]').getBoundingClientRect(); return { y: r.y, h: r.height }; });
|
||||
check('desktop.footerStays', Math.abs(footerAfter.y - footerBefore.y) < 1 && footerAfter.y + footerAfter.h <= opened.inner[1] + 1);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// (b) phone: a touch swipe scrolls the editor; the dialog is not taller than the viewport
|
||||
{
|
||||
const { page, browser, opened, readScroll } = await scenario({ width: 390, height: 844 });
|
||||
check('phone.haBranch', opened.haBranch);
|
||||
check('phone.dialogFitsViewport', opened.dialogRect.h <= opened.inner[1] + 1);
|
||||
check('phone.footerInViewport', !!opened.footer && opened.footer.y + opened.footer.h <= opened.inner[1] + 1);
|
||||
check('phone.editorIsTheScroller', opened.editorScroll[0] > opened.editorScroll[1] + 50);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const x = opened.editor.x + opened.editor.w / 2;
|
||||
const y0 = opened.editor.y + Math.min(opened.editor.h - 40, 300);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: y0 }] });
|
||||
for (let step = 1; step <= 8; step += 1) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y0 - step * 30 }] });
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await page.waitForTimeout(400);
|
||||
const after = await readScroll();
|
||||
check('phone.touchScrollsEditor', after.editor >= 80);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// (c) the stub reproduces the bug: without flexcontent the editor is unbounded and nothing scrolls
|
||||
{
|
||||
const { page, browser, opened, readScroll } = await scenario({ width: 1280, height: 720 }, { flexContent: false });
|
||||
check('witness.noFlexContent', opened.flexContent === false);
|
||||
check('witness.editorUnbounded', opened.editorScroll[0] === opened.editorScroll[1]);
|
||||
await page.mouse.move(opened.editor.x + opened.editor.w / 2, opened.editor.y + 160);
|
||||
await page.mouse.wheel(0, 240);
|
||||
await page.waitForTimeout(250);
|
||||
const after = await readScroll();
|
||||
check('witness.wheelBlocked', after.editor === 0 && after.body === 0);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
finish();
|
||||
@@ -0,0 +1,192 @@
|
||||
// #509: первый показ сводной панели на большом плане.
|
||||
//
|
||||
// Что здесь доказывается и почему смоком, а не юнитом. Обе половины дефекта
|
||||
// видны только в живом кадре: «Source unavailable» вместо значений рисовался
|
||||
// в первом же рендере панели, а подвисание возникало оттого, что агрегаты
|
||||
// считались синхронно внутри render — юнит на чистой функции ни того, ни
|
||||
// другого не замечает. План берётся тот же, на котором мерили в S2
|
||||
// (demo/fixtures/large-house.mjs: 3 пространства, 60 комнат): на демо-доме
|
||||
// расчёт дёшев и дефект не воспроизводится.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const BLOCK = {
|
||||
id: 'first-paint', title: 'General', visible: true, scope: { type: 'all' },
|
||||
values: [
|
||||
{ id: 'devices', label: 'Devices', source: { type: 'system', key: 'device_count' } },
|
||||
{ id: 'area', label: 'Floor area', source: { type: 'system', key: 'total_area' } },
|
||||
{ id: 'clock', label: 'Date and time', source: { type: 'system', key: 'datetime' } },
|
||||
],
|
||||
};
|
||||
|
||||
/** Панель на большом плане: возвращает страницу и хелперы. */
|
||||
async function openLargePlan(page, { reducedMotion = false } = {}) {
|
||||
if (reducedMotion) await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.waitForFunction(() => !!window.__card?._summary);
|
||||
return page.evaluate(async ({ config, block }) => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const settle = async () => { await card.updateComplete; await frame(); await card.updateComplete; };
|
||||
card._config = { ...card._config, language: 'en', kiosk: false };
|
||||
card._serverCanWrite = true;
|
||||
card._haSummaryPanelApi = 1;
|
||||
card.narrow = false;
|
||||
const next = structuredClone(config);
|
||||
next.settings = {
|
||||
...next.settings,
|
||||
summary_panel: { version: 1, title: 'Summary', show_on_mobile: true, blocks: [block] },
|
||||
};
|
||||
card._serverCfg = next;
|
||||
card._settings = next.settings;
|
||||
card._cfgEpoch = (card._cfgEpoch || 0) + 1;
|
||||
card._sendConfigCandidate = async () => {};
|
||||
card._summary.updated();
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
window.__firstPaint = {
|
||||
card, root, frame, settle,
|
||||
overlay: () => root().querySelector('.summary-overlay'),
|
||||
rows: () => [...(root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value') || [])],
|
||||
texts: () => [...(root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value strong') || [])]
|
||||
.map((node) => node.textContent.trim()),
|
||||
pending: () => (root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value-pending') || []).length,
|
||||
};
|
||||
return { ready: !!card._summary, spaces: card._serverCfg.spaces.length };
|
||||
}, { config: fixture.config, block: BLOCK });
|
||||
}
|
||||
|
||||
// --- (а) первый показ: скелеты вместо текста ошибки, кадр не заблокирован ---
|
||||
{
|
||||
const { page, browser } = await launch({ width: 1280, height: 900 });
|
||||
const opened = await openLargePlan(page);
|
||||
check('largePlanReady', opened.ready && opened.spaces === 3);
|
||||
|
||||
const first = await page.evaluate(async () => {
|
||||
const f = window.__firstPaint;
|
||||
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
|
||||
// Долгие задачи главного потока за всё время показа: подвисание может
|
||||
// случиться и не в первом кадре, а сразу после прихода ленивого чанка,
|
||||
// и человек увидит ровно тот же фриз (#509 AC4).
|
||||
//
|
||||
// Считаются только задачи ПОСЛЕ того, как большой план дорисован: сама
|
||||
// отрисовка 60 комнат — тоже длинная задача, но она не про эту задачу и
|
||||
// на CI-раннере попадала в окно наблюдения (#509, первый прогон CI).
|
||||
await new Promise((resolve) => {
|
||||
let quiet = null;
|
||||
const settleObserver = (() => {
|
||||
try {
|
||||
const observer = new PerformanceObserver(() => {
|
||||
clearTimeout(quiet);
|
||||
quiet = setTimeout(() => { observer.disconnect(); resolve(); }, 400);
|
||||
});
|
||||
observer.observe({ entryTypes: ['longtask'] });
|
||||
quiet = setTimeout(() => { observer.disconnect(); resolve(); }, 400);
|
||||
return observer;
|
||||
} catch { resolve(); return null; }
|
||||
})();
|
||||
if (!settleObserver) resolve();
|
||||
});
|
||||
window.__longTasks = [];
|
||||
try {
|
||||
window.__longTaskObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) window.__longTasks.push(Math.round(entry.duration));
|
||||
});
|
||||
window.__longTaskObserver.observe({ entryTypes: ['longtask'] });
|
||||
} catch { window.__longTaskObserver = null; }
|
||||
const started = performance.now();
|
||||
toggle?.click();
|
||||
// Ровно один кадр: именно его и видел человек как «зависание».
|
||||
await f.card.updateComplete;
|
||||
await f.frame();
|
||||
const blockedMs = performance.now() - started;
|
||||
return {
|
||||
blockedMs, rows: f.rows().length, pending: f.pending(), texts: f.texts(),
|
||||
hasOverlay: !!f.overlay(),
|
||||
};
|
||||
});
|
||||
check('overlayShownOnFirstFrame', first.hasOverlay && first.rows === 3);
|
||||
// AC1: до готовности — скелеты; ни одна строка не врёт про недоступный источник.
|
||||
check('skeletonsInsteadOfError', first.pending >= 2);
|
||||
check('noUnavailableOnFirstFrame', !first.texts.some((text) => /unavailable/i.test(text)));
|
||||
// AC4: первый кадр не съеден расчётом. Порог с большим запасом от измеренного
|
||||
// (S2: до правки один только расчёт площади занимал секунды на этом плане).
|
||||
check('firstFrameNotBlocked', first.blockedMs < 400);
|
||||
|
||||
// AC8: скелет живёт ограниченное время — значения приходят следом.
|
||||
const settled = await page.evaluate(async () => {
|
||||
const f = window.__firstPaint;
|
||||
const started = performance.now();
|
||||
const deadline = started + 4000;
|
||||
while (performance.now() < deadline && f.pending() > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await f.card.updateComplete;
|
||||
}
|
||||
window.__longTaskObserver?.takeRecords?.().forEach((entry) => window.__longTasks.push(Math.round(entry.duration)));
|
||||
window.__longTaskObserver?.disconnect?.();
|
||||
return {
|
||||
waitedMs: performance.now() - started, pending: f.pending(), texts: f.texts(),
|
||||
longest: window.__longTasks.length ? Math.max(...window.__longTasks) : 0,
|
||||
tasks: window.__longTasks.slice(0, 8),
|
||||
observed: !!window.__longTaskObserver,
|
||||
};
|
||||
});
|
||||
check('skeletonReplacedByValues', settled.pending === 0);
|
||||
check('valuesAreReal', settled.texts.every((text) => text && !/unavailable/i.test(text)));
|
||||
check('replacedWithinBudget', settled.waitedMs < 6000);
|
||||
// AC4: работа идёт порциями, а не одним куском. Остаточные 150–200 мс — это
|
||||
// подготовка геометрии ОДНОГО пространства (`wallBodiesGeometry`, единый
|
||||
// polyclip-union, неделимый): три такие задачи на дом вместо одной на 1,5 с.
|
||||
// Порог с запасом к раннеру CI; мутант «расчёт целиком» даёт ~1,5 с и падает.
|
||||
console.log(` диагностика: долгих задач ${settled.tasks.length ? settled.tasks.join(', ') : 'нет'};`
|
||||
+ ` значения через ${Math.round(settled.waitedMs)} мс`);
|
||||
check('noLongTaskWhileComputing', !settled.observed || settled.longest < 450);
|
||||
|
||||
// AC5: появление панели анимируется — кадры анимации реально существуют.
|
||||
const motion = await page.evaluate(async () => {
|
||||
const f = window.__firstPaint;
|
||||
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
|
||||
toggle?.click(); // скрыть
|
||||
await f.card.updateComplete;
|
||||
const exiting = (f.overlay()?.getAnimations?.() || []).map((a) => a.effect?.getTiming?.().duration);
|
||||
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||
toggle?.click(); // показать снова
|
||||
await f.card.updateComplete;
|
||||
const entering = (f.overlay()?.getAnimations?.() || []).map((a) => a.effect?.getTiming?.().duration);
|
||||
return { exiting, entering };
|
||||
});
|
||||
check('exitAnimated', motion.exiting.includes(190));
|
||||
check('enterAnimated', motion.entering.includes(190));
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// --- (б) reduced motion: прямоугольник есть, моторики нет ---
|
||||
{
|
||||
const { page, browser } = await launch({ width: 1280, height: 900 });
|
||||
await openLargePlan(page, { reducedMotion: true });
|
||||
const calm = await page.evaluate(async () => {
|
||||
const f = window.__firstPaint;
|
||||
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
|
||||
toggle?.click();
|
||||
await f.card.updateComplete;
|
||||
await f.frame();
|
||||
const skeleton = f.overlay()?.querySelector('.summary-value-pending i');
|
||||
const style = skeleton ? getComputedStyle(skeleton) : null;
|
||||
const overlayAnimations = (f.overlay()?.getAnimations?.() || []).length;
|
||||
return {
|
||||
hasSkeleton: !!skeleton,
|
||||
animationName: style?.animationName || '',
|
||||
opacity: Number(style?.opacity || '0'),
|
||||
overlayAnimations,
|
||||
};
|
||||
});
|
||||
// AC6: прямоугольник остаётся видимым, пульсация выключена.
|
||||
check('skeletonStillDrawn', calm.hasSkeleton && calm.opacity > 0.05);
|
||||
check('skeletonDoesNotPulse', calm.animationName === 'none');
|
||||
// AC5 (вторая половина): при reduced motion панель не анимируется вовсе.
|
||||
check('panelNotAnimatedWhenReduced', calm.overlayAnimations === 0);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
finish();
|
||||
@@ -471,16 +471,18 @@ const recovery = await page.evaluate(async () => {
|
||||
const originalHass = card.hass;
|
||||
const originalCallWS = originalHass.callWS;
|
||||
const originalPrepareImage = card._signer.prepareImage.bind(card._signer);
|
||||
const originalAdoptStructuralResponses = card._adoptStructuralResponses.bind(card);
|
||||
// #500: adoption lives in the identity owner; observe it there.
|
||||
const adoption = card._adoption;
|
||||
const originalAdoptResponses = adoption.adoptResponses.bind(adoption);
|
||||
const recoveryOrder = [];
|
||||
const concurrentBackdrop = 'media-source://image/summary-recovery-490';
|
||||
card._signer.prepareImage = async (_hass, href) => {
|
||||
recoveryOrder.push(`prepare:${href}`);
|
||||
return true;
|
||||
};
|
||||
card._adoptStructuralResponses = (...args) => {
|
||||
adoption.adoptResponses = (...args) => {
|
||||
recoveryOrder.push('adopt');
|
||||
return originalAdoptStructuralResponses(...args);
|
||||
return originalAdoptResponses(...args);
|
||||
};
|
||||
const writes = [];
|
||||
let serverConfig = structuredClone(card._serverCfg);
|
||||
@@ -540,7 +542,7 @@ const recovery = await page.evaluate(async () => {
|
||||
runtime.dialog = null;
|
||||
card.hass = { ...card.hass, callWS: originalCallWS };
|
||||
card._signer.prepareImage = originalPrepareImage;
|
||||
card._adoptStructuralResponses = originalAdoptStructuralResponses;
|
||||
delete adoption.adoptResponses;
|
||||
await card.updateComplete;
|
||||
return {
|
||||
lostAckClosesAsSuccess: firstClosed,
|
||||
|
||||
@@ -1,309 +1,309 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74",
|
||||
"fingerprint": "7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 298891,
|
||||
"initialViewGzipBytes": 300059,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-card.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 301193,
|
||||
"initialPanelGzipBytes": 302365,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 2302,
|
||||
"initialPanelOnlyGzipBytes": 2306,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/de-HksnN-9C.js",
|
||||
"houseplan-assets/editor-CSFqqHwT.js",
|
||||
"houseplan-assets/fr-lBEFwYGK.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
|
||||
"houseplan-assets/iso-scene-render-RhSO-S9j.js",
|
||||
"houseplan-assets/live-interaction-runtime-C_klxsXE.js",
|
||||
"houseplan-assets/pdf-export-CsCJxU0E.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
|
||||
],
|
||||
"lazyGzipBytes": 417329,
|
||||
"lazyGzipBytes": 418179,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/editor-CSFqqHwT.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 205633,
|
||||
"lazyEditorGzipBytes": 205726,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14148,
|
||||
"lazyOnboardingGzipBytes": 14187,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js"
|
||||
"houseplan-assets/de-HksnN-9C.js",
|
||||
"houseplan-assets/fr-lBEFwYGK.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 54843,
|
||||
"lazyLocaleGzipBytes": 54854,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js"
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/iso-scene-render-RhSO-S9j.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 13452,
|
||||
"lazyIsometricGzipBytes": 13460,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 10253,
|
||||
"lazyFurnitureArtGzipBytes": 10255,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/pdf-export-CsCJxU0E.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 120367,
|
||||
"lazyPdfGzipBytes": 120372,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"sha256": "a4abe8807d8c404a7ce9f65b0a72ca3d8f8d4d5bf38fa8220915818f9238c0db",
|
||||
"path": "houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"sha256": "e76da9cb2c5c8a32b2798c6f8785819bd7ff6ee624987feb8ff2c89522f87fef",
|
||||
"rawBytes": 21134,
|
||||
"gzipBytes": 7144,
|
||||
"gzipBytes": 7148,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-IBWzSVOj.js",
|
||||
"sha256": "cf031fb380b4b056c116e14a8cb26729ae4d0b275991b07989917f884f8841ff",
|
||||
"path": "houseplan-assets/de-HksnN-9C.js",
|
||||
"sha256": "3a1ada3b04ef3eb7e9c6856c700d589e90e962a6753087b59f89b481c68a9499",
|
||||
"rawBytes": 91441,
|
||||
"gzipBytes": 27705,
|
||||
"gzipBytes": 27710,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-DKd1NGbT.js",
|
||||
"sha256": "e62e388db3b39eaa5f0e8f9b9676b7a6e30c453f84d22d530740f9be093512d1",
|
||||
"rawBytes": 3837,
|
||||
"path": "houseplan-assets/editor-CSFqqHwT.js",
|
||||
"sha256": "a5adebb3a438356deb0e5a35fa218bcb4ab6a8be23d6e535b6cd350170775926",
|
||||
"rawBytes": 3832,
|
||||
"gzipBytes": 1585,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-T10CV6-a.js",
|
||||
"sha256": "b602e1137a8395f963be744ed43d2054e472093502f687e40e6bd3e7a25a8794",
|
||||
"path": "houseplan-assets/fr-lBEFwYGK.js",
|
||||
"sha256": "de104eb96b8e4fbb2895ab861300878eb3f27dc616e4737bc2e9e818286c9d88",
|
||||
"rawBytes": 93719,
|
||||
"gzipBytes": 27138,
|
||||
"gzipBytes": 27144,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"sha256": "1e26cad8d04b459f4b1140e705a02a1d380b8c49a84f92b8ea2ce3e684544800",
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"sha256": "9b63fd1d6479a45b433b323468a56b46ab0e0e3d2a5bfb82b6349d475e59eee8",
|
||||
"rawBytes": 33655,
|
||||
"gzipBytes": 10253,
|
||||
"gzipBytes": 10255,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/guard-7S6iRB0N.js",
|
||||
"sha256": "67f95421a21516a66157d8fdc0129c2d1280cee31a3621a81039c88835d1c0f8",
|
||||
"path": "houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"sha256": "b98da3eb22fde2449b43c37851d7e2cbbcb14103b20b7c6b417fa4bab2502416",
|
||||
"rawBytes": 518,
|
||||
"gzipBytes": 368,
|
||||
"gzipBytes": 371,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"sha256": "dea85ab6dfeebcb263bd672270041e34473d40b9db24e69259e55d42e5ff44d4",
|
||||
"rawBytes": 1076387,
|
||||
"gzipBytes": 298088,
|
||||
"path": "houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"sha256": "e7bdf9f5d1b699c27b57f25c369b137eceae0cf6f3ec7f2d364ef6c2bd552887",
|
||||
"rawBytes": 1079369,
|
||||
"gzipBytes": 299254,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
|
||||
"houseplan-assets/de-HksnN-9C.js",
|
||||
"houseplan-assets/editor-CSFqqHwT.js",
|
||||
"houseplan-assets/fr-lBEFwYGK.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
|
||||
"houseplan-assets/iso-scene-render-RhSO-S9j.js",
|
||||
"houseplan-assets/live-interaction-runtime-C_klxsXE.js",
|
||||
"houseplan-assets/pdf-export-CsCJxU0E.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"sha256": "bc5163222924f68e6279b412b16a53f28a76b5b4d80d4a03e7fe28c4cce77a39",
|
||||
"rawBytes": 671972,
|
||||
"gzipBytes": 178859,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
|
||||
"sha256": "00be6a1cdb443cd875e736984baf86b77e63d96474ec72d4d66bf2baa9f1a2ba",
|
||||
"rawBytes": 672339,
|
||||
"gzipBytes": 178936,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"sha256": "31ea22267928606938b34c47f1ac8ecc2041048bc8c045a411d7108d353de849",
|
||||
"rawBytes": 28237,
|
||||
"gzipBytes": 7004,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
|
||||
"sha256": "f81b58bde08ff74bab903803cc709e18449900dc91a3f64106db737bbabd55e3",
|
||||
"rawBytes": 28287,
|
||||
"gzipBytes": 7039,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"sha256": "0e18deb183cb473c2a96fb689c91c68a3729b8ad56fb8907b01c30810585dbec",
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
|
||||
"sha256": "968345cc4eaaf6cc46accbdc30b4fff83e3fc40a5a7b7a0f611229f0e53ec6b9",
|
||||
"rawBytes": 11034,
|
||||
"gzipBytes": 3731,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"sha256": "00fd101b4e5469f6d289b77eab3056bedf98082520af9139a1afff5823fcb025",
|
||||
"path": "houseplan-assets/iso-scene-render-RhSO-S9j.js",
|
||||
"sha256": "b5c630c05c0c2648c96006ab51cf271ae2ca585742df4bd9ad456ce9dac86646",
|
||||
"rawBytes": 38562,
|
||||
"gzipBytes": 13084,
|
||||
"gzipBytes": 13089,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/guard-Bdo2w6Sq.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"sha256": "631b277aae71d9977fabb3f330c4a9cdf38e47ec51259584540b747d9c9444df",
|
||||
"path": "houseplan-assets/live-interaction-runtime-C_klxsXE.js",
|
||||
"sha256": "319a2a94cf85acd20b27522ae71b81e25ee1f2d69f1688256c5eeed4dddec3d5",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2124,
|
||||
"gzipBytes": 2129,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"sha256": "48b07b9ecfbbf2c1a8de53edce8bc32b1217e2c2f20eb5ceb24af75999870d8a",
|
||||
"path": "houseplan-assets/pdf-export-CsCJxU0E.js",
|
||||
"sha256": "eccac69e0d54ceeff8fceb8db69a0f20f5339443cb7961f8916f9a850bb1ae6c",
|
||||
"rawBytes": 237254,
|
||||
"gzipBytes": 110114,
|
||||
"gzipBytes": 110117,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-DOpNDtK_.js",
|
||||
"sha256": "4ed28cc81b7f0ccac46e0fb6ac37856ddbdf7813040585447089bf4a0a61805f",
|
||||
"rawBytes": 14811,
|
||||
"gzipBytes": 3390,
|
||||
"path": "houseplan-assets/summary-panel-editor-BC_vG9Hn.js",
|
||||
"sha256": "5c7fd338b94152ae5e1194f07c6353863a9e22c4f2e99424f5795484da989a64",
|
||||
"rawBytes": 14824,
|
||||
"gzipBytes": 3401,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-DXF83oAy.js",
|
||||
"sha256": "51953d004b76561a842f67b9455985cceac2663c52499d1e1acc3a3cdfdaded9",
|
||||
"rawBytes": 2195,
|
||||
"gzipBytes": 1121,
|
||||
"path": "houseplan-assets/summary-panel-metrics-C2M32Xkf.js",
|
||||
"sha256": "bca21f0d66af64c7c1ef501a8cd38e04e2a74ee7196e004f6f60397d9e9a72f1",
|
||||
"rawBytes": 2610,
|
||||
"gzipBytes": 1308,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
|
||||
"sha256": "7598bcc5d26d8d2f9a15cc154c32a0b836ba0ac53b7b5f9b65f3c2c2bc0287d1",
|
||||
"rawBytes": 69530,
|
||||
"gzipBytes": 19034,
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js",
|
||||
"sha256": "54c791fdd19e77c24ac142794c4207345eaec03d2ab15dfdf3b991afa32bb812",
|
||||
"rawBytes": 72272,
|
||||
"gzipBytes": 19729,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-DOpNDtK_.js",
|
||||
"houseplan-assets/summary-panel-metrics-DXF83oAy.js"
|
||||
"houseplan-assets/summary-panel-editor-BC_vG9Hn.js",
|
||||
"houseplan-assets/summary-panel-metrics-C2M32Xkf.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"sha256": "0176688590ae76a8766ee735de892e37f29211a042c27f5a2992eda0cc8ef8d9",
|
||||
"path": "houseplan-assets/zigbee-topology-LI-FVdpo.js",
|
||||
"sha256": "d01ec49d20c5f3dc028a59107f1f9060634ab93d6d112e03a7c2b85792159440",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7424,
|
||||
"gzipBytes": 7431,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js",
|
||||
"sha256": "6b43a567d3686249519d43ef6f04bb9596529d99d04f745944c0e561c0e3034e",
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js",
|
||||
"sha256": "3a0fad7d479bbeb161434e498a3c8ac7f88da62ef37c173b66f95057098bcc97",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1762,
|
||||
"gzipBytes": 1765,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js",
|
||||
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "b8095427f52e7413e84d3202953d15765fbd43d4536226beff01ba7ff692ae2e",
|
||||
"sha256": "21da7b3390ea9d4992e33bf4d6fdf684145b53f407e253a599e70a459585298d",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 803,
|
||||
"gzipBytes": 805,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-DkiI2BOK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "f0a1709a58213029c08e73da2ed81b217827c8f5cdbf96e082385cfb608eabcc",
|
||||
"sha256": "a664721695cf5c85903eea440d05a295cb6d6d2cd1e3669fd5c65c9752534492",
|
||||
"rawBytes": 6124,
|
||||
"gzipBytes": 2302,
|
||||
"gzipBytes": 2306,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-card.js"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{m as e,l as o,t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-DkiI2BOK.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{t as e,l as o,x as t,e4 as s,A as a,e5 as i,e6 as l,e7 as n,dV as r,b as c}from"./houseplan-card-Bj6mVEeM.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{dT as r,dU as e,dV as t}from"./houseplan-card-Bj6mVEeM.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{dS as r,dT as t,dU as e}from"./houseplan-card-DkiI2BOK.js";const s={},a=r(class extends t{constructor(){super(...arguments),this.ot=s}render(r,t){return t()}update(r,[t,s]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return e}else if(this.ot===t)return e;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,s)}});export{a as i};
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{b as a,A as e}from"./houseplan-card-Bj6mVEeM.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-2X-IAkWu.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{b as a,A as e}from"./houseplan-card-DkiI2BOK.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-DQS834xA.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
@@ -6,7 +6,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c46
|
||||
?disabled=${d.busy} aria-label=${b("summary.show_local")}
|
||||
aria-describedby="summary-local-show-hint"
|
||||
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
|
||||
.title=${b("summary.settings")} wide
|
||||
.title=${b("summary.settings")} wide flex-content
|
||||
dismiss-on-scrim aria-busy=${String(d.busy)}
|
||||
@hp-close=${k}>
|
||||
<div class="body summary-editor" @click=${()=>m.closeSource()}>
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{e9 as e,L as t,a2 as n,J as i,an as o,N as r,bo as s,bn as a,a1 as c,a0 as l,ax as d,ea as u,eb as f,bq as m,ec as g}from"./houseplan-card-DkiI2BOK.js";function v(e){if(!e.registry.authoritative)return null;const t=new Set,n=d(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*h(c,l,d=_){try{let u=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=d(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,o,r,g.roomGeom,g.multiWallNodes)||a;v=y(v,s(c,m.physicalBodies)),yield}const _=m.cellCm/o;u+=a(v)*_*_/1e4}return u}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function b(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:m(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function S(e,t){const n=e?.states?.[t];if(!n)return null;const i=u(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{az as e,ea as t,eb as n,bs as i,ec as r,P as a,a5 as s,M as o,aq as c,N as l,bq as d,bp as u,ed as f}from"./houseplan-card-Bj6mVEeM.js";function g(t){if(!t.registry.authoritative)return null;const n=new Set,i=e(t.markers);for(const e of Object.values(t.registry.devices||{}))e?.id&&"service"!==e.entry_type&&!i.devices.has(e.id)&&e.area_id&&t.areaToSpace[e.area_id]&&n.add(e.id);for(const e of t.markers||[]){if(e.removed||"virtual"===e.binding)continue;const r=String(e.binding||"").indexOf(":");if(r<1)continue;const a=e.binding.slice(0,r),s=e.binding.slice(r+1),o="device"===a?t.registry.devices?.[s]:null,c="entity"===a?t.registry.entities?.[s]:null,l="device"===a?s:c?.device_id,d="device"===a?o?.area_id:c?.area_id||l&&t.registry.devices?.[l]?.area_id,u="string"==typeof e.room_id&&e.room_id.length>0&&null===e.area?"":e.area||d||"",f=u&&t.areaToSpace[u]||e.space||t.firstSpaceId;l&&t.registry.devices?.[l]&&t.spaceIds.has(f)&&(!i.devices.has(l)||"entity"===a&&i.liveEntities.has(s))&&n.add(l)}return n}function m(e,t){return e?.length?t?.length?f(e,t):e:t}function v(e,t){try{let n=0;for(const i of t){const t=e.spaces.find(e=>String(e?.id)===i.id);if(!t)continue;const f=r(t,i);let g=null;for(const e of i.rooms){if(!e.id)continue;const t=a(e);if(!t)continue;const n=s(i.rooms,e.id,f.walls,f.openCuts,o,f.cellCm,c,l)||t;g=m(g,d(n,f.physicalBodies))}const v=f.cellCm/c;n+=u(g)*v*v/1e4}return n}catch{return null}}function y(e,t,n,r){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:i(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(r||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function _(e,i){const r=e?.states?.[i];if(!r)return null;const a=t(e,i);return a?n(a,String(r.attributes?.unit_of_measurement||"")):null}export{g as representedHaDeviceIds,_ as summaryEntityValue,y as summarySystemValue,v as totalCleanFloorAreaM2};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{n as t,b as n}from"./zigbee-topology-DOkBU6gZ.js";import{F as e}from"./houseplan-card-Bj6mVEeM.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{n as t,b as n}from"./zigbee-topology-LI-FVdpo.js";import{B as e}from"./houseplan-card-DkiI2BOK.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";try{await import("./houseplan-assets/houseplan-card-Bj6mVEeM.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";try{await import("./houseplan-assets/houseplan-card-DkiI2BOK.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
|
||||
@@ -188,6 +188,12 @@ the same profiler available between stable promotions.
|
||||
initial focus, Escape close event and restore-focus session. Focus sessions
|
||||
are scoped to a card shadow root so nested dialogs return to their parent
|
||||
trigger and dialog replacement still returns to the original outside opener.
|
||||
`flex-content` forwards ha-dialog's `flexcontent`, making HA's `.body` a
|
||||
flex column so a consumer that is itself a scroll container (`min-height: 0`,
|
||||
`overflow: auto`, `overscroll-behavior: contain`) is height-bound and scrolls
|
||||
by itself; without it Chromium stops wheel and touch scroll chaining at the
|
||||
never-scrolling child (#508). The summary-panel settings dialog uses it; the
|
||||
native branch already bounds the surface with its own flex column.
|
||||
Its footer wrapper is a full-width slot item: HA lays the footer slot out as
|
||||
flex, so flattening that wrapper would shrink action rows to their content.
|
||||
The wrapper opts HA's title-height custom property into content sizing so
|
||||
@@ -1359,6 +1365,26 @@ snapshot for every affected space, clears its gestures and geometry history,
|
||||
then best-effort reloads authoritative config. Thus a newer edit made while the
|
||||
rejected request was in flight cannot survive on an unaccepted base (#314).
|
||||
|
||||
**Config/layout identity has one owner** (#500). `src/config-adoption.ts`
|
||||
holds the server config and the device layout together with their revision
|
||||
and content fingerprint; the card exposes `_serverCfg`, `_cfgRev`, `_layout`
|
||||
and `_layoutRev` only as read delegates. The identity changes in exactly
|
||||
three ways — adopting an authoritative response, accepting the reply to our
|
||||
own write (`acceptConfigWrite`, `acceptPairWrite`), restoring the warm cache —
|
||||
and a revision is never taken apart from the body it describes: after
|
||||
`space/delete`, Optimize Undo or Import the revisions come from the re-read
|
||||
`config/get`/`layout/get`, not from the write reply. Every authoritative
|
||||
adoption goes through `adoptAuthoritativeGated`: compare by fingerprint →
|
||||
backdrop readiness (`ContentSigner.prepareImage`) → continuity candidate →
|
||||
adopt → tail. The `reload` profile (initial load, `config_updated`, summary
|
||||
lost-ACK recovery) runs the shared tail (decor assets, initial space, pending
|
||||
nav mode, cache snapshot); the `post-write` profile (the four re-reads after a
|
||||
paired write) ends at adoption and leaves each caller its own tail. Bodies may
|
||||
still be staged locally before a write — that is how the editors work — but
|
||||
only in the files pinned by `test/config-adoption-ownership.test.mjs`, whose
|
||||
counts ratchet down. Feature runtimes see one host method,
|
||||
`_adoptAuthoritative`, instead of the eight steps it replaces.
|
||||
|
||||
**Persisted coordinates have one lattice-aware write boundary** (#291).
|
||||
`canonicalizeConfigGeometry()` / `canonicalizeLayoutGeometry()` /
|
||||
`canonicalizePosition()` own the frontend candidate; mirrored Python functions
|
||||
|
||||
@@ -2,11 +2,29 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Reopening the plan on a page that has already loaded the summary panel no
|
||||
longer triggers a late header re-measurement and an extra round of heavy
|
||||
redraws right after the first frame: the panel's controls are part of the
|
||||
first frame, so the first device/glow update lands on a stable stage
|
||||
([#506](https://github.com/Matysh/houseplan-card/issues/506)).
|
||||
- Switching between spaces no longer replays a neighbouring door's leaf
|
||||
movement, in either direction. The animation now belongs to a real sensor
|
||||
change and nothing else
|
||||
([#525](https://github.com/Matysh/houseplan-card/issues/525)).
|
||||
|
||||
## v1.74.0-beta.1 — 2026-09-10
|
||||
|
||||
- Alignment guides follow the object again while you drag it: the marker in the
|
||||
device editor, the shape and the cursor in the backdrop and plan editors. The
|
||||
dashed line and its anchor dot appear the moment the object lines up with
|
||||
another one, instead of after the gesture is over
|
||||
([#521](https://github.com/Matysh/houseplan-card/issues/521)).
|
||||
|
||||
- The summary panel no longer freezes the first frame on a large plan, and it
|
||||
no longer says «Source unavailable» while its values are still being
|
||||
computed: rows show a pulsing placeholder that is replaced by the numbers as
|
||||
they arrive, and the panel fades in and out again
|
||||
([#509](https://github.com/Matysh/houseplan-card/issues/509)).
|
||||
|
||||
- The summary panel settings dialog scrolls again in Home Assistant — with the
|
||||
mouse wheel on desktop and with a swipe on phones; the header and the action
|
||||
buttons stay in place
|
||||
([#508](https://github.com/Matysh/houseplan-card/issues/508)).
|
||||
|
||||
## v1.73.0 — 2026-09-09
|
||||
|
||||
@@ -54,6 +72,14 @@
|
||||
card-mod rules, without depending on private CSS classes
|
||||
([#489](https://github.com/Matysh/houseplan-card/issues/489)).
|
||||
|
||||
## v1.73.0-beta.9 — 2026-09-09
|
||||
|
||||
- Reopening the plan on a page that has already loaded the summary panel no
|
||||
longer triggers a late header re-measurement and an extra round of heavy
|
||||
redraws right after the first frame: the panel's controls are part of the
|
||||
first frame, so the first device/glow update lands on a stable stage
|
||||
([#506](https://github.com/Matysh/houseplan-card/issues/506)).
|
||||
|
||||
## v1.73.0-beta.8 — 2026-09-09
|
||||
|
||||
- Attachment uploads no longer count their own in-flight file twice against
|
||||
|
||||
@@ -8,11 +8,28 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Повторное открытие плана на странице, где сводная панель уже загружена,
|
||||
больше не вызывает позднего перемера шапки и лишнего круга тяжёлых
|
||||
перерисовок сразу после первого кадра: элементы панели входят в первый кадр,
|
||||
и первое обновление устройств/свечения ложится на стабильную сцену
|
||||
([#506](https://github.com/Matysh/houseplan-card/issues/506)).
|
||||
- При переключении между пространствами двери и окна больше не доигрывают
|
||||
движение створки соседней двери — ни в одну сторону, ни в другую. Анимация
|
||||
остаётся только за настоящей сменой состояния датчика
|
||||
([#525](https://github.com/Matysh/houseplan-card/issues/525)).
|
||||
|
||||
## v1.74.0-beta.1 — 2026-09-10
|
||||
|
||||
- Направляющие выравнивания снова идут за объектом во время жеста: за значком
|
||||
в редакторе устройств, за фигурой и курсором в подложке и в плане. Пунктирная
|
||||
линия и точка-якорь появляются в тот момент, когда объект встаёт на одну ось
|
||||
с другим, а не после того, как жест закончился
|
||||
([#521](https://github.com/Matysh/houseplan-card/issues/521)).
|
||||
|
||||
- Сводная панель больше не подвешивает первый кадр на большом плане и не пишет
|
||||
«Source unavailable», пока значения ещё считаются: в строках появляются
|
||||
пульсирующие плашки, которые плавно сменяются числами, а сама панель снова
|
||||
появляется и исчезает плавно
|
||||
([#509](https://github.com/Matysh/houseplan-card/issues/509)).
|
||||
|
||||
- Диалог настроек сводной панели в Home Assistant снова прокручивается —
|
||||
колесом мыши на десктопе и свайпом на телефоне; шапка и кнопки остаются на
|
||||
месте ([#508](https://github.com/Matysh/houseplan-card/issues/508)).
|
||||
|
||||
## v1.73.0 — 2026-09-09
|
||||
|
||||
@@ -61,6 +78,14 @@
|
||||
card-mod, без привязки к приватным CSS-классам
|
||||
([#489](https://github.com/Matysh/houseplan-card/issues/489)).
|
||||
|
||||
## v1.73.0-beta.9 — 2026-09-09
|
||||
|
||||
- Повторное открытие плана на странице, где сводная панель уже загружена,
|
||||
больше не вызывает позднего перемера шапки и лишнего круга тяжёлых
|
||||
перерисовок сразу после первого кадра: элементы панели входят в первый кадр,
|
||||
и первое обновление устройств/свечения ложится на стабильную сцену
|
||||
([#506](https://github.com/Matysh/houseplan-card/issues/506)).
|
||||
|
||||
## v1.73.0-beta.8 — 2026-09-09
|
||||
|
||||
- Загрузка вложений больше не считает собственный ещё не сохранённый файл
|
||||
|
||||
@@ -166,8 +166,18 @@ npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=art
|
||||
npm run golden:capture
|
||||
npm run golden:verify
|
||||
npm run golden:accept -- --reviewed
|
||||
|
||||
# Docs screenshots whose pixels did not change (a version bump, a refactor):
|
||||
# re-capture locally, compare decoded RGBA against the committed frames and,
|
||||
# if every frame is identical, refresh only the manifest fingerprints (#512).
|
||||
npm run docs:accept -- --identical
|
||||
```
|
||||
|
||||
Golden frames never show the real card version: the harness sets the test-only
|
||||
seam `window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before the card is
|
||||
created, so a version bump alone changes no baseline (#512, see
|
||||
`demo/golden/README.md`).
|
||||
|
||||
The config audit performs no network requests and does not rewrite the input.
|
||||
Its registry and lifecycle rules are documented in `CONFIG-COMPATIBILITY.md`.
|
||||
The blocking performance workflow runs its independent profile pairs in
|
||||
@@ -355,9 +365,16 @@ fallback. They still gate assets on the exact tagged SHA, so adopting the new
|
||||
path does not weaken releases created through the old path.
|
||||
|
||||
Tag `vX.Y.Z` + GitHub Release → `.github/workflows/release.yml` resolves that
|
||||
tag to its exact commit, waits for every Validate run of the SHA to complete
|
||||
successfully, then builds and attaches `houseplan-card.js`. A missing, failed,
|
||||
cancelled or one-hour-timed-out Validate withholds the asset. Bump the version
|
||||
tag to its exact commit, waits for the latest non-cancelled Validate run of the
|
||||
SHA to complete successfully (#511: a cancelled run is not a verdict, a later
|
||||
re-run or another-baseline comparison refreshes an older result), then builds
|
||||
and attaches `houseplan-card.js`. A missing, failed or one-hour-timed-out latest
|
||||
Validate withholds the asset; stable releases additionally need the same for
|
||||
Full Performance and a green E2E run on a real Home Assistant: `release.yml`
|
||||
dispatches `e2e.yml` in `Matysh/houseplan-e2e` with the tag (the suite installs
|
||||
the release's `houseplan.zip` into HA in docker) and waits for it (#514). A red
|
||||
E2E withholds the assets — open the linked run, the Playwright traces and
|
||||
screenshots are in its artifacts; fix, then cut a new tag. Bump the version
|
||||
everywhere in sync: `src/houseplan-card.ts` (CARD_VERSION), `package.json`,
|
||||
`custom_components/houseplan/manifest.json`, `custom_components/houseplan/const.py`.
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<!-- release: v1.73.0 -->
|
||||
<!-- release: v1.74.0-beta.1 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- House Plan теперь открывается отдельной страницей из бокового меню Home Assistant; новая настраиваемая сводная панель показывает состояния и итоги по дому ([#486](https://github.com/Matysh/houseplan-card/issues/486), [#437](https://github.com/Matysh/houseplan-card/issues/437)).
|
||||
- План можно выгрузить в PDF A4; отрезки цепочек стен сохраняются сразу, а сохранение и восстановление планов стали надёжнее ([#53](https://github.com/Matysh/houseplan-card/issues/53), [#478](https://github.com/Matysh/houseplan-card/issues/478), [#491](https://github.com/Matysh/houseplan-card/issues/491)).
|
||||
- Радары присутствия получили настройку источников, калибровку и живое отображение на плане — первый этап поддержки; границы комфортной температуры настраиваются отдельно для каждой комнаты ([#485](https://github.com/Matysh/houseplan-card/issues/485), [#487](https://github.com/Matysh/houseplan-card/issues/487)).
|
||||
- Сводная панель больше не подвешивает первый кадр на большом плане и не показывает «Source unavailable», пока значения ещё считаются ([#509](https://github.com/Matysh/houseplan-card/issues/509)).
|
||||
- Диалог настроек сводной панели снова прокручивается — колесом мыши на десктопе и свайпом на телефоне ([#508](https://github.com/Matysh/houseplan-card/issues/508)).
|
||||
- Направляющие выравнивания снова идут за значком, фигурой и курсором во время жеста в трёх редакторах ([#521](https://github.com/Matysh/houseplan-card/issues/521)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Open House Plan as a standalone page from the Home Assistant sidebar; a new configurable summary panel shows household states and totals ([#486](https://github.com/Matysh/houseplan-card/issues/486), [#437](https://github.com/Matysh/houseplan-card/issues/437)).
|
||||
- Export your plan to an A4 PDF; wall-chain segments save immediately, and plan saving and recovery are more reliable ([#53](https://github.com/Matysh/houseplan-card/issues/53), [#478](https://github.com/Matysh/houseplan-card/issues/478), [#491](https://github.com/Matysh/houseplan-card/issues/491)).
|
||||
- Presence radars gain source setup, calibration and live on-plan observations in this first stage of support; each room can have its own comfort-temperature limits ([#485](https://github.com/Matysh/houseplan-card/issues/485), [#487](https://github.com/Matysh/houseplan-card/issues/487)).
|
||||
- The summary panel no longer freezes the first frame on a large plan and no longer says "Source unavailable" while its values are still being computed ([#509](https://github.com/Matysh/houseplan-card/issues/509)).
|
||||
- The summary panel settings dialog scrolls again — with the mouse wheel on desktop and with a swipe on phones ([#508](https://github.com/Matysh/houseplan-card/issues/508)).
|
||||
- Alignment guides follow the marker, the shape and the cursor again while you drag, in all three editors ([#521](https://github.com/Matysh/houseplan-card/issues/521)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
Сообщество / Community: [Telegram](https://t.me/ha_houseplan)
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.73.0/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.73.0/docs/CHANGELOG.md)
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.74.0-beta.1/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.74.0-beta.1/docs/CHANGELOG.md)
|
||||
|
||||
@@ -17,12 +17,12 @@ change must pass through a published beta/RC before stable. Stable release
|
||||
commits are promotion-only (versions, generated bundles and release/changelog
|
||||
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
## Snapshot (2026-09-09)
|
||||
## Snapshot (2026-09-10)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.73.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Current local cycle | Stable promotion of the eight published v1.73.0 betas, through v1.73.0-beta.8. Highlights: the native HA sidebar page (#486), configurable designer-aligned summary panel (#437/#505), PDF export (#53), presence-radar Stage 1 (#485), room comfort limits (#487), wall-chain simplification (#478/#477) and reliability improvements. No new product behaviour is added during promotion. The main-only workflow mirrors were merged without changing the beta.8 tree. Publication requires the complete local gate, reviewed golden frames where version text changes, and green Validate plus Full Performance on the exact release SHA; stable assets also require the compositor-continuity check. |
|
||||
| Version | **v1.74.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION in the card and the editor runtime) |
|
||||
| Current local cycle | v1.74.0-beta.1 opens the line after the stable v1.73.0. User-visible: the summary panel no longer freezes the first frame on a large plan and no longer shows "Source unavailable" while its values are still being computed (#509), its settings dialog scrolls again with the wheel and with a swipe (#508), and the alignment guides follow the gesture again in all three editors — the marker, the shape and the drawing cursor (#521, a regression that shipped in v1.72.0 and v1.73.0). Everything else in this beta is internal: one owner for config/layout identity and the adoption sequence (#500) plus the cold-start regression it left behind — the authoritative adoption now keeps its own task and the first stable frame of a 60-room house is back inside its budget (#520) — and the process/CI work: review waits for a green Validate with diff mutants on the exact material (#510), merge-candidate publishes and validates the merge candidate (#492), the release gate judges the latest non-cancelled run (#511), the version text left the golden frames behind a seam with a local `docs:accept --identical` path (#512), the full mutation registry moved to a nightly schedule (#513), specs live in the issue body (#517), and the Windows gate is green with CI-pinned toolchain (#496). Publication requires green Validate on the exact candidate SHA; `main` stays at v1.73.0. |
|
||||
| Hidden Alpha Stage | #89 Stage 1 ships in v1.63.0-beta.1, #122 Stage 2 in v1.64.0 and #160 Stage 3 in v1.73.0-beta.1. The same hidden `iso` view uses the fixed 4° camera, raised/tethered device-room-lock overlays, deeper openings and bounded theme materials; #471 removes the overlay plates from paint while retaining their safety geometry. Since #448 the experiment is enabled only through the single indefinite browser-local `hp_alpha` gate; it is not expiring and has no per-stage key. Flat remains default; editors, `houseplan-space-card`, floor effects, stored coordinates and HA actions remain unchanged. Stage 3 stays internal and is absent from public changelog/user documentation. |
|
||||
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
|
||||
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
|
||||
полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда,
|
||||
`--shard=i/4`), перед стабильным релизом и по понедельникам. Дешёвая половина
|
||||
`--shard=i/4`) каждую ночь по расписанию (01:00 UTC); в цикле разработки и в
|
||||
релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам
|
||||
заводит issue с отчётом (#472, #513). Дешёвая половина
|
||||
идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи —
|
||||
`node scripts/mutation-gate.mjs --changed origin/dev..HEAD`: гоняются только
|
||||
мутанты, чьи patch-файлы или **входы гарда** задеты диффом (#332, #475, #492).
|
||||
@@ -46,7 +48,15 @@ GUARD_INPUTS` (умолчание `backend-test-guard.mjs` —
|
||||
Бандл собирается только мутантам с браузерным гвардом; компиляция тестов в
|
||||
worktree стартует с тёплого `test-build/` основного дерева.
|
||||
|
||||
В CI `changed_mutants` добавляет `--ledger=<файл>` — журнал пойманных
|
||||
В CI `changed_mutants` бежит не на каждом пуше, а по запросу (#510):
|
||||
`workflow_dispatch validate.yml -f mutants=true` (его делают ревью-конвейер на
|
||||
материале ревью и слияние на кандидате), `full=true` (ночь, кнопка), PR и кандидат
|
||||
беты (трейлер `Release:`). Обычный push в ветку задачи обходится дешёвыми гейтами:
|
||||
за 08–09.09 мутанты на промежуточных пушах стоили 48 из 56 часов job-минут и в
|
||||
основном отменялись следующим пушем. Доказательство мутантов для ревью — именно
|
||||
dispatch-прогон на точном SHA; зелёный push-прогон им не является.
|
||||
|
||||
`changed_mutants` добавляет `--ledger=<файл>` — журнал пойманных
|
||||
свидетелей (#481): после каждого пойманного мутанта в файл пишется отпечаток
|
||||
его входов (файлы патча, все входы гарда по замыканию выше, объявление мутанта;
|
||||
строка версии продукта нормализована), и мутант с тем же отпечатком в следующем
|
||||
@@ -55,6 +65,24 @@ worktree стартует с тёплого `test-build/` основного д
|
||||
что отменённый пуш или таймаут не пропадают даром. Полный прогон и `--id`
|
||||
журнал не читают; `--ledger` без `--changed` — ошибка.
|
||||
|
||||
## E2E на реальном Home Assistant (#514)
|
||||
|
||||
Репозиторий `Matysh/houseplan-e2e`: настоящий HA в docker, House Plan из
|
||||
`houseplan.zip` релиза, 13 сценариев Playwright (боковая панель, дашборды,
|
||||
роли, телефон, PDF, рестарт HA, первый запуск, обновление). Ночью — по
|
||||
расписанию на последней бете; для **стабильного** релиза `release.yml`
|
||||
запускает его на теге и ждёт зелёного (`scripts/e2e-gate.mjs`): красный —
|
||||
ассеты не публикуются. В цикле разработки и на бетах не участвует.
|
||||
|
||||
## Версия в кадрах и попиксельная приёмка (#512)
|
||||
|
||||
Golden-кадры и скриншоты документации не должны меняться от bump версии. Для
|
||||
golden отображаемая версия идёт через seam `displayVersion()` (`src/card-version.ts`,
|
||||
глобальная `__HP_VERSION_OVERRIDE__` — только для харнесов; продукт её не задаёт),
|
||||
и харнес фиксирует `0.0.0-golden`. Для docs-скриншотов есть локальная приёмка
|
||||
`npm run docs:accept -- --identical`: кадры сравниваются по декодированным
|
||||
пикселям, и при полном совпадении обновляется только отпечаток исходников.
|
||||
|
||||
## Manifest входов: какие job запускать и что хешировать (#492)
|
||||
|
||||
Один модуль, `scripts/check-inputs.mjs`, объявляет каждую проверку Validate
|
||||
@@ -1036,8 +1064,9 @@ node scripts/pre-push-gate.mjs --max-smokes=3 --max-mutants=1
|
||||
- **громко перечисляет, чего не проверял** — молчаливый пропуск дважды стоил
|
||||
проекту дня (#171, #207), а «Verified» без названной команды и её результата
|
||||
доказательством не является;
|
||||
- **не претендует на полноту.** Golden, полная матрица смоков, HA-харнесс и весь
|
||||
мутационный реестр — предрелизный гейт, а не этот набор.
|
||||
- **не претендует на полноту.** Golden, полная матрица смоков, HA-харнесс —
|
||||
heavy-набор Validate на кандидате; весь мутационный реестр — ночное
|
||||
расписание (#513), а не этот набор.
|
||||
|
||||
Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а
|
||||
свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"oxipng": "oxipng 10.2.0",
|
||||
"sourceFingerprint": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceFingerprint": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"captureScriptSha256": "9e4b0ae533407fe003a129078aec020f2c83d67cdb6fc6da696ce3d829beaeb5",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "76c35a87810e3dc6890bda7b43da133f57d8860031204a62cda7115c15950090"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "795585acb09488236ebb8028a7fcbd918404c5fe01560bb9b4559de13d441625"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "9f0970de09772674c3156b9fc5b2d49de0ceb22be11ecf8b136047d37d100a84"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "0c354a79f9c3660788b6c5e889a27b6bd78b1f209dc0eef6bb43e2c1344028c4"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "64169c543291bc4df806dcf8ff484b42c8ea55eb343323d945344e0e28f2ce97"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "3c8703175605fcf0387d654cd30783ecfd33e21743e70b45190a5ae1a8d355c2"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "5d6193202102f5e6d85567f1111fa105895b933979fbf369c6ba0b1029deb823"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "375900a29aa62632883ede6727b7dbb8b48cabe9d7d0e16425f9779c837f3dd9"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -103,7 +103,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "3d85424676c823ddca3fa7dab3cce089b6030e48c05d16d2d48d922c66c0cbd2"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "ac962820f8e1c5cb5b042d39b18dc95905fa840cb2d4749060d991be4f6977c2"
|
||||
},
|
||||
"pdf-export": {
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"theme": "light",
|
||||
"language": "en",
|
||||
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
|
||||
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
|
||||
"imageSha256": "4537692fd57a3ed95528eddd748eee5cb6e456ffab7cc49169853d62459252cd"
|
||||
}
|
||||
},
|
||||
@@ -138,6 +138,8 @@
|
||||
"pdf-export"
|
||||
],
|
||||
"witnesses": 7,
|
||||
"floor": 2
|
||||
"floor": 2,
|
||||
"lastWriteWasFingerprintOnly": true,
|
||||
"identicalPixels": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
# CODE-REVIEW-500-r1
|
||||
|
||||
- **Issue:** #500 — Архитектура: выделить одну границу владения config/adoption
|
||||
- **Материал:** `0905f67ec0d8fb830065e909988f442c884320c2` (ветка `issue/500-config-adoption-boundary`, ребейз на `origin/dev` = `0c748282`)
|
||||
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 10 коммитов (4 ТЗ/докс-ревью + 1 реализация модуля + делегаты + 7 вызывающих + 1 тесты/lint/docs + 1 фикс реактивности)
|
||||
- **ТЗ:** тело `docs/specs/500-config-adoption-boundary.md`, зелёное ревью r3 (`docs/reviews/SPEC-REVIEW-500-r3.md`)
|
||||
- **Заход:** r1 · блокирующих циклов израсходовано 0/4 (это первый заход)
|
||||
|
||||
## Скоуп
|
||||
|
||||
Извлечение владения идентичностью `{config, rev, fingerprint}` × `{layout, rev,
|
||||
fingerprint}` в новый модуль `src/config-adoption.ts`; единая гейт-
|
||||
последовательность `adoptAuthoritativeGated` (compare → prepareImage →
|
||||
continuity → adopt → хвост профиля) для семи вызывающих в двух профилях
|
||||
(`reload`/`post-write`); сужение `SummaryPanelHost` и host-контрактов
|
||||
editor-/onboarding-runtime; перенос `rollbackOptimistic` в модуль; единственное
|
||||
намеренное изменение поведения — гейт готовности фона на четырёх post-write
|
||||
путях, ревизии `space/delete` берутся из `config/get`/`layout/get`, а не из
|
||||
ответа `delete`. `User-Visible: no` на всех коммитах, changelog не тронут.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
**Дешёвые гейты** — не гонял заново: Validate на точном материале ревью
|
||||
(`0905f67e`) зелёный целиком —
|
||||
https://github.com/Matysh/houseplan-card/actions/runs/34462124328 (`headSha`
|
||||
подтверждён `gh run view` перед использованием результата). Это покрывает
|
||||
`tsc --noEmit`, `npm test` (2494+ юнитов), `npm run build` + сверку бандл-
|
||||
деревьев, `no-new-any`, `process-gate`, `check-docs` (WARN о протухшем
|
||||
скриншот-отпечатке — это предупреждение обычного push, не гейт кандидата,
|
||||
блокирующим не является).
|
||||
|
||||
**Мутанты по диффу** — проверил логи всех трёх шардов `Мутанты по диффу
|
||||
(N/3)` этого же прогона (`gh run view --job <id> --log`), а не только факт
|
||||
green: все четыре новых мутанта пойманы на материале ревью —
|
||||
|
||||
| Мутант | Файл лога (шард) | Результат |
|
||||
|---|---|---|
|
||||
| `config-adoption-echo-clears-history` | 2/3 | `тест покраснел, как обязан` |
|
||||
| `config-adoption-rev-from-foreign-response` | 3/3 | `тест покраснел, как обязан` |
|
||||
| `config-adoption-rollback-ignores-rev` | 1/3 | `тест покраснел, как обязан` |
|
||||
| `post-write-skips-asset-gate` | 3/3 | `тест покраснел, как обязан` |
|
||||
|
||||
Плюс чистые (немутированные) прогоны в тех же логах подтверждают отсутствие
|
||||
регрессии по соседним защитам на этом материале: `node --test
|
||||
test/config-adoption.test.mjs`, `node demo/smoke_room_resize.mjs` (сам фикс
|
||||
реактивности `0905f67e` — эту же регрессию поймал предыдущий красный Validate
|
||||
на `c360bcc9`), `node demo/smoke_summary_panel.mjs`, `node demo/
|
||||
smoke_marker_write_rollback.mjs`, `node demo/smoke_device_position_history.mjs`,
|
||||
`node demo/smoke_summary_panel_polish.mjs` — все чистые.
|
||||
|
||||
**Браузерные смоки, не покрытые CI на этом SHA.** Тяжёлые job'ы (`smoke`,
|
||||
`golden`, `performance_smoke`) в этом прогоне `skipped` — обычный push без
|
||||
`Release:`, не кандидат (см. `PROCESS.md` §8/AGENTS.md, ожидаемо). Отдельно
|
||||
проверил: ни `demo/smoke_post_write_adoption.mjs` (новый смок, единственное
|
||||
прямое свидетельство AC4), ни `demo/smoke_danger_confirmation.mjs` не
|
||||
зарегистрированы guard'ом ни одного мутанта в `scripts/mutation-gate.mjs` —
|
||||
значит job «Мутанты по диффу» их тоже не запускал ни разу на этом материале
|
||||
(грепом по логам всех трёх шардов подтверждено отсутствие). Автор сам об этом
|
||||
написал в хендоффе («в моей среде нет Chromium»). Прогнал сам:
|
||||
|
||||
```
|
||||
npm run build && npm run bundle:sync
|
||||
node demo/smoke_post_write_adoption.mjs → OK, все 20 проверок true
|
||||
node demo/smoke_danger_confirmation.mjs → OK, все 18 проверок true
|
||||
```
|
||||
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` даёт 47 прямых
|
||||
совпадений (порог «широкого» — больше 47, эта задача чуть ниже порога, но
|
||||
широка по природе диффа: тронуты повсеместно читаемые `_cfgRev`/`_layoutRev`/
|
||||
`_model`/`_serverCanWrite`). Полный прогон всех 47 непропорционален объёму
|
||||
находок и является предрелизным гейтом (§8); выбрал те, что прямо касаются
|
||||
изменённых путей (adoption/rollback/reactivity) и либо подтвердил зелёными
|
||||
через логи CI-мутантов, либо прогнал сам. Остальные 45 не прогонял — по
|
||||
диффу они читают эти поля так же, как раньше (делегаты не меняют семантику
|
||||
чтения), а не изменённое поведение.
|
||||
|
||||
**Инварианты модели** — диффом задета идентичность, не геометрия рёбер/
|
||||
толщины/ссылок; `npm run invariants` не запускал (не требуется правилом:
|
||||
геометрия и её ссылки не менялись, только владелец ревизии/тела).
|
||||
|
||||
**pytest/golden/perf** — не задеты диффом (только `src/**`, backend не
|
||||
тронут; визуал не меняется, ТЗ §7 и §14 это явно фиксируют); не гонял.
|
||||
|
||||
## AC → доказательство (проверено чтением и/или прогоном)
|
||||
|
||||
| AC | Чем доказан | Проверено | Чем краснеет |
|
||||
|---|---|---|---|
|
||||
| AC1 | `test/config-adoption-ownership.test.mjs` | чтением: пересчитал оба регекса вручную по `src/**` — `_serverCfg =` 5(editor)+3(card), `_layout =` 5(editor)+9(card), совпадает с allowlist дословно; `IDENTITY_WRITE` не находит присваиваний вне модуля | вернуть присваивание в любом модуле — красный (мутация не нужна, тест — прямой grep) |
|
||||
| AC2 | `test/config-adoption-ownership.test.mjs` + чтение | подтверждено: `_adoptStructuralResponses` как host-метод не существует нигде вне `config-adoption.ts`; `SummaryPanelHost`/`HouseplanEditorHostPort` сужены точно по §6.4 | оставить старый метод в любом файле — красный |
|
||||
| AC3 | `test/config-adoption.test.mjs` (17 кейсов) + существующие смоки/юниты | таблица переходов (эхо/смена/только layout/virtual lights/gated-последовательность/reactive-контракт) прочитана целиком, поведенчески эквивалентна старому `_adoptStructuralResponses`; `post-write` подтверждён без пост-шагов тестом на строке 215 | мутант `config-adoption-echo-clears-history` пойман на материале (см. таблицу выше) |
|
||||
| AC4 | `demo/smoke_post_write_adoption.mjs` + I2-юнит | **прогнал сам** — 20/20 true; частично не покрыт: ветка отказа гейта (`asset-wait`) ни одним из четырёх вызывающих не тестируется (см. находку M-1 ниже) | мутант `post-write-skips-asset-gate` пойман на материале |
|
||||
| AC5 | `test/config-adoption.test.mjs` round-trip | прочитано, ключи `LS_CFG` побайтово совпадают со старым форматом | мутант не заведён (чистый юнит, признание правила §2.7 достаточно) |
|
||||
| AC6 | `test/config-adoption.test.mjs` (перенесённые кейсы #439/#442) | прочитано, идентично прежней логике `rollbackOptimistic` | мутант `config-adoption-rollback-ignores-rev` пойман на материале |
|
||||
| AC7 | `test/core-file-budget.test.mjs`, `bundle:budget`, `test/bundle-assets.test.mjs` | `wc -l src/houseplan-card.ts` = 13605 < потолка 13650; `INITIAL_VIEW_GZIP_CEILING` 300 300 с датированной записью в `scripts/bundle-budget.mjs`; общий бюджет 301 066 не тронут | поднять любой из потолков — красный по правилу теста (не прогонял мутацию, чтение констант и тест-файла достаточно) |
|
||||
| AC8 | ревью кода | `docs/ARCHITECTURE.md` содержит абзац о границе (владелец, три способа смены ревизии, единая последовательность, оба профиля) — соответствует факту кода | — |
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium-1 (в скоупе задачи). Post-write гейт: отказ (`asset-wait`) не останавливает хвост вызывающего ни в одном из четырёх путей — единственная защита AC4 не проверена для своей же отказной ветки и частично не работает
|
||||
|
||||
**Где:** `src/houseplan-editor-runtime.ts:8693` (`_deleteSpace`, editor),
|
||||
`src/houseplan-editor-runtime.ts:9575` (`_undoPlanOptimization`),
|
||||
`src/houseplan-editor-runtime.ts:9734` (`_applyBackupImport`),
|
||||
`src/houseplan-onboarding-runtime.ts:477` (`_deleteSpace`, onboarding).
|
||||
|
||||
**Что происходит.** У трёх reload-вызывающих (`_loadFromServer`,
|
||||
`_reloadConfigOnly`, summary recovery) при `adopted.status !== 'adopted'`
|
||||
метод полностью прерывается (`return`/`throw`) — хвост не выполняется вовсе,
|
||||
структура и на глаз, и в идентичности остаётся прежней, а
|
||||
`_scheduleLoadRetry(true)` (вызванный внутри `adoptAuthoritativeGated`) сам
|
||||
подтянет данные позже. Ни один из четырёх **post-write** вызывающих так не
|
||||
делает:
|
||||
|
||||
- `_undoPlanOptimization` (`:9575`) вообще не читает `adopted.status` —
|
||||
`_geometryHistory.clear()`, `_devicePositionHistory.clear()`,
|
||||
`_canOptimizeUndo = false`, `_undoKind = null`, `_cfgEpoch++`,
|
||||
`_maybeRebuildDevices()`, `_cacheSnapshot()` и тост
|
||||
`gs.optimize_undone`/`backup.import_undone` выполняются безусловно.
|
||||
- `_deleteSpace` (оба входа, `:8693` и onboarding `:477`) и
|
||||
`_applyBackupImport` (`:9734`) читают `adopted.status` **только** для
|
||||
одного шага — выбора видимого пространства (`_commitSpace`/
|
||||
`_adoptInitialSpace`). Закрытие диалога, `_regSignature = ''`,
|
||||
`_maybeRebuildDevices()`, `_cacheSnapshot()` (у Import) и финальный тост
|
||||
выполняются безусловно, тем же порядком, что и при успешном adoption.
|
||||
|
||||
**Почему это дефект, а не стиль.** До #500 у post-write путей вообще не было
|
||||
асинхронного гейта — `_adoptStructuralResponses` было синхронным и всегда
|
||||
адоптировало то, что пришло; ветки «не адоптировано» просто не существовало.
|
||||
Она появляется **именно этой задачей** (тем самым единственным намеренным
|
||||
изменением поведения из §6.3 ТЗ) — и именно поэтому её последствия для
|
||||
вызывающих — предмет этого ревью, а не наследие. Сценарий реалистичен и
|
||||
прямо описан в самом ТЗ (§6.3): «Undo/Import могут вернуть другой фон» — то
|
||||
есть конкурентная смена `plan_url` между записью и повторным чтением, из-за
|
||||
которой `prepareImage` не готов вовремя, — ровно тот случай, который смок
|
||||
`demo/smoke_post_write_adoption.mjs` воспроизводит для успешного пути
|
||||
(`assetReady` всегда `true` в фикстуре). При отказе `prepareImage`
|
||||
(например, временная сетевая ошибка на media-source) сервер уже необратимо
|
||||
выполнил операцию (space удалено / undo применён / импорт применён), а
|
||||
карточка: (а) для Undo — показывает тост «отменено» и гасит аффорданс Undo,
|
||||
хотя видимая модель ещё старая (пост-оптимизационная, не отменённая) —
|
||||
несовпадение тоста с фактическим состоянием экрана; (б) для всех четырёх —
|
||||
пересобирает устройства/кэширует снимок по **старой** (неадоптированной)
|
||||
идентичности, хотя гейт был явно введён, чтобы не показывать переходное/
|
||||
рассинхронизированное состояние. Ретрай внутри `adoptAuthoritativeGated`
|
||||
рано или поздно подтянет верные данные, поэтому дефект самоисцеляющийся и не
|
||||
портит серверные данные — отсюда Medium, а не High.
|
||||
|
||||
**Чем красит/красится.** Ни `test/config-adoption.test.mjs`, ни
|
||||
`demo/smoke_post_write_adoption.mjs` не подают `assetReady: false` ни в один
|
||||
из четырёх post-write сценариев — ветка `status !== 'adopted'` для
|
||||
post-write профиля ни разу не проходится ни юнитом, ни смоком. Убедиться
|
||||
легко: временная правка `card._signer.prepareImage = async () => false;`
|
||||
перед любым из четырёх вызовов в `demo/smoke_post_write_adoption.mjs`
|
||||
сегодня не роняет ни одну из существующих проверок смока (они не смотрят на
|
||||
это состояние) — то есть отсутствие проверки подтверждено, а не
|
||||
предположено.
|
||||
|
||||
**Требуется:** привести хвосты `_deleteSpace` (оба входа),
|
||||
`_undoPlanOptimization`, `_applyBackupImport` к тому же правилу, что уже
|
||||
работает в reload-профиле — полностью пропускать хвост (закрытие диалога,
|
||||
очистки, тост, `_cacheSnapshot`, выбор пространства) при
|
||||
`adopted.status !== 'adopted'`, либо явно обосновать в ТЗ/ревью, почему
|
||||
post-write хвост обязан выполняться даже без структурного adoption — и
|
||||
покрыть выбранное поведение тестом/смоком, который умеет упасть на снятой
|
||||
защите. Находка в скоупе задачи (это ровно те четыре пути, которые ТЗ §6.3
|
||||
называет объектом гейта) — чинится в этом же issue, второй ревью-цикл не по
|
||||
лимиту §4, а по итогу вердикта.
|
||||
|
||||
### Low-1 (снимается записью, не требует правки). Риск-таблица ТЗ §12 содержит неверный факт о `static properties`, но код всё равно корректен
|
||||
|
||||
ТЗ (§12) утверждает: «`_serverCfg`, `_layout`, `_cfgRev` и fingerprint не
|
||||
входят в `static properties` хоста (проверено на `a824acc1`)». Проверка
|
||||
чтением `git show a824acc1:src/houseplan-card.ts` показывает, что `_layout`
|
||||
и `_serverCfg` **были** объявлены `{ state: true }` уже на этом коммите (до
|
||||
#500) — то есть до задачи они были обычными реактивными Lit-полями без
|
||||
собственных аксессоров, и замена их на кастомные getter/setter действительно
|
||||
меняла контракт реактивности (Lit пропускает генерацию своего аксессора,
|
||||
когда `prototype.hasOwnProperty(name)` истинно). Утверждение спеки было
|
||||
неверным в момент зелёного ревью ТЗ r1–r3 — но реальный код это не сломало
|
||||
благодаря отдельному фикс-коммиту `0905f67e` (`onBodyReplaced` →
|
||||
`requestUpdate`), написанному именно потому, что red Validate на `c360bcc9`
|
||||
поймал регрессию `smoke_room_resize` от этого же факта. Итог: код корректен и
|
||||
подтверждён смоком/юнитом, найденная неточность — в тексте уже принятого ТЗ,
|
||||
которое не переоткрывается этим ревью. Фиксирую как Low без блокировки.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Модуль `src/config-adoption.ts`: ссылочная идентичность (I4), приём
|
||||
ревизии только с телом (I2), эхо не трогает историю/эпоху (I3) — все три
|
||||
инварианта подтверждены и чтением, и тестами/мутантами.
|
||||
- Реактивный контракт `onBodyReplaced` → `requestUpdate(field, previous)`
|
||||
корректно воспроизводит поведение снятого Lit-аксессора; `willUpdate`
|
||||
(`houseplan-card.ts:4111`) по-прежнему держит инвариант эпохи геометрии.
|
||||
- Сужение `SummaryPanelHost`/`HouseplanEditorHostPort` до одного метода —
|
||||
соответствует §6.4 дословно (проверено построчно).
|
||||
- `plan-optimize-write.ts`, `space-copy-runtime.ts`, `vacuum-calibration-write.ts`,
|
||||
`editors/vacuum-maps-section.ts` — переведены на `acceptPairWrite`/
|
||||
`beginOptimistic`/`stageLocalConfig`/`host._rollbackOptimistic`, прямых
|
||||
записей идентичности не осталось (подтверждено AC1-тестом и ручным grep).
|
||||
- `serialized-write-queue.ts` лишился `rollbackOptimistic`; единственный
|
||||
вызывающий отката — `houseplan-card.ts` (`_rollbackOptimistic`), как того
|
||||
требует AC2-тест.
|
||||
- Бюджеты AC7: факт (13605 строк, 299 771 Б) внутри новых потолков с
|
||||
датированной записью причины пересчёта.
|
||||
- Трейлеры всех десяти коммитов: `Issue: #500` + `User-Visible: no`,
|
||||
changelog не тронут — согласовано с `User-Visible: no`.
|
||||
- `docs/ARCHITECTURE.md` (AC8) описывает границу фактически точно.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- Полный набор из 47 «прямых совпадений» `smoke-select` — прогнал только те,
|
||||
что напрямую относятся к изменённому поведению (adoption/rollback/
|
||||
reactivity), остальные читают делегируемые поля так же, как раньше;
|
||||
полный набор — предрелизный гейт (§8).
|
||||
- `npm run invariants` — диффом не задета геометрия/толщина/ссылки, только
|
||||
владение ревизией и телом.
|
||||
- `python -m pytest tests_backend` — backend не тронут диффом.
|
||||
- `golden:verify`/`performance_smoke` — визуал и перф-профили не в AC и не
|
||||
меняются по диффу (подтверждено ТЗ §7/§14 и составом diff: только `src/**`
|
||||
frontend, никаких изменений в рендер-путях).
|
||||
- Собственный повторный прогон `tsc`/`npm test`/`npm run build` — не
|
||||
дублировал: зелёный Validate подтверждён на точном материале ревью через
|
||||
`gh run view` (headSha сверен).
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- SHA материала: `0905f67ec0d8fb830065e909988f442c884320c2`
|
||||
- Дерево: рабочая копия уже на этом SHA (`git rev-parse HEAD` совпадает)
|
||||
- ТЗ: `docs/specs/500-config-adoption-boundary.md`, зелёное ревью
|
||||
`docs/reviews/SPEC-REVIEW-500-r3.md` на коммите `622470ff`
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/500-config-adoption-boundary`, коммит `0905f67ec0d8` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `5c8ab48044237c58112ba21b6d38a8de22a371c1`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 5c8ab4804423
|
||||
```
|
||||
- Тело issue: `d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5`
|
||||
- ТЗ `docs/specs/500-config-adoption-boundary.md`, блоб `892240ddf10b28b483d865aa21e7132a1c0b7aaa`
|
||||
```
|
||||
git log --all --find-object=892240ddf10b28b483d865aa21e7132a1c0b7aaa -- docs/specs/500-config-adoption-boundary.md
|
||||
```
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,228 @@
|
||||
# CODE-REVIEW-500-r2
|
||||
|
||||
- **Issue:** #500 — Архитектура: выделить одну границу владения config/adoption
|
||||
- **Материал:** `93c6c7551b3a7f3df1901e69888611b1a7375ac4` (ветка `issue/500-config-adoption-boundary`); рабочая копия была на этом SHA на момент старта ревью
|
||||
- **Заход:** r2 · блокирующих циклов израсходовано 1/4 до этого раунда
|
||||
|
||||
## Почему разбор полный, а не по дельте
|
||||
|
||||
Конвейер привёл ветку к `dev` перед ревью: поверх материала r1 (`0905f67e` / его копия после переноса истории — `da9a58bf`) легло три коммита `dev`
|
||||
(`f6eac1d9`, `67c780a7`, `2783ceff` — перестройка джоба «Мутанты по диффу» на 6 шардов с предварительным планированием, #518). После ребейза это
|
||||
другой код (PROCESS.md §7.2), поэтому разбор в этом раунде — полный, не ограниченный дельтой r1→r2.
|
||||
|
||||
Фактическая проверка: `git diff da9a58bf 93c6c755` (вне `dist/**` и бандл-деревьев) показывает, что содержимое самой задачи №500
|
||||
(`src/**`, `test/**`, `docs/specs/**`) **не изменилось** — ребейз принёс исключительно инфраструктурные правки `.github/workflows/validate.yml`,
|
||||
`scripts/mutation-gate.mjs`, `PROCESS.md`, `test/mutation-gate.test.mjs`, `test/validate-*.test.mjs` (#518, не связано с #500). Правки самой
|
||||
задачи r1→r2 — единственный коммит `93c6c755` (тот же, что `da9a58bf`, с иным родителем), закрывающий Medium-1 r1. Несмотря на это, ниже
|
||||
разобран весь диапазон `origin/dev...HEAD` (34 нестандартных файла, ~2864/-396 строк), а не только коммит M1-фикса — так требует правило
|
||||
полного разбора при ребейзе, и по факту в ходе разбора это окупилось (см. находку M1-2 ниже, обнаруженную независимым прогоном, а не чтением диффа).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Извлечение владения идентичностью `{config, rev, fingerprint} × {layout, rev, fingerprint}` в `src/config-adoption.ts`; единая
|
||||
гейт-последовательность `adoptAuthoritativeGated` (compare → prepareImage → continuity → adopt → хвост профиля) для семи вызывающих в
|
||||
двух профилях (`reload`/`post-write`); сужение `SummaryPanelHost` и host-контрактов editor-/onboarding-runtime; перенос `rollbackOptimistic`
|
||||
в модуль; единственное намеренное изменение поведения — гейт готовности фона на четырёх post-write путях, ревизии `space/delete` берутся из
|
||||
`config/get`/`layout/get`, а не из ответа `delete`. `User-Visible: no` на всех двенадцати коммитах диапазона, changelog не тронут (подтверждено
|
||||
диффом — ни один файл `docs/CHANGELOG*.md` не изменён).
|
||||
|
||||
M1-фикс этого раунда (`93c6c755`): все четыре post-write вызывающих (`_deleteSpace` × 2 рантайма, `_undoPlanOptimization`, `_applyBackupImport`)
|
||||
теперь при `adopted.status !== 'adopted'` полностью пропускают хвост (тост, выбор пространства, очистки, `_cacheSnapshot`), освобождают `busy`
|
||||
своего диалога и выходят — как это уже делали три reload-пути.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
**Дешёвые гейты — не гонял заново отдельно, но перепроверил напрямую.** Validate на точном материале (`93c6c755`) зелёный целиком:
|
||||
https://github.com/Matysh/houseplan-card/actions/runs/34472616328 (`headSha` подтверждён). Из осторожности (полный разбор) всё равно
|
||||
прогнал сам и получил тот же результат: `npx tsc -p tsconfig.test.json` + `npm run build` (tsc --noEmit + rollup) — чисто; `npm test` —
|
||||
**2508 pass / 0 fail / 1 skip**; `npm run bundle:sync` — дерево `dist`/`custom_components/houseplan/frontend`/`demo/srv/assets` совпадает
|
||||
(рабочая копия чистая после сборки, `git status --short` пуст); `npm run bundle:budget` — initial View 299 774 Б (потолок 300 300 ±2000,
|
||||
запас budget 1292 Б, известное предупреждение низкого запаса #367/#474, не блокирует); `node scripts/no-new-any.mjs` — 0 новых `any` на
|
||||
596 добавленных строках `src/**`. `node --test test/core-file-budget.test.mjs` — 7/7, `houseplan-card.ts` 13605 строк < потолка 13650.
|
||||
|
||||
**ВАЖНО, разошлось с записью в хендоффе (Matysh, комментарий #12):** «Мутанты по диффу» и «Смоки в браузере» в прогоне `34472616328`
|
||||
имеют статус **`skipped`**, не `success` — это обычный push без `Release:`, тяжёлые джобы на нём не запускаются (см. PROCESS.md §8, ожидаемо).
|
||||
Формулировка «Validate зелёный целиком» в контексте этой задачи верна буквально (все незапущенные джобы = `skipped`, это не красный), но
|
||||
**не покрывает** ни один из четырёх мутантов-защитников (`config-adoption-echo-clears-history`, `config-adoption-rev-from-foreign-response`,
|
||||
`config-adoption-rollback-ignores-rev`, `post-write-skips-asset-gate`), ни браузерные смоки. Прогнал их лично:
|
||||
|
||||
```
|
||||
node scripts/mutation-gate.mjs --id=config-adoption-echo-clears-history → поймано 1 из 1
|
||||
node scripts/mutation-gate.mjs --id=config-adoption-rev-from-foreign-response → поймано 1 из 1
|
||||
node scripts/mutation-gate.mjs --id=config-adoption-rollback-ignores-rev → поймано 1 из 1
|
||||
node scripts/mutation-gate.mjs --id=post-write-skips-asset-gate → поймано 1 из 1
|
||||
```
|
||||
|
||||
Гард всех четырёх мутантов — `node --test test/config-adoption.test.mjs` (юнит), не браузерный смок; это подтверждает наблюдение r1-ревью,
|
||||
что `demo/smoke_post_write_adoption.mjs` не зарегистрирован гардом ни одного мутанта и, значит, ни разу не проверялся автоматически на
|
||||
материале ревью ни в r1, ни здесь.
|
||||
|
||||
**Браузерные смоки — прогнал сам (Chromium доступен в этой среде).** Собрал бандл (`npm run build && npm run bundle:sync`, дерево чистое)
|
||||
и прогнал по прямым совпадениям diff'а (`node scripts/smoke-select.mjs --base origin/dev --head HEAD` → 48 прямых совпадений, порог
|
||||
«широкого» символа — 47) те, что относятся к изменённому поведению (adoption/rollback/reactivity/M1), а не ко всем 48 — полный прогон
|
||||
остаётся предрелизным гейтом (PROCESS.md §8):
|
||||
|
||||
| Смок | Результат |
|
||||
|---|---|
|
||||
| `demo/smoke_post_write_adoption.mjs` (AC4, единственный прямой свидетель, переписан этим раундом) | **КРАСНЫЙ** — 1 проверка из 36 падает, см. находку ниже |
|
||||
| `demo/smoke_room_resize.mjs` (свидетель реактивности `onBodyReplaced`, красневший на предыдущем кандидате r1) | OK |
|
||||
| `demo/smoke_summary_panel.mjs` (lost-ACK/summary recovery через `_adoption`) | OK |
|
||||
| `demo/smoke_danger_confirmation.mjs` (глушит `_adoptAuthoritative`) | OK |
|
||||
| `demo/smoke_device_position_history.mjs` (тронут диффом, автор не прогонял — «нет Chromium») | OK |
|
||||
| `demo/smoke_area_relocation_safety.mjs` (тронут диффом, автор не прогонял) | OK |
|
||||
|
||||
Остальные 42 прямых совпадения не прогонял — по диффу они читают делегируемые поля (`_cfgRev`/`_layoutRev`/`_model`/`_serverCanWrite` и т.п.)
|
||||
так же, как раньше (делегаты не меняют семантику чтения), не изменённое поведение; это тот же критерий отбора, что применил r1.
|
||||
|
||||
**Инварианты модели** — не запускал: диффом задета идентичность (revision/fingerprint/owner), не геометрия рёбер/толщины/ссылок; ни
|
||||
`layout` (структура устройств), ни `rooms`/`wall_segments`/`marker.space` не меняют формат или содержимое, меняется только то, кто и когда
|
||||
пишет их идентичность.
|
||||
|
||||
**pytest/golden/perf** — не задеты диффом (backend не тронут ни одним файлом; визуал не меняется — ни один файл рендера/стилей в диффе);
|
||||
не гонял, как и в r1.
|
||||
|
||||
**Один источник числа** — не применимо: `User-Visible: no`, пользователю ничего не показывается; единственные числа в диффе — внутренние
|
||||
бюджеты сборки (см. AC7), они не дублируются в интерфейсе.
|
||||
|
||||
## Находка
|
||||
|
||||
### Medium-1 (в скоупе задачи, новая в r2). Единственный автоматический свидетель AC4/M1 красный на материале ревью — `demo/smoke_post_write_adoption.mjs` падает на `onboardingDeleteRefusedAdoptsNothing`, и ни один гейт CI это не поймал
|
||||
|
||||
**Воспроизведение (детерминированно, 3/3 прогонов подряд):**
|
||||
|
||||
```
|
||||
npm run build && npm run bundle:sync
|
||||
node demo/smoke_post_write_adoption.mjs
|
||||
# ...
|
||||
# FAILED (1):
|
||||
# - onboardingDeleteRefusedAdoptsNothing: expected true, got false
|
||||
```
|
||||
|
||||
**Что происходит.** После M1-фикса смок расширен сценариями с `assetReady=false` (проверка отказной ветки, ровно то, что требовала r1
|
||||
находка). Для сценария «удаление пространства через onboarding-рантайм при отказе гейта» проверка `card._cfgRev === 10 && card._layoutRev
|
||||
=== 20 && card._serverCfg.spaces.length === 2 && ...` не проходит: `card._cfgRev` равен **11**, хотя adoption корректно отказал (в `order`
|
||||
нет `'adopt'`, `retries === 1`, тостов нет, диалог освобождён — то есть сам M1-фикс отработал правильно).
|
||||
|
||||
**Причина (доказана чтением и инструментированным прогоном, не догадкой).** `reset()` в смоке (строки 82–93) вызывает
|
||||
`adoption.restoreCached(...)`, но не отменяет отложенные записи — `_saveConfigDebounced`/`_persistLayout` (существовавший до #500
|
||||
механизм; другие смоки, например `demo/smoke_danger_confirmation.mjs:196`, явно вызывают `.cancel()` для этой же гигиены). Если к
|
||||
моменту очередного сценария есть **не связанная с ним** отложенная запись конфига (в этом прогоне она возникает во время предыдущего
|
||||
успешного `editorDelete`/аналогичного сценария), `_deleteSpace()`/`_applyBackupImport()` в начале своего `try`-блока безусловно вызывают
|
||||
`this.host._saveConfigDebounced.flush()` (код, не относящийся к #500, не изменён этой задачей) — что немедленно шлёт `houseplan/config/set`
|
||||
через фейковый `card.hass.callWS`. Смок не обрабатывает этот тип сообщения (`default: return {}`), ответ приходит без `rev`, и
|
||||
`MutableConfigAdoption.acceptConfigWrite` (одна из трёх санкционированных точек записи ревизии, I1) применяет документированный запасной
|
||||
вариант «`rev ?? rev + 1`» (§15 п.3 ТЗ) — **к чужому, неотносящемуся к текущему сценарию телу**. Ревизия увеличивается на 1 (10 → 11)
|
||||
именно в тот момент, когда `onboardingDelete-refused` уже сбросил состояние через `restoreCached`. Поскольку эта конкретная итерация —
|
||||
refused (гейт отказывает), собственный adoption сценария так и не перезаписывает испорченную ревизию — она доживает до финальной проверки.
|
||||
|
||||
Подтверждено инструментированным прогоном (обёртка `acceptConfigWrite`/`restoreCached` во временной копии смока вне репозитория,
|
||||
рабочая копия репозитория не менялась — `git status --short` пуст на всём протяжении проверки): без правки — `cfgRev: 11` и
|
||||
трасса показывает лишний `acceptConfigWrite({...}, {})` между двумя `restoreCached`; с добавлением `card._saveConfigDebounced.cancel();
|
||||
card._persistLayout.cancel();` в `reset()` — трасса чистая (0 лишних вызовов), все четыре refused-сценария дают `cfgRev: 10` корректно.
|
||||
|
||||
**Это дефект теста, не продукта.** С реальным backend `houseplan/config/set` всегда возвращает `rev` (см. `custom_components/houseplan/
|
||||
websocket_api.py:1587-1637`, ответ без `expected_rev`/`rev` — это `conflict`, не успех); запасной вариант `+1` уже задокументирован в ТЗ
|
||||
§15 п.3 как «с реальным backend недостижимо» и не входит в скоуп этой задачи. Сам M1-фикс (ранний `return`/пропуск хвоста во всех
|
||||
четырёх вызывающих) при этом подтверждён верным — как прямым чтением диффа (см. таблицу AC ниже), так и тем, что ТРИ из четырёх
|
||||
refused-сценариев смока (`editorDelete`, `optimizeUndo`, `importApply`) проходят корректно на этом же материале и том же прогоне.
|
||||
|
||||
**Почему Medium, а не Low.** Это не стилистическая придирка: единственный автоматический свидетель, который должен доказывать закрытие
|
||||
r1-находки M1 для конкретно этого пути (`_deleteSpace` в onboarding-рантайме), **красный на материале, поданном на ревью**, и ни один
|
||||
CI-гейт этого не заметил — «Мутанты по диффу» и «Смоки в браузере» на этом SHA `skipped` (обычный push), а гард всех четырёх мутантов —
|
||||
юнит-тест, не этот смок. Без ручной проверки (эта проверка — «работает ли оно» и есть мандат ревьюера) дефект теста ушёл бы в `dev`
|
||||
непойманным, а следующий раунд, который тронет этот файл, унаследовал бы красный тест молча.
|
||||
|
||||
**Требуется:** добавить `card._saveConfigDebounced.cancel(); card._persistLayout.cancel();` (или эквивалентную изоляцию) в `reset()`
|
||||
смока `demo/smoke_post_write_adoption.mjs`, перепрогнать все 36 проверок до зелёного, приложить результат. Правка не продуктового кода —
|
||||
только `demo/**`, но всё равно проходит ещё один цикл ревью (правило §4: Medium в скоупе чинится и перепроверяется).
|
||||
|
||||
## AC → повторно проверено на материале r2
|
||||
|
||||
| AC | Статус | Как проверено в этом раунде |
|
||||
|---|---|---|
|
||||
| AC1 (идентичность пишет только модуль) | Подтверждено | `node --test test/config-adoption-ownership.test.mjs` (5/5), плюс независимый grep `_cfgRev\s*=`/`_layoutRev\s*=` вне `config-adoption.ts` — ноль совпадений |
|
||||
| AC2 (семь путей через `adoptAuthoritativeGated`, старый seam не встречается) | Подтверждено | `grep -rl _adoptStructuralResponses src/**/*.ts` → только `config-adoption.ts` (внутренняя функция без подчёркивания); typecheck зелёный |
|
||||
| AC3 (поведенческая нейтральность, post-write без пост-шагов) | Подтверждено | `node --test test/config-adoption.test.mjs` (25/25); `smoke_summary_panel`, `smoke_danger_confirmation`, `smoke_room_resize` — зелёные, прогнаны лично |
|
||||
| AC4 (post-write гейт + ревизии из re-read, включая отказную ветку) | **Частично** — код верен (прочитан построчно, все 4 вызывающих симметрично пропускают хвост), но единственный автоматический свидетель красный на материале (Medium-1) | `demo/smoke_post_write_adoption.mjs` — 35/36; unit `post-write profile: a refused gate adopts nothing…` (r1 M1) зелёный |
|
||||
| AC5 (тёплый старт round-trip) | Подтверждено (унаследовано, не задето диффом раунда) | `test/config-adoption.test.mjs` round-trip кейсы в общем прогоне 25/25 |
|
||||
| AC6 (optimistic rollback) | Подтверждено (унаследовано) | мутант `config-adoption-rollback-ignores-rev` — поймано 1/1 лично |
|
||||
| AC7 (бюджеты) | Подтверждено, факт обновлён этим раундом | `npm run bundle:budget` → 299 774 Б, совпадает с записью в `scripts/bundle-budget.mjs`; `core-file-budget` 13605 < 13650 |
|
||||
| AC8 (документация) | Подтверждено | `docs/ARCHITECTURE.md` абзац о границе присутствует и фактически точен (сверен построчно) |
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Сам M1-фикс: все четыре post-write вызывающих (`_deleteSpace` в `houseplan-editor-runtime.ts:8691-8697` и
|
||||
`houseplan-onboarding-runtime.ts:477-489`, `_undoPlanOptimization:9575-9576`, `_applyBackupImport:9735-9736`) симметрично проверяют
|
||||
`adopted.status !== 'adopted'` и делают ранний `return` до любого хвоста (тост/выбор пространства/очистки/кэш); для `_undoPlanOptimization`
|
||||
`finally` по-прежнему снимает `_optimizeUndoBusy` — подтверждено чтением управления потоком, `return` внутри `try` гарантированно
|
||||
проходит `finally`.
|
||||
- `adoptAuthoritativeGated` (`src/config-adoption.ts:415-446`) — единая точка, гейт `prepareImage` вызывается только при
|
||||
`structuralChanged`, на отказе не адоптирует ничего и планирует `_scheduleLoadRetry(true)`; корректно для обоих профилей.
|
||||
- Три из четырёх refused-сценариев смока (`editorDelete`, `optimizeUndo`, `importApply`) реально доказывают отказную ветку своих путей —
|
||||
не только код прочитан, но и тест прошёл на живом прогоне.
|
||||
- Ревизии из delete-ответа больше нигде не читаются (`grep` по `config_rev`/`layout_rev` в теле `space/delete` подтверждает — оба входа
|
||||
используют только `config/get`/`layout/get`).
|
||||
- Бюджеты и trailers всех 12 коммитов диапазона (`Issue: #500`, `User-Visible: no`) — в порядке; `docs/CHANGELOG*.md` не тронуты, что
|
||||
согласовано с `User-Visible: no`.
|
||||
- Три коммита `dev`, принесённые ребейзом (#518, мутанты по диффу на 6 шардов с предварительным планированием), не пересекаются по файлам
|
||||
с продуктовым кодом #500 (кроме общего `scripts/mutation-gate.mjs`, куда #500 лишь добавляет свои 4 определения мутантов в конец
|
||||
массива — конфликта семантики нет, все 4 подтверждены пойманными лично).
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- Полный набор 48 прямых совпадений `smoke-select` — прогнал 6, относящихся к изменённому поведению; остальные 42 диффом не меняют
|
||||
семантику чтения (та же логика отбора, что в r1). Полный набор — предрелизный гейт (PROCESS.md §8).
|
||||
- `npm run invariants` — диффом не задета геометрия рёбер/толщины/ссылок, только владелец ревизии и тела.
|
||||
- `python -m pytest tests_backend` — backend не тронут диффом.
|
||||
- `golden:verify`/`performance_smoke` — визуал и перф-профили не в AC и не меняются по диффу.
|
||||
- `git diff da9a58bf 93c6c755` за пределами `src/**`/`test/**`/`docs/specs/**` (т.е. сами принесённые ребейзом инфра-файлы `validate.yml`,
|
||||
`mutation-gate.mjs` §518-часть, `PROCESS.md`) — не проверял по существу: это код `dev`, не материал задачи #500, он уже прошёл свой
|
||||
собственный процесс в `dev`.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| Medium-1 (все четыре post-write вызывающих не пропускают хвост при `asset-wait`, отказная ветка не тестируется) | Коммит `93c6c755` добавляет ранний `return`/скип хвоста во всех четырёх вызывающих + 1 юнит + 16 новых проверок смока на отказную ветку | `src/houseplan-editor-runtime.ts:8694,9575,9736`, `src/houseplan-onboarding-runtime.ts:480`; `test/config-adoption.test.mjs` тест «post-write profile: a refused gate adopts nothing…»; смок расширен, но красный на 1/36 — см. Medium-1 этого раунда (продуктовый код верен, тест — нет) |
|
||||
| Low-1 (риск-таблица ТЗ §12 неточна про `static properties`) | Строка риска переписана на фактический механизм (`onBodyReplaced` → `requestUpdate`) | `docs/specs/500-config-adoption-boundary.md`, диф в `93c6c755` |
|
||||
|
||||
## Унаследовано из r1 (только то, что дельта r1→r2 не задевает содержательно)
|
||||
|
||||
Несмотря на полный разбор кода в этом раунде (обязателен из-за ребейза, см. выше), ниже — фактические выводы r1, которые я **не
|
||||
пересчитывал заново с нуля**, а верифицировал спот-чеками, совпавшими с r1 буквально (то есть не «доверие на слово», а подтверждение):
|
||||
|
||||
- AC1/AC2 точные числа (18 присваиваний `_serverCfg =` в 8 модулях до переноса, allowlist после переноса) — r1 пересчитал вручную по
|
||||
`src/**`; в этом раунде перепроверено через `test/config-adoption-ownership.test.mjs` (не переписан диффом M1-фикса) и независимый grep,
|
||||
оба совпадают с выводом r1. Документ r1: `docs/reviews/CODE-REVIEW-500-r1.md`, материал `0905f67ec0d8`.
|
||||
- AC5 (тёплый старт round-trip) и AC6 (rollback) логика — код `config-adoption.ts` в этих методах не тронут коммитом `93c6c755` (diff
|
||||
только в `adoptAuthoritativeGated`/асимметрии post-write-вызывающих и bundle-budget-комментарии); r1 проверил их чтением и мутантами,
|
||||
здесь перепроверено повторным прогоном тех же мутантов на новом SHA — совпадает.
|
||||
- Сужение `SummaryPanelHost`/`HouseplanEditorHostPort` (§6.4 ТЗ) — не задето диффом M1-фикса; r1 сверил построчно, здесь не
|
||||
пересматривалось заново, только подтверждено отсутствием изменений в `git diff da9a58bf 93c6c755 -- src/summary-panel-host.ts`.
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- SHA материала: `93c6c7551b3a7f3df1901e69888611b1a7375ac4`
|
||||
- Дерево: рабочая копия была на этом SHA на момент начала ревью (`git rev-parse HEAD` совпадал); после сборки/тестов дерево осталось
|
||||
чистым (`git status --short` пуст)
|
||||
- ТЗ: `docs/specs/500-config-adoption-boundary.md`, откорректирован в этом же диапазоне коммитом `93c6c755` (риск-таблица §12, допущение
|
||||
§15 п.8); зелёное спек-ревью `docs/reviews/SPEC-REVIEW-500-r3.md`
|
||||
- Предыдущий код-ревью: `docs/reviews/CODE-REVIEW-500-r1.md`, материал `0905f67ec0d8`, вердикт `yellow · High 0 · Medium 1`
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/500-config-adoption-boundary`, коммит `93c6c7551b3a` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `4967ea22e3c4465c17aab7e4ed7bc929f13acc38`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 4967ea22e3c4
|
||||
```
|
||||
- Тело issue: `d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5`
|
||||
- ТЗ `docs/specs/500-config-adoption-boundary.md`, блоб `b7b4f02c6ca5a57092a360f1f664656b62ed968c`
|
||||
```
|
||||
git log --all --find-object=b7b4f02c6ca5a57092a360f1f664656b62ed968c -- docs/specs/500-config-adoption-boundary.md
|
||||
```
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,174 @@
|
||||
# CODE-REVIEW-500-r3
|
||||
|
||||
- **Issue:** #500 — Архитектура: выделить одну границу владения config/adoption
|
||||
- **Материал:** `be6d57e96f425ceefa7ce5888785ac522e277b26` (ветка `issue/500-config-adoption-boundary`); рабочая копия уже была на этом SHA
|
||||
- **Заход:** r3 · блокирующих циклов израсходовано 2/4 до этого раунда
|
||||
|
||||
## Почему разбор по дельте, а не заново
|
||||
|
||||
Материал и SHA предыдущего раунда объявлены в `docs/reviews/CODE-REVIEW-500-r2.md`: `93c6c7551b3a7f3df1901e69888611b1a7375ac4`. Этот SHA
|
||||
живой и является прямым предком текущего `HEAD` (`git merge-base --is-ancestor 93c6c755 be6d57e9` → да; цепочка `93c6c755 → 5acf04fa
|
||||
(docs: review document for #500) → be6d57e9` линейна, без ребейза). Дельта:
|
||||
|
||||
```
|
||||
git diff 93c6c755..be6d57e9 --stat
|
||||
demo/smoke_post_write_adoption.mjs | 7 ++
|
||||
docs/reviews/CODE-REVIEW-500-r2.md | 228 +++++++++++++++++++++++++++++++++++++ (документ предыдущего раунда, не код)
|
||||
scripts/mutation-gate.mjs | 14 +++
|
||||
```
|
||||
|
||||
Один коммит реализации (`be6d57e9`) поверх doc-коммита `5acf04fa`. Продуктовый код (`src/**`) дельта не трогает вообще — только `demo/**`
|
||||
(смок) и `scripts/**` (реестр мутантов), то есть class B по AGENTS.md. Ребейза на ушедший вперёд `dev` не было, контракт поведения не
|
||||
менялся, новая подсистема не затронута, объём дельты (21 добавленная строка) на порядки меньше исходной задачи — все условия §2.10 для
|
||||
разбора по дельте выполнены; полный разбор не требуется.
|
||||
|
||||
## Скоуп дельты
|
||||
|
||||
Единственная находка r2 (Medium-1: `demo/smoke_post_write_adoption.mjs` детерминированно красный на материале ревью,
|
||||
`onboardingDeleteRefusedAdoptsNothing: expected true, got false`, причина — чужая отложенная запись `_saveConfigDebounced`/
|
||||
`_persistLayout` из предыдущего сценария доживает до следующего). Ответ автора («Ответ на код-ревью r2 — заход 3»): `reset()` смока
|
||||
теперь отменяет обе отложенные записи в начале, плюс добавлен мутант `post-write-tail-runs-on-refused-gate` с гардом на этот смок, чтобы
|
||||
witness больше не мог оставаться незапущенным CI незамеченным (ровно то, что произошло на r1→r2: witness был красным целый раунд, и ни
|
||||
один гейт CI его не поймал, потому что ни один мутант не называл его своим гардом).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
**Дешёвые гейты — подтверждены Validate на точном материале ревью, не перегонял отдельно.**
|
||||
https://github.com/Matysh/houseplan-card/actions/runs/34476048247, `headSha` = `be6d57e96f425ceefa7ce5888785ac522e277b26` (сверено
|
||||
`gh run view --json headSha`) — `success`. В этом прогоне джоб «Мутанты по диффу» **не был `skipped`** (в отличие от r2): это ревью-кандидат
|
||||
(метка `S7-code-review` запускает Validate с мутантами по диффу на самом материале, PROCESS.md §2.7/AGENTS.md), поэтому прогон покрывает не
|
||||
только `tsc --noEmit`/`npm test`/`npm run build`+bundle-sync, но и мутанты. Прочитал логи всех 6 шардов (`gh run view --job <id> --log`),
|
||||
не только факт green:
|
||||
|
||||
```
|
||||
Шард 3/6, лог 102867009880:
|
||||
ok чистый прогон: node demo/smoke_post_write_adoption.mjs
|
||||
ok post-write-tail-runs-on-refused-gate: тест покраснел, как обязан
|
||||
```
|
||||
|
||||
То есть CI сам подтвердил и чистый прогон смока (зелёный), и что новый мутант, снимающий ранний `return` в `_undoPlanOptimization`, красит
|
||||
именно этот смок — то есть witness теперь реально запускается конвейером и реально ловит регрессию, чего не было в r1/r2.
|
||||
|
||||
Дополнительно к CI-логам прогнал сам (Chromium в этой среде есть, `npm ci` не требовался):
|
||||
|
||||
```
|
||||
npm run build && npm run bundle:sync → дерево чистое (git status --short пуст)
|
||||
node demo/smoke_post_write_adoption.mjs × 3 подряд → OK, все 36 проверок true (все три прогона идентичны)
|
||||
```
|
||||
|
||||
**Негативная проба (тест умеет падать) — воспроизвёл сам, не полагаясь на слово автора.** Временно убрал ровно те две строки
|
||||
(`card._saveConfigDebounced.cancel(); card._persistLayout.cancel();`) из `reset()` в рабочей копии, прогнал смок, вернул файл на место:
|
||||
|
||||
```
|
||||
FAILED (1):
|
||||
- onboardingDeleteRefusedAdoptsNothing: expected true, got false
|
||||
```
|
||||
|
||||
Дословно совпадает с воспроизведением, описанным в находке r2 — причина найдена верно, фикс минимален и точно закрывает названный
|
||||
механизм, а не маскирует симптом. `git status --short` пуст после отката правки — рабочая копия не оставлена мутированной.
|
||||
|
||||
**Мутант `post-write-tail-runs-on-refused-gate` (`scripts/mutation-gate.mjs`)** — цель патча (`src/houseplan-editor-runtime.ts:9578`,
|
||||
`if (adopted.status !== 'adopted') return; // asset wait: the scheduled reload owns the tail`) уникальна в `src/**`
|
||||
(`grep -rn "adopted.status !== 'adopted'" src/houseplan-editor-runtime.ts src/houseplan-onboarding-runtime.ts` даёt 4 совпадения, но
|
||||
искомая строка с комментарием — ровно одна); `find`-строка мутанта совпадает с кодом дословно. Гард — тот же смок, что и находка;
|
||||
структурная валидность записи (порядок, отсутствие дублей) подтверждена зелёными `test/mutation-gate.test.mjs`/`test/mutation-gate-
|
||||
report.test.mjs` в том же CI-прогоне.
|
||||
|
||||
**Что не прогонял и почему.** `npm run invariants` — дельта не касается геометрии/толщины/ссылок, только тестовую гигиену и реестр
|
||||
мутантов. `python -m pytest tests_backend` — backend не в дельте. `golden:verify`/`performance_smoke` — визуал не меняется, дельта не
|
||||
рендер. `node scripts/check-docs.mjs` отдельно не гонял — дельта не трогает `src/**` (только `demo/**`/`scripts/**`), отпечаток
|
||||
скриншотов от неё не протухает; в Validate-прогоне `S7` docs-job не входит в набор джобов ревью-кандидата (полный `docs`-джоб — часть
|
||||
предполётных проверок, прошёл в этом же прогоне success). Полный `smoke-select` (48 прямых совпадений по прошлому раунду) не прогонял
|
||||
заново — дельта меняет один-единственный файл смока и реестр мутантов, остальные 47 совпадений её не касаются: это не изменение
|
||||
семантики читаемых полей, а изоляция сценариев внутри одного смока.
|
||||
|
||||
## Закрытие раунда r2
|
||||
|
||||
| Находка r2 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| Medium-1 (единственный автоматический свидетель AC4/M1, `demo/smoke_post_write_adoption.mjs`, детерминированно красный на материале ревью из-за не отменённых отложенных записей в `reset()`) | Коммит `be6d57e9` добавляет `card._saveConfigDebounced.cancel(); card._persistLayout.cancel();` в начало `reset()` | `demo/smoke_post_write_adoption.mjs:86-87`; подтверждено CI (`Мутанты по диффу 3/6`: чистый прогон ok) и лично (3/3 прогона OK, негативная проба красная на снятой правке) |
|
||||
| Сопутствующая дыра (witness не был зарегистрирован ни одним мутантом — CI не гонял его ни разу за весь r1/r2) | Новый мутант `post-write-tail-runs-on-refused-gate` с гардом на этот смок | `scripts/mutation-gate.mjs` (+14 строк); подтверждено CI-логом: `post-write-tail-runs-on-refused-gate: тест покраснел, как обязан` |
|
||||
|
||||
## AC → что изменилось в этом раунде
|
||||
|
||||
Только AC4 затронут дельтой (единственный незакрытый пункт из r2 — «код верен, свидетель красный»). Остальные AC дельта не задевает
|
||||
(дельта не трогает `src/**`).
|
||||
|
||||
| AC | Статус | Как проверено в этом раунде |
|
||||
|---|---|---|
|
||||
| AC4 (post-write гейт + ревизии из re-read, включая отказную ветку) | **Подтверждено полностью** | Единственный автоматический свидетель (`demo/smoke_post_write_adoption.mjs`) теперь зелёный и на материале ревью в CI, и в трёх личных прогонах; негативная проба лично воспроизведена и совпадает с диагнозом r2; новый мутант закрывает дыру «witness никогда не запускался» |
|
||||
|
||||
## Унаследовано из r2 (дельта не задевает — продуктовый код `src/**` не менялся)
|
||||
|
||||
- AC1 (идентичность пишет только модуль), AC2 (семь путей через `adoptAuthoritativeGated`) — код `src/**` не тронут дельтой r2→r3;
|
||||
документ r2 перепроверил их чтением и тестом `config-adoption-ownership.test.mjs`. Документ: `docs/reviews/CODE-REVIEW-500-r2.md`,
|
||||
материал `93c6c7551b3a`.
|
||||
- AC3 (поведенческая нейтральность, профиль `post-write` без пост-шагов) — не задето, тот же материал r2.
|
||||
- AC5 (тёплый старт round-trip), AC6 (optimistic rollback) — код `config-adoption.ts` не в дельте; r2 подтвердил мутантами
|
||||
`config-adoption-rollback-ignores-rev` и повторным прогоном round-trip юнитов; в этом раунде мутант перепроверен CI на новом SHA (лог
|
||||
шарда, `тест покраснел, как обязан`) без изменения содержания.
|
||||
- AC7 (бюджеты) — `houseplan-card.ts`/бандл не менялись дельтой (diff-статистика: только `demo/**`+`scripts/**`); факты r2 (13605 строк,
|
||||
299 774 Б) остаются в силе, бюджет не пересчитывался, так как нечему.
|
||||
- AC8 (документация) — `docs/ARCHITECTURE.md` не в дельте.
|
||||
- Ревизии `space/delete` из `config/get`/`layout/get`, а не из ответа `delete` — продуктовая логика не в дельте, r2 подтвердил grep'ом.
|
||||
- M1-фикс r1 (четыре post-write вызывающих симметрично пропускают хвост при отказе гейта) — продуктовый код не в дельте r2→r3; r2
|
||||
перепроверил чтением построчно (`houseplan-editor-runtime.ts:8694,9575,9736`, `houseplan-onboarding-runtime.ts:480`), это не
|
||||
переоткрывается.
|
||||
- Материал/содержание задачи в целом (одна граница владения, единая гейт-последовательность, `plan-optimize-write`/`space-copy`/vacuum-
|
||||
писатели, сужение `SummaryPanelHost`/host-портов) — покрыто полным разбором r1 и повторным полным разбором r2 (ребейз на `dev` принёс
|
||||
инфраструктуру #518, не тронувшую #500), эта дельта их не касается.
|
||||
|
||||
## Что проверено и корректно (эта дельта)
|
||||
|
||||
- Фикс `reset()` изолирует сценарии смока так же, как уже делает `demo/smoke_danger_confirmation.mjs:196` — согласованный с остальной
|
||||
кодовой базой паттерн, не разовый хак.
|
||||
- Единственная затронутая строка продуктового кода — цель нового мутанта, не сам продуктовый код; дельта не меняет поведение карточки.
|
||||
- Новый мутант зарегистрирован единожды, без дублей, структура записи проходит `test/mutation-gate.test.mjs`/`test/mutation-gate-
|
||||
report.test.mjs`.
|
||||
- Trailers коммита `be6d57e9`: `Issue: #500`, `User-Visible: no` — верно (правка только `demo/**`+`scripts/**`, поведение карточки не
|
||||
меняется); changelog не тронут, согласовано.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- Полный набор `smoke-select` (48 совпадений) — дельта не меняет семантику чтения ни одного поля за пределами `smoke_post_write_
|
||||
adoption.mjs`; полный набор — предрелизный гейт (§8).
|
||||
- `npm run invariants`, `python -m pytest tests_backend`, `golden:verify`, `performance_smoke` — дельта не задевает геометрию, backend
|
||||
или рендер.
|
||||
- Полный повторный аудит продуктового кода #500 (`config-adoption.ts`, семь вызывающих, host-делегаты) — не задет дельтой, унаследован
|
||||
из r1/r2 (см. раздел выше), не пересматривался заново.
|
||||
- Собственный повторный прогон `tsc`/`npm test`/`npm run build` с нуля — не дублировал: Validate на точном материале ревью зелёный
|
||||
(headSha сверен), включая на этот раз и мутанты по диффу; сверх этого лично прогнал сам смок (3×) и негативную пробу.
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- SHA материала: `be6d57e96f425ceefa7ce5888785ac522e277b26`
|
||||
- Дерево: рабочая копия была на этом SHA весь разбор; после build/bundle-sync и временной негативной пробы дерево возвращено в исходное
|
||||
состояние (`git status --short` пуст)
|
||||
- Предыдущий код-ревью: `docs/reviews/CODE-REVIEW-500-r2.md`, материал `93c6c7551b3a7f3df1901e69888611b1a7375ac4`, вердикт
|
||||
`yellow · High 0 · Medium 1`
|
||||
- ТЗ: `docs/specs/500-config-adoption-boundary.md`, зелёное ревью `docs/reviews/SPEC-REVIEW-500-r3.md` на `622470ff`; дельта r2→r3 файл
|
||||
ТЗ не трогает
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка r2 (Medium-1) закрыта содержательно и подтверждена независимо: CI поймал новый мутант на смоке-свидетеле, я лично
|
||||
прогнал смок трижды (зелёный) и воспроизвёл красную пробу без фикса (совпадает дословно с диагнозом r2). Новых High/Medium/Low в дельте
|
||||
не найдено. AC4 — последний ранее не полностью закрытый пункт — подтверждён полностью. Зелёный.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/500-config-adoption-boundary`, коммит `be6d57e96f42` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `b756dee77c425861ccf68d44c06aa57a65336a78`
|
||||
```
|
||||
git log --all --format='%H %T' | grep b756dee77c42
|
||||
```
|
||||
- Тело issue: `d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5`
|
||||
- ТЗ `docs/specs/500-config-adoption-boundary.md`, блоб `b7b4f02c6ca5a57092a360f1f664656b62ed968c`
|
||||
```
|
||||
git log --all --find-object=b7b4f02c6ca5a57092a360f1f664656b62ed968c -- docs/specs/500-config-adoption-boundary.md
|
||||
```
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,74 @@
|
||||
# CODE-REVIEW — issue #502, заход r1
|
||||
|
||||
**Материал:** `441d3cc3de3dcf76446e4e914b72c16671494f83` (единственный коммит поверх `origin/dev`; ветка приведена к `dev` конвейером — до ребейза было `1d581fb4`, после — другой код, разбор ниже полный, не по дельте, как и предписано при таком переносе).
|
||||
|
||||
**Класс изменения:** B (гейты/тестовая инфраструктура) — `test/i18n-dead-keys.test.mjs` (правка) + `test/helpers/i18n-consumers.mjs` (новый файл). Продуктовый код (`src/**`) не тронут. `User-Visible: no`.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Задача сужает критерий «динамический потребитель i18n-ключа» в гейте `test/i18n-dead-keys.test.mjs`: конкатенация/шаблонная строка признаётся потребителем семьи ключей только если её статическая часть содержит `.` (разделитель `namespace.key`) и хотя бы одну букву. До правки `'r' + Date.now().toString(36)` превращался в `^r.+$` и маскировал реально мёртвые ключи на «r» (обнаружено на `radar.bad_references`, #485). Пользователь ничего не наблюдает — гейт защищает переводчика/владельца от накопления неиспользуемых переводов.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
- Прочитан диф целиком (`git diff origin/dev...HEAD`, 2 файла, +279/−68) и оба файла построчно.
|
||||
- Прочитано тело issue #502 целиком (проблема, ТЗ, контракт п.1–5, «принято предположительно», зависимости, таблица AC, откат) и все комментарии (S2 → S4-spec-review → зелёный вердикт спек-ревью r1 → кандидат реализации → красный Validate по внешней причине → возврат в S7 с объяснением).
|
||||
- Дешёвые гейты **не перегонялись повторно** — Validate на точном материале ревью (`441d3cc3`) зелёный: https://github.com/Matysh/houseplan-card/actions/runs/34445317690 (`headSha` подтверждён `gh run view`, `conclusion: success`). Job «Фронтенд: типы, юниты, мутанты, синхрон бандла» — success, три job'а «Мутанты по диффу» — success (мутант `i18n-dead-key-returns` пойман). HACS/hassfest/backend/smoke/golden/perf — `skipped`, что корректно: диф не затрагивает пути, от которых они зависят.
|
||||
- Тем не менее прогнан локально сам изменённый тестовый файл — это дешево и напрямую доказывает AC1–AC4:
|
||||
`node --test test/i18n-dead-keys.test.mjs` → 9/9 green, диагностика: `discarded 821 dynamic patterns; keys covered only by discarded patterns: none`.
|
||||
- `npx tsc --noEmit -p tsconfig.test.json` — чисто (новый `.mjs`-helper не ломает тестовый typecheck).
|
||||
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → «Исполняемого frontend-диффа нет (src/**/*.ts не тронут). Browser-smoke этим диффом не выбираются». Подтверждает, что смоки/golden/check-docs здесь не нужны — диф не трогает `src/**`.
|
||||
- Проверено чтением: `radar.bad_references` действительно используется как строковый литерал в `src/radar-setup.ts:334` (зависимость из раздела «Зависимости» ТЗ уже закрыта, #485 закрыт) — значит на реальном дереве список `DYNAMIC_KEY_FAMILIES` пуст обоснованно, а не по недосмотру.
|
||||
- Трейлеры коммита проверены: `Issue: #502`, `User-Visible: no` — корректно, changelog не тронут (`User-Visible: no` не требует правки обоих changelog), проверено `git diff --stat` по путям `docs/CHANGELOG*`.
|
||||
|
||||
## Проверка контракта и AC по коду
|
||||
|
||||
| # | Контракт/AC | Где выполнено | Проверено |
|
||||
|---|---|---|---|
|
||||
| п.1/AC1 | Литерал без точки + выражение — не потребитель | `isKeyShapedPattern` (`i18n-consumers.mjs:66-68`) требует `staticText.includes('.')` и `/[A-Za-z]/` | юнит-тест `AC1` зелёный; вручную прослежена трассировка для `'r' + Date.now()...`, `` `${a}b` ``, `'x' + id` — везде `staticText` не содержит `.` |
|
||||
| п.2 | Паттерн вида `^r.+$` невозможен по построению | требование точки в `isKeyShapedPattern` устраняет этот класс паттернов целиком | прочитано, логически подтверждено: без `.` в статике `isKeyShapedPattern` всегда false |
|
||||
| AC2 | `` `radar.${code}` ``, `prefix + '.title'`, `` `${ns}.aria` `` остаются потребителями | тест `key-shaped joins keep consuming their families (#502 AC2)` | зелёный, плюс негативные проверки `doesNotMatch('room.name', …)`, `doesNotMatch('x.titles', …)` — паттерн не расползается |
|
||||
| AC3 | Мёртвый ключ ловится даже когда id-генератор делит первую букву | тест `a dead key is reported even when an id generator shares its first letter (#502 AC3)` на синтетическом AST/словаре | зелёный: `unusedKeys` → `['probe.dead']`, `discarded.length === 1` |
|
||||
| п.3/AC4 | `DYNAMIC_KEY_FAMILIES` — по записи на семью, пустая причина/непокрытый паттерн — красный тест | `familyProblems` (`i18n-consumers.mjs:150-165`) + тест `every declared dynamic key family...` + `an explicit family rescues a data-driven key... (#502 AC4)` (4 варианта дефектов) | зелёный на всех 4 синтетических дефектах; на реальном дереве `DYNAMIC_KEY_FAMILIES = []` — подтверждено `node --test`: основной тест «every i18n key has a … consumer» зелёный, т.е. 0 непокрытых ключей и семьи не нужны |
|
||||
| п.4 | Реально мёртвые ключи не маскируются, не подсовываются в семьи ради зелёного гейта | `radar.bad_references` теперь потребляется литералом в `src/radar-setup.ts:334`; список семей пуст, а не дополнен фиктивной записью под этот ключ | подтверждено чтением `src/radar-setup.ts:334` и всех 4 словарей |
|
||||
| п.5 | Сообщение об ошибке не изменено по форме | `test/i18n-dead-keys.test.mjs`, строка с `Unused i18n keys: ...` — текст идентичен версии на `dev` | сверено дифом — да, не тронуто |
|
||||
| AC5 | Число отброшенных паттернов и список ключей «только на отброшенном» — записаны | `narrowingReport` печатает через `t.diagnostic`; автор привёл в issue-комментарии реализации точный вывод (`discarded 821 …, none`) | воспроизведено локально: тот же вывод — `discarded 821 dynamic patterns; keys covered only by discarded patterns: none` |
|
||||
|
||||
Отдельно проверено «принятое предположительно» (`` `${a}.${b}` ``, `a + '.' + b` — не потребители, т.к. статика — одна точка без буквы, что дало бы `^.+\..+$`, совпадающий с любым ключом): прослежена ручная трассировка построения `staticText` для обоих случаев — действительно даёт `'.'` без буквы, что и требуется отбросить по контракту п.2 (иначе паттерн вида «пустой хвост + точка + пустой хвост» был бы новой версией того же дефекта `^r.+$`, только в другой форме). Решение корректно и в скоупе задачи.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Диф ограничен `test/**`, что и требовал контракт (класс B, один файл + опциональный helper — использован).
|
||||
- Чистые функции (`expressionPattern`, `isKeyShapedPattern`, `collectConsumers`, `unusedKeys`, `familyProblems`, `narrowingReport`) действительно чистые, без чтения `src/**`/словарей — юнит-тесты гоняют их на синтетическом AST, как и требовало «принято предположительно» ТЗ.
|
||||
- Мутационный гейт (Validate, «Мутанты по диффу», 3/3 success) подтверждает, что тесты не декоративны: дисциплина «тест должен уметь падать» удовлетворена независимым инструментом, а не только моим прочтением.
|
||||
- Зависимость на #485 (`radar.bad_references`) действительно разрешена до реализации, в правильном порядке, как и требовал раздел «Зависимости».
|
||||
- Трейлеры, класс изменения, отсутствие правок в `dist/**`/changelog — всё соответствует `User-Visible: no`.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- **Полный `npm test`, `npx tsc --noEmit` (весь проект), `npm run build` со сверкой бандла** — не перегонял: Validate уже зелёный на точном SHA `441d3cc3` (ссылка выше), и диф не может задеть сборку (тестовый файл не входит в бандл).
|
||||
- **Смоки, golden, performance, pytest** — не прогонял: `smoke-select.mjs` прямо говорит «Browser-smoke этим диффом не выбираются», диф не трогает `src/**`, `custom_components/**/*.py`, стили или рендер. Golden/check-docs также не нужны по той же причине (нет изменений видимого результата).
|
||||
- **Инварианты модели (`npm run invariants`)** — не запускал: диф не касается геометрии, `layout`, `marker.space`, `open_spans`, толщины стен.
|
||||
- **Ручной прогон полного `node --test` по всему `test/**`** — не делал отдельно (кроме изменённого файла): это покрыто job'ом «Фронтенд» в уже зелёном Validate.
|
||||
|
||||
## Находки
|
||||
|
||||
Нет. High: 0, Medium: 0, Low: 0.
|
||||
|
||||
## Вывод
|
||||
|
||||
Контракт (п.1–5), все пять AC и «принятое предположительно» выполнены и подтверждены как автотестом (девять юнитов в изменённом файле, включая мутационную проверку в CI), так и чтением продуктового кода в точке, где решается судьба конкретного ранее мёртвого ключа (`src/radar-setup.ts:334`). Диф строго ограничен заявленным классом B, трейлеры корректны, откат тривиален (задача сама это утверждает, и это подтверждается: изменения лежат в двух тестовых файлах без побочных эффектов на продукт).
|
||||
|
||||
Вердикт: зелёный.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/502-i18n-dead-keys-narrow`, коммит `441d3cc3de3d` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `5d0db6767239964f0c804f9212d5b038fca0dd3d`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 5d0db6767239
|
||||
```
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,123 @@
|
||||
# CODE-REVIEW-508-r1
|
||||
|
||||
Issue: #508 — «Сводная панель: в диалоге настроек не работает прокрутка — колесом мыши и touch на мобильных»
|
||||
Материал: `768e1281f9119b12d018b416372984ff17eae62d` (ветка `issue/508-summary-dialog-scroll`, ребейзнута конвейером на `dev`: `7e5d394a → 768e1281`, +2 коммита dev).
|
||||
Заход: r1 · блокирующих циклов израсходовано 0 из 2. Ребейз довёл ветку до другого дерева (§7.2) → разбор полный.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Один коммит, класс A (`src/hp-dialog.ts`, `src/summary-panel-editor.ts`) + класс B (новый смок,
|
||||
контрактный юнит-тест, два мутанта) + класс C (`docs/ARCHITECTURE.md`, оба CHANGELOG) + класс D
|
||||
(пересобранный бандл во всех трёх копиях, `docs/images/screenshots.json` — только фингерпринт/`sourceSha256`,
|
||||
`imageSha256` не изменились, что соответствует заявленному «11/11 попиксельно»).
|
||||
|
||||
Правка ровно та, что описана в ТЗ (docs зафиксированы в теле issue, `SPEC-REVIEW-508-r1.md` →
|
||||
`SPEC-REVIEW-508-r2.md`, вердикт зелёный): `hp-dialog` получает opt-in булев атрибут `flex-content`
|
||||
(`flexcontent` на `ha-dialog`, обе HA-ветки рендера — с `described-by` и без), диалог настроек сводной
|
||||
панели его объявляет. Нативная ветка (`<dialog>`) не тронута ни строкой.
|
||||
|
||||
**J1** (`docs/SCOPE.md`) — обзорный диалог сводной панели входит в «show the whole home at a glance»;
|
||||
это чистый баг-фикс регресса после #505, никакой новой функциональности и расширения скоупа нет.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты на этом SHA уже подтверждены Validate
|
||||
(https://github.com/Matysh/houseplan-card/actions/runs/34395116095, `headSha=768e1281`, job
|
||||
«Фронтенд: типы, юниты, мутанты, синхрон бандла» — success). Тем не менее часть из них прогнана
|
||||
повторно вживую, потому что дёшево и диф трогает `src/**` (общий компонент `hp-dialog`, используемый
|
||||
девятью другими вызывающими).
|
||||
|
||||
| Гейт | Прогнал | Результат |
|
||||
|---|---|---|
|
||||
| `npx tsc --noEmit` + `rollup` (`npm run bundle:sync`) | да | сборка чистая; пересобранный `dist/` и обе копии (`custom_components/.../frontend`, `demo/srv/assets`) байт-в-байт совпали с закоммиченными — `git status` после пересборки пуст |
|
||||
| `npm test` | да | 2437 тестов, 2436 pass, 1 skip (известный `smoke_opening_measure` не запускался тут — не относится к юнитам), 0 fail |
|
||||
| `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | да | «Новых any нет» (10 добавленных строк в 2 файлах) |
|
||||
| `node scripts/check-docs.mjs` (диф трогает `src/**`) | да | «7 files, 12 external links» — зелёный, `sourceFingerprint` в `screenshots.json` уже обновлён коммитом |
|
||||
| `npm run bundle:budget` | да | initial View 298979 B (потолок 299600±2000); предупреждение о запасе <15000 Б — существующий проектный долг (#367/#474), эта задача добавляет один атрибут разметки и не сдвигает бюджет заметно; не находка этой задачи |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | да | «НЕОПРЕДЕЛЁННОСТЬ» — 2 файла, 0 символов на изменённых строках, порог «широкого» не достигнут. Решение ревьюера ниже |
|
||||
| `node demo/smoke_summary_dialog_scroll.mjs` (AC1/AC2, новый) | да | `OK`, все 14 проверок зелёные (desktop wheel/footer, phone touch/viewport, witness без `flexcontent`) |
|
||||
| `node demo/smoke_summary_panel_polish.mjs` (AC3, прямое совпадение — единственный смок диалога панели) | да | `OK`; файл смока не менялся (`git diff` пуст) — «без правок его ожиданий» выполнено |
|
||||
| `node demo/smoke_dialog_modal_recovery.mjs`, `node demo/smoke_esc_dialogs.mjs`, `node demo/smoke_dialog_footer_width.mjs` (зарегистрированная связь: общий `hp-dialog`, фокус/эскейп/футер) | да | все три `OK` |
|
||||
| `node scripts/mutation-gate.mjs --id=summary-dialog-drops-flex-content` | да | «чистый прогон» → зелёный, мутация → «тест покраснел, как обязан», поймано 1 из 1 |
|
||||
| `node scripts/mutation-gate.mjs --id=hp-dialog-ignores-flex-content` | да | то же, поймано 1 из 1 |
|
||||
| in-memory негативная проба AC4 (`test/hp-dialog-contract.test.mjs`, добавление `flex-content` в `hp-confirm.ts` только в памяти интерпретатора, без записи в дерево) | да | ассерт `=== 1` дал бы 2 → тест обязан покраснеть |
|
||||
|
||||
**Не прогонял:** полный `golden:verify`, `python -m pytest tests_backend`, полную матрицу смоков (237 шт.),
|
||||
performance-профили.
|
||||
|
||||
- `golden` — диф не меняет ни один существующий рендер: атрибут opt-in, `?flexcontent=` в Lit убирает
|
||||
атрибут целиком при `false`, поэтому разметка всех прочих `hp-dialog` не меняется ни на байт; golden
|
||||
снимается в demo-стенде, где вообще используется нативная ветка (`<dialog>`), которую диф не трогает.
|
||||
golden-сцен с префиксом `summary` в репозитории нет (снято ещё в SPEC-REVIEW r1 M1, AC3 переписан на
|
||||
единственный смок). Риск нулевой — не прогонял намеренно, а не пропустил.
|
||||
- `pytest tests_backend` — диф не касается `custom_components/houseplan/**/*.py` вовсе.
|
||||
- Полная матрица смоков и перф-профили — не названы в AC; `smoke-select` вернул «НЕОПРЕДЕЛЁННОСТЬ», выбор
|
||||
сузил вручную до прямого совпадения (диалог панели) и зарегистрированных связей (общий `hp-dialog`:
|
||||
восстановление модального стека, Escape-стек, ширина футера). Остальные 231 смока к этому диффу не
|
||||
привязаны сильнее «общий компонент» и по правилу прогона не требуют.
|
||||
|
||||
## AC → доказательство → чем краснеет
|
||||
|
||||
| AC | Доказано | Чем краснеет |
|
||||
|---|---|---|
|
||||
| AC1 (HA-ветка: колесо крутит `.summary-editor`, шапка/футер неподвижны) | `demo/smoke_summary_dialog_scroll.mjs` сценарий (a), прогнан лично — `OK` | оба мутанта `summary-dialog-drops-flex-content` и `hp-dialog-ignores-flex-content`, прогнаны лично: «покраснел, как обязан» |
|
||||
| AC2 (touch 390×844 крутит редактор; диалог ≤ вьюпорта; футер доступен) | сценарий (b), прогнан лично — `OK` | те же два мутанта (общий guard — атрибут) |
|
||||
| AC3 (нативная ветка не меняется, `smoke_summary_panel_polish` зелёный без правок ожиданий) | прогнан лично — `OK`; `git diff` файла смока пуст | не защитный AC (сравнение факта с ожиданием, не guard) — правило §2.7 не требует красноты |
|
||||
| AC4 (прочие `hp-dialog` без `flex-content` не меняют раскладку) | `test/hp-dialog-contract.test.mjs` (в составе `npm test`, прогнан лично) + чтение: `?flexcontent=` в Lit опускает атрибут при `false`, значит разметка девяти прочих вызывающих идентична | in-memory негативная проба (см. выше, без записи в дерево): подмена показала бы `optIns.length === 2` → ассерт `=== 1` красный |
|
||||
| AC5 (оба мутанта пойманы штатным раннером) | сами прогоны мутантов, см. выше | это и есть доказательство; вторичной пробы не нужно |
|
||||
| AC6 (`User-Visible: yes`, оба changelog, i18n/модель/перф не задеты) | `git diff` коммита: `docs/CHANGELOG.md` + `docs/CHANGELOG.ru.md` в том же коммите; ни один `translations/**`/`i18n` файл не в диффе; в `src/hp-dialog.ts` нет новых обработчиков — только проброс булева атрибута в существующий рендер | не защитный AC (перечисление фактов), доказано чтением диффа |
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Обе HA-ветки рендера (`describedBy` и без) получили `?flexcontent=${this.flexContent}` — прочитано в
|
||||
`src/hp-dialog.ts:515,531`, подтверждено юнит-тестом и мутантом.
|
||||
- Нативная `<dialog>`-ветка (`_usesHaDialog()` false) не содержит ни одного упоминания
|
||||
`flexcontent`/CSS-селектора на `:host([flex-content])` — сброс атрибута не имеет побочных эффектов там,
|
||||
где используется golden/demo.
|
||||
- Диагноз (scroll chaining обрывается на height-unbound `overflow:auto + overscroll-behavior:contain`)
|
||||
independently подтверждён автором на живом стенде HA 2026.9.1 (`ha-dialog.flexContent = true` →
|
||||
`.summary-editor` 477/819, колесо работает) — это сильнее, чем воспроизведение только в стабе; стаб
|
||||
смока копирует контракт `.body{overflow:auto}` + `:host([flexcontent]) .body{display:flex}`, о котором
|
||||
говорит и уже существующий комментарий `demo/capture_summary_panel_505.mjs` (#505).
|
||||
- `docs/TOUCH-SUPPORT.md:48` прямо называет «simple settings form» сводной панели гарантированной View-
|
||||
touch-поверхностью — включение touch в скоуп задачи не расширение, а обязательное соответствие контракту.
|
||||
- Терминология changelog («сводной панели», «настройки») совпадает с `docs/USER-GUIDE.ru.md:264`.
|
||||
- `docs/images/screenshots.json`: только `sourceFingerprint`/`sourceSha256` изменились, все `imageSha256`
|
||||
идентичны — соответствует заявленному в хендоффе `docs:accept --identical` (11/11 пиксель-в-пиксель, первое
|
||||
боевое использование #512).
|
||||
- Один коммит, трейлеры `Issue: #508` и `User-Visible: yes` на месте; `Release:`/`Baseline-Reviewed:` не
|
||||
требуются — `demo/golden/baselines/**` не тронут.
|
||||
- Скоуп не расширен: единственный вызывающий (`summary-panel-editor.ts`) объявляет атрибут, остальные восемь
|
||||
— нет (проверено и тестом, и построчным чтением).
|
||||
|
||||
## Находки
|
||||
|
||||
Нет. High: 0, Medium: 0, Low: 0.
|
||||
|
||||
## Унаследовано из ревью ТЗ (для полноты трассировки)
|
||||
|
||||
Спек-ревью прошло два раунда (`SPEC-REVIEW-508-r1.md` → жёлтый, `SPEC-REVIEW-508-r2.md` → зелёный на
|
||||
SHA `f02af9330b17a37eea6c8086f4335cbbc7265d36`): M1 (AC3 ссылался на несуществующий golden-префикс
|
||||
`summary-*`) и M2 (не было раздела «откат») закрыты правкой тела issue. Код-ревью это не переоткрывает —
|
||||
AC3 в теле issue на момент код-ревью уже опирается только на `smoke_summary_panel_polish`, а раздел
|
||||
«Откат» присутствует и соответствует факту (opt-in атрибут, ревёрт коммита без миграций).
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. Все AC доказаны исполнением (кроме AC3/AC6, не заявляющих защиты — доказаны прямым сравнением),
|
||||
диагноз независимо подтверждён на реальном стенде HA, оба защитных мутанта лично прогнаны и красные без
|
||||
патча/зелёные с ним, регресс на нативную ветку и остальные восемь `hp-dialog` исключён и тестом, и
|
||||
построчным чтением кода. Находок нет.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/508-summary-dialog-scroll`, коммит `7e5d394a936a` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `1594677359b610e22fc5886db02f66bc3c943322`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 1594677359b6
|
||||
```
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,193 @@
|
||||
# CODE-REVIEW-508-r2
|
||||
|
||||
Issue: #508 — «Сводная панель: в диалоге настроек не работает прокрутка — колесом мыши и touch на мобильных»
|
||||
Материал: `6ac513caa09a8e583869879412aa3a0c67d04488` (рабочая копия уже на нём). Ветка приведена конвейером к
|
||||
`dev` до ревью: `e23d742c → 6ac513ca`, поверх легло 1 коммит `dev` (несвязанный — `docs: review document
|
||||
for #502`).
|
||||
Заход: r2 · блокирующих циклов израсходовано 0 из 4. Ребейз на ушедший вперёд `dev` формально делает
|
||||
дерево другим (§7.2) → разбор полный, а не по дельте.
|
||||
|
||||
## Почему вообще r2, если r1 был зелёным без находок
|
||||
|
||||
Код-ревью r1 (`docs/reviews/CODE-REVIEW-508-r1.md`, материал `768e1281`) уже завершилось вердиктом
|
||||
«зелёный, находок нет» с исчерпывающим личным прогоном гейтов, мутантов, смоков и подтверждением автора
|
||||
на живом стенде HA. Сразу после публикации вердикта автор сообщил в issue: «Автоматическое ревью не
|
||||
отработало» — статусная метка не переставилась, конвейер оставил задачу на месте. Затем конвейер
|
||||
перебазировал ветку на успевший уйти вперёд `dev` (там landing получил несвязанный коммит с ревью-доком
|
||||
для issue #502) и передал её на повторное ревью уже под новым SHA. Автор с момента r1 в код и ТЗ не
|
||||
вносил ничего — это не возврат на правки, а техническое повторение ревью из-за сбоя механики метки плюс
|
||||
ребейз. Поэтому раздела «находка → возврат → правка» в этом раунде по существу нет: проверка ниже —
|
||||
подтверждение, что ребейз не изменил и не сломал ничего, а не разбор чьих-то правок.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Не изменился с r1: один функциональный коммит (`e38beed7`, экс-`768e1281`) — класс A (`src/hp-dialog.ts`,
|
||||
`src/summary-panel-editor.ts`) + класс B (новый смок `demo/smoke_summary_dialog_scroll.mjs`, контрактный
|
||||
юнит-тест `test/hp-dialog-contract.test.mjs`, два мутанта в `scripts/mutation-gate.mjs`) + класс C
|
||||
(`docs/ARCHITECTURE.md`, оба CHANGELOG) + класс D (пересобранный бандл во всех копиях,
|
||||
`docs/images/screenshots.json` — только фингерпринт). `hp-dialog` получает opt-in булев атрибут
|
||||
`flex-content` (`?flexcontent` на `ha-dialog`, обе HA-ветки рендера); нативная `<dialog>`-ветка не тронута.
|
||||
Диалог настроек сводной панели его объявляет — единственный из десяти вызывающих `<hp-dialog>`.
|
||||
|
||||
J1 (`docs/SCOPE.md`) — обзорная сводная панель входит в «show the whole home at a glance»; чистый
|
||||
баг-фикс регресса после #505, скоуп не расширен, продукт не меняет договор.
|
||||
|
||||
## Как проверялось (это не дельта — это независимая перепроверка «ничего не изменилось»)
|
||||
|
||||
Первый шаг — не поверить заявлению «ребейз ничего не тронул», а доказать его:
|
||||
|
||||
```
|
||||
git diff 768e1281 e38beed7 --stat # r1-материал vs текущий фикс-коммит
|
||||
```
|
||||
|
||||
Результат: единственная разница — файл `docs/reviews/SPEC-REVIEW-502-r1.md` (несвязанный коммит `dev` для
|
||||
issue #502). Все файлы продукта — `src/hp-dialog.ts`, `src/summary-panel-editor.ts`,
|
||||
`test/hp-dialog-contract.test.mjs`, `docs/ARCHITECTURE.md`, оба CHANGELOG, `scripts/mutation-gate.mjs`,
|
||||
`demo/smoke_summary_dialog_scroll.mjs`, весь пересобранный бандл — побайтово идентичны материалу r1.
|
||||
`docs/images/screenshots.json` проверен построчно (`grep imageSha256` на диффе): изменились только
|
||||
`sourceFingerprint`/`sourceSha256`-контекст, ни один `imageSha256` не сдвинулся.
|
||||
|
||||
Это и определило объём: код доказанно тот же, что уже получил зелёный вердикт с личными прогонами гейтов,
|
||||
мутантов и смоков (не только заявлением автора — заявление в r1 тоже было перепроверено исполнением). Все
|
||||
выводы AC1–AC6 из r1 остаются в силе автоматически, потому что применяются к тому же байт-в-байт коду. Тем
|
||||
не менее раунд объявлен «полным» (ребейз, §7.2), поэтому ниже — не просто ссылка на r1, а собственный
|
||||
прогон части гейтов на текущем SHA `6ac513ca`, чтобы исключить эффекты самого ребейза (окружение сборки,
|
||||
конфликт слияния бандла и т. п.), а не только идентичность текста диффа.
|
||||
|
||||
### Дешёвые гейты
|
||||
|
||||
Validate на `6ac513ca` зелёный (дано в задаче ревью, https://github.com/Matysh/houseplan-card/actions/runs/34397342070)
|
||||
— `tsc`, `npm test` (2436/2436), `npm run build` со сверкой бандла уже подтверждены конвейером на этом
|
||||
самом SHA, повторно не гонял.
|
||||
|
||||
### Прогнано лично на `6ac513ca` (не на материале r1 — на текущем SHA)
|
||||
|
||||
| Гейт/смок | Результат |
|
||||
|---|---|
|
||||
| `node --test test/hp-dialog-contract.test.mjs` (AC1 текстовая часть, AC4) | 3/3 pass |
|
||||
| `node scripts/check-docs.mjs` (диф трогает `src/**`) | «Documentation checks passed (7 files, 12 external links)» — совпадает с числом из r1 |
|
||||
| `node scripts/bundle-sync.mjs` (пересборка + раскладка по трём местам: `dist`, `custom_components/houseplan/frontend`, `demo/srv/assets`) | выполнено; `git status --porcelain` после — пусто (отслеживаемые копии не разошлись с коммитом) |
|
||||
| `node demo/smoke_summary_dialog_scroll.mjs` (AC1/AC2, прямое совпадение) | `OK` |
|
||||
| `node demo/smoke_summary_panel_polish.mjs` (AC3, прямое совпадение — файл смока не менялся) | `OK` |
|
||||
| `node demo/smoke_dialog_modal_recovery.mjs` (зарегистрированная связь: общий `hp-dialog`) | `OK` |
|
||||
| `node demo/smoke_esc_dialogs.mjs` (зарегистрированная связь) | `OK` |
|
||||
| `node demo/smoke_dialog_footer_width.mjs` (зарегистрированная связь) | `OK` |
|
||||
| `node scripts/mutation-gate.mjs --id=summary-dialog-drops-flex-content` | чистый прогон OK → мутация: «тест покраснел, как обязан» → поймано 1 из 1 |
|
||||
| `node scripts/mutation-gate.mjs --id=hp-dialog-ignores-flex-content` | то же → поймано 1 из 1 |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «НЕОПРЕДЕЛЁННОСТЬ» — 2 файла `src/**`, 0 символов на изменённых строках (тот же результат, что в r1); выбор смоков выше сделан вручную: прямое совпадение (диалог панели) + зарегистрированные связи (общий `hp-dialog`) |
|
||||
| `grep -rn "<hp-dialog" src/` | 10 вызывающих (включая `summary-panel-editor.ts`); ровно список из `test/hp-dialog-contract.test.mjs` AC4, opt-in — один |
|
||||
|
||||
Изначально попытка запустить браузерные смоки провалилась ошибкой окружения
|
||||
(`Failed to fetch dynamically imported module: http://demo.local/...`, `page.waitForFunction` timeout) —
|
||||
это была не регрессия, а отсутствие локальной копии `demo/srv/assets` (она не хранится в репозитории,
|
||||
`scripts/bundle-sync.mjs`: «копий две с половиной», рабочая копия стенда собирается заново). Тот же
|
||||
таймаут воспроизводился на `demo/smoke_summary_panel_polish.mjs`, файл которого этот диф не трогает вовсе
|
||||
— это подтвердило, что причина в окружении ревью-песочницы, а не в коде. После `node
|
||||
scripts/bundle-sync.mjs` (пересборка из уже собранного `dist/`) оба смока пошли зелёными.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
Код-ревью r1 не имело находок (High 0, Medium 0, Low 0) — блокирующих находок для закрытия нет. Раунд r2
|
||||
открылся не как возврат на правки, а как техническое повторение из-за несработавшей автоматической
|
||||
перестановки метки после зелёного вердикта r1, дополненное последующим ребейзом на ушедший вперёд `dev`
|
||||
(конвейер, не автор). Автор кода/ТЗ с момента r1 не менял — независимо доказано выше диффом
|
||||
`768e1281..e38beed7`.
|
||||
|
||||
| Находка r1 | Статус | Где видно |
|
||||
|---|---|---|
|
||||
| (нет находок) | — | `docs/reviews/CODE-REVIEW-508-r1.md`, раздел «Находки»: «Нет. High: 0, Medium: 0, Low: 0» |
|
||||
|
||||
## Унаследовано из r1
|
||||
|
||||
Без повторного самостоятельного вывода, но с проверкой байт-в-байт идентичности кода (см. «Как
|
||||
проверялось»), наследую из `docs/reviews/CODE-REVIEW-508-r1.md` (материал `768e1281f9119b12d018b416372984ff17eae62d`,
|
||||
дерево `1594677359b610e22fc5886db02f66bc3c943322`):
|
||||
|
||||
- диагноз (scroll chaining обрывается на height-unbound `.summary-editor` под `ha-dialog`, `overflow:
|
||||
auto` + `overscroll-behavior: contain`) — независимо подтверждён автором на живом стенде HA 2026.9.1, не
|
||||
только в стабе смока;
|
||||
- личные прогоны `tsc`, `npm test` (2437/2436 pass+1 skip), `no-new-any`, `bundle:budget` на материале
|
||||
r1 — не переигрывал повторно, т. к. Validate CI зелёный на `6ac513ca` покрывает те же проверки на
|
||||
байт-в-байт идентичном коде;
|
||||
- вывод AC6 (i18n/модель/перф не задеты, трейлеры `Issue:`/`User-Visible: yes` и оба CHANGELOG в одном
|
||||
коммите) — перепроверен построчно, см. «AC → доказательство» ниже, поэтому это скорее «переподтверждено»,
|
||||
чем слепо унаследовано;
|
||||
- трассировку к спек-ревью (`SPEC-REVIEW-508-r1.md` жёлтый → `SPEC-REVIEW-508-r2.md` зелёный на SHA
|
||||
`f02af9330b17a37eea6c8086f4335cbbc7265d36`, M1/M2 закрыты правкой тела issue) — код-ревью r1 её не
|
||||
переоткрывало, r2 тоже не переоткрывает: текст issue с тех пор не менялся.
|
||||
|
||||
## AC → доказательство → чем краснеет (переподтверждено лично на `6ac513ca`)
|
||||
|
||||
| AC | Доказано | Чем краснеет |
|
||||
|---|---|---|
|
||||
| AC1 (HA-ветка: колесо крутит `.summary-editor`, шапка/футер неподвижны) | `demo/smoke_summary_dialog_scroll.mjs` сценарий (a) — `OK`, лично | оба мутанта, лично: «покраснел, как обязан» |
|
||||
| AC2 (touch 390×844 крутит редактор; диалог ≤ вьюпорта; футер доступен) | сценарий (b) — `OK`, лично | те же два мутанта |
|
||||
| AC3 (нативная ветка не меняется) | `smoke_summary_panel_polish` — `OK`, лично; файл смока не в диффе | не защитный AC |
|
||||
| AC4 (прочие 9 `hp-dialog` без `flex-content`) | `test/hp-dialog-contract.test.mjs` — 3/3 pass, лично; `grep` по `src/**` — 10 вызывающих, 1 opt-in | ассерт `=== 1` в тесте (in-memory проба r1 сохраняет силу — код теста не менялся) |
|
||||
| AC5 (оба мутанта пойманы) | оба мутанта лично прогнаны — «поймано 1 из 1» каждый | это и есть доказательство |
|
||||
| AC6 (`User-Visible: yes`, оба changelog, i18n/модель/перф не задеты) | `git show --stat e38beed7`: `docs/CHANGELOG.md`+`docs/CHANGELOG.ru.md` в том же коммите; `git log -1` коммита содержит `Issue: #508` и `User-Visible: yes`; ни один `i18n`/`translations` файл не в диффе; `src/hp-dialog.ts` diff — только проброс булева атрибута, новых обработчиков нет | не защитный AC, доказано чтением |
|
||||
|
||||
## Одно число — один источник
|
||||
|
||||
Диф не вводит и не дублирует ни одну видимую пользователю величину (площадь, значение, подпись): меняется
|
||||
только внутренняя раскладка скролл-контейнера через булев атрибут. `test/single-source-numbers.test.mjs`
|
||||
не затрагивается и не требуется для этой правки.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Обе HA-ветки рендера (`describedBy` и без) несут `?flexcontent=${this.flexContent}` — строки
|
||||
`src/hp-dialog.ts:515,531`, подтверждено и тестом, и мутантами лично.
|
||||
- Нативная `<dialog>`-ветка не содержит `flexcontent`/связанных селекторов — регресс на неё исключён и
|
||||
тестом (`smoke_summary_panel_polish`), и построчным чтением.
|
||||
- `docs/TOUCH-SUPPORT.md:48–49` называет «simple settings form» сводной панели гарантированной View-touch-
|
||||
поверхностью — включение touch в скоуп не расширение, а обязательное соответствие контракту.
|
||||
- Терминология CHANGELOG.ru.md («сводной панели», «настроек») совпадает с `docs/USER-GUIDE.ru.md:264`
|
||||
(«Настройки сводной панели»).
|
||||
- `docs/images/screenshots.json`: только `sourceFingerprint`/`sourceSha256`; все 11 `imageSha256`
|
||||
идентичны — докс-скриншоты не изменились по существу.
|
||||
- Один коммит (`e38beed7`), трейлеры `Issue: #508` и `User-Visible: yes` на месте.
|
||||
- После `bundle-sync` рабочая копия чиста (`git status --porcelain` пуст) — три копии бандла (`dist`,
|
||||
`custom_components/houseplan/frontend`, `demo/srv/assets`) согласованы с закоммиченным.
|
||||
- Ребейз сам по себе не внёс никакого продуктового изменения — доказано диффом, а не заявлено.
|
||||
|
||||
## Находки
|
||||
|
||||
Нет. High: 0, Medium: 0, Low: 0.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- `npm run golden:verify` — атрибут opt-in, `?flexcontent=` в Lit убирает атрибут целиком при `false`,
|
||||
разметка прочих `hp-dialog` не меняется ни на байт; golden-сцен диалога сводной панели в репозитории нет
|
||||
(снято ещё в `SPEC-REVIEW-508-r1.md` M1). Риск нулевой, решение то же, что в r1, переподтверждено тем же
|
||||
рассуждением на идентичном коде.
|
||||
- `python -m pytest tests_backend` — диф не касается `custom_components/houseplan/**/*.py`.
|
||||
- Полная матрица смоков (237 шт.) и перф-профили — не названы в AC; `smoke-select` вернул
|
||||
«НЕОПРЕДЕЛЁННОСТЬ» (2 файла `src/**`, 0 символов на изменённых строках, порог «широкого» — 47 смоков).
|
||||
Выбор сужен вручную до прямого совпадения (`smoke_summary_dialog_scroll`, `smoke_summary_panel_polish`) и
|
||||
зарегистрированных связей на общий `hp-dialog` (`smoke_dialog_modal_recovery`, `smoke_esc_dialogs`,
|
||||
`smoke_dialog_footer_width`) — все пять прогнаны лично, все зелёные.
|
||||
- `npm run invariants` — диф не трогает геометрию/рёбра комнат/`layout`/`marker.space`/`open_spans`, гейт не
|
||||
применим.
|
||||
- Полный `npm test` (все 2436 юнитов) — не переигрывал повторно вживую: Validate CI уже подтвердил его
|
||||
зелёным на этом самом SHA `6ac513ca` (ссылка выше), а не на другом; вместо повторного полного прогона
|
||||
лично выполнен целевой `test/hp-dialog-contract.test.mjs`.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. Разбор — полный (ребейз, §7.2), но не «с нуля»: доказано диффом, что код байт-в-байт идентичен
|
||||
уже одобренному материалу r1, и это доказательство переподтверждено собственными прогонами гейтов, смоков
|
||||
и мутантов на текущем SHA `6ac513ca`, а не принятием чужого вердикта на веру. Находок нет. Раунд не
|
||||
образует цикл (§4) — вердикт зелёный.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/508-summary-dialog-scroll`, коммит `e23d742cd508` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `5fc02b5d1de102ed9770f28fc5eb3a41098cbc11`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 5fc02b5d1de1
|
||||
```
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,206 @@
|
||||
# CODE-REVIEW-508-r3
|
||||
|
||||
Issue: #508 — «Сводная панель: в диалоге настроек не работает прокрутка — колесом мыши и touch на мобильных»
|
||||
Материал: `be93291065a54f8152d0f1945bde973c10613d36` (рабочая копия уже на нём).
|
||||
Заход: r3 · блокирующих циклов израсходовано 0 из 2 (лёгкий трек — лимит код-ревью общий, не «2»
|
||||
для спека; счётчик циклов см. ниже).
|
||||
|
||||
## Почему вообще r3, если r1 и r2 уже были зелёными без находок
|
||||
|
||||
`git log --oneline origin/dev..HEAD`:
|
||||
|
||||
```
|
||||
be932910 docs: review document for #508
|
||||
6ac513ca docs: review document for #508
|
||||
e38beed7 fix: the summary panel settings dialog scrolls in Home Assistant
|
||||
```
|
||||
|
||||
Ровно три коммита поверх `origin/dev`, ребейза с момента r2 не было (в отличие от r1→r2, где ветку
|
||||
перебазировали на ушедший вперёд `dev`). Один функциональный коммит — `e38beed7` (уже проверенный
|
||||
в r1 и переподтверждённый байт-в-байт в r2). Два следующих — `6ac513ca` и `be932910` — не что иное
|
||||
как публикация конвейером собственных документов предыдущих раундов ревью:
|
||||
`6ac513ca` добавляет `docs/reviews/CODE-REVIEW-508-r1.md` (123 строки), `be932910` добавляет
|
||||
`docs/reviews/CODE-REVIEW-508-r2.md` (193 строки). Ни один из них не трогает ни строки продукта.
|
||||
|
||||
Материалом r2 был явно назван `6ac513caa09a8e583869879412aa3a0c67d04488`
|
||||
(`docs/reviews/CODE-REVIEW-508-r2.md:4`). Дельта r2→r3:
|
||||
|
||||
```
|
||||
git diff 6ac513ca..be932910 --stat
|
||||
docs/reviews/CODE-REVIEW-508-r2.md | 193 +++++++++++++++++++++++++++++++++++++
|
||||
1 file changed, 193 insertions(+)
|
||||
```
|
||||
|
||||
Это ровно случай, описанный в `AGENTS.md` («Do not merge into `dev` by hand»,
|
||||
абзац про #499): «When the only difference from the reviewed material is the pipeline's own
|
||||
review-document commit, the next run re-applies the green verdict without calling the model».
|
||||
Формально этот раунд не должен был доходить до модели вообще — но, как и после r1 (см. комментарии
|
||||
issue: «Автоматическое ревью не отработало… Статусная метка не менялась»), автоматическая
|
||||
перестановка метки/повторное применение вердикта снова не сработало, и модельное ревью запущено
|
||||
повторно. Это техническое повторение уровня механики конвейера, а не возврат на правки: автор с
|
||||
момента r1 не менял ни кода, ни ТЗ.
|
||||
|
||||
Раздела «находка → возврат → правка» в этом раунде по существу нет — ниже задокументирована
|
||||
проверка того, что дельта действительно пуста по продукту, а не принята на слово.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Не изменился с r1/r2: один функциональный коммит `e38beed7` — класс A (`src/hp-dialog.ts`,
|
||||
`src/summary-panel-editor.ts`) + класс B (`demo/smoke_summary_dialog_scroll.mjs`,
|
||||
`test/hp-dialog-contract.test.mjs`, два мутанта в `scripts/mutation-gate.mjs`) + класс C
|
||||
(`docs/ARCHITECTURE.md`, оба CHANGELOG) + класс D (пересобранный бандл во всех копиях,
|
||||
`docs/images/screenshots.json` — только фингерпринт). `hp-dialog` получает opt-in булев атрибут
|
||||
`flex-content` (`?flexcontent` на `ha-dialog`, обе HA-ветки рендера); нативная `<dialog>`-ветка не
|
||||
тронута. Диалог настроек сводной панели — единственный из десяти вызывающих `<hp-dialog>`, который
|
||||
его объявляет.
|
||||
|
||||
J1 (`docs/SCOPE.md`) — обзорная сводная панель входит в «show the whole home at a glance»; чистый
|
||||
баг-фикс регресса после #505, скоуп не расширен, продуктовый договор не меняется.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Первый шаг — не поверить заявлению «дельта r2→r3 это только паблиш-коммит», а доказать его (см. выше
|
||||
`git diff 6ac513ca..be932910 --stat`), и дополнительно исключить более широкий рассинхрон:
|
||||
|
||||
```
|
||||
git diff e38beed7..HEAD -- . ':!docs/reviews' --stat → пусто
|
||||
```
|
||||
|
||||
То есть от коммита фикса `e38beed7` до текущего HEAD `be932910` **ни один файл вне `docs/reviews/`**
|
||||
не изменился ни на байт — ни `src/**`, ни тесты, ни бандл, ни changelog, ни `docs/images/screenshots.json`.
|
||||
Продуктовый код текущего материала побайтово идентичен коду, который уже дважды получил зелёный
|
||||
вердикт с личными прогонами гейтов, мутантов и смоков (r1 — на `768e1281`, r2 — независимо
|
||||
переподтверждено исполнением на `6ac513ca` после ребейза). Все выводы AC1–AC6 из r1/r2 остаются в
|
||||
силе автоматически.
|
||||
|
||||
### Дешёвые гейты
|
||||
|
||||
Validate на `be932910` — success, подтверждено напрямую:
|
||||
|
||||
```
|
||||
gh run view 34403312576 --repo Matysh/houseplan-card --json headSha,conclusion,status
|
||||
→ {"conclusion":"success","headSha":"be93291065a54f8152d0f1945bde973c10613d36","status":"completed"}
|
||||
```
|
||||
|
||||
`headSha` совпадает с материалом раунда. `tsc`, `npm test`, `npm run build` со сверкой бандла —
|
||||
покрыты этим прогоном, повторно не гонял: код с r2 не изменился (доказано диффом выше), а сам прогон
|
||||
состоялся уже на итоговом SHA этого раунда, не на промежуточном.
|
||||
|
||||
### Что не потребовалось прогонять лично
|
||||
|
||||
Поскольку дельта r2→r3 не касается ни одного файла продукта, теста, смока или бандла — только двух
|
||||
markdown-файлов ревью-документов, — новых причин перегонять `check-docs` (диф не трогает `src/**`),
|
||||
смоки, мутанты, golden, `invariants` или `pytest` в этом раунде нет: они уже прогнаны лично в r1
|
||||
(на идентичном коде) и переподтверждены исполнением в r2 (на идентичном коде после ребейза). Смотри
|
||||
раздел «Унаследовано из r2» — там же таблица AC, которую заново выводить не по чему: дельта её не
|
||||
задевает.
|
||||
|
||||
Единственная проверка, которую стоило сделать заново именно для r3, — не «те же ли гейты снова
|
||||
зелёные», а «действительно ли материал r3 это тот же код» — она выполнена выше диффом и является
|
||||
самостоятельным доказательством, а не переносом чужого вывода.
|
||||
|
||||
## Закрытие раунда r2
|
||||
|
||||
Код-ревью r2 не имело находок (High 0, Medium 0, Low 0) — блокирующих находок для закрытия нет.
|
||||
|
||||
| Находка r2 | Статус | Где видно |
|
||||
|---|---|---|
|
||||
| (нет находок) | — | `docs/reviews/CODE-REVIEW-508-r2.md`, раздел «Находки»: «Нет. High: 0, Medium: 0, Low: 0» |
|
||||
|
||||
## Унаследовано из r2
|
||||
|
||||
Без повторного самостоятельного вывода, но с проверкой байт-в-байт идентичности продуктового кода
|
||||
(раздел «Как проверялось» выше — `git diff e38beed7..HEAD -- . ':!docs/reviews'` пуст), наследую из
|
||||
`docs/reviews/CODE-REVIEW-508-r2.md` (материал `6ac513caa09a8e583869879412aa3a0c67d04488`):
|
||||
|
||||
- диагноз (scroll chaining обрывается на height-unbound `.summary-editor` под `ha-dialog`,
|
||||
`overflow: auto` + `overscroll-behavior: contain`), независимо подтверждённый автором на живом
|
||||
стенде HA 2026.9.1 — не только в стабе смока;
|
||||
- полную таблицу «AC → доказательство → чем краснеет» (AC1–AC6) — код и тесты, на которых она
|
||||
построена, не изменились ни строкой;
|
||||
- личные прогоны `test/hp-dialog-contract.test.mjs` (3/3), `check-docs` (7 files, 12 links),
|
||||
`bundle-sync` (три копии согласованы), `demo/smoke_summary_dialog_scroll.mjs` (AC1/AC2 — 14/14),
|
||||
`demo/smoke_summary_panel_polish.mjs` (AC3), `demo/smoke_dialog_modal_recovery.mjs`,
|
||||
`demo/smoke_esc_dialogs.mjs`, `demo/smoke_dialog_footer_width.mjs`, оба защитных мутанта
|
||||
(`summary-dialog-drops-flex-content`, `hp-dialog-ignores-flex-content` — «поймано 1 из 1» каждый) —
|
||||
выполнены на `6ac513ca`, применимы без изменений к `be932910`, поскольку продуктовый и тестовый код
|
||||
между ними идентичен;
|
||||
- решения «не проверял»: `golden:verify` (opt-in атрибут, golden-сцен диалога панели нет),
|
||||
`pytest tests_backend` (диф не про Python), полная матрица смоков/перф-профили (`smoke-select` —
|
||||
«НЕОПРЕДЕЛЁННОСТЬ», выбор сужен вручную до прямого совпадения + зарегистрированных связей),
|
||||
`npm run invariants` (диф не про геометрию/`layout`/`marker.space`);
|
||||
- трассировку к спек-ревью (`SPEC-REVIEW-508-r1.md` жёлтый → `SPEC-REVIEW-508-r2.md` зелёный на SHA
|
||||
`f02af9330b17a37eea6c8086f4335cbbc7265d36`) — код-ревью её не переоткрывало ни в r1, ни в r2, не
|
||||
переоткрывает и сейчас: текст issue с тех пор не менялся.
|
||||
|
||||
## AC → доказательство (унаследовано без повторного вывода, см. выше)
|
||||
|
||||
| AC | Статус |
|
||||
|---|---|
|
||||
| AC1 (HA-ветка: колесо крутит `.summary-editor`, шапка/футер неподвижны) | доказан, `smoke_summary_dialog_scroll` сценарий (a) — код не менялся с r2 |
|
||||
| AC2 (touch 390×844 крутит редактор; диалог ≤ вьюпорта; футер доступен) | доказан, сценарий (b) — код не менялся с r2 |
|
||||
| AC3 (нативная ветка не меняется) | доказан, `smoke_summary_panel_polish` — файл смока по-прежнему не в диффе |
|
||||
| AC4 (прочие 9 `hp-dialog` без `flex-content`) | доказан, `test/hp-dialog-contract.test.mjs` — код теста не менялся |
|
||||
| AC5 (оба мутанта пойманы) | доказан — мутанты и продукт не менялись |
|
||||
| AC6 (`User-Visible: yes`, оба changelog, i18n/модель/перф не задеты) | доказан чтением — коммит `e38beed7` тот же самый |
|
||||
|
||||
## Одно число — один источник
|
||||
|
||||
Диф не вводит и не дублирует ни одну видимую пользователю величину. Ничего не изменилось с r2, где
|
||||
это уже проверено; дельта r3 (два markdown-файла ревью) не содержит вообще никаких величин,
|
||||
видимых пользователю.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Дельта r2→r3 состоит ровно из одного файла, `docs/reviews/CODE-REVIEW-508-r2.md` — публикация
|
||||
ревью-документа предыдущего раунда конвейером, не правка автора.
|
||||
- Продуктовый код, тесты, бандл, changelog, документация (кроме `docs/reviews/`) между `e38beed7` и
|
||||
текущим HEAD побайтово идентичны — доказано `git diff --stat` с пустым результатом.
|
||||
- Validate зелёный на точном SHA материала (`be932910`), headSha совпадает — проверено напрямую
|
||||
через `gh run view`.
|
||||
- Ребейза с r2 не было (`origin/dev..HEAD` — ровно три коммита той же ветки), поэтому это
|
||||
действительно раунд «по дельте» (§2.10), а не «разбор полный» по правилу ребейза.
|
||||
|
||||
## Находки
|
||||
|
||||
Нет. High: 0, Medium: 0, Low: 0.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- Полный `npm test`, `tsc`, `npm run build` вживую — покрыты Validate на этом самом SHA `be932910`
|
||||
(ссылка выше, headSha сверен), а не на промежуточном; продуктовый код с r2 не изменился.
|
||||
- Смоки, мутанты, `check-docs`, `bundle-sync` — не перегонял лично в этом раунде: дельта их не
|
||||
задевает (ни один из изменившихся файлов не входит в их область), а на идентичном коде они уже
|
||||
лично прогнаны и зафиксированы в r1/r2 с результатами.
|
||||
- `golden:verify`, `pytest tests_backend`, полная матрица смоков, перф-профили, `npm run invariants` —
|
||||
решение то же, что в r1/r2, и по той же причине (opt-in атрибут без влияния на golden-сцены; диф не
|
||||
про Python; не в AC/`smoke-select`; диф не про геометрию) — не переигрывал повторно, так как
|
||||
предпосылки решения (состав диффа) не изменились.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. Разбор — по дельте (§2.10), ребейза не было. Дельта r2→r3 — исключительно публикация
|
||||
конвейером ревью-документа r2, продуктовый код и тесты байт-в-байт идентичны материалу r2, что
|
||||
доказано диффом, а не принято на слово. Validate зелёный на точном SHA материала. Находок нет.
|
||||
Раунд не образует цикл (§4) — вердикт зелёный.
|
||||
|
||||
Отдельное наблюдение вне находок задачи: этот раунд полностью соответствует условию AGENTS.md
|
||||
(#499) «единственное отличие от материала — паблиш-коммит ревью-документа конвейера» — по этому
|
||||
правилу перестановка метки должна была произойти автоматически, без модельного вызова. Она снова
|
||||
не произошла (как и после r1), и это тот же класс сбоя механики, что уже отмечен в комментариях
|
||||
issue после r1 и r2. Находкой в рамках code-review #508 это не является (инфраструктура конвейера
|
||||
вне скоупа задачи), но стоит того, чтобы владелец знал: сбой перестановки метки после зелёного
|
||||
вердикта повторился третий раз подряд на одном и том же issue.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/508-summary-dialog-scroll`, коммит `be93291065a5` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `7499e3434f083728210a04ff0d1237202bf30bbe`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 7499e3434f08
|
||||
```
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,221 @@
|
||||
# CODE-REVIEW-509-r1
|
||||
|
||||
Заход r1 · блокирующих циклов израсходовано 0 из 4 (первый прогон дispatch на `172d9d5a` покраснел на собственном смоке автора, до чтения кода ревьюером — цикл не считается, PROCESS.md §2.9/#510).
|
||||
|
||||
Материал: диапазон `origin/dev..HEAD`, `HEAD` = `9b00002a5b09f64a4fea0b51bc03ca6be317ec7b` (рабочая копия уже на нём, `git fetch`/`checkout` не выполнялись).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Одна поверхность — runtime сводной панели (`src/summary-panel-runtime-loaded.ts`,
|
||||
`src/summary-panel-metrics.ts`, `src/summary-panel-style.ts`) плюс новый смок
|
||||
`demo/smoke_summary_first_paint.mjs`, четыре записи в `scripts/mutation-gate.mjs`
|
||||
и оба changelog. Модель данных, конфигурация и i18n не менялись — подтверждено
|
||||
чтением диффа (`git diff origin/dev...HEAD --stat`): изменений в `src/i18n/**`
|
||||
или в схеме конфигурации нет.
|
||||
|
||||
Контракт задан ТЗ (issue #509, полный трек, зелёный спек-ревью r2,
|
||||
`docs/reviews/SPEC-REVIEW-509-r2.md`): AC1–AC9 (AC7 — оба changelog).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Прочитан весь диф построчно (`git diff origin/dev...HEAD -- src/... test/...
|
||||
demo/smoke_summary_first_paint.mjs scripts/mutation-gate.mjs docs/CHANGELOG*`),
|
||||
сверены сигнатуры `wallBodiesGeometry` / `multiWallNodesForGeometry` /
|
||||
`innerContourForRoom` (`src/wall-thickness.ts:2434,2750,3662`) с новым вызовом
|
||||
`spaceWallGeometry` — аргументы и их порядок совпадают со схемой, которой уже
|
||||
пользуется карточка (`_innerContour`).
|
||||
|
||||
Прослежена машина состояний `valueState()` / `scheduleMetrics()` /
|
||||
`advanceMetrics()` / `computeDeviceCount()` вручную по всем ветвям: модуль не
|
||||
загружен → `pending`; entity-источник резолвится синхронно, минуя агрегаты;
|
||||
`device_count`/`total_area` без мемо → `pending` + планирование; с устаревшим
|
||||
мемо → `ready` со старым числом + фоновый пересчёт (AC9); `datetime` не
|
||||
зависит от агрегатов вовсе. Прослежен `resetLifecycle()`/`current(generation)`
|
||||
на предмет использования устаревшего таймера/генератора после отключения
|
||||
панели — `run()` первым делом обнуляет `metricsRefresh`, затем проверяет
|
||||
`current(generation)` и выходит без побочных эффектов; утечки не нашёл.
|
||||
|
||||
Дисциплина «тест умеет падать» проверена не на слово: каждый из четырёх новых
|
||||
мутантов прогнан ЛОКАЛЬНО в этой рабочей копии командой `node
|
||||
scripts/mutation-gate.mjs --id=<mutant>` — все четыре поймали свою поломку
|
||||
(«поймано 1 из 1»):
|
||||
|
||||
- `summary-first-paint-shows-unavailable` (AC1, guard — смок)
|
||||
- `summary-metrics-block-first-frame` (AC4, guard — смок)
|
||||
- `summary-area-recomputes-walls-per-room` (AC3, guard — юнит)
|
||||
- `summary-stale-metric-falls-back-to-skeleton` (AC9, guard — юнит)
|
||||
|
||||
Дополнительно эти же четыре мутанта независимо подтверждены в CI на этом же
|
||||
SHA: два — прямым прогоном dispatch-джобов `Мутанты по диффу (1/3)` и `(3/3)`
|
||||
(runs/34457268966), два — валидным переиспользованием ledger-кеша с прогона
|
||||
`(2/3)` того же диспатча, отпечаток входов (файл патча + файл гарда) не
|
||||
менялся между `172d9d5a` и `9b00002a` (единственная правка между ними —
|
||||
`demo/smoke_summary_first_paint.mjs`, к мутантам шарда 2 отношения не имеющая
|
||||
— проверено `git diff 172d9d5a..9b00002a --stat`). У самого мутанта
|
||||
`summary-stale-metric-falls-back-to-skeleton` найден «поймано» ещё и на
|
||||
исходном красном прогоне `34456100054` (шард 2 там был зелёным сам по себе,
|
||||
красным был только шард 1/3 из-за флейка в смоке, не в мутанте) — см.
|
||||
job `102802793874`.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **AC1/AC2** — `valueState()` возвращает `pending` пока `metricsModule` не
|
||||
загружен (для ЛЮБОГО источника, включая entity), и не путает это с
|
||||
`unavailable`; для реально отсутствующей сущности — честный `unavailable`.
|
||||
Юнит `#509 AC1/AC2/AC9` (`test/summary-panel-runtime.test.mjs`) гоняет обе
|
||||
ветки, включая живую и мёртвую сущность. Локально: `node --test
|
||||
test/summary-panel-runtime.test.mjs` — 5/5 ok.
|
||||
- **AC3** — `spaceWallGeometry` считает `wallBodiesGeometry` +
|
||||
`multiWallNodesForGeometry` один раз на пространство и передаёт в
|
||||
`innerContourForRoom` как `sharedRoomWallGeometry`/`sharedMultiWallNodes`;
|
||||
сигнатуры совпадают построчно с уже принятым способом, которым карточка
|
||||
передаёт `_innerContour`. Юнит со счётчиком вызовов подтверждает «один
|
||||
вызов на пространство, не на комнату» и что результат не меняется
|
||||
(`4.32` что со spy, что без); второй юнит подтверждает ускорение на
|
||||
фикстуре `large-house` (порог 2500 мс, грубый, не флейкует). Оба зелёные
|
||||
локально.
|
||||
- **AC4** — тяжёлый агрегат больше не считается внутри `renderPanel()`:
|
||||
`valueState()` только читает мемо и планирует фоновый пересчёт
|
||||
(`scheduleMetrics()`), сам расчёт стартует через
|
||||
`setTimeout(0)→rAF→setTimeout(0)`, как записано в «Принятых технических
|
||||
предположениях» ТЗ. Мутант, возвращающий синхронный расчёт в путь
|
||||
рендера (`summary-metrics-block-first-frame`), ловится смоком. Локально
|
||||
пересобранный бандл + `node demo/smoke_summary_first_paint.mjs`: первый
|
||||
кадр не заблокирован, 3 строки со скелетами, ни одного «unavailable».
|
||||
- **AC5** — анимация входа/выхода (190 мс, #505) реально проигрывается,
|
||||
потому что главный поток свободен к моменту показа; смок читает
|
||||
`getAnimations()` на обоих переходах. Проверено локально — `enterAnimated`
|
||||
и `exitAnimated` подтверждены смоком (полный вывод: `OK`).
|
||||
- **AC6** — `@media (prefers-reduced-motion: reduce)` глушит и пульсацию
|
||||
скелета, и fade-in значения, прямоугольник остаётся. Смок проверяет
|
||||
вычисленный `animationName === 'none'` и видимость плашки — оба true в
|
||||
локальном прогоне.
|
||||
- **AC8** — расчёт стартует сразу после показанного кадра (не отложен до
|
||||
простоя), скелет сменяется значением за разумное время: локально
|
||||
`значения через 2822 мс` (порог смока 6000 мс; машина ревьюера медленнее
|
||||
CI-раннера автора, где было ~1555 мс, — оба варианта далеко от порога).
|
||||
- **AC9** — устаревшее мемо не сбрасывается в скелет: `valueState()`
|
||||
возвращает `ready` со старым числом и планирует пересчёт в фоне, пока
|
||||
`metricsFresh()` не станет `true`. Юнит меняет `host._cfgEpoch` и
|
||||
проверяет ровно эту последовательность; мутант, убирающий проверку
|
||||
свежести, ловится.
|
||||
- **AC7** — оба changelog правлены в этом же диффе, синхронно по смыслу
|
||||
(en/ru), со ссылкой на #509; новых i18n-ключей нет (проверено — diff не
|
||||
трогает `src/i18n/**`); `docs/images/screenshots.json` меняет только
|
||||
`sourceFingerprint`/`sourceSha256`, все `imageSha256` — те же самые, то
|
||||
есть кадры документации побитово не изменились (`docs:accept --identical`,
|
||||
подтверждено логом job `docs` в CI на этом SHA).
|
||||
- **Одно число — один источник.** `totalCleanFloorAreaM2` остаётся
|
||||
единственной функцией, вычисляющей площадь для панели; никто другой в
|
||||
`src/**` её не вызывает (проверено `grep` по собранному бандлу и
|
||||
исходникам) — карточка и PDF используют отдельный путь через
|
||||
`_innerContour`, что и раньше, и ТЗ явно не заявляет это в скоуп. Новый
|
||||
путь (генератор) не создаёт второго источника числа — `totalCleanFloorAreaM2`
|
||||
остался тонкой синхронной обёрткой над тем же генератором, который
|
||||
использует и панель.
|
||||
- Типы: `npx tsc --noEmit` (через `npm run build`) — зелёный локально;
|
||||
`npm run bundle:sync` пересобрал бандл и синхронизировал три копии без
|
||||
расхождений.
|
||||
|
||||
## Гейты: что прогнал и почему
|
||||
|
||||
**Зелёный Validate уже на этом SHA (`9b00002a`), переиспользован без
|
||||
повторного прогона:**
|
||||
- push-прогон `34457195661` — предполётные проверки (докс/провенанс/процесс).
|
||||
- push-прогон на предыдущем коммите `172d9d5a` (`34455999479`, тот же диф по
|
||||
продукту, следующий коммит менял только смок) — job «Фронтенд: типы,
|
||||
юниты, мутанты, синхрон бандла» зелёный целиком: `npm run typecheck`,
|
||||
`no-new-any`, `npm test` (полный набор), `npm run build`, сверка
|
||||
бандл-деревьев, `bundle:budget`.
|
||||
- dispatch-прогон с мутантами `34457268966` — все три шарда `Мутанты по
|
||||
диффу` зелёные на точном `9b00002a`, включая чистый прогон
|
||||
`node demo/smoke_summary_first_paint.mjs`, `node --test
|
||||
test/summary-panel.test.mjs`, `node --test test/summary-panel-runtime.test.mjs`.
|
||||
|
||||
**Прогнал сам, локально, на этой же рабочей копии** (Validate не покрывает
|
||||
браузерные смоки для обычного пуша, а «работает ли оно» — моя ответственность):
|
||||
- `npm run build` — зелёный (typecheck включён).
|
||||
- `npm run bundle:sync` — зелёный, три копии бандла синхронны.
|
||||
- `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node
|
||||
--test test/summary-panel-runtime.test.mjs test/summary-panel.test.mjs` —
|
||||
25 + 5 тестов, все ok (в т.ч. новые `#509 AC1/AC2/AC9` и `#509 AC3` × 2).
|
||||
- `node demo/smoke_summary_first_paint.mjs` — `OK` (все 14 проверок).
|
||||
- `node demo/smoke_summary_panel.mjs` — `OK` (прямое совпадение по
|
||||
`_config/_layoutRev/_model`, `scripts/smoke-select.mjs` тоже его называет).
|
||||
- `node demo/smoke_summary_panel_polish.mjs`, `node
|
||||
demo/smoke_summary_warm_attach.mjs` — `OK` (заявлены автором как
|
||||
прогнанные; перепроверил сам вместо того, чтобы поверить на слово).
|
||||
- Каждый из 4 новых мутантов по отдельности: `node scripts/mutation-gate.mjs
|
||||
--id=<id>` — все 4 «поймано 1 из 1» (список выше).
|
||||
|
||||
**Не прогонял и почему:**
|
||||
- `npm test` целиком у себя — не требуется: уже зелёный в CI на диффе,
|
||||
который отличается от `HEAD` только смоком (не входит в `npm test`);
|
||||
прогнал точечно изменённые файлы вместо этого.
|
||||
- `npm run golden:verify` — в `demo/golden/**` нет ни одного сценария со
|
||||
сводной панелью (`grep -rl summary demo/golden/` — пусто); диф не может
|
||||
изменить ни один принятый кадр. AC7 отдельно ожидает «не ожидается»
|
||||
изменения golden.
|
||||
- `npm run invariants` — диф не трогает геометрическую модель (рёбра,
|
||||
записи толщины, `layout`, `marker.space`, `open_spans`): он лишь
|
||||
переиспользует уже существующие геометрические функции, не меняя, что
|
||||
они возвращают (доказано юнитом AC3 — результат идентичен). Инвариантов
|
||||
ребра/толщины это не касается.
|
||||
- `python -m pytest tests_backend` — диф не трогает
|
||||
`custom_components/**/*.py`.
|
||||
- `smoke_summary_dialog_scroll.mjs` (#508) и остальные «прямые совпадения»
|
||||
из `smoke-select` (`smoke_junction_holes`, `smoke_glow_fail_dark`,
|
||||
`smoke_wallthick_standalone` и т.п.) — все они совпали по предельно общим
|
||||
символам (`cellCm`, `NORM_W`, `GRID_PITCH`, `wallBodiesGeometry` как имя
|
||||
функции, не как поведение) и не имеют отношения к загрузочному состоянию
|
||||
панели или к переносу расчёта во времени — единственному, что меняет этот
|
||||
диф в геометрическом коде. Посмотрел список, прогонять не стал.
|
||||
- performance-профили — не названы в AC; AC4/AC8 доказываются собственным
|
||||
смоком задачи.
|
||||
|
||||
## Находки
|
||||
|
||||
**Low-1 (не блокирует, снимаю с записью).** Комментарий в
|
||||
`scheduleMetrics()` (`src/summary-panel-runtime-loaded.ts:~735`): «Каждая
|
||||
порция короче кадра, между порциями браузер обрабатывает ввод и рисует» —
|
||||
неточен для одного случая. Переход генератора `cleanFloorAreaSteps` между
|
||||
двумя ПРОСТРАНСТВАМИ выполняет `spaceWallGeometry` (единый polyclip-union)
|
||||
целиком внутри одного вызова `.next()`, без промежуточного `yield` — это
|
||||
именно тот «неделимый» кусок, из-за которого автор в этом же заходе поднял
|
||||
порог смока `noLongTaskWhileComputing` с 250 до 450 мс (коммит `9b00002a`,
|
||||
сам объясняет это в сообщении коммита и в комментарии смока). Итог по
|
||||
существу верный (AC4/AC8 выполняются, мутант «весь расчёт целиком»
|
||||
по-прежнему падает — перепроверено локально), но приведённая фраза
|
||||
обещает больше, чем код гарантирует: разработчик, который прочитает только
|
||||
её, ожидает, что ни один кусок не превышает ~16 мс, а по факту до трёх
|
||||
кусков по дому могут занять до ~450 мс каждый. Прошу поправить формулировку
|
||||
в следующей правке этого файла (не отдельным заходом ради одной фразы) —
|
||||
не блокирует, продуктовое поведение проверено и корректно.
|
||||
|
||||
## Итог
|
||||
|
||||
High: 0. Medium: 0 (обе Medium-находки спек-ревью были закрыты AC8/AC9 ещё
|
||||
на этапе ТЗ и подтверждены здесь тестами, которые умеют падать). Low: 1,
|
||||
снята с записью выше.
|
||||
|
||||
Все девять AC (AC1–AC9) доказаны либо тестом, который я лично проверил на
|
||||
способность падать (мутационным гейтом или прогоном красной/зелёной ветки),
|
||||
либо прямым чтением с перепроверкой сигнатур. Оба changelog на месте,
|
||||
i18n не тронут, модель данных не тронута, «одно число — один источник»
|
||||
выполняется. Возвращать в скоуп нечего.
|
||||
|
||||
**Вердикт: зелёный.**
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/509-summary-first-paint`, коммит `9b00002a5b09` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `017ac1f24095d4f7009510c19a82a9672984f656`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 017ac1f24095
|
||||
```
|
||||
- Тело issue: `6224b491f7bef6e6962692c3c901bf5121432fd1e0244740514a420aff96ba36`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,191 @@
|
||||
# CODE-REVIEW-510-r1
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/510
|
||||
- **Материал:** `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff` (рабочая копия на нём; `git rev-parse HEAD` сверен непосредственно перед выводом)
|
||||
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4
|
||||
- **Ветка:** `issue/510-mutants-on-candidate-and-review-gate`
|
||||
- **ТЗ:** `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md`, ревью ТЗ зелёное на r4 (`docs/reviews/SPEC-REVIEW-510-r4.md`)
|
||||
|
||||
Ветка приведена к `dev` конвейером до ревью: поверх старого материала `025634cc`
|
||||
легло 4 коммита `dev` (в том числе #511 `ci: release gate judges the latest
|
||||
non-cancelled Validate run of the SHA`), и текущий тип — `cbece632` — другой
|
||||
код по содержимому дерева (§7.2). Разбор ниже полный, не по дельте.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Полный трек, класс B/C: `.github/workflows/{process,validate}.yml`,
|
||||
`scripts/{classify-changes,merge-candidate,validate-gate,mutation-gate}.mjs`,
|
||||
пять тестовых файлов, `PROCESS.md`, `AGENTS.md`, `docs/TESTING.md`,
|
||||
`docs/specs/README.md`. Ни одного файла класса A — `src/**` не тронут,
|
||||
`User-Visible: no` на каждом коммите, changelog не требуется.
|
||||
|
||||
Предмет: `changed_mutants` в Validate идёт только по запросу (dispatch,
|
||||
PR, ночь, кандидат беты), ревью-конвейер и слияние кандидата сами
|
||||
запускают Validate с мутантами на материале и ждут его перед тем, как
|
||||
читать код или сливать в `dev`; правила хендоффа (один пуш на заход).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Зелёного Validate на `cbece632` в момент ревью не найдено — гейты
|
||||
прогнаны вручную.
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| unit | `npm test` | 2419 тестов, 2418 pass, 1 skip (`issue 281 private exact fixture` — предсуществующий, привязан к отсутствующей приватной фикстуре, не в этом диффе), 0 fail |
|
||||
| build + бандл | `npm run build` затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | сборка зелёная, копии идентичны |
|
||||
| check-docs | не прогонялся | `src/**` не тронут — гейт не относится к этому диффу (§8: отпечаток считается по `src/**`, здесь его нет) |
|
||||
| smoke / golden / pytest / invariants / perf | не прогонялись | ни одна геометрия, ни один визуальный путь, ни один Python-файл, ни один perf-профиль не задеты; AC6 явно фиксирует «`src/**` без изменений» |
|
||||
| мутанты реестра, заявленные ТЗ §8/AC4 | `node scripts/mutation-gate.mjs --id=<id>` по каждому из пяти | все пять «поймано 1 из 1» (таблица ниже) |
|
||||
| таргетированные unit-тесты диффа | `node --test test/{review-doc-guard,validate-workflow,validate-gate,merge-candidate,classify-changes}.test.mjs` | 103/103 pass |
|
||||
|
||||
### AC → доказательство → чем краснеет
|
||||
|
||||
| AC | Доказано | Чем краснеет |
|
||||
|---|---|---|
|
||||
| AC1 (обычный push не запускает `changed_mutants`; dispatch/PR/schedule/кандидат беты — запускают) | `test/classify-changes.test.mjs` (`mutantsRequested` таблица) + структурный тест `validate-workflow.test.mjs` на `if: needs.changes.outputs.mutants_requested == 'true'` | мутант `mutants-run-on-every-push` (classify всегда true) — прогнан лично, «поймано 1 из 1» |
|
||||
| AC2 (гейт находит/запускает/красный/таймаут/несовпадение SHA, ревью не идёт без зелёного) | `test/validate-gate.test.mjs` (9 сценариев: chужой skip-dispatch игнорируется, push не доказательство, красный, таймаут, материал сменился, слежение за tracked-run) + `test/review-doc-guard.test.mjs` (структура `process.yml`: порядок шагов, условия `proceed`, сохранённый конъюнкт `reuse != 'true'`) | мутанты `review-starts-on-red-validate` и `review-trusts-push-run-without-mutants` — прогнаны лично, оба «поймано 1 из 1» |
|
||||
| AC3 (слияние кандидата ждёт dispatch с мутантами, не push) | `test/merge-candidate.test.mjs` (dispatch после push, до validate; `waitValidate(candidate, {event:'workflow_dispatch'})`) | мутанты `merge-waits-push-run-without-mutants` и `merge-pushes-unvalidated-candidate` (переанкорен коммитом `cbece632`) — прогнаны лично, оба «поймано 1 из 1» |
|
||||
| AC4 (три мутанта протокола пойманы штатным раннером) | см. строку выше — фактически пять мутантов, не три (`mutants-run-on-every-push`, `review-starts-on-red-validate`, `review-trusts-push-run-without-mutants`, `merge-waits-push-run-without-mutants`, `merge-pushes-unvalidated-candidate`) | все пять — «поймано 1 из 1», лично воспроизведено, не только со слов хендоффа |
|
||||
| AC5 (доки обновлены) | проверено чтением: `PROCESS.md` (§10.4 «Ревью не начинается на красном коде», «Один хендофф — один пуш»), `AGENTS.md` (тот же блок), `docs/TESTING.md` («Мутанты по диффу»), `docs/specs/README.md` (строка на ТЗ) — построчно сверены с реализацией | не защитный AC (текст/наличие раздела) — свидетель обычное сравнение, третий столбец не нужен (§2.7) |
|
||||
| AC6 (perf/touch/UX не затронуты, `src/**` не тронут) | `git diff origin/dev...HEAD --stat` — ни одного файла `src/**` | не защитный AC — сравнение diff-stat |
|
||||
|
||||
### Демонстрация: тест умеет падать (снятая защита вручную, помимо реестра)
|
||||
|
||||
Для `provesMutants` и `isMutantRun` дополнительно прогнал `test/validate-gate.test.mjs`
|
||||
после ручного снятия проверки `run.conclusion !== 'success'` (без реестра
|
||||
`mutation-gate.mjs`) — красный, три упавших сценария (red/timeout/missing
|
||||
перестают различаться). Восстановлено сразу после проверки, в диффе не
|
||||
осталось.
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе задачи — чинится в этой же задаче, без отдельного issue)
|
||||
|
||||
**M1. Отменённый (`cancelled`) dispatch-прогон читается как красный, а не игнорируется — то же семейство дефектов, которое #511 только что исправил в соседнем скрипте той же ветки dev.**
|
||||
|
||||
- Файлы: `scripts/validate-gate.mjs:32` (`if (run.conclusion !== 'success') return { result: 'red', ... }`), тот же паттерн в `scripts/merge-candidate.mjs:147` (`result: run.conclusion === 'success' ? 'green' : 'red'`), которым #510 начал подавать новый вид прогона (`event: 'workflow_dispatch'`) — раньше туда попадал только push.
|
||||
- **Сценарий отказа:** пока гейт (или слияние кандидата) ждёт свой dispatch-прогон Validate, кто-то с правом записи запускает ещё один `workflow_dispatch` на той же ветке (ручная проверка, вторая попытка после сетевого сбоя, повторный запуск job). Группа concurrency dispatch-прогонов — `validate-dispatch-<ref>` с `cancel-in-progress: true` (сохранена этим же ТЗ §4, `validate.yml:37`) — отменяет прогон гейта. `validate-gate.mjs` находит его по точному SHA, `run.status === 'completed'`, `run.conclusion === 'cancelled'` — не `'success'` — и до проверки job'ов мутантов сразу возвращает `{result:'red', note:'dispatch-прогон завершился: cancelled'}`. Задача уходит в `S6-in-progress` с сообщением «Validate — red», хотя материал никто не отверг: прогон просто заменён.
|
||||
- **Воспроизведено лично** (не только рассуждением):
|
||||
```js
|
||||
import { validateGate } from './scripts/validate-gate.mjs';
|
||||
const SHA = 'a'.repeat(40);
|
||||
const ops = {
|
||||
listRuns: async () => [{ databaseId: 1, status: 'completed', conclusion: 'cancelled',
|
||||
url: 'https://run/cancelled', event: 'workflow_dispatch', headSha: SHA }],
|
||||
listRunsOnRef: async () => [], jobs: async () => [],
|
||||
dispatch: async () => { throw new Error('не должно понадобиться'); },
|
||||
sleep: async () => {}, now: () => 0,
|
||||
};
|
||||
await validateGate({ ref: 'issue/1', sha: SHA, ops, pollMs: 1000 });
|
||||
// → { result: 'red', url: 'https://run/cancelled', note: 'dispatch-прогон завершился: cancelled' }
|
||||
```
|
||||
Ни `test/validate-gate.test.mjs`, ни `test/merge-candidate.test.mjs` не содержат сценария с `conclusion: 'cancelled'` (`grep -i cancel` по обоим файлам и по обоим скриптам — пусто).
|
||||
- **Почему это в скоупе, а не смежная находка:** `scripts/validate-gate.mjs` — новый файл этой самой задачи, и его прямая цель — не тратить впустую цикл ревью/минуты раннера на прогон, который ничего не доказал. Отменённый прогон — ровно такой случай: он не про красный код, а про замещённый прогон, и должен либо игнорироваться (как уже сделано для «зелёного чужого dispatch без мутантов» — `ignored`/`tracked = null`/`continue`), либo триггерить повторный dispatch, а не отправлять задачу автору с ложным «Validate red». Тот же коммит-предок (`dev`, #511, `scripts/release-gate.mjs`: `latestRelevantRun` — «cancelled run proves nothing either way») решает точно эту проблему для соседнего скрипта той же категории («дождаться зелёного Validate на SHA») буквально в этом же ребейзе — то есть паттерн исправления уже есть в дереве, просто не применён к новому коду.
|
||||
- **Не блокирует:** направление отказа безопасное (не пропускает недоказанный код в ревью/слияние), это не потеря доказательства, а лишний беспричинный откат к автору — то есть дефект существующий, но не High. Чинится в этой же задаче (Medium в скоупе, §2.7/#202) — добавить `cancelled` в игнорируемые исходы (как для skip-dispatch) в обоих местах и тест на сценарий.
|
||||
|
||||
Других Medium/High не найдено.
|
||||
|
||||
### Low
|
||||
|
||||
Не найдено требующих записи — единственная спорная деталь (M1) достаточно
|
||||
конкретна и воспроизведена, чтобы быть Medium, а не «наблюдением».
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- `.github/workflows/process.yml`: новый шаг `gate` стоит после `material` и
|
||||
`reuse` и до `validated`/установки зависимостей/`Review` — порядок
|
||||
подтверждён и структурным тестом, и чтением файла. `proceed` подставлен
|
||||
ровно на место конъюнкта `steps.rebase.outputs.conflict != 'true'` во всех
|
||||
перечисленных ТЗ шагах; прочие конъюнкты (`reuse != 'true'`, `stage ==
|
||||
'code'`, `decide.outputs.green == 'true'`) не тронуты — reuse-тест #499
|
||||
(«модель не вызывается») остаётся зелёным без изменений своей логики.
|
||||
- `.github/workflows/validate.yml`: вход `mutants` (default `false`),
|
||||
`mutants_requested` в выходах `changes`, `changed_mutants.if` заменён с
|
||||
файлового отбора на `mutants_requested == 'true'` — job исполняется даже
|
||||
на пустом отборе файлов (подтверждено чтением тела job: матрица из трёх
|
||||
шардов не имеет собственного условия внутри, только job-level `if`), что
|
||||
и требуется, чтобы `skipped` однозначно означал «не запрашивали».
|
||||
- `scripts/classify-changes.mjs`: `mutantsRequested` — `pull_request`/
|
||||
`schedule` → true, `workflow_dispatch` → `full || mutants`, `push` → только
|
||||
по трейлеру `Release:`. Таблица тестов покрывает все ветки, включая
|
||||
«кнопка без запроса» (`full=false, mutants=false` → false).
|
||||
- `scripts/validate-gate.mjs`: `isMutantRun`/`provesMutants` корректно
|
||||
отличают чужой зелёный dispatch без исполненных job мутантов (`skipped`)
|
||||
от настоящего доказательства; таймаут и «материал сменился» (dispatch
|
||||
появился на другом SHA) разведены и оба протестированы.
|
||||
- `scripts/merge-candidate.mjs`: `dispatchValidate` вызывается один раз
|
||||
после успешного `pushWithLease` кандидата, `waitValidate` теперь
|
||||
фильтрует по `event`, что делает возможным ждать именно dispatch, а не
|
||||
push-прогон на той же SHA — порядок вызовов (push → dispatch → validate)
|
||||
подтверждён тестом на реальном git (`на настоящем git: чистый ребейз`).
|
||||
- Пять мутантов реестра (AC4) лично прогнаны через `node
|
||||
scripts/mutation-gate.mjs --id=<id>` — все дают «поймано 1 из 1», не
|
||||
только заявлены хендоффом.
|
||||
- Документация (`PROCESS.md`, `AGENTS.md`, `docs/TESTING.md`,
|
||||
`docs/specs/README.md`) построчно сверена с реализацией — расхождений
|
||||
не найдено; `AGENTS.md` и `PROCESS.md` описывают именно то место
|
||||
мутантов и то же правило хендоффа, которое реализует код.
|
||||
- Асимметрии между `validate-gate.mjs` (гейт перед ревью) и
|
||||
`merge-candidate.mjs` (гейт перед слиянием) нет там, где она была бы
|
||||
находкой: у `merge-candidate.mjs` SHA кандидата всегда свежесозданный
|
||||
(только что запушен через `--force-with-lease`), поэтому там нет риска
|
||||
подобрать «чужой» зелёный push-прогон без мутантов, который решает
|
||||
`provesMutants` в `validate-gate.mjs` — разное решение для разных
|
||||
сценариев обосновано, не дефект.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- `check-docs.mjs`, golden, смоки, инварианты модели, `pytest
|
||||
tests_backend`, perf-профили — diff не касается `src/**`,
|
||||
`custom_components/**/*.py` или геометрии; AC6 явно фиксирует отсутствие
|
||||
влияния. Прогон был бы тратой времени без цели.
|
||||
- Зеркалирование `process.yml`/`validate.yml` в `main` (AC5, §5.3
|
||||
спеки) — по правилу #454 происходит **после** слияния в `dev`; на этом
|
||||
SHA проверять нечего, это шаг публикации, не код-ревью.
|
||||
- Реальный прогон `validate-gate.mjs`/`dispatchValidate` против живого GitHub
|
||||
Actions (`realOps`, `gh` CLI) — не воспроизводил: `gh` в этом окружении
|
||||
не аутентифицирован на этот репозиторий для целей ревью, а хендофф-
|
||||
комментарий уже приводит два реальных дешёвых/dispatch-прогона на
|
||||
материале (`34367209869` зелёный за 1:56, `34368454919` зелёный с тремя
|
||||
исполненными job «Мутанты по диффу») — доверяю этим ссылкам как факту
|
||||
внешнего состояния, а не как замене чтения кода; логика `ops`-инъекции
|
||||
проверена юнитами и мутантами, что и есть предмет код-ревью для скрипта
|
||||
без сети.
|
||||
- Полный `npm run gate:small` / `--smokes` не запускал — при отсутствии
|
||||
`src/**` в диффе браузерные смоки не выбираются `smoke-select.mjs` в
|
||||
принципе (нет входов), прогон дал бы пустой список.
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- SHA: `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff` (сверено `git rev-parse HEAD`
|
||||
непосредственно перед выводом вердикта).
|
||||
- Ветка: `issue/510-mutants-on-candidate-and-review-gate`.
|
||||
- Ребейз: старый материал хендоффа `025634cc` не входит в историю `cbece632`
|
||||
(`git log --oneline 025634cc..cbece632` не показывает общего предка в
|
||||
выводимом диапазоне — ветка была переиграна поверх ушедшего вперёд `dev`,
|
||||
включая #511). Полный разбор выполнен по этой причине, не по дельте.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Жёлтый: одна находка Medium в скоупе (M1), High нет. AC1–AC6 доказаны и
|
||||
перепроверены лично (тесты + пять мутантов + сборка), гейты класса
|
||||
code — зелёные без ссылки на CI (typecheck/test/build прогнаны в этом
|
||||
ревью). Возврат автору для правки M1 в этой же задаче.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/510-mutants-on-candidate-and-review-gate`, коммит `025634cc7599` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `b043dcd5e460e5e25561c530355b262c018f4c2c`
|
||||
```
|
||||
git log --all --format='%H %T' | grep b043dcd5e460
|
||||
```
|
||||
- ТЗ `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md`, блоб `df99bed3c772e12fcfde5439176d6827fe6d79dd`
|
||||
```
|
||||
git log --all --find-object=df99bed3c772e12fcfde5439176d6827fe6d79dd -- docs/specs/510-mutants-on-candidate-and-review-waits-validate.md
|
||||
```
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,245 @@
|
||||
# CODE-REVIEW-510-r2
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/510
|
||||
- **Материал:** `00130e08aa247cd8796a99125deb7f9b5131bfa1` (рабочая копия на нём; `git rev-parse HEAD` сверен непосредственно перед выводом)
|
||||
- **Заход:** r2 · блокирующих циклов израсходовано (после этого раунда) 2 из 4
|
||||
- **Ветка:** `issue/510-mutants-on-candidate-and-review-gate`
|
||||
- **Предыдущий раунд:** CODE-REVIEW-510-r1 (жёлтый, Medium M1), материал `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff`
|
||||
- **ТЗ:** `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md` (r2 добавил одно предложение про `cancelled`), ревью ТЗ зелёное на r4 (`docs/reviews/SPEC-REVIEW-510-r4.md`)
|
||||
|
||||
## Скоуп раунда
|
||||
|
||||
`git log --oneline cbece632..00130e08`:
|
||||
|
||||
```
|
||||
00130e08 ci: a cancelled Validate dispatch proves nothing to the review gate
|
||||
f6e64ba9 docs: review document for #510
|
||||
```
|
||||
|
||||
Никакого ребейза между r1 и r2 не было: `00130e08` — прямой потомок `cbece632`
|
||||
(два коммита сверху), дерево то же самое плюс точечный фикс M1. Это не смена
|
||||
кода по содержанию (§7.2) — разбор ведётся по дельте.
|
||||
|
||||
`git diff cbece632..00130e08 --stat`:
|
||||
|
||||
```
|
||||
docs/reviews/CODE-REVIEW-510-r1.md | 191 +++++++++
|
||||
docs/specs/510-mutants-on-candidate-and-review-waits-validate.md | 2 +-
|
||||
scripts/merge-candidate.mjs | 4 +-
|
||||
scripts/mutation-gate.mjs | 11 ++
|
||||
scripts/validate-gate.mjs | 10 +-
|
||||
test/validate-gate.test.mjs | 18 ++
|
||||
6 files changed, 233 insertions(+), 3 deletions(-)
|
||||
```
|
||||
|
||||
Предмет дельты — ровно закрытие M1 из r1: «отменённый (`cancelled`)
|
||||
dispatch-прогон читается как красный вместо того, чтобы игнорироваться»,
|
||||
найденное в двух местах (`scripts/validate-gate.mjs` и
|
||||
`scripts/merge-candidate.mjs`). Разбор этого раунда сосредоточен на том,
|
||||
чем и насколько полно M1 закрыт; остальные AC (1, 3, 4, 5, 6) delta не
|
||||
задевает — унаследованы из r1 без повторной проверки (раздел ниже).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Зелёного Validate на `00130e08` не найдено (см. заголовок задачи) — гейты
|
||||
прогнаны лично.
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| unit (полный набор) | `npm test` | 2421 тестов, 2420 pass, 1 skip (предсуществующий #281, не в этом диффе), 0 fail |
|
||||
| build + бандл | `npm run build` затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | сборка зелёная, копии идентичны |
|
||||
| check-docs / golden / смоки / инварианты / pytest / perf | не прогонялись | `src/**` и `custom_components/**/*.py` в дельте не тронуты — те же основания, что в r1 (AC6) |
|
||||
| новый мутант протокола `review-returns-task-on-cancelled-dispatch` | `node scripts/mutation-gate.mjs --id=review-returns-task-on-cancelled-dispatch` | `поймано 1 из 1` (лично, не со слов хендоффа) |
|
||||
| пять мутантов, подтверждённых в r1 (AC4) | не перепрогонялись | делта не трогает их патчи/guard-и (`mutation-gate.mjs` только добавил новую запись) — унаследовано |
|
||||
|
||||
### Демонстрация: тест умеет падать — и её отсутствие там, где оно важно
|
||||
|
||||
Для нового мутанта `review-returns-task-on-cancelled-dispatch` заявленный
|
||||
раннер сам это доказывает («тест покраснел, как обязан»). Но M1 в r1 назвал
|
||||
**два** места с одним и тем же паттерном — `scripts/validate-gate.mjs` и
|
||||
`scripts/merge-candidate.mjs`. Проверил вторую половину лично:
|
||||
|
||||
```
|
||||
cp scripts/merge-candidate.mjs /tmp/merge-candidate.mjs.bak
|
||||
# вручную снял фильтр отменённых прогонов:
|
||||
# const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
|
||||
# → const runs = all.filter((x) => (!event || x.event === event));
|
||||
node --test test/merge-candidate.test.mjs # 10/10 pass — ни одного упавшего
|
||||
npm test # 2420/2420 pass — ни одного упавшего
|
||||
cp /tmp/merge-candidate.mjs.bak scripts/merge-candidate.mjs # восстановлено, git diff пуст
|
||||
```
|
||||
|
||||
Результат: снятие фикса в `merge-candidate.mjs` **не роняет ни одного теста
|
||||
во всём проекте**. См. находку ниже.
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе задачи — чинится в этой же задаче, без отдельного issue)
|
||||
|
||||
**M1 (новая, r2). Закрытие M1(r1) асимметрично: половина фикса в
|
||||
`scripts/merge-candidate.mjs` не защищена ни одним тестом или мутантом.**
|
||||
|
||||
- Файл: `scripts/merge-candidate.mjs:145` — `waitValidate` внутри `realOps`:
|
||||
```js
|
||||
const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
|
||||
```
|
||||
Сам код корректен и делает ровно то, что требует обновлённый текст ТЗ
|
||||
(«то же в `waitValidate` слияния»): отменённый прогон исключается из
|
||||
списка кандидатов, гейт слияния ждёт замену вместо того, чтобы красить
|
||||
кандидата.
|
||||
- **Но:** ни один тест не вызывает эту ветку кода. `test/merge-candidate.test.mjs`
|
||||
проверяет `mergeCandidate()` только с полностью замоканным `ops.waitValidate`
|
||||
(строки 83, 207) либо со «настоящим git», где `ops.waitValidate` тоже
|
||||
подменён фейком (строка 207 того же теста) — реальная реализация
|
||||
`waitValidate` внутри `realOps()` не вызывается **ни разу** ни в одном
|
||||
тесте проекта. Соответственно ни один из пяти мутантов, привязанных к
|
||||
`merge-candidate.mjs` (`merge-pushes-unvalidated-candidate`,
|
||||
`merge-ignores-lease-rejection`, `merge-waits-push-run-without-mutants` и
|
||||
др., `scripts/mutation-gate.mjs:3696,3707,8178`), не патчит и не может
|
||||
поймать регресс именно этой строки — реестр про неё вообще не знает.
|
||||
- **Воспроизведено лично** (см. блок выше): при полностью снятом фильтре
|
||||
`x.conclusion !== 'cancelled'` весь набор (`npm test`, 2420 тестов)
|
||||
остаётся зелёным. Это ровно тот класс дефекта, о котором предупреждает
|
||||
сама дисциплина ревью этого проекта («зелёный тест без реальной защиты»,
|
||||
PROCESS.md §2.7, прецеденты #423/#430) — только тут защиты нет вообще,
|
||||
а не «не та».
|
||||
- **Почему это Medium, а не Low/наблюдение:** `scripts/validate-gate.mjs`
|
||||
получил в этом же коммите два прицельных теста и мутант реестра для
|
||||
ровно того же паттерна; `scripts/merge-candidate.mjs` — тот же паттерн,
|
||||
та же формулировка ТЗ, но без единого свидетеля. Будущий рефакторинг
|
||||
(например, унификация двух копий одной и той же логики фильтрации,
|
||||
которую сам этот диф оставил задублированной) может тихо вернуть
|
||||
«cancelled = red» именно в путь слияния кандидата — и ничего в CI об
|
||||
этом не сообщит. Это не гипотетический сценарий, а прямое следствие
|
||||
того, что issue #510 в целом решает задачу «не тратить впустую циклы
|
||||
ревью/минуты раннера»: путь слияния (`merge-candidate.mjs`) — как раз
|
||||
тот, где отменённый прогон при повторной попытке гейта слияния красит
|
||||
готового к мержу кандидата.
|
||||
- **Почему в скоупе:** M1 в r1 явно называла оба файла как один и тот же
|
||||
дефект («тот же паттерн в `scripts/merge-candidate.mjs:147`»); закрытие
|
||||
найдено только частичным — код исправлен, но не тестом, который умеет
|
||||
упасть. Чинится добавлением теста, эксплуатирующего `realOps().waitValidate`
|
||||
напрямую (с фейковым `sh`/`gh` или через существующий git-фикстурный тест
|
||||
строка ~200, но с подставленным реальным `waitValidate` вместо фейка) плюс
|
||||
записи в реестр мутантов.
|
||||
|
||||
Других Medium/High не найдено.
|
||||
|
||||
### Low
|
||||
|
||||
Не найдено требующих записи.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| **M1** (`cancelled` dispatch читается как `red` вместо игнорирования) — половина 1: `scripts/validate-gate.mjs:32` | Код: `validate-gate.mjs:60-66` — ветка `run.conclusion === 'cancelled'` добавляет прогон в `ignored`, сбрасывает `tracked`, продолжает цикл (ждёт замену либо диспатчит свой). Тест: `test/validate-gate.test.mjs:109-125` — два новых сценария («замена уже есть» / «замены нет»), лично прогнаны, зелёные. Мутант: `scripts/mutation-gate.mjs` id `review-returns-task-on-cancelled-dispatch` — лично прогнан, `поймано 1 из 1` | `git diff cbece632..00130e08 -- scripts/validate-gate.mjs test/validate-gate.test.mjs scripts/mutation-gate.mjs`; вывод `node scripts/mutation-gate.mjs --id=review-returns-task-on-cancelled-dispatch` выше |
|
||||
| **M1** — половина 2: `scripts/merge-candidate.mjs:147` (было) | Код исправлен: `merge-candidate.mjs:145` — тот же фильтр `x.conclusion !== 'cancelled'` внутри `waitValidate`. Тест/мутант — **отсутствуют**, регрессия не ловится (см. воспроизведение выше) | `git diff cbece632..00130e08 -- scripts/merge-candidate.mjs`; ручной откат фикса + `npm test` (2420/2420 pass) выше — новая находка M1(r2) |
|
||||
| Low (r1: «не найдено требующих записи») | — | нечего закрывать, r1 не оставил Low |
|
||||
|
||||
## Унаследовано из r1
|
||||
|
||||
Принято без повторной проверки в этом раунде — дельта (`docs/specs/*.md` +1
|
||||
строка, `scripts/{merge-candidate,mutation-gate,validate-gate}.mjs`,
|
||||
`test/validate-gate.test.mjs`) их не задевает. Источник — CODE-REVIEW-510-r1.md
|
||||
на материале `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff`:
|
||||
|
||||
- **AC1** (обычный push не запускает `changed_mutants`; dispatch/PR/schedule/кандидат
|
||||
беты — запускают) — доказано `test/classify-changes.test.mjs` +
|
||||
`test/validate-workflow.test.mjs`, мутант `mutants-run-on-every-push`
|
||||
(«поймано 1 из 1» в r1). Файлы дельтой не тронуты.
|
||||
- **AC2** (гейт находит/запускает/красный/таймаут/несовпадение SHA) — доказано
|
||||
`test/validate-gate.test.mjs` (исходные 9 сценариев r1) + `review-doc-guard.test.mjs`,
|
||||
мутанты `review-starts-on-red-validate` / `review-trusts-push-run-without-mutants`.
|
||||
`.github/workflows/process.yml` дельтой r1→r2 не тронут вовсе.
|
||||
- **AC3** (слияние ждёт dispatch с мутантами, не push) — доказано
|
||||
`test/merge-candidate.test.mjs`, мутанты `merge-waits-push-run-without-mutants` /
|
||||
`merge-pushes-unvalidated-candidate`. Логика диспатча/ожидания, за исключением
|
||||
фильтра `cancelled` (см. новую находку M1 выше), дельтой не изменена.
|
||||
- **AC4** (мутанты протокола пойманы штатным раннером) — пять мутантов r1
|
||||
подтверждены лично в r1; дельта r2 добавляет к ним шестой
|
||||
(`review-returns-task-on-cancelled-dispatch`, подтверждён в этом раунде).
|
||||
Остальные пять не перепрогонялись — их патчи/guard-файлы не менялись.
|
||||
- **AC5** (доки описывают место мутантов и правила хендоффа) —
|
||||
`PROCESS.md`/`AGENTS.md`/`docs/TESTING.md`/`docs/specs/README.md` сверены
|
||||
построчно в r1; в дельте r1→r2 из этого списка правок нет (только сам
|
||||
ТЗ-файл получил одно уточняющее предложение — см. `git diff` выше,
|
||||
прочитано, соответствует коду).
|
||||
- **AC6** (`src/**` не тронут, perf/touch/UX не задеты) — верно и для этой
|
||||
дельты: `git diff cbece632..00130e08 --stat` не содержит ни одного файла
|
||||
`src/**`.
|
||||
- Асимметрия дизайна между `validate-gate.mjs` (учитывает чужой push-прогон)
|
||||
и `merge-candidate.mjs` (SHA кандидата всегда свежий) — признана
|
||||
обоснованной в r1, дельта её не меняет.
|
||||
|
||||
## Что проверено и корректно (в этом раунде)
|
||||
|
||||
- `scripts/validate-gate.mjs`: ветка `cancelled` корректно встроена в
|
||||
существующий цикл `ignored`/`tracked=null`/`continue` — не ломает уже
|
||||
протестированные сценарии (chужой skip-dispatch, красный, таймаут,
|
||||
«материал сменился», слежение за tracked-run — все 9 старых тестов плюс
|
||||
2 новых зелёные).
|
||||
- `scripts/merge-candidate.mjs`: сама формула фильтра корректна и
|
||||
синтаксически идентична логике `validate-gate.mjs` — по коду поведение
|
||||
правильное (проверено чтением и ручным откатом-и-восстановлением,
|
||||
описанным выше), проблема только в отсутствии автотеста.
|
||||
- Правка ТЗ (`docs/specs/...md`, одна строка) точно описывает реализованное
|
||||
поведение обоих мест, включая «то же в `waitValidate` слияния» — текст
|
||||
не разошёлся с кодом.
|
||||
- `scripts/mutation-gate.mjs`: новая запись `review-returns-task-on-cancelled-dispatch`
|
||||
синтаксически корректна (`find`/`replace` совпадают один-в-один со строкой
|
||||
кода), `guard` запускает верный тестовый файл, лично прогнана — красит
|
||||
тест при внесении мутации и не красит при чистом дереве.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- `check-docs.mjs`, golden, смоки, инварианты модели, `pytest tests_backend`,
|
||||
perf-профили — дельта не касается `src/**`, `custom_components/**/*.py`
|
||||
или геометрии; основание не изменилось с r1 (AC6).
|
||||
- Пять мутантов протокола, подтверждённых в r1 (`mutants-run-on-every-push`,
|
||||
`review-starts-on-red-validate`, `review-trusts-push-run-without-mutants`,
|
||||
`merge-waits-push-run-without-mutants`, `merge-pushes-unvalidated-candidate`) —
|
||||
не перепрогонял: дельта не меняет ни их `patches`, ни `guard`-файлы
|
||||
(`git diff` выше показывает только добавление новой записи в реестр).
|
||||
- Реальный прогон `validate-gate.mjs`/`merge-candidate.mjs` против живого
|
||||
GitHub Actions — не воспроизводил (та же причина, что в r1: `gh` в этом
|
||||
окружении не аутентифицирован для целей ревью); логика проверена юнитами,
|
||||
мутантами и, для найденного пробела, ручным откатом-фикса — это и есть
|
||||
предмет код-ревью для скрипта без сети.
|
||||
- Зеркалирование `process.yml`/`validate.yml` в `main` (AC5, §5.3 ТЗ) — шаг
|
||||
публикации после слияния, не код-ревью.
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- SHA: `00130e08aa247cd8796a99125deb7f9b5131bfa1` (сверено `git rev-parse HEAD`
|
||||
перед выводом).
|
||||
- Предыдущий материал (r1, код): `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff`.
|
||||
- Ребейза между раундами не было — прямые 2 коммита сверху, дельта локальна.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Жёлтый: одна находка Medium в скоупе (M1(r2) — половина фикса M1(r1) в
|
||||
`scripts/merge-candidate.mjs` не защищена тестом/мутантом, регресс
|
||||
воспроизведён лично и не ловится ни одним из 2420 тестов проекта), High
|
||||
нет. Остальное из r1 (AC1, AC3–AC6, половина M1 в `validate-gate.mjs`)
|
||||
закрыто полностью и подтверждено. Возврат автору для добавления теста
|
||||
(и, по возможности, мутанта) на `realOps().waitValidate` в
|
||||
`scripts/merge-candidate.mjs`.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/510-mutants-on-candidate-and-review-gate`, коммит `00130e08aa24` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `e53cf9a26b9c66fb7602e485b8dffd1c7d286212`
|
||||
```
|
||||
git log --all --format='%H %T' | grep e53cf9a26b9c
|
||||
```
|
||||
- ТЗ `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md`, блоб `d68ed7818b4c77535305d0aae7da9765a2ad6c61`
|
||||
```
|
||||
git log --all --find-object=d68ed7818b4c77535305d0aae7da9765a2ad6c61 -- docs/specs/510-mutants-on-candidate-and-review-waits-validate.md
|
||||
```
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,230 @@
|
||||
# CODE-REVIEW-510-r3
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/510
|
||||
- **Материал:** `615181050b9ba4d4568834d56ac5a0ed97ca804d` (рабочая копия на нём; `git rev-parse HEAD` сверен перед выводом)
|
||||
- **Заход:** r3 · блокирующих циклов израсходовано 2 из 4 (зелёный вердикт цикла не образует, #227)
|
||||
- **Ветка:** `issue/510-mutants-on-candidate-and-review-gate`
|
||||
- **Предыдущий раунд:** CODE-REVIEW-510-r2 (жёлтый, Medium M1(r2)), материал `00130e08aa247cd8796a99125deb7f9b5131bfa1`
|
||||
- **ТЗ:** `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md` (без изменений в этой дельте), ревью ТЗ зелёное на r4 (`docs/reviews/SPEC-REVIEW-510-r4.md`)
|
||||
|
||||
## Скоуп раунда
|
||||
|
||||
Ребейза между r2 и r3 не было: `git merge-base --is-ancestor 00130e08 HEAD`
|
||||
подтверждает прямое потомство, `git log --oneline 00130e08..HEAD` даёт ровно
|
||||
два коммита сверху:
|
||||
|
||||
```
|
||||
61518105 test: the real waitValidate is exercised against a cancelled dispatch
|
||||
8acbd333 docs: review document for #510
|
||||
```
|
||||
|
||||
`git diff 00130e08..HEAD --stat` (без `docs/reviews/**`):
|
||||
|
||||
```
|
||||
scripts/merge-candidate.mjs | 8 +-
|
||||
scripts/mutation-gate.mjs | 11 ++
|
||||
test/merge-candidate.test.mjs | 39 ++++
|
||||
3 files changed, 55 insertions(+), 3 deletions(-)
|
||||
```
|
||||
|
||||
Предмет дельты — ровно и единственно закрытие **M1(r2)**: половина фикса
|
||||
M1(r1) в `scripts/merge-candidate.mjs::waitValidate` (отменённый dispatch не
|
||||
должен читаться как красный) была исправлена в коде, но не имела ни одного
|
||||
теста или мутанта — реальная реализация `waitValidate` внутри `realOps()`
|
||||
никогда не вызывалась ни в одном тесте проекта. Разбор этого раунда
|
||||
сосредоточен на том, закрыт ли этот пробел; AC1–AC6 дельта не задевает и
|
||||
наследуются из r1/r2 без повторной проверки (раздел ниже).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Зелёного Validate на `61518105` нет — проверено лично:
|
||||
`gh run list --repo Matysh/houseplan-card --workflow validate.yml --commit 61518105 …`
|
||||
вернул `[]` (прогона на этом SHA не существует вовсе). Гейты прогнаны лично.
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| unit (полный набор) | `npm test` | 2423 тестов, 2422 pass, 1 skip (предсуществующий #281, не в этом диффе), 0 fail |
|
||||
| build + бандл (2 committed-копии) | `npm run build` затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | сборка зелёная; `cmp` — идентичны; `git status --porcelain` после сборки пуст — рабочая копия и так была на актуальном бандле |
|
||||
| смок-селектор | `node scripts/smoke-select.mjs --base 00130e08 --head HEAD` | «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут). Browser-smoke этим диффом не выбираются — выбирать нечего. Тронуто файлов: 4» — браузерные смоки не запускались, основание — вывод инструмента, а не тема диффа |
|
||||
| check-docs / golden / инварианты / pytest / perf | не прогонялись | дельта не трогает `src/**` ни `custom_components/**/*.py` (подтверждено тем же diff --stat и выводом smoke-select) |
|
||||
| новый мутант `merge-trusts-cancelled-dispatch` | `node scripts/mutation-gate.mjs --id=merge-trusts-cancelled-dispatch` | `поймано 1 из 1` (лично) |
|
||||
| пять мутантов r1 + один мутант r2 (`review-returns-task-on-cancelled-dispatch`) | не перепрогонялись | делта r2→r3 не трогает их `patches`/`guard` — новая запись реестра добавлена, старые не изменены (`git diff` выше показывает только вставку блока) |
|
||||
|
||||
### Демонстрация: тест умеет падать (независимая проверка, не со слов автора)
|
||||
|
||||
Ручной откат фикса, тот же приём, что в r2, — на текущем коде:
|
||||
|
||||
```
|
||||
# вручную снял фильтр в scripts/merge-candidate.mjs:
|
||||
# const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
|
||||
# → const runs = all.filter((x) => (!event || x.event === event));
|
||||
node --test test/merge-candidate.test.mjs
|
||||
```
|
||||
|
||||
Результат: **10 pass, 2 fail** — оба новых теста краснеют независимо друг от
|
||||
друга:
|
||||
|
||||
```
|
||||
not ok 11 - #510 r2 M1: realOps.waitValidate ignores a cancelled dispatch and follows its replacement
|
||||
expected: { result: 'green', url: 'https://run/3' }
|
||||
actual: не тот исход (продолжает опрашивать не туда/красит раньше)
|
||||
not ok 12 - #510 r2 M1: realOps.waitValidate with only a cancelled dispatch reports missing after the appear window, never red
|
||||
expected: 'missing'
|
||||
actual: 'red'
|
||||
```
|
||||
|
||||
Файл восстановлен, `git status --porcelain` пуст. Это прямой контраст с r2,
|
||||
где тот же ручной откат при том же полном `npm test` **не ронял ни одного
|
||||
теста из 2420** — асимметрия закрыта: обе ветки (`green`-цепочка через
|
||||
замену и одинокий `cancelled` без замены) теперь имеют падающего свидетеля,
|
||||
не только мутант из реестра.
|
||||
|
||||
Штатный мутант `merge-trusts-cancelled-dispatch`
|
||||
(`scripts/mutation-gate.mjs:8161`) патчит ровно ту же строку тем же
|
||||
`find`/`replace`, гвард — `node --test test/merge-candidate.test.mjs`,
|
||||
прогнан лично: `поймано 1 из 1`.
|
||||
|
||||
## Находки
|
||||
|
||||
Новых High/Medium/Low не найдено. Дельта узкая, точечная и делает ровно то,
|
||||
что требовала находка M1(r2) — не больше и не меньше.
|
||||
|
||||
Отдельно проверено, что фикс не меняет поведение продакшен-пути: единственный
|
||||
вызов `realOps({ repo, token })` в `scripts/merge-candidate.mjs:242` не
|
||||
передаёт `exec`, поэтому в реальном запуске используется тот же `sh`
|
||||
(`spawnSync`-обёртка), что и до этой правки — инъекция существует только для
|
||||
теста. Проверено чтением (`grep -rn "realOps(" ...` — единственный
|
||||
продакшен-вызов без параметра `exec`).
|
||||
|
||||
## Закрытие раунда r2
|
||||
|
||||
| Находка r2 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| **M1(r2)**: половина фикса M1(r1) в `scripts/merge-candidate.mjs::waitValidate` не защищена ни одним тестом/мутантом — реальная реализация не вызывалась ни в одном тесте проекта, ручной откат фильтра оставлял весь `npm test` (2420/2420) зелёным | `realOps()` получил инъекцию `exec` (по умолчанию `sh`, поведение продакшена не изменилось — см. выше); `test/merge-candidate.test.mjs:248-278` — два новых теста гоняют **реальный** `waitValidate` через скриптованные ответы `gh run list`, не подменяя саму функцию; мутант `merge-trusts-cancelled-dispatch` добавлен в `scripts/mutation-gate.mjs:8161-8171` | `git diff 00130e08..HEAD -- scripts/merge-candidate.mjs scripts/mutation-gate.mjs test/merge-candidate.test.mjs`; независимый ручной откат в этом раунде — 2/12 теста падают (было 0/2420 в r2); `node scripts/mutation-gate.mjs --id=merge-trusts-cancelled-dispatch` → «поймано 1 из 1» |
|
||||
|
||||
## Унаследовано из r1/r2
|
||||
|
||||
Принято без повторной проверки в этом раунде — дельта
|
||||
(`scripts/merge-candidate.mjs` +4/-4 внутри `realOps`, `scripts/mutation-gate.mjs`
|
||||
+11, `test/merge-candidate.test.mjs` +39) не задевает ни один из перечисленных
|
||||
предметов. Источники: CODE-REVIEW-510-r1.md (материал `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff`)
|
||||
и CODE-REVIEW-510-r2.md (материал `00130e08aa247cd8796a99125deb7f9b5131bfa1`).
|
||||
|
||||
- **AC1** (обычный push не запускает `changed_mutants`; dispatch/PR/schedule/кандидат
|
||||
беты — запускают) — доказано `test/classify-changes.test.mjs` +
|
||||
`test/validate-workflow.test.mjs`, мутант `mutants-run-on-every-push`
|
||||
(«поймано 1 из 1» в r1). Файлы дельтой r2→r3 не тронуты.
|
||||
- **AC2** (гейт находит/запускает/красный/таймаут/несовпадение SHA, включая
|
||||
`cancelled`) — доказано `test/validate-gate.test.mjs`, мутанты
|
||||
`review-starts-on-red-validate` / `review-trusts-push-run-without-mutants` /
|
||||
`review-returns-task-on-cancelled-dispatch` (последний подтверждён в r2).
|
||||
`scripts/validate-gate.mjs` дельтой r2→r3 не тронут вовсе.
|
||||
- **AC3** (слияние ждёт dispatch с мутантами, не push) — доказано
|
||||
`test/merge-candidate.test.mjs`, мутанты `merge-waits-push-run-without-mutants` /
|
||||
`merge-pushes-unvalidated-candidate`. Логика диспатча и ожидания вне участка
|
||||
`cancelled`-фильтра дельтой не изменена.
|
||||
- **AC4** (мутанты протокола пойманы штатным раннером) — шесть мутантов r1/r2
|
||||
(`mutants-run-on-every-push`, `review-starts-on-red-validate`,
|
||||
`review-trusts-push-run-without-mutants`, `merge-waits-push-run-without-mutants`,
|
||||
`merge-pushes-unvalidated-candidate`, `review-returns-task-on-cancelled-dispatch`)
|
||||
не перепрогонялись — их `patches`/`guard` не менялись (`git diff` показывает
|
||||
только добавление новой записи `merge-trusts-cancelled-dispatch`). Седьмой
|
||||
мутант этого раунда прогнан лично и подтверждён выше.
|
||||
- **AC5** (доки описывают место мутантов и правила хендоффа) — сверены
|
||||
построчно в r1, дельта r2→r3 не касается `PROCESS.md`/`AGENTS.md`/
|
||||
`docs/TESTING.md`/`docs/specs/README.md` (только `scripts/**` и `test/**`).
|
||||
- **AC6** (`src/**` не тронут, perf/touch/UX не задеты) — верно и для этой
|
||||
дельты: `git diff 00130e08..HEAD --stat` не содержит ни одного файла
|
||||
`src/**`, подтверждено также выводом `smoke-select.mjs`.
|
||||
- Асимметрия дизайна между `validate-gate.mjs` и `merge-candidate.mjs` —
|
||||
признана обоснованной в r1, дельта её не меняет.
|
||||
- Замечание r2 о риске дублирования одинаковой формулы фильтра в двух файлах
|
||||
(`validate-gate.mjs` и `merge-candidate.mjs`) — это было наблюдение о
|
||||
будущем рефакторинге, не находка с блокирующим статусом; r2 не потребовала
|
||||
унификации, только теста на существующий код. Дельта r3 её не адресует и не
|
||||
обязана была: предмет M1(r2) — отсутствие свидетеля, а не дублирование кода.
|
||||
|
||||
## Что проверено и корректно (в этом раунде)
|
||||
|
||||
- `scripts/merge-candidate.mjs`: инъекция `exec = sh` в сигнатуру `realOps`
|
||||
консервативна — единственный продакшен-вызов (`mergeCandidate` внутри
|
||||
`main()`, строка 242) не передаёт `exec`, значит в реальном запуске
|
||||
используется прежний `sh`/`spawnSync`; поведение продакшена не изменилось
|
||||
(проверено чтением всех вызовов `realOps(` в дереве).
|
||||
- `test/merge-candidate.test.mjs:239-246` (`scriptedExec`): фейковый `exec`
|
||||
отвечает только на `gh run list`, на любой другой вызов бросает — новые
|
||||
тесты не могут случайно замаскировать вызов `git`/`gh workflow run`
|
||||
реальной командой; поведение проверено запуском (12/12 pass).
|
||||
Оба теста используют управляемые `sleep`/`now` (счётчик `clock`), поэтому
|
||||
не спят по-настоящему и не зависят от таймингов среды.
|
||||
- Тест 1 (`ignores a cancelled dispatch and follows its replacement`)
|
||||
проверяет **обе** стороны контракта одним прогоном: что `cancelled` не даёт
|
||||
зелёный/красный результат по первому ответу (иначе `gh.calls()` было бы
|
||||
1, а не 3) и что итоговый green берётся из `replacement`, а не из
|
||||
`cancelled`.
|
||||
- Тест 2 (`reports missing after the appear window, never red`) закрывает
|
||||
сценарий «замены не будет вовсе» — обязательный по тексту AC2/M1(r1)
|
||||
(«ждём замену, а не красим») симметрично для пути слияния.
|
||||
- Новая запись реестра `merge-trusts-cancelled-dispatch`
|
||||
(`scripts/mutation-gate.mjs:8161-8171`): `find`/`replace` совпадают
|
||||
один-в-один со строкой кода на момент проверки, `guard` запускает
|
||||
правильный тестовый файл, лично прогнана — красит при внесении мутации
|
||||
(`поймано 1 из 1`) и не красит на чистом дереве.
|
||||
- Двойное независимое воспроизведение (ручной откат + штатный мутант) даёт
|
||||
одинаковый вывод: регрессия ловится, причём **сильнее**, чем закрывает
|
||||
минимальный порог §2.7 (для чистых юнитов достаточно ручного прогона со
|
||||
снятой защитой — здесь есть и он, и зарегистрированный мутант).
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- `check-docs.mjs`, golden, браузерные смоки, инварианты модели,
|
||||
`pytest tests_backend`, perf-профили — дельта не касается `src/**`,
|
||||
`custom_components/**/*.py` или геометрии; `smoke-select.mjs` подтверждает
|
||||
это инструментально, а не по теме диффа.
|
||||
- Шесть мутантов протокола, подтверждённых в r1/r2 — не перепрогонял: дельта
|
||||
не меняет ни их `patches`, ни `guard`-файлы (диф показывает только вставку
|
||||
новой записи).
|
||||
- Реальный прогон `merge-candidate.mjs` против живого GitHub Actions — не
|
||||
воспроизводил (та же причина, что в r1/r2: цель кода-ревью для скрипта без
|
||||
сети — юниты, мутанты и ручное воспроизведение регрессии, что и сделано).
|
||||
- Зеркалирование `process.yml`/`validate.yml` в `main` (AC5, §5.3 ТЗ) — шаг
|
||||
публикации после слияния, вне код-ревью.
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- SHA: `615181050b9ba4d4568834d56ac5a0ed97ca804d` (сверено `git rev-parse HEAD`
|
||||
непосредственно перед выводом).
|
||||
- Предыдущий материал (r2, код): `00130e08aa247cd8796a99125deb7f9b5131bfa1`.
|
||||
- Ребейза между раундами не было — прямые 2 коммита сверху
|
||||
(`git merge-base --is-ancestor 00130e08 HEAD` — успех), дельта локальна.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. M1(r2) закрыта полностью: тестом на реальную реализацию
|
||||
`waitValidate` (не фейк) и отдельно зарегистрированным мутантом
|
||||
`merge-trusts-cancelled-dispatch`; независимое воспроизведение регрессии в
|
||||
этом раунде даёт 2 упавших теста из 12 (в r2 было 0 из 2420 на том же
|
||||
приёме) — асимметрия «код исправлен, но не защищён» устранена. High и Medium
|
||||
не найдено. Дешёвые гейты (`typecheck`, `npm test`, `build`+сверка бандла)
|
||||
зелёные, лично прогнаны за отсутствием зелёного Validate на этом SHA;
|
||||
`smoke-select.mjs` подтверждает отсутствие исполняемого frontend-диффа —
|
||||
браузерные проверки не нужны по инструменту, а не по теме. AC1–AC6
|
||||
наследуются из r1/r2 без повторной проверки — дельта их не задевает.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/510-mutants-on-candidate-and-review-gate`, коммит `615181050b9b` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `1cc8f27b4d5e0648f5133206f0843a91cca54754`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 1cc8f27b4d5e
|
||||
```
|
||||
- ТЗ `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md`, блоб `d68ed7818b4c77535305d0aae7da9765a2ad6c61`
|
||||
```
|
||||
git log --all --find-object=d68ed7818b4c77535305d0aae7da9765a2ad6c61 -- docs/specs/510-mutants-on-candidate-and-review-waits-validate.md
|
||||
```
|
||||
- Вердикт конвейера: `green` · High 0
|
||||