Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005eb7fa1b | ||
|
|
1af9cfce42 | ||
|
|
b49983fd8d | ||
|
|
1885c779e8 | ||
|
|
a65db3985d | ||
|
|
2af7b0b5a2 | ||
|
|
5e13e34024 | ||
|
|
185eef7af2 | ||
|
|
6d6db12c79 | ||
|
|
4241bb56f8 | ||
|
|
0033138ab2 | ||
|
|
5bf1868490 | ||
|
|
e4fe502e0c | ||
|
|
0fa7c2881a | ||
|
|
e6ac0337d0 | ||
|
|
02a523045b | ||
|
|
133fdc9c10 | ||
|
|
56a3977363 | ||
|
|
5fa3733c7e | ||
|
|
e50c01245a | ||
|
|
4799c6be0c | ||
|
|
99ceb1e57f | ||
|
|
cf3010cd0c | ||
|
|
bd860a7594 | ||
|
|
09ab9266c0 | ||
|
|
6683e526d8 | ||
|
|
c0a4fa29ee | ||
|
|
e943e2f1b2 | ||
|
|
9410be6028 | ||
|
|
d2e7626b2c | ||
|
|
e023adbbe5 | ||
|
|
dcb752db73 | ||
|
|
a44b664311 | ||
|
|
7105e645d9 | ||
|
|
f82141cd23 | ||
|
|
482afb73eb | ||
|
|
add9dfe3a4 | ||
|
|
b76f89a282 | ||
|
|
ddc141b1be | ||
|
|
0ac63b6296 | ||
|
|
c397b78e87 | ||
|
|
4dbdb446f8 | ||
|
|
51fde854e3 | ||
|
|
4a798e3e13 | ||
|
|
8442538b6b | ||
|
|
d5725c2fab | ||
|
|
052e19715e | ||
|
|
30ea73b0aa | ||
|
|
68cfbdff69 | ||
|
|
0d91c1e18e | ||
|
|
a4e8dd7ba7 | ||
|
|
e8b3c861b2 | ||
|
|
da597da274 | ||
|
|
4c065f5ec4 | ||
|
|
1af0cc1711 | ||
|
|
18cd0c8b01 | ||
|
|
ace6e62f89 | ||
|
|
2652bb360d | ||
|
|
7c6e631560 | ||
|
|
cb19b7d52e | ||
|
|
06c7934667 | ||
|
|
5582e9a35d | ||
|
|
636d0a56fa | ||
|
|
9e71334cb4 | ||
|
|
142f7ec121 | ||
|
|
1391aeacfe | ||
|
|
3aba493b25 | ||
|
|
6bd197ffd7 | ||
|
|
55d5a560ad | ||
|
|
691cea074b | ||
|
|
8fc3ec783b | ||
|
|
2f968996b1 | ||
|
|
9a131cca25 | ||
|
|
e44b3c97b0 | ||
|
|
66ffd6fdda | ||
|
|
81f38689bb | ||
|
|
bff47f522b | ||
|
|
abfaae3e38 | ||
|
|
969847a4c4 | ||
|
|
bc8c368db9 | ||
|
|
0312104527 | ||
|
|
77fa698a27 | ||
|
|
7b0747888c | ||
|
|
dc9ced2fb4 | ||
|
|
61c874953a | ||
|
|
09b5d394a5 | ||
|
|
e4b4f33c6d | ||
|
|
5fd9716906 | ||
|
|
1fb3a5754e | ||
|
|
0df5db8b1c | ||
|
|
c8e9597228 | ||
|
|
67505f8343 | ||
|
|
b2024c6626 | ||
|
|
b7a9cc4edf | ||
|
|
f4098fcf09 | ||
|
|
05b2c74442 | ||
|
|
1e2eba95b1 | ||
|
|
6836561340 | ||
|
|
6811cc0908 | ||
|
|
d3468c1fde | ||
|
|
579d6e5f4e | ||
|
|
edf1cca8e3 | ||
|
|
115a7d4ccd | ||
|
|
44a35fde0f | ||
|
|
6872dc420d | ||
|
|
2396044bae | ||
|
|
619516806f | ||
|
|
fab0c38ca9 | ||
|
|
fe2fcef735 | ||
|
|
ed13b4a5ca | ||
|
|
10d5b5a288 | ||
|
|
50a00e41a1 | ||
|
|
e6be43b90f | ||
|
|
483c29cc39 | ||
|
|
1ecd267138 | ||
|
|
5c560df1a2 | ||
|
|
ecc3d6a88b | ||
|
|
5b8b6899da | ||
|
|
81210f7afc | ||
|
|
fd43a25746 | ||
|
|
33470306d2 | ||
|
|
82bb03cdf9 | ||
|
|
a8aeecc32c | ||
|
|
90a00670a7 | ||
|
|
8369c0e839 | ||
|
|
07f1b8c674 | ||
|
|
afd5d589d5 | ||
|
|
99c6cd09f3 | ||
|
|
6176f02430 | ||
|
|
01d7554607 | ||
|
|
30bf18f508 | ||
|
|
9848f4a0cb | ||
|
|
5ba46dfd97 | ||
|
|
48308209c2 | ||
|
|
e57e1d920c | ||
|
|
c3278ddd07 | ||
|
|
0fd2331d4a | ||
|
|
7763af6b8e | ||
|
|
d486c64576 | ||
|
|
603bb3647f | ||
|
|
fb6d7ea693 | ||
|
|
73eb92257e | ||
|
|
62f398d359 | ||
|
|
38e6869c90 | ||
|
|
86778ed452 | ||
|
|
812e5ff391 | ||
|
|
810ad01ce9 | ||
|
|
2935c293e1 | ||
|
|
cb1e4cea64 | ||
|
|
4a84734b18 | ||
|
|
a0716dc404 | ||
|
|
442731e8eb | ||
|
|
723ebb8f61 | ||
|
|
8e0358e7dd | ||
|
|
0cb38e5562 | ||
|
|
ea50548b70 | ||
|
|
5f000cb6fb | ||
|
|
be0277f5f5 |
@@ -0,0 +1,84 @@
|
||||
# Скриншоты документации снимаются здесь и только здесь (#246).
|
||||
#
|
||||
# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре:
|
||||
# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка
|
||||
# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять
|
||||
# целиком. Одно окружение убирает этот шум насовсем.
|
||||
#
|
||||
# Джоба ничего не коммитит: она публикует артефакт, который человек принимает
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
name: Docs screenshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ветка или SHA, с которого снимать
|
||||
required: false
|
||||
default: dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
capture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Тот же кэш и тот же отказ от --with-deps, что в smoke/golden (#175, #206):
|
||||
# системные библиотеки Chromium уже в образе раннера.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the bundle the screenshots must come from
|
||||
run: npm run build
|
||||
- name: Capture
|
||||
run: node demo/docs/capture.mjs
|
||||
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
|
||||
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
|
||||
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
|
||||
# браузер: тот же Chromium и десять изменившихся картинок означают, что
|
||||
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
|
||||
# разницу рендеринга.
|
||||
- name: Вердикт
|
||||
run: |
|
||||
git status --porcelain docs/images
|
||||
changed=$(git diff --name-only docs/images | grep -c png || true)
|
||||
before=$(git show HEAD:docs/images/screenshots.json | node -e \
|
||||
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})")
|
||||
after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)")
|
||||
echo "--- изменившихся PNG: $changed"
|
||||
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
|
||||
if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
|
||||
echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт."
|
||||
echo "Смотрите на кадры: если изменение ожидаемое, принимайте."
|
||||
elif [ "$before" != "$after" ]; then
|
||||
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
|
||||
echo "Это ожидаемо один раз — при переходе на канонический прогон."
|
||||
echo "Если Chromium сменился неожиданно, сверьте закреплённую версию в package-lock."
|
||||
else
|
||||
echo "ВЕРДИКТ: ничего не изменилось, принимать нечего."
|
||||
fi
|
||||
- name: Upload candidate
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: docs-screenshots
|
||||
path: |
|
||||
docs/images/*.png
|
||||
docs/images/screenshots.json
|
||||
if-no-files-found: error
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
- id: decide
|
||||
@@ -63,19 +64,41 @@ jobs:
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
|
||||
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
|
||||
# код-ревью упиралось бы в review-4 после одной правки.
|
||||
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
|
||||
#
|
||||
# Этап опознаётся по имени документа в теле комментария. Если документа
|
||||
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
|
||||
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
|
||||
done_cycles=0
|
||||
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
|
||||
# только для имени документа и метки: два захода с одинаковым номером
|
||||
# означают, что второй документ перезапишет первый и артефакт ревью
|
||||
# исчезнет.
|
||||
#
|
||||
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
|
||||
# «отправка на ревью → вердикт с блокирующими находками → возврат
|
||||
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
|
||||
# Зелёный ничего на правки не вернул и цикла не образует.
|
||||
#
|
||||
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
|
||||
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
|
||||
# велит вернуть S7-code-review после ребейза, и этот заход добивал
|
||||
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
|
||||
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
|
||||
# зелёным CI: работа встала, хотя после вердикта не было ни одной
|
||||
# правки продуктового кода.
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
if [ -n "$stage" ]; then
|
||||
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
|
||||
--json comments \
|
||||
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
@@ -109,19 +132,33 @@ jobs:
|
||||
refuse "стоит blocked — конвейер не запускается" \
|
||||
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
|
||||
elif [ "$EXHAUSTED" = "true" ]; then
|
||||
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
|
||||
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
|
||||
# метка могла остаться от прежнего правила, когда бюджет тратил и
|
||||
# зелёный вердикт (#227).
|
||||
stale=""
|
||||
if [ "$spent" -lt "$limit" ]; then
|
||||
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
|
||||
fi
|
||||
refuse "стоит review-4 — решение за владельцем" \
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||||
elif [ "$done_cycles" -ge "$limit" ]; then
|
||||
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
|
||||
elif [ "$spent" -ge "$limit" ]; then
|
||||
echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан"
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
|
||||
# Перечень учтённого обязателен: иначе владельцу приходится читать
|
||||
# всю ленту, чтобы понять, из чего сложился счёт.
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||||
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
|
||||
|
||||
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
|
||||
$spent_list"
|
||||
stage=""
|
||||
else
|
||||
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
|
||||
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
|
||||
fi
|
||||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
@@ -218,6 +255,10 @@ jobs:
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
# уничтожать его собственный артефакт (#220).
|
||||
REVIEW_DOC: ${{ runner.temp }}/review-document.md
|
||||
with:
|
||||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||||
@@ -230,7 +271,11 @@ jobs:
|
||||
Этап: ${{ needs.guard.outputs.stage }}
|
||||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||||
code — код-ревью (PROCESS.md §2.7)
|
||||
Цикл: r${{ needs.guard.outputs.cycle }}
|
||||
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
|
||||
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
@@ -308,12 +353,26 @@ jobs:
|
||||
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
|
||||
а стоят они минуты:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла.
|
||||
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
|
||||
`src/**`: отпечаток скриншотов документации считается по всему
|
||||
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
|
||||
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
|
||||
с красным job `docs` до следующей задачи (#237).
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
|
||||
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
|
||||
только когда задача действительно задевает всё;
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
|
||||
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
|
||||
Их 167; прогон всех уместен только когда задача действительно
|
||||
задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
|
||||
`smoke_wall_junctions`, который по названию про стыки стен, а не
|
||||
про толщину отрезка. Инструмент печатает три вида ответа, и они
|
||||
разные: «прямое совпадение» — смок называет изменённый символ,
|
||||
«зарегистрированная связь» — смок проверяет следствие контракта,
|
||||
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
|
||||
разрешение ничего не прогонять. Вывод инструмента прикладывается
|
||||
к комментарию ревью вместе с решением по каждой строке: прогнал
|
||||
либо не прогнал и почему. Слабые связи (одно распространённое
|
||||
имя) — повод посмотреть, а не обязанность прогонять;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
@@ -347,19 +406,30 @@ jobs:
|
||||
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
|
||||
считается и прямо запрещено §12.
|
||||
|
||||
Напиши полный документ ревью в файл
|
||||
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
|
||||
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
|
||||
находки с воспроизведением, что проверено и корректно, чего не
|
||||
проверял. Для r2 и дальше добавь два раздела: «Закрытие раунда
|
||||
r<N-1>» — таблица «находка | чем закрыта | где это видно», и
|
||||
«Унаследовано из r<N-1>» — что принято без повторной проверки, с
|
||||
документом и SHA. Каталог docs/reviews/ создай, если его нет. Больше не
|
||||
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
|
||||
Напиши полный документ ревью в файл, путь которого лежит в
|
||||
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
|
||||
|
||||
Почему не в docs/reviews: документ там был некоммитнутым файлом того
|
||||
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
|
||||
#220 три раунда подряд документ исчезал — восстановление дерева
|
||||
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
|
||||
ревью, потому что он untracked. В репозиторий его положит шаг
|
||||
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
|
||||
|
||||
В самом репозитории не создавай файлов вообще: любые изменения в
|
||||
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
|
||||
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
|
||||
code, с номером issue и заходом.
|
||||
|
||||
Содержание документа: скоуп, как проверялось, находки с
|
||||
воспроизведением, что проверено и корректно, чего не проверял. Для
|
||||
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
|
||||
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
|
||||
что принято без повторной проверки, с документом и SHA.
|
||||
|
||||
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
|
||||
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
|
||||
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
|
||||
`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
|
||||
|
||||
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
|
||||
@@ -378,6 +448,7 @@ jobs:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
@@ -391,8 +462,29 @@ jobs:
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||||
# Рабочая копия отбрасывается ДО того, как документ попадёт в дерево:
|
||||
# ревьюер правит код, проверяя «умеет ли тест падать», и его правки
|
||||
# публиковаться не должны.
|
||||
git checkout -- . 2>/dev/null || true
|
||||
# docs/reviews исключён из уборки: ревьюер мог написать документ по
|
||||
# старому пути, и клин не должен его съесть до `git add` — ровно так
|
||||
# оба пути остаются работоспособными.
|
||||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||||
# Документ приезжает извне репозитория (#220). Три раунда подряд он
|
||||
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
|
||||
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
|
||||
# `git clean -fd` сносят собственный артефакт ревью, потому что он
|
||||
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
|
||||
# страшна.
|
||||
if [ -f "$SOURCE" ]; then
|
||||
mkdir -p docs/reviews
|
||||
cp "$SOURCE" "$doc"
|
||||
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
|
||||
else
|
||||
# Совместимость: ревьюер мог написать по старому пути, если промпт
|
||||
# ещё не обновился в этой ветке.
|
||||
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
|
||||
fi
|
||||
git add docs/reviews 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
|
||||
@@ -408,7 +500,7 @@ jobs:
|
||||
# остались только комментариями, метки переставились, и пропажу
|
||||
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
|
||||
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
|
||||
echo "::error::вердикт есть, а документа $doc нет ни в рабочей копии, ни в $target — ревью без артефакта (#171)"
|
||||
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
|
||||
exit 1
|
||||
fi
|
||||
git -c user.name="claude[bot]" \
|
||||
|
||||
@@ -358,9 +358,13 @@ complete Linux CI artifact; never accept a partial scenario or images merely to
|
||||
CI green. See `demo/golden/README.md`.
|
||||
|
||||
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
|
||||
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
|
||||
`assertFreshDemoBundle` before recording any result; a missing or mismatched
|
||||
fingerprint is a hard failure, not a warning.
|
||||
TypeScript and package-lock build inputs. Every browser check must verify it
|
||||
before trusting a result — benchmarks, golden runs and documentation captures
|
||||
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
|
||||
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
|
||||
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
|
||||
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
|
||||
assertions go red and part stay green, which reads as a logic defect.
|
||||
|
||||
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
|
||||
@@ -307,11 +307,24 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
## 4. Лимит циклов ревью: 4
|
||||
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
|
||||
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**.
|
||||
|
||||
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
|
||||
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
|
||||
- **Зелёный вердикт цикла не образует** и бюджет не тратит (решение владельца
|
||||
2026-08-20, issue #227): он ничего не вернул на правки. Практический случай —
|
||||
зелёное ревью, слияние которого не удалось: конвейер сам предписывает ребейз и
|
||||
возврат метки, и этот заход не должен наказываться. Раньше счётчик считал все
|
||||
вердикты подряд, и на #225 последовательность жёлтый → зелёный → ребейз дала
|
||||
`review-4` на задаче с зелёным ревью и зелёным CI.
|
||||
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
|
||||
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
|
||||
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
|
||||
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
|
||||
циклов (шесть документов = четыре цикла плюс два ребейза).
|
||||
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
|
||||
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
|
||||
лимита, конвейер сообщает пересчёт, но метку не трогает.
|
||||
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
|
||||
решение одно из трёх:
|
||||
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
|
||||
@@ -488,9 +501,12 @@ issue #NN
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
|
||||
High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору)
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
|
||||
блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… ·
|
||||
Документ: docs/reviews/…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору.
|
||||
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
|
||||
его не тратят, поэтому заход и K расходятся, #227)
|
||||
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
|
||||
|
||||
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
|
||||
@@ -522,18 +538,39 @@ npx tsc --noEmit
|
||||
npm test
|
||||
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
|
||||
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
|
||||
node demo/smoke_<целевые>.mjs
|
||||
npm run golden:verify # если менялся визуал
|
||||
node scripts/check-docs.mjs # если менялся src/**
|
||||
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
|
||||
```
|
||||
|
||||
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
|
||||
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
|
||||
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (их 167 — прогон всех
|
||||
уместен только когда задача задевает всё; какие относятся к диффу, печатает
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
|
||||
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при изменении видимого
|
||||
результата, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и
|
||||
принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный
|
||||
артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же
|
||||
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
|
||||
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
|
||||
человек.
|
||||
|
||||
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
|
||||
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
|
||||
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
|
||||
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
|
||||
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
|
||||
при следующей задаче (#237). Пересъёмка — `npm run build && node
|
||||
demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
@@ -46,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.66.0"
|
||||
VERSION = "1.67.0-beta.2"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Lossless, allow-listed canonicalisation of persisted geometry.
|
||||
|
||||
The frontend mirrors this module in src/coordinate-canonicalization.ts.
|
||||
Keep the precision, scalar formula and field allow-list in lockstep; a shared
|
||||
fixture is exercised by both runtimes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
"""Return one stable IEEE-754 representation for an allow-listed number."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return value
|
||||
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
|
||||
result = sign * (
|
||||
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
|
||||
/ COORDINATE_FACTOR
|
||||
)
|
||||
if result == 0:
|
||||
return 0.0
|
||||
return result
|
||||
|
||||
|
||||
def _record(value: Any) -> dict[str, Any] | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _records(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_number(record[name])
|
||||
|
||||
|
||||
def _point(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for index in range(min(2, len(value))):
|
||||
value[index] = canonicalize_number(value[index])
|
||||
|
||||
|
||||
def _points(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for point in value:
|
||||
_point(point)
|
||||
|
||||
|
||||
def canonicalize_position(position: Any) -> Any:
|
||||
"""Canonicalise x/y in one layout record without touching its metadata."""
|
||||
result = copy.deepcopy(position)
|
||||
record = _record(result)
|
||||
if record is not None:
|
||||
_fields(record, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||
"""Canonicalise x/y in every layout record, preserving future fields."""
|
||||
result = copy.deepcopy(layout)
|
||||
record = _record(result)
|
||||
if record is None:
|
||||
return result
|
||||
for position in record.values():
|
||||
item = _record(position)
|
||||
if item is not None:
|
||||
_fields(item, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_config_geometry(config: Any) -> Any:
|
||||
"""Canonicalise only the named persisted geometry fields."""
|
||||
result = copy.deepcopy(config)
|
||||
root = _record(result)
|
||||
if root is None:
|
||||
return result
|
||||
|
||||
for space in _records(root.get("spaces")):
|
||||
_fields(
|
||||
space,
|
||||
(
|
||||
"plan_x",
|
||||
"plan_y",
|
||||
"plan_scale",
|
||||
"plan_scale_x",
|
||||
"plan_scale_y",
|
||||
"plan_angle",
|
||||
),
|
||||
)
|
||||
|
||||
for room in _records(space.get("rooms")):
|
||||
_fields(room, ("x", "y", "w", "h"))
|
||||
_points(room.get("poly"))
|
||||
|
||||
for wall in _records(space.get("walls")):
|
||||
_point(wall.get("a"))
|
||||
_point(wall.get("b"))
|
||||
|
||||
for opening in _records(space.get("openings")):
|
||||
_fields(opening, ("x", "y", "angle", "length"))
|
||||
host = _record(opening.get("host"))
|
||||
if host is not None:
|
||||
_fields(host, ("t",))
|
||||
|
||||
for decor in _records(space.get("decor")):
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
_fields(decor, ("x", "y", "w", "h", "angle"))
|
||||
elif kind == "text":
|
||||
_fields(decor, ("x", "y", "scale", "angle"))
|
||||
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
_points(draft.get("points"))
|
||||
|
||||
for partition in _records(space.get("partitions")):
|
||||
_point(partition.get("a"))
|
||||
_point(partition.get("b"))
|
||||
|
||||
for column in _records(space.get("wall_columns")):
|
||||
_point(column.get("center"))
|
||||
if column.get("shape") == "square":
|
||||
_fields(column, ("angle",))
|
||||
|
||||
for span in _records(space.get("open_spans")):
|
||||
_point(span.get("a"))
|
||||
_point(span.get("b"))
|
||||
|
||||
for marker in _records(root.get("markers")):
|
||||
_fields(marker, ("angle",))
|
||||
|
||||
return result
|
||||
@@ -17,6 +17,7 @@ import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -348,6 +349,24 @@ def placement_manifest(config: dict[str, Any], layout: dict[str, Any]) -> list[d
|
||||
|
||||
|
||||
def _internal_path(root: Path, url: str) -> tuple[str, Path] | None:
|
||||
# A url is parsed as a url, not as a string: everything after "?" or "#"
|
||||
# addresses the transfer, never the file. Legacy attachments carry a
|
||||
# cache-buster (".../files/m1/doc.pdf?v=1783170649"), and while the string
|
||||
# form fed "doc.pdf?v=1783170649" to sanitize_filename the name never
|
||||
# matched itself — the reference read as internal-but-non-canonical and
|
||||
# every backup holding one refused to import (issue #225). Path segments
|
||||
# keep doing the guarding: dropping the query cannot widen what a segment
|
||||
# is allowed to be.
|
||||
#
|
||||
# Only a same-document reference may be trusted this way: with a scheme or
|
||||
# an authority the path belongs to another host, and taking it would let
|
||||
# "https://evil.example/houseplan_files/files/m1/doc.pdf" resolve onto a
|
||||
# local file (review CODE-REVIEW-225-r1, M1). Such a url stays external,
|
||||
# which is also what _looks_internal says about it.
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return None
|
||||
url = parsed.path
|
||||
content_plan = CONTENT_URL + "/plans/_/"
|
||||
if url.startswith(content_plan) or url.startswith(PLANS_URL + "/"):
|
||||
prefix = content_plan if url.startswith(content_plan) else PLANS_URL + "/"
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.66.0"
|
||||
"version": "1.67.0-beta.2"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ from .const import (
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -157,7 +161,7 @@ def layout_store_payload(
|
||||
}
|
||||
if metadata:
|
||||
out.update(metadata)
|
||||
out["layout"] = layout
|
||||
out["layout"] = canonicalize_layout_geometry(layout)
|
||||
out["rev"] = rev
|
||||
return out
|
||||
|
||||
@@ -205,7 +209,8 @@ async def async_save_config_state(
|
||||
except (TypeError, ValueError):
|
||||
previous_rev = 0
|
||||
|
||||
payload = {"config": config, "rev": rev}
|
||||
canonical_config = canonicalize_config_geometry(config)
|
||||
payload = {"config": canonical_config, "rev": rev}
|
||||
await runtime.config_store.async_save(payload)
|
||||
|
||||
# The config is already durable at this point. Reconciliation remains a
|
||||
@@ -216,7 +221,7 @@ async def async_save_config_state(
|
||||
try:
|
||||
await async_reconcile_virtual_lights(
|
||||
runtime.virtual_light_store,
|
||||
config,
|
||||
canonical_config,
|
||||
rev,
|
||||
previous_config_rev=previous_rev,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,12 @@ import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.houseplan.coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
canonicalize_position,
|
||||
)
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||
@@ -522,11 +528,18 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
POS_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
),
|
||||
canonicalize_position,
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(
|
||||
vol.Schema({str: POS_SCHEMA}),
|
||||
vol.Length(max=MAX_LAYOUT),
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
@@ -1124,38 +1137,41 @@ MARKER_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
),
|
||||
canonicalize_config_geometry,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,10 @@ from .const import (
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
@@ -426,8 +430,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if kind == "full":
|
||||
backup = {
|
||||
"kind": "import",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
@@ -444,16 +452,20 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
final_metadata[_OPTIMIZE_BACKUP] = backup
|
||||
pending = {
|
||||
"kind": "import",
|
||||
"config": target_config,
|
||||
"layout": target_layout,
|
||||
"config": canonicalize_config_geometry(target_config),
|
||||
"layout": canonicalize_layout_geometry(target_layout),
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"final_metadata": final_metadata,
|
||||
}
|
||||
rollback = {
|
||||
"kind": "import_rollback",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
@@ -566,6 +578,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
||||
if layout == data.get("layout", {}):
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
@@ -627,7 +642,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
if layout.get(msg["device_id"]) == msg["pos"]:
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
|
||||
return
|
||||
layout = {**layout, msg["device_id"]: msg["pos"]}
|
||||
# keep the revision: a point-wise write used to drop it, which made the
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
@@ -735,7 +753,10 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
rt, data, new_layout, new_rev,
|
||||
metadata={
|
||||
**_optimizer_backup_after_layout_maintenance(data, new_rev),
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
"repair_backup": {
|
||||
"space": space_id,
|
||||
"positions": canonicalize_layout_geometry(touched),
|
||||
},
|
||||
},
|
||||
remove=("repair_backup",),
|
||||
)
|
||||
@@ -1281,6 +1302,17 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
if msg["config"] == data.get("config"):
|
||||
# A semantic no-op still has to reconcile Repairs with external
|
||||
# file-system changes. It must not create a revision, event, or
|
||||
# discard the optimizer snapshot merely to refresh diagnostics.
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": int(current_rev)})
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
@@ -1397,15 +1429,19 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = {
|
||||
"kind": "optimize",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout", {})
|
||||
),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
pending = {
|
||||
"config": msg["config"],
|
||||
"layout": msg["layout"],
|
||||
"config": canonicalize_config_geometry(msg["config"]),
|
||||
"layout": canonicalize_layout_geometry(msg["layout"]),
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": False,
|
||||
@@ -1475,8 +1511,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
|
||||
backup = layout_data[_OPTIMIZE_BACKUP]
|
||||
restored_kind = str(backup.get("kind") or "optimize")
|
||||
restored_config = backup.get("config") or DEFAULT_CONFIG
|
||||
restored_layout = backup.get("layout") or {}
|
||||
restored_config = canonicalize_config_geometry(
|
||||
backup.get("config") or DEFAULT_CONFIG
|
||||
)
|
||||
restored_layout = canonicalize_layout_geometry(
|
||||
backup.get("layout") or {}
|
||||
)
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
pending = {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// #199: same-process production-builder baseline versus the complete Optimize
|
||||
// preflight wrapper on the deterministic 3-floor large-house fixture.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
checkOptimizeGeometry,
|
||||
prepareSpacePhysicalGeometryInputs,
|
||||
} from '../test-build/plan-geometry-preflight.js';
|
||||
import { spaceModels } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
floorFootprintGeometry,
|
||||
wallBodiesGeometry,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 20;
|
||||
const ABSOLUTE_P95_MS = 250;
|
||||
const RELATIVE_RATIO = 1.2;
|
||||
const RELATIVE_NOISE_MS = 15;
|
||||
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const models = spaceModels(fixture.config);
|
||||
const prepared = fixture.config.spaces.map((space, index) =>
|
||||
prepareSpacePhysicalGeometryInputs(space, models[index]));
|
||||
|
||||
const directProductionPass = () => {
|
||||
for (const input of prepared) {
|
||||
const hasWalls = input.walls.length > 0 || input.physicalBodies.length > 0;
|
||||
const united = hasWalls
|
||||
? wallBodiesGeometry(
|
||||
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
input.physicalBodies,
|
||||
)
|
||||
: null;
|
||||
if (hasWalls && united == null) throw new Error(`baseline wall failure: ${input.space.id}`);
|
||||
if (input.space.rooms.length && united?.paperGeom == null) {
|
||||
const floor = floorFootprintGeometry(
|
||||
input.space.rooms, input.walls, input.openCuts,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
);
|
||||
if (floor == null) throw new Error(`baseline floor failure: ${input.space.id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const completePreflight = () => {
|
||||
const result = checkOptimizeGeometry(fixture.config);
|
||||
if (!result.ok || result.spaces.length !== LARGE_HOUSE_COUNTS.floors
|
||||
|| result.spaces.some((space) => space.status !== 'ok')) {
|
||||
throw new Error(`candidate preflight failure: ${JSON.stringify(result.spaces)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sample = (operation) => {
|
||||
const start = performance.now();
|
||||
operation();
|
||||
return performance.now() - start;
|
||||
};
|
||||
const run = (operation) => {
|
||||
for (let index = 0; index < WARMUPS; index++) operation();
|
||||
return Array.from({ length: SAMPLES }, () => sample(operation));
|
||||
};
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values),
|
||||
median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95),
|
||||
max: Math.max(...values),
|
||||
});
|
||||
|
||||
const baseline = summary(run(directProductionPass));
|
||||
const candidate = summary(run(completePreflight));
|
||||
const relativeLimit = baseline.p95 * RELATIVE_RATIO + RELATIVE_NOISE_MS;
|
||||
const pass = candidate.p95 <= ABSOLUTE_P95_MS && candidate.p95 <= relativeLimit;
|
||||
const report = {
|
||||
issue: 199,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
baseline,
|
||||
candidate,
|
||||
budgets: {
|
||||
absoluteP95Ms: ABSOLUTE_P95_MS,
|
||||
relativeRatio: RELATIVE_RATIO,
|
||||
relativeNoiseMs: RELATIVE_NOISE_MS,
|
||||
relativeLimitP95Ms: relativeLimit,
|
||||
},
|
||||
pass,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -29,3 +29,32 @@ export async function assertFreshDemoBundle(page, root = process.cwd()) {
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/** Env switch that lets a debugging session run against a stale bundle. */
|
||||
export const ALLOW_STALE_BUNDLE = 'HP_ALLOW_STALE_BUNDLE';
|
||||
|
||||
/**
|
||||
* The freshness gate for every browser check, escape hatch included (#236).
|
||||
*
|
||||
* The smoke launcher had no freshness check at all, while golden runs and
|
||||
* benchmarks did. A smoke against a stale `demo/srv/assets/houseplan-card.js`
|
||||
* does not fail cleanly: on #234 three assertions went red and a fourth went
|
||||
* GREEN, because the old code was wrong in two places that agreed with each
|
||||
* other. A partly-red partly-green result looks like a logic defect and sends
|
||||
* the reader hunting in the wrong file.
|
||||
*
|
||||
* Skipping is allowed for debugging, but never silently: a skipped guard that
|
||||
* says nothing is the same silent success this project keeps digging out.
|
||||
*/
|
||||
export async function assertFreshDemoBundleUnlessAllowed(
|
||||
page, root = process.cwd(), env = process.env,
|
||||
) {
|
||||
if (env[ALLOW_STALE_BUNDLE]) {
|
||||
console.warn(
|
||||
`[houseplan] ${ALLOW_STALE_BUNDLE} is set — bundle freshness NOT verified. `
|
||||
+ 'A red result may mean a stale bundle rather than a defect (#236).',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return assertFreshDemoBundle(page, root);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||||
import { launch } from '../serve.mjs';
|
||||
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUTPUT = resolve(ROOT, 'docs/images');
|
||||
@@ -16,75 +17,6 @@ const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/h
|
||||
const SCRIPT = fileURLToPath(import.meta.url);
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
export const DOC_SCREENSHOT_VERSION = 1;
|
||||
export const DOC_SCREENSHOTS = Object.freeze([
|
||||
{
|
||||
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
|
||||
title: 'House Plan', language: 'en', theme: 'dark',
|
||||
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
|
||||
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
|
||||
},
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
|
||||
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
|
||||
deviceName: 'Living-room ceiling light',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
|
||||
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
|
||||
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'room-card',
|
||||
},
|
||||
{
|
||||
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
|
||||
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const roomCardClip = (page) => page.evaluate(() => {
|
||||
const card = window.__goldenCard;
|
||||
@@ -179,7 +111,12 @@ const browserErrors = [];
|
||||
page.on('pageerror', (error) => browserErrors.push(error.message));
|
||||
|
||||
try {
|
||||
const fingerprint = await assertFreshDemoBundle(page, ROOT);
|
||||
// Свежесть бандла проверяется строго, вместе с версией: картинки обязаны
|
||||
// приехать из бандла, собранного из ЭТОГО дерева. А в манифест пишется
|
||||
// версионно-нечувствительный отпечаток (#245) — номер версии на скриншотах
|
||||
// не виден, и требовать из-за него пересъёмки нечестно.
|
||||
await assertFreshDemoBundle(page, ROOT);
|
||||
const fingerprint = visualFingerprint(ROOT);
|
||||
const scenarios = {};
|
||||
for (const scenario of DOC_SCREENSHOTS) {
|
||||
await prepareGoldenScenario(page, scenario);
|
||||
@@ -207,6 +144,9 @@ try {
|
||||
const manifest = {
|
||||
version: DOC_SCREENSHOT_VERSION,
|
||||
fixture: 'synthetic-only',
|
||||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||||
chromium: browser.version(),
|
||||
sourceFingerprint: fingerprint,
|
||||
captureScriptSha256: sha256(readFileSync(SCRIPT)),
|
||||
command: 'npm run build && node demo/docs/capture.mjs',
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Каталог сценариев съёмки документации. Отдельным модулем, потому что его
|
||||
* читают трое: сам капчур, `scripts/check-docs.mjs` и приёмка артефакта
|
||||
* `scripts/docs-accept.mjs` (#246). Импортировать его из `capture.mjs` нельзя —
|
||||
* тот скрипт при импорте поднимает браузер и снимает картинки.
|
||||
*/
|
||||
export const DOC_SCREENSHOT_VERSION = 1;
|
||||
export const DOC_SCREENSHOTS = Object.freeze([
|
||||
{
|
||||
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
|
||||
title: 'House Plan', language: 'en', theme: 'dark',
|
||||
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
|
||||
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
|
||||
},
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
|
||||
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
|
||||
deviceName: 'Living-room ceiling light',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
|
||||
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
|
||||
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'room-card',
|
||||
},
|
||||
{
|
||||
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
|
||||
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
]);
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,91 +1,101 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 33,
|
||||
"acceptedAt": "2026-08-20T12:25:00.582Z",
|
||||
"sourceFingerprint": "38466828a3a7330ef1b18d79f0bc1a96f236046a1f8107c3dc6b59b77d170700",
|
||||
"matrixVersion": 37,
|
||||
"acceptedAt": "2026-08-22T20:56:32.588Z",
|
||||
"sourceFingerprint": "6f74212de54120aa7ef28c2573e909f9f6db3eb6435298be34e90deadf32ac40",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
|
||||
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
|
||||
"isometric-live-layers-dark": "01660f4cf3e7344a87c9acc9e80239757b0ef5f365a37257c4b147144399b7cc",
|
||||
"isometric-no-borders-dark": "ac92e782e5f58e514c3731f742ead6939cde3344810fe2f2080876b3deb1335b",
|
||||
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
|
||||
"isometric-large-warm-remount-dark": "37b47189e395d56e746501955a5b215298a3261a49c7f27e248b7957b2f31e46",
|
||||
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
|
||||
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
|
||||
"room-label-parity-view-dark": "3a636de579cd2d1de338d017ed6b53c3e2f70a901a0f5c81dfca85870d1b4bd5",
|
||||
"room-label-parity-plan-dark": "e782f0386a6b1e75c7bb9f135e9ec01a9ff4b540f0384d518c7ea9f2198683ea",
|
||||
"room-label-parity-view-light": "6eb2ba0023932c9b4e82a45b7ca9565a9ed1b89d14e543facbaf1f84aa5db9ff",
|
||||
"room-label-parity-plan-light": "333139affbf8936554072aebcaf58b4640779adbd6a64686c0929ca071063733",
|
||||
"isometric-geometry-view-dark": "d4b3918e0597dee2e059153d98812a5035da0f82da20e43ae810b83b8e0ac965",
|
||||
"isometric-geometry-view-light": "e68183fed65d263a2bd80e7600f548a4ab03c03cbfcffd2abea0e011fec0f353",
|
||||
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
|
||||
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
|
||||
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
|
||||
"isometric-large-warm-remount-dark": "59da499800f92b96d06f6eae85382a7c307f0234a0122dfb3c758de926467e84",
|
||||
"geometry-view-dark-fit": "e680793d24a575727d5b87910e824f9ba1fbdb9d7a0c6f60b8bfe839afe73a98",
|
||||
"geometry-view-light-fit": "8a2fa3c2c7f28cb55fb3119b645fa68b92dbda71706642e38dba569a7f770eea",
|
||||
"room-label-parity-view-dark": "b35675251b85144bca1bc15ed7bf27a4dd1eaa0bfc288e3e3b0d1cdefac79ae5",
|
||||
"room-label-parity-plan-dark": "64827483632ce5b66e2ae5cdf7c6858ca7062c3499b1967e8ca31ebca0cb5af1",
|
||||
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
|
||||
"room-label-parity-plan-light": "a01fbb6a8ddc2151c31ba8c199356d909b8c169898f4719cc00e58a7a1b00a63",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
|
||||
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
|
||||
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
|
||||
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
|
||||
"geometry-plan-editor-dark": "a3828bdd877c23d283dcd30dc88287c2104c8d39e849aa826a76190d7daa30ae",
|
||||
"plan-snap-endpoint-light": "2df0bd2abcb615d6bd1a4422b29747f95a1edd7b02d4ae3a6b333e24b27e9f78",
|
||||
"plan-snap-line-gaps-dark": "a29d42ae49a0a513dc36e933bdd2b5f3cb97aa40b0e7ec897c602fe65a5a5fe8",
|
||||
"wall-junctions-plan-preview-light": "cdb96edabd5b04a47e5026eed4255e06b802bd8ba7f0c7ba2cab66c4ae2f371a",
|
||||
"wall-junctions-plan-t-dark": "ec2b29ca87bba9b41e4c22ce94d2c4fe06c6d9c0833152d3a6224371763ae6bc",
|
||||
"day-cycle-dawn-dark": "aa332443ebe5e93b11c569cd4804a0995e1e76b7a5b6aa6844d001a0f6c12991",
|
||||
"day-cycle-day-dark": "af1ab469144fbb3c73d99ff2817f86ec9b8704eadfaef83905cac645eb2eab07",
|
||||
"day-cycle-dusk-dark": "19fdca90fbd9c902bc734b3dc4e5e50c1aa06b22a6e8a19079ad35cd86beb1fb",
|
||||
"day-cycle-night-dark": "b5530a74422e577db90b340a395c0c097907244b330c535169a8e5c4edc8c24b",
|
||||
"geometry-plan-editor-dark": "9da7bacff03d7a159507587355f2251d28f0025c8ff6186a7924001b49679a1c",
|
||||
"space-tab-drop-before-light": "c9f79192a38f70d16fb92fbbe925f411bede05594d703c2bc74c28a96141a495",
|
||||
"space-tab-drop-after-dark": "0def812dba49350d1c9d0e8405e6ce51454e9e33f83126bc4a85ac97f7e70066",
|
||||
"plan-snap-endpoint-light": "cd6592803c6bdebd8ebb80a70ab2345ec3da5f2967dc630eca267e846e3aee7f",
|
||||
"plan-snap-line-gaps-dark": "fb298a4149e062baf4e788cc6e38f6f18a8cb9cff5631848c061c7884771a4ac",
|
||||
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
|
||||
"wall-junctions-plan-t-dark": "40f0b57e2b713df28c2769ba44931b165aaa669d4c0b3dd97d562e904292d449",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "c42203eea92d2bc972eed829aa726ad51f36fc76d0ae049730d0f4155ef8efbf",
|
||||
"junction-patch-resilience-plan-dark": "5b0499409dc7e0d04006c4f0cfec1b4a97a55151934294acb0f9040d62b7d445",
|
||||
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "c398f53391bc697e859ba389bac0ce586526d6f14c85f32d9a4b314bebe902ab",
|
||||
"opening-placement-passage-thick-wall-dark": "98bb63f72886986d71d598fffcaa4fb8e7dce3e7f3a27b5afb6012f9eac4537c",
|
||||
"opening-placement-passage-thick-wall-light": "2f19063a0e792c7bec92f3e4069739954dcdd79eb4933355a575aec5463b9ade",
|
||||
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
|
||||
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
|
||||
"tray-wide-selection-en": "457b53c4e5ae5f691457f98f3c2fd255e5b7ed1c599af92c303c93c11f87b7d4",
|
||||
"tray-wide-tool-ru": "1fcc25ee374c95f51c71b33720daa37f82a92ccd52119b8bdb48de5484591e49",
|
||||
"tray-medium-group-en": "3bebd852929ff67cff11827d09c4859e32e17fa0be36fcabd4a591c4728a4827",
|
||||
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
|
||||
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
|
||||
"tray-narrow-tool-ru": "ccf11295b104cecd4bf1e1bc95417003e2c40cfba29b5555ac9ba021b90d16a6",
|
||||
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
|
||||
"openings-thick-wall-dark": "0c33cdb4637ee9788e75931ba142099e1d068b770e26ca24f4a59f8631f3373f",
|
||||
"opening-placement-door-thick-wall-dark": "3f76b530f29d3aeaecdbfc5a98f5543dc3448198529b32eb0ebd75e812fcba5a",
|
||||
"opening-placement-passage-thick-wall-dark": "5449cefb21e9c17713b1723d85a80f307c7abd81f305e7e0ac654ddddcc919f3",
|
||||
"opening-placement-passage-thick-wall-light": "616c9977489b8c036fa95d41b50f5f710319e27cb09376a3917c08b3b48986e0",
|
||||
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "b3594128285415f650f10f09782d59bdb286bf2f0a881f87fec6416bef108559",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "209e602446ed79de7f7c5931f96e7d8dc0fdeb311083f01ef73b4df9c5d9e1b6",
|
||||
"geometry-decor-editor-dark": "3a98ded3a0c13306bbb61681dec12879c9baec5e2969a6fb06188693f1de32b7",
|
||||
"tray-wide-selection-en": "a4f275a1cf737eb186d281edc73d9ddc54d9d582dfd7b310433b5be44941106e",
|
||||
"tray-wide-tool-ru": "f9dc7e76ebbe13fbba81938a2164244c6cba74e4ef14d716a4dab090ecaf600b",
|
||||
"tray-medium-group-en": "42e007eeb83f1745a2040fd5d331aedcf1e8bb87b9f07835e92e744cc5e41190",
|
||||
"tray-medium-selection-ru": "951c3c68516f8d400dd5bb6d5eddfb1fda2b29923281c8c73da342ba55f22408",
|
||||
"tray-narrow-palette-en": "405db03787009eccf3faf7dd93e0b7f557d27d66f02fc5dcb815883bf673a3b7",
|
||||
"tray-narrow-tool-ru": "fce66752a474bc5a330306fad21fc42a0f863576fb73b770cb03423fcbfb6a24",
|
||||
"geometry-diagonal-45-opening-dark": "41b62c9f308b5d8d2e97d4e4d26170c66f40470c11c0ed6df16d6ff7281cb49e",
|
||||
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
|
||||
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
|
||||
"decor-over-opaque-hover-light": "eae79026256367a4cfaa5f99393ec9c66dbf23908c50ccc0f541c139d1baeda0",
|
||||
"decor-over-glow-base-dark": "72a83f08f832c219d8e0e1b6f94d689c250342aa95d30e8c1f79cf9f9548ccab",
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "3f02abbcb3379b2e224ba7dd3126ec4cb736c0f71edbcdd2f2961c33d7d1b12a",
|
||||
"device-value-badge-positions-dark": "831f04e8b97e4cb473e2c9e53976a5422dd3718eb74086ed2503ffc6d3f5ad87",
|
||||
"device-icon-state-table-light": "b956debb1e3845c03b1a69ba1a45f65b682ea6ee058f40003657bb6f5ad09ee8",
|
||||
"device-icon-state-table-dark": "bf3caed74f7832a142f730481e50f9e2706aeeee009b39118a9ac4d011954e7c",
|
||||
"device-text-shell-long-light": "4ad4f05ebd24d65b98bd4c32f71184088062ae75a52a5a7cc54300ea1961b924",
|
||||
"device-text-shell-long-dark": "f35d426dccd0705465bfedc112776df06233aaa5ffc37765dd71222787cfb3d6",
|
||||
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
|
||||
"lighting-fill-light-axis-split-dark": "4cb3f7591f005768d1d8e42f651184a7317b439318302d6bc48fcca000266c82",
|
||||
"lighting-fill-temp-axis-split-dark": "47383a2e81b34cd7cf385a0f33efe755906c1cb20de8345dc69f599597378801",
|
||||
"lighting-fill-lqi-axis-split-dark": "480b518fb2da24fc7d70297a4456a92485f5f5629a6bc951a36eedcc7631e5dc",
|
||||
"lighting-temp-glow-dark": "a5c8f7b981d4f310ca8cf6c19f72236dc29abdd88a1d60be6ef916dd9091f7b8",
|
||||
"lighting-temp-glow-light": "86620d7b68e797469fbec36438bf2725bd913bc8b4641dbe75c03098210d17f7",
|
||||
"lighting-custom-glow-dark": "86a1ba7ac8e6bc9515a4df550065c60fd600ce6898bdc5447122c9fc87fb3965",
|
||||
"lighting-opaque-glow-two-doorways-dark": "0cff53678f935bb9cee4570a47bf34a05d71b1fd512d76158e846c0b1aa7a5c5",
|
||||
"lighting-custom-glow-light": "2568f1ca7be270faabd2c97d955fd6e27dec00e921d8e6f21ab7425a86e7f958",
|
||||
"lighting-temp-glow-no-sources-dark": "ee37a7dda19a0bfe02405d4e9cf61023cecb623896388a4197adc4a5afe6b584",
|
||||
"lighting-temp-glow-room-override-dark": "fa20be4d2a29710143f437d5049c6e71959c6a56df2a12a9d2e9728e7833d9ed",
|
||||
"lighting-manual-auto-spill-overlap-dark": "9bb31a72bfa073eae4630ea5edf01b5a7dbb1885eb25153c4bf3b2574fc6e5c5",
|
||||
"hover-over-glow-dark": "d505d1514e492dd3f5e299ebace0392e14734f21626310a8efdc0d148a00ddf0",
|
||||
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
|
||||
"large-house-zoom-040-dark": "0e36d124049b1a1513050bd3b1c24a3ae0bc60105de986d3238c2752ec9aeddb",
|
||||
"large-house-zoom-250-dark": "4f0f4151ae4a2c68929baf70d2da411e8c10c51e94c54ce7ca7af5f197aaa3f6",
|
||||
"large-house-warm-remount-dark": "99ef82407bd148cef968e99a593f25da06da2df7363af842f617113349c7ee85",
|
||||
"device-dialog-desktop-en": "ea341f6ea9db5f61bbcb6fd7ca5078fd2f347023de4279cb70bf190c1379c364",
|
||||
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
|
||||
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
|
||||
"device-icon-state-table-light": "73a51bf46b42869d5c333bb42fa17d7a3570e2c95ebef9cb740f353703433b59",
|
||||
"device-icon-state-table-dark": "7cc888cd2f8f3359cf86171dc7152cc94bea0126b82a4626af4d182c33ea37ce",
|
||||
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
|
||||
"device-text-shell-long-dark": "c8fe32aced704c7e7562f0c8499183fad848a977063ab731401cc01e7f20ef38",
|
||||
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
|
||||
"lighting-fill-light-axis-split-dark": "e45d44496c83ad92f8d5bd058821e6d71c97c48a2b66c1933206535aea2655c1",
|
||||
"lighting-fill-temp-axis-split-dark": "895e1e0beee38bf659b1313ed9951353a7792d2e8ae3a8620c4cf7a4f6a84a4d",
|
||||
"lighting-fill-lqi-axis-split-dark": "4a029b17419aaf60feab23f7f0cb385d97353e3aec0e39ed67e9ac94f6c08029",
|
||||
"lighting-temp-glow-dark": "3ce504d83fda2ba5d772c25f8cbb47c379b335addd8650f6933b58f22f5655a7",
|
||||
"lighting-temp-glow-light": "e9f2db0a686a9e20bf71564c10bab826a2a473c73d572a6b67ef02a735d61e8e",
|
||||
"lighting-custom-glow-dark": "94cc6135573aa9462de9e7f309dea62e236668b84970bae35d48f1aad338f8f4",
|
||||
"lighting-opaque-glow-two-doorways-dark": "ee3c53a408f6b62aa6b770925fa6948934e0c1c6e73877e379695bc260b0ceac",
|
||||
"lighting-custom-glow-light": "aa9477aaff4d137abfcb6d67a5cf62b91f41b3631383ee8edb7499802a5c9b87",
|
||||
"lighting-temp-glow-no-sources-dark": "cd992af54d1c9dcc58bbd0b21b15b475be6f19eaabcf9ea866bfa82f205460c9",
|
||||
"lighting-temp-glow-room-override-dark": "66a418829b51379d024c542f8b9a12bd42d72afa81a6bf66561d6498ac4cdb45",
|
||||
"lighting-manual-auto-spill-overlap-dark": "0dc780335317d6dd577c8d5539f33223228074e1c95e936e4666f50a8db09739",
|
||||
"hover-over-glow-dark": "49136f6073662482ebf49a28f8f22b698d2c20a9cb64b5667d111b9835717968",
|
||||
"hover-nested-room-dark": "6ed270f62e7c1cb5e28e800cae46b4839987467bf6b2933c1af212f9b716f2fe",
|
||||
"large-house-zoom-040-dark": "a21faa9eafea706cf8d6366e93f526a8f83798b6b6cf058ee43010607182677d",
|
||||
"large-house-zoom-250-dark": "7acd4cc3b3dfa2124f7c6839e78c9539dcb224af47a321049095cd7ff9e3de68",
|
||||
"large-house-warm-remount-dark": "2532c6e438b219a2bebeab3658b184c3b6d2f5401fd485a660d96f95d32f382a",
|
||||
"device-dialog-desktop-en": "a6637997458ef1def3d30ffaccb6bbc9728dbc4ba2f02400a7334915a8101bbe",
|
||||
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "c74c83ecfb4ee6cd693d87d4516e9ee4920ad453f5b7b9606cd98853b7273568",
|
||||
"decor-color-popover-mobile-ru": "af002e4c3106c008bbdb58ed1f00d8f8415dc86a8dfab2552defb4d16d8bbe71",
|
||||
"decor-color-popover-desktop-en": "11189416fd3752310979444aa92ce38764996655871009ff028ba9b4b31959a8",
|
||||
"general-color-popover-desktop-en": "da85d0159802afce90fd87ecf940ef1b97ffd57c27fbdc79469cc11082b9a482",
|
||||
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
|
||||
"decor-color-popover-mobile-ru": "fcdd138e396654e3517927c27d2062663c61860e8b9903471fa44651a4cbdefd",
|
||||
"decor-color-popover-desktop-en": "46e78386308cd109e25e7b41967421dfa4fbbfb4639710c59e9cc2d88a10fde0",
|
||||
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
|
||||
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
|
||||
"space-room-color-popover-desktop-ru": "a5d62d68097af1ff5d2556be0c889efc25768ffc61af5d302f0b5775e082f296",
|
||||
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
|
||||
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
|
||||
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
|
||||
"space-room-color-popover-desktop-ru": "0c6077300d31ad144b6b1b30f1c50c78a92f0a982f31a8a0c248d0b133a34cb9",
|
||||
"backup-full-preview-desktop-en": "434567d8f97cb09bf217c353bb38e3c4e3aa4c1b837c722a946d75c2c40f34af",
|
||||
"backup-plan-only-export-desktop-en": "8cb234a8d71be11acc6e7bae775f409d3c4b41705072c610f13f40dde06c9c0d",
|
||||
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
|
||||
"optimize-preflight-dialog-dark-en": "d42cab423d2838b7592b661548f7d524d6403db5258419e635733af9ffdda685",
|
||||
"optimize-preflight-dialog-light-ru": "d8262ed5bf93347c4727365aab71897dcb2f24f0c8196e4fcdcbf43b07919b5a"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 334 KiB |
@@ -158,6 +158,86 @@ export function prepareGoldenFixture(scenario) {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (scenario.openingSymbolContract) {
|
||||
const contract = scenario.openingSymbolContract;
|
||||
if (!['room', 'partition'].includes(contract.kind)
|
||||
|| !['flat', 'iso'].includes(contract.surface)
|
||||
|| !(contract.wallCm > 0)
|
||||
|| !Array.isArray(contract.openings) || !contract.openings.length) {
|
||||
throw new Error(`invalid golden openingSymbolContract: ${scenario.id}`);
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const opening of contract.openings) {
|
||||
if (!opening?.id || seen.has(opening.id)
|
||||
|| !['door', 'window', 'gate'].includes(opening.type)
|
||||
|| !Number.isFinite(opening.at) || !Number.isFinite(opening.length)
|
||||
|| !(opening.length > 0) || !['center', 'edge'].includes(opening.offset)
|
||||
|| typeof opening.flipV !== 'boolean') {
|
||||
throw new Error(`invalid golden opening symbol entry: ${opening?.id || '<empty>'}`);
|
||||
}
|
||||
if (opening.type === 'gate' && opening.offset !== 'center')
|
||||
throw new Error(`golden gate must remain centred: ${opening.id}`);
|
||||
seen.add(opening.id);
|
||||
}
|
||||
const tl = [0.08, 0.08], tr = [0.92, 0.08];
|
||||
const mr = [0.92, 0.50], br = [0.92, 0.92];
|
||||
const bl = [0.08, 0.92], ml = [0.08, 0.50];
|
||||
const wall = (a, b, cm = 15) => ({
|
||||
key: fixtureWallKey(a, b), a: [...a], b: [...b], cm,
|
||||
});
|
||||
let rooms;
|
||||
let walls;
|
||||
let partitions;
|
||||
let openings;
|
||||
if (contract.kind === 'room') {
|
||||
rooms = [
|
||||
{ id: 'golden-opening-upper', name: 'Upper', area: null,
|
||||
poly: [tl, tr, mr, ml].map((point) => [...point]) },
|
||||
{ id: 'golden-opening-lower', name: 'Lower', area: null,
|
||||
poly: [ml, mr, br, bl].map((point) => [...point]) },
|
||||
];
|
||||
walls = [
|
||||
wall(tl, tr), wall(tr, mr), wall(mr, ml, contract.wallCm), wall(ml, tl),
|
||||
wall(mr, br), wall(br, bl), wall(bl, ml),
|
||||
];
|
||||
partitions = [];
|
||||
openings = contract.openings.map((opening) => ({
|
||||
id: opening.id, type: opening.type,
|
||||
x: opening.at, y: 0.50, angle: 0, length: opening.length,
|
||||
flip_v: opening.flipV,
|
||||
}));
|
||||
} else {
|
||||
if (!Array.isArray(contract.a) || !Array.isArray(contract.b)
|
||||
|| contract.a.length !== 2 || contract.b.length !== 2
|
||||
|| ![...contract.a, ...contract.b].every(Number.isFinite)) {
|
||||
throw new Error(`invalid golden diagonal partition: ${scenario.id}`);
|
||||
}
|
||||
rooms = [{ id: 'golden-opening-room', name: 'Opening symbols', area: null,
|
||||
poly: [tl, tr, br, bl].map((point) => [...point]) }];
|
||||
walls = [wall(tl, tr), wall(tr, br), wall(br, bl), wall(bl, tl)];
|
||||
const a = contract.a, b = contract.b;
|
||||
partitions = [{
|
||||
id: 'golden-opening-partition', a: [...a], b: [...b], cm: contract.wallCm,
|
||||
}];
|
||||
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI;
|
||||
openings = contract.openings.map((opening) => ({
|
||||
id: opening.id, type: opening.type,
|
||||
x: a[0] + (b[0] - a[0]) * opening.at,
|
||||
y: a[1] + (b[1] - a[1]) * opening.at,
|
||||
angle, length: opening.length, flip_v: opening.flipV,
|
||||
host: { kind: 'partition', id: 'golden-opening-partition', t: opening.at },
|
||||
}));
|
||||
}
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
title: 'Opening symbol contract',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
rooms, walls, partitions, openings, wall_columns: [], decor: [],
|
||||
});
|
||||
}
|
||||
const requireSpace = () => {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
@@ -215,6 +295,18 @@ export function prepareGoldenFixture(scenario) {
|
||||
}
|
||||
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
|
||||
}
|
||||
if (scenario.decorOverride) {
|
||||
const space = requireSpace();
|
||||
const known = new Set();
|
||||
for (const shape of scenario.decorOverride) {
|
||||
if (!shape?.id || known.has(shape.id))
|
||||
throw new Error(`golden decorOverride has missing/duplicate id: ${shape?.id || '<empty>'}`);
|
||||
if (!['line', 'rect', 'ellipse', 'text', 'furniture'].includes(shape.kind))
|
||||
throw new Error(`golden decorOverride has unknown kind: ${shape.kind}`);
|
||||
known.add(shape.id);
|
||||
}
|
||||
space.decor = structuredClone(scenario.decorOverride);
|
||||
}
|
||||
if (scenario.openingGeometry) {
|
||||
const space = requireSpace();
|
||||
const opening = (space.openings || []).find(
|
||||
@@ -599,7 +691,36 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.dialog === 'device') {
|
||||
if (scenario.dialog === 'optimize-preflight') {
|
||||
const names = [
|
||||
'Ground floor', 'Garage', card._t('gs.align_preflight_space', { n: '3' }), 'Attic',
|
||||
];
|
||||
const failures = names.map((displayName, index) => ({
|
||||
spaceId: `golden-failure-${index + 1}`,
|
||||
displayName,
|
||||
status: 'failed',
|
||||
reason: 'wall-null',
|
||||
}));
|
||||
card._alignDialog = {
|
||||
report: {}, config: card._serverCfg, layout: card._layout,
|
||||
preflight: {
|
||||
fingerprint: 'golden-optimize-preflight',
|
||||
spaces: failures,
|
||||
failures,
|
||||
ok: false,
|
||||
},
|
||||
cm: 0, where: '', changed: true, busy: false,
|
||||
};
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
if (!body?.textContent?.includes(names[0])
|
||||
|| !body.textContent.includes(card._t('gs.align_preflight_hint'))
|
||||
|| dialog.querySelector('.btn.on')) {
|
||||
throw new Error('golden Optimize preflight failure dialog is incomplete');
|
||||
}
|
||||
} else if (scenario.dialog === 'device') {
|
||||
card._setMode('devices');
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
@@ -820,6 +941,74 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario });
|
||||
if (scenario.tabDrag) {
|
||||
const drag = await page.evaluate((placement) => {
|
||||
const card = window.__goldenCard;
|
||||
const tabs = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
|
||||
if (tabs.length < 3) throw new Error('golden tab drag requires at least three spaces');
|
||||
const source = placement === 'before' ? tabs.at(-1) : tabs[0];
|
||||
const target = placement === 'before' ? tabs[0] : tabs.at(-1);
|
||||
const point = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
};
|
||||
window.__goldenTabTrustedMove = false;
|
||||
window.__goldenTabCapturedTargetId = null;
|
||||
card.renderRoot.querySelector('.tabs').addEventListener('pointermove', (event) => {
|
||||
if (!event.buttons || !event.isTrusted) return;
|
||||
window.__goldenTabTrustedMove = true;
|
||||
window.__goldenTabCapturedTargetId = event.target
|
||||
?.closest?.('[data-hp="space-tab"]')?.dataset?.id || null;
|
||||
}, { capture: true });
|
||||
return {
|
||||
sourceId: source.dataset.id,
|
||||
targetId: target.dataset.id,
|
||||
source: point(source),
|
||||
target: point(target),
|
||||
};
|
||||
}, scenario.tabDrag);
|
||||
await page.mouse.move(drag.source.x, drag.source.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(drag.target.x, drag.target.y, { steps: 5 });
|
||||
const state = await page.evaluate(async ({ expected, sourceId, targetId }) => {
|
||||
const card = window.__goldenCard;
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const source = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.find((tab) => tab.dataset.id === sourceId);
|
||||
const target = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.find((tab) => tab.dataset.id === targetId);
|
||||
const targetRect = target?.getBoundingClientRect();
|
||||
const shadow = getComputedStyle(target).boxShadow;
|
||||
const insetX = Number(shadow.match(
|
||||
/([+-]?\d+(?:\.\d+)?)px\s+0px\s+0px\s+0px\s+inset/,
|
||||
)?.[1] || 0);
|
||||
const ok = window.__goldenTabTrustedMove
|
||||
&& window.__goldenTabCapturedTargetId === sourceId
|
||||
&& card._tabDrag?.id === sourceId
|
||||
&& card._tabDrag?.targetId === targetId
|
||||
&& card._tabDrag?.placement === expected
|
||||
&& source?.classList.contains('dragging')
|
||||
&& target?.classList.contains(`drop-${expected}`)
|
||||
&& !target?.classList.contains(`drop-${expected === 'before' ? 'after' : 'before'}`)
|
||||
&& targetRect?.width > 0 && targetRect?.height > 0
|
||||
&& (expected === 'before' ? insetX > 0 : insetX < 0);
|
||||
return {
|
||||
ok,
|
||||
trusted: window.__goldenTabTrustedMove,
|
||||
capturedTargetId: window.__goldenTabCapturedTargetId,
|
||||
sourceId,
|
||||
targetId,
|
||||
placement: card._tabDrag?.placement || null,
|
||||
targetSize: targetRect ? [targetRect.width, targetRect.height] : null,
|
||||
insetX,
|
||||
};
|
||||
}, { expected: scenario.tabDrag, sourceId: drag.sourceId, targetId: drag.targetId });
|
||||
if (!state.ok) {
|
||||
throw new Error(`semantic golden tab drag failed: ${JSON.stringify(state)}`);
|
||||
}
|
||||
result.tabDrag = state;
|
||||
}
|
||||
if (scenario.hoverDevice) {
|
||||
const point = await page.evaluate((id) => {
|
||||
const marker = window.__goldenCard?.renderRoot?.querySelector(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 33;
|
||||
export const GOLDEN_MATRIX_VERSION = 37;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -11,6 +11,159 @@ const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, max
|
||||
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
|
||||
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
|
||||
|
||||
const openingRoomContract = {
|
||||
kind: 'room', wallCm: 30, surface: 'flat',
|
||||
openings: [
|
||||
{ id: 'golden-centred-door', type: 'door', at: 0.25, length: 0.10,
|
||||
flipV: false, offset: 'center' },
|
||||
{ id: 'golden-centred-window', type: 'window', at: 0.50, length: 0.10,
|
||||
flipV: false, offset: 'center' },
|
||||
{ id: 'golden-centred-gate', type: 'gate', at: 0.75, length: 0.10,
|
||||
flipV: false, offset: 'center' },
|
||||
],
|
||||
};
|
||||
|
||||
const openingPartitionContract = {
|
||||
kind: 'partition', wallCm: 30, surface: 'flat',
|
||||
a: [0.14, 0.22], b: [0.86, 0.78],
|
||||
openings: [
|
||||
{ id: 'golden-diagonal-door', type: 'door', at: 0.22, length: 0.09,
|
||||
flipV: false, offset: 'center' },
|
||||
{ id: 'golden-diagonal-window', type: 'window', at: 0.50, length: 0.09,
|
||||
flipV: false, offset: 'center' },
|
||||
{ id: 'golden-diagonal-gate', type: 'gate', at: 0.78, length: 0.09,
|
||||
flipV: false, offset: 'center' },
|
||||
],
|
||||
};
|
||||
|
||||
const openingFlipContract = {
|
||||
kind: 'room', wallCm: 30, surface: 'flat',
|
||||
openings: [
|
||||
{ id: 'golden-door-default', type: 'door', at: 0.16, length: 0.08,
|
||||
flipV: false, offset: 'center' },
|
||||
{ id: 'golden-door-flipped', type: 'door', at: 0.30, length: 0.08,
|
||||
flipV: true, offset: 'edge' },
|
||||
{ id: 'golden-window-default', type: 'window', at: 0.44, length: 0.08,
|
||||
flipV: false, offset: 'center' },
|
||||
{ id: 'golden-window-flipped', type: 'window', at: 0.58, length: 0.08,
|
||||
flipV: true, offset: 'edge' },
|
||||
{ id: 'golden-gate-default', type: 'gate', at: 0.72, length: 0.08,
|
||||
flipV: false, offset: 'center', turnPair: 'golden-gate-turn' },
|
||||
{ id: 'golden-gate-flipped', type: 'gate', at: 0.86, length: 0.08,
|
||||
flipV: true, offset: 'center', turnPair: 'golden-gate-turn' },
|
||||
],
|
||||
};
|
||||
|
||||
const openingIsoContract = {
|
||||
kind: 'room', wallCm: 30, surface: 'iso',
|
||||
openings: [
|
||||
{ id: 'golden-iso-door', type: 'door', at: 0.20, length: 0.09,
|
||||
flipV: false, offset: 'center' },
|
||||
{ id: 'golden-iso-window', type: 'window', at: 0.40, length: 0.09,
|
||||
flipV: false, offset: 'center' },
|
||||
{ id: 'golden-iso-gate-default', type: 'gate', at: 0.62, length: 0.09,
|
||||
flipV: false, offset: 'center', turnPair: 'golden-iso-gate-turn' },
|
||||
{ id: 'golden-iso-gate-flipped', type: 'gate', at: 0.82, length: 0.09,
|
||||
flipV: true, offset: 'center', turnPair: 'golden-iso-gate-turn' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Exact existing baseline impact measured against origin/dev with the same
|
||||
* Chromium build. Keep this list reviewable: shared visual fixtures make an
|
||||
* opening-only change visible in editor/dialog captures too. */
|
||||
export const OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT = Object.freeze([
|
||||
'isometric-geometry-view-dark',
|
||||
'isometric-geometry-view-light',
|
||||
'isometric-live-layers-dark',
|
||||
'isometric-no-borders-dark',
|
||||
'isometric-touch-kiosk-dark',
|
||||
'isometric-large-warm-remount-dark',
|
||||
'geometry-view-dark-fit',
|
||||
'geometry-view-light-fit',
|
||||
'room-label-parity-view-dark',
|
||||
'room-label-parity-plan-dark',
|
||||
'room-label-parity-view-light',
|
||||
'room-label-parity-plan-light',
|
||||
'day-cycle-dawn-dark',
|
||||
'day-cycle-day-dark',
|
||||
'day-cycle-dusk-dark',
|
||||
'day-cycle-night-dark',
|
||||
'geometry-plan-editor-dark',
|
||||
'space-tab-drop-before-light',
|
||||
'space-tab-drop-after-dark',
|
||||
'plan-snap-endpoint-light',
|
||||
'plan-snap-line-gaps-dark',
|
||||
'junction-patch-resilience-plan-dark',
|
||||
'opening-placement-door-thick-wall-dark',
|
||||
'opening-placement-passage-thick-wall-dark',
|
||||
'opening-placement-passage-thick-wall-light',
|
||||
'geometry-devices-editor-dark',
|
||||
'geometry-decor-editor-dark',
|
||||
'tray-wide-selection-en',
|
||||
'tray-wide-tool-ru',
|
||||
'tray-medium-group-en',
|
||||
'tray-medium-selection-ru',
|
||||
'tray-narrow-palette-en',
|
||||
'tray-narrow-tool-ru',
|
||||
'geometry-diagonal-45-opening-dark',
|
||||
'openings-thick-wall-dark',
|
||||
'lighting-glow-sun-dark',
|
||||
'device-value-badge-positions-dark',
|
||||
'device-icon-state-table-light',
|
||||
'device-icon-state-table-dark',
|
||||
'device-text-shell-long-light',
|
||||
'device-text-shell-long-dark',
|
||||
'lighting-sun-window-state-only-dark',
|
||||
'lighting-fill-light-axis-split-dark',
|
||||
'lighting-fill-temp-axis-split-dark',
|
||||
'lighting-fill-lqi-axis-split-dark',
|
||||
'lighting-temp-glow-dark',
|
||||
'lighting-temp-glow-light',
|
||||
'lighting-custom-glow-dark',
|
||||
'lighting-opaque-glow-two-doorways-dark',
|
||||
'lighting-custom-glow-light',
|
||||
'lighting-temp-glow-no-sources-dark',
|
||||
'lighting-temp-glow-room-override-dark',
|
||||
'lighting-manual-auto-spill-overlap-dark',
|
||||
'hover-over-glow-dark',
|
||||
'hover-nested-room-dark',
|
||||
'large-house-zoom-040-dark',
|
||||
'large-house-zoom-250-dark',
|
||||
'large-house-warm-remount-dark',
|
||||
'device-dialog-desktop-en',
|
||||
'device-help-popover-light-ru',
|
||||
'decor-color-popover-desktop-en',
|
||||
'general-color-popover-desktop-en',
|
||||
'space-room-color-popover-desktop-ru',
|
||||
'backup-full-preview-desktop-en',
|
||||
'backup-plan-only-export-desktop-en',
|
||||
'optimize-preflight-dialog-dark-en',
|
||||
'optimize-preflight-dialog-light-ru',
|
||||
]);
|
||||
|
||||
const decorLayerFixture = [
|
||||
{ id: 'golden-decor-axis', kind: 'line', x1: 0.10, y1: 0.54, x2: 0.90, y2: 0.54,
|
||||
color: '#ff0033', opacity: 1, width_cm: 8 },
|
||||
{ id: 'golden-decor-rect', kind: 'rect', x: 0.15, y: 0.28, w: 0.16, h: 0.12,
|
||||
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#ff6680', fill_opacity: 1 },
|
||||
{ id: 'golden-decor-ellipse', kind: 'ellipse', x: 0.69, y: 0.28, w: 0.16, h: 0.12,
|
||||
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#ff6680', fill_opacity: 1 },
|
||||
{ id: 'golden-decor-text', kind: 'text', x: 0.24, y: 0.72, text: 'DECOR',
|
||||
color: '#ff0033', opacity: 1, size_cm: 14 },
|
||||
{ id: 'golden-decor-sofa', kind: 'furniture', symbol: 'sofa',
|
||||
x: 0.68, y: 0.66, w: 0.18, h: 0.13, color: '#ff0033', opacity: 1, width_cm: 2 },
|
||||
];
|
||||
const decorLayerProbes = {
|
||||
color: '#ff0033', radius: 2, minMatchingFraction: 0.6,
|
||||
points: [
|
||||
{ id: 'left-room', x: 0.25, y: 0.54 },
|
||||
{ id: 'opening-tunnel', x: 0.50, y: 0.54 },
|
||||
{ id: 'right-room', x: 0.75, y: 0.54 },
|
||||
],
|
||||
};
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -78,6 +231,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'space-tab-drop-before-light', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-geometry', mode: 'plan', tabDrag: 'before',
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'space-tab-drop-after-dark', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-geometry', mode: 'plan', tabDrag: 'after',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
@@ -113,8 +272,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
// complete 90 cm door preset while still proving rotation, inner-face
|
||||
// offset and ruler placement on a thick wall.
|
||||
// complete 90 cm door preset while still proving rotation, centreline
|
||||
// placement and ruler geometry on a thick wall.
|
||||
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
|
||||
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
@@ -126,6 +285,19 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
mode: 'plan', openingPreview: { type: 'passage', pointer: [0.48, 0.65] },
|
||||
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'opening-symbol-room-wall-light', fixture: 'visual', space: 'golden-opening-symbols',
|
||||
mode: 'view', openingSymbolContract: openingRoomContract,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-symbol-diagonal-partition-dark', fixture: 'visual', space: 'golden-opening-symbols',
|
||||
mode: 'view', openingSymbolContract: openingPartitionContract,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-symbol-flip-pairs-light', fixture: 'visual', space: 'golden-opening-symbols',
|
||||
mode: 'view', openingSymbolContract: openingFlipContract,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-opening-symbol-parity-dark', fixture: 'visual', space: 'golden-opening-symbols',
|
||||
mode: 'view', labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
openingSymbolContract: openingIsoContract,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
|
||||
@@ -167,6 +339,17 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
}],
|
||||
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3, dpr2: true },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'decor-over-opaque-hover-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#2255cc', a: 1 }, glowEnabled: false,
|
||||
sunRays: false, showBorders: false, showNames: false, hideOpenings: true,
|
||||
hoverRoom: 'light-left', decorOverride: decorLayerFixture,
|
||||
decorPixelProbes: decorLayerProbes,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'decor-over-glow-base-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'glow', glowEnabled: true, allLightsOff: true,
|
||||
sunRays: false, showBorders: false, showNames: false, hideOpenings: true,
|
||||
decorOverride: decorLayerFixture, decorPixelProbes: decorLayerProbes,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'none', glowEnabled: false, hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
@@ -382,4 +565,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-space', language: 'ru', theme: 'light',
|
||||
viewport: { width: 390, height: 820 }, ...page },
|
||||
{ id: 'optimize-preflight-dialog-dark-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'optimize-preflight', language: 'en', theme: 'dark',
|
||||
viewport: { width: 900, height: 760 }, ...page },
|
||||
{ id: 'optimize-preflight-dialog-light-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'optimize-preflight', language: 'ru', theme: 'light',
|
||||
viewport: { width: 900, height: 760 }, ...page },
|
||||
]);
|
||||
|
||||
@@ -302,6 +302,61 @@ async function countWarmPixels(page, png, region) {
|
||||
}, { png64: png.toString('base64'), region });
|
||||
}
|
||||
|
||||
/** Semantic guard for #231. Sample named plan points from the actual browser
|
||||
* capture; the decor node merely existing in DOM is insufficient when an
|
||||
* opaque floor layer is painted after it. */
|
||||
async function inspectDecorPixels(page, png, clip, spec) {
|
||||
return page.evaluate(async ({ png64, clip, spec }) => {
|
||||
const card = window.__goldenCard;
|
||||
const svg = card?.renderRoot?.querySelector('.stage .zoomwrap > svg');
|
||||
const decor = card?.renderRoot?.querySelector('.decorlayer');
|
||||
const matrix = svg?.getScreenCTM?.();
|
||||
if (!svg || !decor || !matrix) throw new Error('semantic golden decor layer is missing');
|
||||
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
||||
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width; canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
||||
const cssWidth = clip?.width || document.documentElement.clientWidth;
|
||||
const cssHeight = clip?.height || document.documentElement.clientHeight;
|
||||
const scaleX = image.width / Math.max(1, cssWidth);
|
||||
const scaleY = image.height / Math.max(1, cssHeight);
|
||||
const originX = clip?.x || 0, originY = clip?.y || 0;
|
||||
const expected = String(spec.color || '').match(/^#([0-9a-f]{6})$/i)?.[1];
|
||||
if (!expected) throw new Error(`semantic golden decor color is invalid: ${spec.color}`);
|
||||
const rgb = [0, 2, 4].map((offset) => parseInt(expected.slice(offset, offset + 2), 16));
|
||||
const radius = Math.max(0, Math.round(spec.radius || 0));
|
||||
return spec.points.map((probe) => {
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = Number(probe.x) * 1000; point.y = Number(probe.y) * 1000;
|
||||
const screen = point.matrixTransform(matrix);
|
||||
const cx = Math.round((screen.x - originX) * scaleX);
|
||||
const cy = Math.round((screen.y - originY) * scaleY);
|
||||
let matching = 0, sampled = 0;
|
||||
for (let y = cy - radius; y <= cy + radius; y++) {
|
||||
for (let x = cx - radius; x <= cx + radius; x++) {
|
||||
if (x < 0 || y < 0 || x >= image.width || y >= image.height) continue;
|
||||
const offset = (y * image.width + x) * 4;
|
||||
const distance = Math.max(
|
||||
Math.abs(pixels[offset] - rgb[0]),
|
||||
Math.abs(pixels[offset + 1] - rgb[1]),
|
||||
Math.abs(pixels[offset + 2] - rgb[2]),
|
||||
);
|
||||
if (pixels[offset + 3] > 240 && distance <= 48) matching++;
|
||||
sampled++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: probe.id, matching, sampled,
|
||||
fraction: sampled ? matching / sampled : 0,
|
||||
pixel: [cx, cy],
|
||||
};
|
||||
});
|
||||
}, { png64: png.toString('base64'), clip, spec });
|
||||
}
|
||||
|
||||
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
|
||||
* glyph pixels, not just an empty surface or a stale open-state flag. */
|
||||
async function countHelpTextPixels(page, png, clip, spec) {
|
||||
@@ -428,6 +483,119 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
|
||||
}, { png64: png.toString('base64'), clip, spec });
|
||||
}
|
||||
|
||||
/** Prove the opening-centre contract from rendered geometry before a PNG is
|
||||
* compared. The screenshot remains useful for human review, but a shifted
|
||||
* symbol, shallow jamb or cancelled gate turn must fail semantically even
|
||||
* when no reviewed baseline exists yet. */
|
||||
async function assertOpeningSymbolContract(page, contract) {
|
||||
return page.evaluate((expected) => {
|
||||
const card = window.__goldenCard;
|
||||
const root = card?.renderRoot;
|
||||
const space = card?._serverCfg?.spaces?.find((item) => item.id === card._space);
|
||||
if (!card || !root || !space) throw new Error('semantic golden opening card is missing');
|
||||
const epsilon = 1e-5;
|
||||
const parsePair = (value, name) => {
|
||||
const match = String(value || '').match(new RegExp(`${name}\\(([-+0-9.eE]+)[ ,]+([-+0-9.eE]+)\\)`));
|
||||
if (!match) throw new Error(`semantic golden cannot parse ${name}: ${value}`);
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
};
|
||||
const parseTurn = (leaf) => {
|
||||
const value = leaf?.style?.transform || leaf?.getAttribute?.('style') || '';
|
||||
const match = String(value).match(/rotate\(([-+0-9.eE]+)deg\)/);
|
||||
if (!match) throw new Error(`semantic golden cannot parse gate turn: ${value}`);
|
||||
return Number(match[1]);
|
||||
};
|
||||
const openingCfg = new Map((space.openings || []).map((opening) => [opening.id, opening]));
|
||||
const turns = new Map();
|
||||
const rows = [];
|
||||
|
||||
if (expected.surface === 'flat') {
|
||||
const renderedById = new Map((card._openingsR || []).map((opening) => [opening.id, opening]));
|
||||
const fullDepth = (expected.wallCm / card._cellCm) * card._gridPitch;
|
||||
for (const item of expected.openings) {
|
||||
const cfg = openingCfg.get(item.id);
|
||||
const rendered = renderedById.get(item.id);
|
||||
const group = root.querySelector(`.opening[data-id="${CSS.escape(item.id)}"]`);
|
||||
if (!cfg || !rendered || !group || cfg.type !== item.type
|
||||
|| !!cfg.flip_v !== item.flipV) {
|
||||
throw new Error(`semantic golden opening config/render mismatch: ${item.id}`);
|
||||
}
|
||||
if (Math.hypot(rendered.rx - cfg.x * 1000, rendered.ry - cfg.y * 1000) > epsilon)
|
||||
throw new Error(`semantic golden opening left its wall centerline: ${item.id}`);
|
||||
const scale = group.querySelector(':scope > g[transform^="scale("]');
|
||||
const body = scale?.querySelector(':scope > g[transform^="translate("]');
|
||||
if (!scale || !body) throw new Error(`semantic golden visible group is missing: ${item.id}`);
|
||||
const translation = parsePair(body.getAttribute('transform'), 'translate');
|
||||
const offset = Math.hypot(...translation);
|
||||
const expectedOffset = item.offset === 'center' ? 0 : fullDepth / 2;
|
||||
if (Math.abs(offset - expectedOffset) > epsilon) {
|
||||
throw new Error(`semantic golden visible-group offset failed for ${item.id}: `
|
||||
+ `${offset} != ${expectedOffset}`);
|
||||
}
|
||||
const jambs = [...scale.querySelectorAll(':scope > line')];
|
||||
if (jambs.length !== 2 || jambs.some((line) => {
|
||||
const depth = Math.abs(Number(line.getAttribute('y2')) - Number(line.getAttribute('y1')));
|
||||
return Math.abs(depth - fullDepth) > epsilon;
|
||||
})) {
|
||||
throw new Error(`semantic golden full-depth jamb failed: ${item.id}`);
|
||||
}
|
||||
if (item.type === 'window' && !body.querySelector('.op-glass'))
|
||||
throw new Error(`semantic golden window group lost its glass: ${item.id}`);
|
||||
let turn = null;
|
||||
if (item.type === 'gate') {
|
||||
const scalePair = parsePair(scale.getAttribute('transform'), 'scale');
|
||||
if (Math.abs(scalePair[1] - 1) > epsilon)
|
||||
throw new Error(`semantic golden gate regained scaleY flip: ${item.id}`);
|
||||
turn = parseTurn(group.querySelector('.op-leaf'));
|
||||
if (Math.abs(Math.abs(turn) - 10) > epsilon)
|
||||
throw new Error(`semantic golden gate turn is not 10 degrees: ${item.id}`);
|
||||
if (item.turnPair) turns.set(item.id, { pair: item.turnPair, flipV: item.flipV, turn });
|
||||
}
|
||||
rows.push({ id: item.id, offset, jambDepth: fullDepth, turn });
|
||||
}
|
||||
} else {
|
||||
const bases = card._isoSource?.()?.build?.().openings || [];
|
||||
for (const item of expected.openings) {
|
||||
const cfg = openingCfg.get(item.id);
|
||||
const basis = bases.find((opening) => opening.id === item.id);
|
||||
if (!cfg || !basis || cfg.type !== item.type || !!cfg.flip_v !== item.flipV
|
||||
|| !basis.leaves.length) {
|
||||
throw new Error(`semantic golden Iso opening mismatch: ${item.id}`);
|
||||
}
|
||||
const centre = basis.leaves.length === 1
|
||||
? [
|
||||
basis.leaves[0].hinge[0] + basis.leaves[0].closedVector[0] / 2,
|
||||
basis.leaves[0].hinge[1] + basis.leaves[0].closedVector[1] / 2,
|
||||
]
|
||||
: [
|
||||
basis.leaves.reduce((sum, leaf) => sum + leaf.hinge[0], 0) / basis.leaves.length,
|
||||
basis.leaves.reduce((sum, leaf) => sum + leaf.hinge[1], 0) / basis.leaves.length,
|
||||
];
|
||||
const offset = Math.hypot(centre[0] - cfg.x * 1000, centre[1] - cfg.y * 1000);
|
||||
if (item.offset !== 'center' || offset > epsilon)
|
||||
throw new Error(`semantic golden Iso centre failed for ${item.id}: ${offset}`);
|
||||
const turn = item.type === 'gate' ? basis.leaves[0].turnDeg : null;
|
||||
if (turn != null && Math.abs(Math.abs(turn) - 10) > epsilon)
|
||||
throw new Error(`semantic golden Iso gate turn failed: ${item.id}`);
|
||||
if (item.turnPair) turns.set(item.id, { pair: item.turnPair, flipV: item.flipV, turn });
|
||||
rows.push({ id: item.id, offset, turn });
|
||||
}
|
||||
if (root.querySelectorAll('.iso-opening-panel').length < expected.openings.length)
|
||||
throw new Error('semantic golden Iso opening panels are missing');
|
||||
}
|
||||
|
||||
for (const pairName of new Set([...turns.values()].map((item) => item.pair))) {
|
||||
const pair = [...turns.values()].filter((item) => item.pair === pairName)
|
||||
.sort((a, b) => Number(a.flipV) - Number(b.flipV));
|
||||
if (pair.length !== 2 || pair[0].flipV || !pair[1].flipV
|
||||
|| Math.abs(pair[0].turn + pair[1].turn) > epsilon) {
|
||||
throw new Error(`semantic golden gate flip does not reverse turn: ${pairName}`);
|
||||
}
|
||||
}
|
||||
return { surface: expected.surface, rows };
|
||||
}, contract);
|
||||
}
|
||||
|
||||
let baselineManifest = null;
|
||||
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
||||
if (existsSync(baselineManifestPath)) {
|
||||
@@ -461,6 +629,11 @@ try {
|
||||
pageErrors.length = 0;
|
||||
result.runtime = await prepareGoldenScenario(page, scenario);
|
||||
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
|
||||
if (scenario.openingSymbolContract) {
|
||||
result.openingSymbolContract = await assertOpeningSymbolContract(
|
||||
page, scenario.openingSymbolContract,
|
||||
);
|
||||
}
|
||||
if (scenario.openingGeometry) {
|
||||
result.openingGeometry = await page.evaluate((expected) => {
|
||||
const card = window.__goldenCard;
|
||||
@@ -565,6 +738,18 @@ try {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.decorPixelProbes) {
|
||||
const samples = await inspectDecorPixels(page, actual, clip, scenario.decorPixelProbes);
|
||||
result.decorPixelProbes = samples;
|
||||
const failed = samples.filter((sample) =>
|
||||
sample.fraction < scenario.decorPixelProbes.minMatchingFraction);
|
||||
if (failed.length) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: decor is hidden/tinted at `
|
||||
+ failed.map((sample) => `${sample.id} (${sample.matching}/${sample.sampled})`).join(', '),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.helpTextRegion) {
|
||||
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
|
||||
result.helpTextPixels = sample.textPixels;
|
||||
@@ -654,6 +839,13 @@ try {
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
// Tab-divider goldens deliberately hold the real pointer through the
|
||||
// screenshot. Always release it before the shared page is reused, even
|
||||
// when semantic validation or capture failed.
|
||||
if (scenario.tabDrag) {
|
||||
try { await page.mouse.up(); } catch { /* the browser may already be closing */ }
|
||||
}
|
||||
}
|
||||
results.push(result);
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// Shared launcher for demo captures: starts headless Chromium serving demo/srv/
|
||||
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
|
||||
import { chromium } from 'playwright';
|
||||
import { assertFreshDemoBundleUnlessAllowed } from './bundle-freshness.mjs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
||||
// Корень репозитория, а не каталог раздачи: фингерпринт считается по
|
||||
// src/** и demo/fixtures, которых внутри demo/srv нет (#236).
|
||||
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
||||
// ---- assertion harness (audit T1) --------------------------------------
|
||||
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
|
||||
@@ -64,6 +68,14 @@ export async function launch(
|
||||
});
|
||||
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
|
||||
// Свежесть бандла проверяется здесь, а не в каждом смоке (#236). Смок читает
|
||||
// demo/srv/assets/houseplan-card.js; если туда не скопирован свежий dist,
|
||||
// проверяется прежняя версия карточки — и результат выглядит осмысленным,
|
||||
// потому что часть проверок краснеет, а часть зеленеет. На #234 это стоило
|
||||
// круга разбора: три проверки упали, четвёртая ложно прошла, поскольку старый
|
||||
// код одинаково врал в двух местах, которые сверялись друг с другом.
|
||||
// golden и бенчмарки эту защиту имели с самого начала, смоки — нет.
|
||||
await assertFreshDemoBundleUnlessAllowed(page, REPO_ROOT);
|
||||
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
|
||||
// the stage height settles — real pointer interaction cannot hit a hidden
|
||||
// plan, so every smoke starts where the user does: with the plan revealed.
|
||||
|
||||
@@ -38,22 +38,27 @@ const res = await page.evaluate(async () => {
|
||||
clientX: r.left + r.width * fx, clientY: r.top + r.height * fy }));
|
||||
await c.updateComplete;
|
||||
out.normalClickWorks = c._path.length === 1;
|
||||
// 5) delroom: клик по карточке не зовёт confirm (переопределим)
|
||||
let confirmCalled = false;
|
||||
window.confirm = () => { confirmCalled = true; return false; };
|
||||
c._tool = 'delroom'; c._path = []; await c.updateComplete;
|
||||
// 5) delroom: клик по карточке устройства не заводит удаление комнаты.
|
||||
// Раньше здесь подменялся window.confirm; #228 заменил его собственным
|
||||
// диалогом карточки, и проверка стала невыполнимой в обе стороны — confirm
|
||||
// теперь не звонит никто. Признак — открытый диалог.
|
||||
c._tool = 'delroom'; c._path = []; c._roomDeleteDialog = null; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
out.delroomIgnored = c._roomDeleteDialog === null;
|
||||
// 6) Delete has exactly one semantic result: the clicked room disappears;
|
||||
// it never closes a virtual boundary or merges a neighbour into it.
|
||||
const roomCount = c._curSpaceCfg.rooms.length;
|
||||
const deleteId = room.id;
|
||||
window.confirm = () => true;
|
||||
c._deleteRoomClick(c._roomCenter(room));
|
||||
await c.updateComplete;
|
||||
// Клик по комнате только спрашивает; удаляет ответ на вопрос (#228).
|
||||
out.deleteAsksFirst = c._roomDeleteDialog?.roomId === deleteId;
|
||||
c._confirmRoomDelete(false);
|
||||
await c.updateComplete;
|
||||
out.deleteOnlyClickedRoom = c._curSpaceCfg.rooms.length === roomCount - 1
|
||||
&& !c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
|
||||
out.deleteClosesDialog = c._roomDeleteDialog === null;
|
||||
c._undoGeometry();
|
||||
await c.updateComplete;
|
||||
out.deleteUndoRestoresRoom = c._curSpaceCfg.rooms.length === roomCount
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* #231: decor is one composition layer above every floor treatment and below
|
||||
* live light, sun, physical geometry, opening symbols and HTML overlays.
|
||||
*
|
||||
* The raster probes are intentional. A DOM-only assertion used to pass while
|
||||
* an opaque room fill still erased decor from the rendered plan.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
const stage = page.locator('houseplan-card').locator('.stage').first();
|
||||
|
||||
const fixture = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = () => c.shadowRoot || c.renderRoot;
|
||||
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
const pitch = 1 / 240;
|
||||
const wallKey = (a, b) => {
|
||||
const q = (value) => Math.round(value / pitch) * pitch;
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
angle = Math.round(angle * 1800) / 1800;
|
||||
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
|
||||
};
|
||||
const wall = (a, b) => ({ key: wallKey(a, b), a, b, cm: 30 });
|
||||
const update = async () => {
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const roomPoly = [[0.1, 0.15], [0.9, 0.15], [0.9, 0.85], [0.1, 0.85]];
|
||||
sp.plan_url = null;
|
||||
sp.view_box = [0, 0, 1, 1];
|
||||
sp.cell_cm = 5;
|
||||
sp.rooms = [{
|
||||
id: 'decor-order-room', name: 'Decor room', area: 'decor_order_area', poly: roomPoly,
|
||||
settings: {},
|
||||
}];
|
||||
sp.walls = roomPoly.map((a, index) => wall(a, roomPoly[(index + 1) % roomPoly.length]));
|
||||
sp.openings = [
|
||||
{ id: 'decor-order-window', type: 'window', x: 0.5, y: 0.15, angle: 0, length: 0.24 },
|
||||
];
|
||||
sp.decor = [
|
||||
{ id: 'decor-order-line', kind: 'line', x1: 0.5, y1: 0.04, x2: 0.5, y2: 0.96,
|
||||
color: '#ff0033', opacity: 1, width_cm: 8 },
|
||||
{ id: 'decor-order-rect', kind: 'rect', x: 0.18, y: 0.27, w: 0.18, h: 0.12,
|
||||
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#ff6680', fill_opacity: 1 },
|
||||
{ id: 'decor-order-ellipse', kind: 'ellipse', x: 0.64, y: 0.27, w: 0.18, h: 0.12,
|
||||
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#ff6680', fill_opacity: 1 },
|
||||
{ id: 'decor-order-text', kind: 'text', x: 0.27, y: 0.68, text: 'DECOR',
|
||||
color: '#ff0033', opacity: 1, size_cm: 14 },
|
||||
{ id: 'decor-order-furniture', kind: 'furniture', symbol: 'sofa',
|
||||
x: 0.64, y: 0.61, w: 0.18, h: 0.13, color: '#ff0033', opacity: 1, width_cm: 2 },
|
||||
];
|
||||
sp.settings = {
|
||||
...(sp.settings || {}), fill_mode: 'custom', custom_fill: { c: '#2255cc', a: 1 },
|
||||
show_borders: false, show_names: false, hide_openings: true,
|
||||
hide_decor: false, glow_enabled: false, sun_rays: false,
|
||||
};
|
||||
c._setMode('view');
|
||||
await update();
|
||||
|
||||
const svg = root().querySelector('.stage .zoomwrap > svg');
|
||||
const stageRect = root().querySelector('.stage').getBoundingClientRect();
|
||||
const ctm = svg.getScreenCTM();
|
||||
const toStage = (x, y) => {
|
||||
const point = svg.createSVGPoint(); point.x = x; point.y = y;
|
||||
const screen = point.matrixTransform(ctm);
|
||||
return [screen.x - stageRect.left, screen.y - stageRect.top];
|
||||
};
|
||||
const decor = root().querySelector('.decorlayer');
|
||||
const room = root().querySelector('[data-hp="room"][data-id="decor-order-room"]');
|
||||
const dataTunnel = root().querySelector(
|
||||
'.opening-tunnels[data-layer="data"] [data-id="decor-order-window"]',
|
||||
);
|
||||
const follows = (lower, upper) => !!lower && !!upper
|
||||
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
const kinds = new Set([...root().querySelectorAll('.decorlayer [data-hp="decor"]')]
|
||||
.map((node) => node.dataset.kind));
|
||||
return {
|
||||
before: {
|
||||
allDecorKindsRender: ['line', 'rect', 'ellipse', 'text', 'furniture']
|
||||
.every((kind) => kinds.has(kind)),
|
||||
oneDecorLayer: root().querySelectorAll('.decorlayer').length === 1,
|
||||
dataFloorBeforeDecor: follows(room, decor) && follows(dataTunnel, decor),
|
||||
dataTunnelExists: !!dataTunnel,
|
||||
},
|
||||
probes: { room: toStage(500, 500), tunnel: toStage(500, 150) },
|
||||
stored: JSON.stringify(sp.decor),
|
||||
};
|
||||
});
|
||||
|
||||
const customShot = await stage.screenshot({ animations: 'disabled' });
|
||||
|
||||
const hover = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const room = root.querySelector('[data-hp="room"][data-id="decor-order-room"]');
|
||||
room?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const hoverFill = root.querySelector('.room-hover-fill-layer');
|
||||
const decor = root.querySelector('.decorlayer');
|
||||
return {
|
||||
hoverFillExists: !!hoverFill,
|
||||
hoverBeforeDecor: !!hoverFill && !!decor
|
||||
&& !!(hoverFill.compareDocumentPosition(decor) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
};
|
||||
});
|
||||
const hoverShot = await stage.screenshot({ animations: 'disabled' });
|
||||
|
||||
const glow = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
root.querySelector('[data-hp="room"][data-id="decor-order-room"]')?.dispatchEvent(
|
||||
new PointerEvent('pointerleave', { pointerType: 'mouse' }),
|
||||
);
|
||||
sp.rooms[0].settings = {};
|
||||
sp.settings = {
|
||||
...sp.settings, fill_mode: 'glow', glow_enabled: true,
|
||||
show_borders: false, hide_openings: true, sun_rays: false,
|
||||
};
|
||||
c._serverCfg.settings = {
|
||||
...(c._serverCfg.settings || {}),
|
||||
fill_colors: {
|
||||
...(c._serverCfg.settings?.fill_colors || {}),
|
||||
glow_base: { c: '#2255cc', a: 1 },
|
||||
},
|
||||
};
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const base = root.querySelector('.glow-base-layer');
|
||||
const tunnel = root.querySelector(
|
||||
'.opening-tunnels[data-layer="glow-base"] [data-id="decor-order-window"]',
|
||||
);
|
||||
const decor = root.querySelector('.decorlayer');
|
||||
const liveGlow = root.querySelector('.glow-pools-frame');
|
||||
const follows = (lower, upper) => !!lower && !!upper
|
||||
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
return {
|
||||
glowBaseExists: !!base,
|
||||
glowTunnelExists: !!tunnel,
|
||||
glowBaseBeforeDecor: follows(base, decor) && follows(tunnel, decor),
|
||||
decorBeforeLiveGlow: follows(decor, liveGlow),
|
||||
};
|
||||
});
|
||||
const glowShot = await stage.screenshot({ animations: 'disabled' });
|
||||
|
||||
const decodePixels = await page.evaluate(async ({ shots, probes }) => {
|
||||
const decode = async (base64) => {
|
||||
const image = new Image();
|
||||
image.src = `data:image/png;base64,${base64}`;
|
||||
await image.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width; canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
return { width: image.width, height: image.height,
|
||||
pixels: context.getImageData(0, 0, image.width, image.height).data };
|
||||
};
|
||||
const images = await Promise.all(shots.map(decode));
|
||||
const isDecorRed = ({ width, height, pixels }, [rawX, rawY]) => {
|
||||
let red = 0; let samples = 0;
|
||||
const cx = Math.round(rawX), cy = Math.round(rawY);
|
||||
for (let y = cy - 2; y <= cy + 2; y++) for (let x = cx - 2; x <= cx + 2; x++) {
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
const offset = (y * width + x) * 4;
|
||||
const r = pixels[offset], g = pixels[offset + 1], b = pixels[offset + 2];
|
||||
if (r > 180 && r > g * 1.8 && r > b * 1.35) red++;
|
||||
samples++;
|
||||
}
|
||||
return samples > 0 && red / samples >= 0.6;
|
||||
};
|
||||
const verdict = (image) => ({
|
||||
room: isDecorRed(image, probes.room),
|
||||
tunnel: isDecorRed(image, probes.tunnel),
|
||||
});
|
||||
return images.map(verdict);
|
||||
}, {
|
||||
shots: [customShot, hoverShot, glowShot].map((shot) => shot.toString('base64')),
|
||||
probes: fixture.probes,
|
||||
});
|
||||
|
||||
const parity = await page.evaluate(async (stored) => {
|
||||
const c = window.__card;
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
const update = async () => {
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
sp.settings = {
|
||||
...sp.settings, show_borders: true, hide_openings: false,
|
||||
sun_rays: true, north_deg: 0,
|
||||
};
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 0, elevation: 25, rising: true },
|
||||
} } };
|
||||
await update();
|
||||
const decor = root.querySelector('.decorlayer');
|
||||
const follows = (lower, upper) => !!lower && !!upper
|
||||
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
const liveGlow = root.querySelector('.glow-pools-frame');
|
||||
const sun = root.querySelector('.sunlayer');
|
||||
const walls = root.querySelector('.wallbodies');
|
||||
const opening = root.querySelector('[data-hp="opening"][data-id="decor-order-window"]');
|
||||
const devLayer = root.querySelector('.devlayer');
|
||||
const paper = root.querySelector('.hp-paperg');
|
||||
const out = {
|
||||
backdropBeforeDecor: follows(paper, decor),
|
||||
liveGlowAfterDecor: follows(decor, liveGlow),
|
||||
sunAfterDecor: follows(decor, sun),
|
||||
wallsAfterDecor: follows(decor, walls),
|
||||
openingSymbolsAfterDecor: follows(decor, opening),
|
||||
htmlDevicesAndLabelsAfterDecor: follows(decor, devLayer),
|
||||
renderDoesNotRewriteDecor: JSON.stringify(sp.decor) === stored,
|
||||
};
|
||||
|
||||
sp.settings.hide_decor = true;
|
||||
c._setMode('view'); await update();
|
||||
out.hideDecorStillHidesView = !root.querySelector('.decorlayer');
|
||||
c._setMode('decor'); await update();
|
||||
out.ownEditorOverridesHide = !!root.querySelector('.decorlayer')
|
||||
&& root.querySelectorAll('.decorlayer [data-hp="decor"]').length >= 5;
|
||||
return out;
|
||||
}, fixture.stored);
|
||||
|
||||
const result = {
|
||||
...fixture.before,
|
||||
...hover,
|
||||
...glow,
|
||||
customFillKeepsDecorPixels: decodePixels[0].room,
|
||||
customTunnelKeepsDecorPixels: decodePixels[0].tunnel,
|
||||
hoverDoesNotTintDecorPixels: decodePixels[1].room,
|
||||
hoverTunnelKeepsDecorPixels: decodePixels[1].tunnel,
|
||||
glowBaseKeepsDecorPixels: decodePixels[2].room,
|
||||
glowTunnelKeepsDecorPixels: decodePixels[2].tunnel,
|
||||
...parity,
|
||||
};
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||