Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6473d5e4b3 | ||
|
|
fe70c1e803 | ||
|
|
348dd8468b | ||
|
|
6a151d1ead | ||
|
|
9c2f08bd40 | ||
|
|
259fbc97ac | ||
|
|
5c9aae24e2 | ||
|
|
a4e6a22662 | ||
|
|
bae897bd22 | ||
|
|
f6f877e393 | ||
|
|
f60383d274 | ||
|
|
dcfabcc8ef | ||
|
|
2a6e6b5e96 | ||
|
|
d34c7563e3 | ||
|
|
0c5683d3bf | ||
|
|
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 | ||
|
|
a20dd54ba6 | ||
|
|
3af04840a9 | ||
|
|
d68e958c0d | ||
|
|
e0b3c4715d | ||
|
|
4aa9d1b2bb | ||
|
|
d55e0dd298 | ||
|
|
ce400859f3 | ||
|
|
bc75e00c2d | ||
|
|
84fa434a64 | ||
|
|
16c8a28aaf | ||
|
|
6786c48a0e | ||
|
|
28d6b9ccd9 | ||
|
|
b56e122b07 | ||
|
|
3d11758e2b | ||
|
|
4c512fa59f | ||
|
|
72275de567 | ||
|
|
f9accc1198 | ||
|
|
c6ff34cea3 | ||
|
|
9e8393f874 | ||
|
|
39456dc44c | ||
|
|
c547586dc9 | ||
|
|
fe33deaa13 | ||
|
|
8ba840aa74 | ||
|
|
ea33edebf9 | ||
|
|
2b45086794 | ||
|
|
75ad20a1df | ||
|
|
cbaa7b0cb9 | ||
|
|
adc1d15b98 | ||
|
|
a14de0a122 | ||
|
|
0e5ee030fe | ||
|
|
c749d68a4d | ||
|
|
0c93be9e85 | ||
|
|
9f25f17c83 | ||
|
|
1521d3716a | ||
|
|
fbb426afc9 | ||
|
|
c5ba699cf1 | ||
|
|
b7bfc92d4e | ||
|
|
120d41317c | ||
|
|
5cae1fdf82 | ||
|
|
ec6f77b014 | ||
|
|
efcdb269e8 | ||
|
|
9f5ebec6f2 | ||
|
|
3490faae0d | ||
|
|
6063eead10 | ||
|
|
b89456ca81 | ||
|
|
6c3d376b2b | ||
|
|
c08d5a88ae | ||
|
|
0bd6094ca8 | ||
|
|
d197381894 | ||
|
|
4d1b62b57c | ||
|
|
303f710e03 | ||
|
|
270cf634e6 | ||
|
|
4e82976b4a | ||
|
|
6feb0189ee | ||
|
|
ce304646c8 | ||
|
|
4fc0f7bd32 | ||
|
|
93200eb56a | ||
|
|
aa97e7e0bc | ||
|
|
72eae1059c | ||
|
|
11abc0292f | ||
|
|
c447e41d4d | ||
|
|
c22b39e8fc | ||
|
|
842f9dc30e | ||
|
|
9daa2e91fd | ||
|
|
6fdb7dce1a | ||
|
|
7af6d742b9 | ||
|
|
040db9ad12 | ||
|
|
48bcdafab9 | ||
|
|
6731691ad8 | ||
|
|
2310e6d88a | ||
|
|
424e613c6f | ||
|
|
517a7101e7 | ||
|
|
a84338b042 | ||
|
|
2d5fec09d6 | ||
|
|
5ce3ceeca7 | ||
|
|
ef22b236f8 | ||
|
|
a797752c89 | ||
|
|
833e8e5472 | ||
|
|
9b05dd598d | ||
|
|
cd17a0b00f | ||
|
|
3a68efa62f | ||
|
|
2dd1731cc4 | ||
|
|
e158f8fdcc | ||
|
|
edf532e217 | ||
|
|
56e01148f8 | ||
|
|
d31ad3c562 | ||
|
|
5c09591ce9 | ||
|
|
5dc9016645 | ||
|
|
6f89002e3a | ||
|
|
4c8ba981e7 | ||
|
|
15dc8adc4f | ||
|
|
ad8e7a50cc | ||
|
|
f287bddd97 | ||
|
|
5ff80f3bdc | ||
|
|
e6016a966f | ||
|
|
142bd611b1 | ||
|
|
1290927f10 | ||
|
|
84e62dcd0f | ||
|
|
f7b811a621 | ||
|
|
950403debd | ||
|
|
009fed9bc0 | ||
|
|
10c0f3c95a | ||
|
|
f7abf14abd | ||
|
|
8b8b9ed90d | ||
|
|
7b759f316b | ||
|
|
b583d663e3 | ||
|
|
88a28775e7 | ||
|
|
cd029a0415 | ||
|
|
bf83246b7b | ||
|
|
4089c912c6 | ||
|
|
0b411dd802 | ||
|
|
07b0b3dec2 | ||
|
|
2ef32417bd | ||
|
|
016c75f539 | ||
|
|
71d369a295 | ||
|
|
f66e671b89 | ||
|
|
41e2cfffc6 | ||
|
|
a0c4c1cdd8 | ||
|
|
edfea67ddd | ||
|
|
57ba75b9da | ||
|
|
804b282f5f | ||
|
|
a356ec29ab | ||
|
|
67bf85e7d2 | ||
|
|
2453ec0d7f | ||
|
|
fb265282cc | ||
|
|
c9a83af50e | ||
|
|
f08c4adabe | ||
|
|
6e93aa705c | ||
|
|
dd2e0e9b08 | ||
|
|
d01d0926be | ||
|
|
74d19d4d14 | ||
|
|
c1dde9a0cf | ||
|
|
56f31dc199 | ||
|
|
db57180956 | ||
|
|
bd9409b33d | ||
|
|
b203e8faeb | ||
|
|
63249ffd97 | ||
|
|
327c35f606 | ||
|
|
fe7b28f3a7 | ||
|
|
19e92e0cc0 | ||
|
|
9bde4b1a6d | ||
|
|
1bf90ee0d8 | ||
|
|
fcee724638 | ||
|
|
f69ac71ef7 | ||
|
|
3540d24f18 | ||
|
|
25ea8fefab | ||
|
|
9ec3636a42 | ||
|
|
07d0c2ef86 | ||
|
|
053007414d | ||
|
|
fc22d9a6c5 | ||
|
|
fbbaed22de | ||
|
|
29ce5d9e65 | ||
|
|
3a8aae06d1 | ||
|
|
a75c729d87 | ||
|
|
a55ba3de8b | ||
|
|
2f0dc44f27 | ||
|
|
1c3404fdd0 | ||
|
|
3e335b8808 | ||
|
|
e2bb90b59b | ||
|
|
a80fa1fa5e | ||
|
|
0e53b1b0d6 | ||
|
|
fa015907d4 | ||
|
|
5d04e9b7c2 | ||
|
|
89789d8fa6 | ||
|
|
6846ffb828 | ||
|
|
c8755b7c51 | ||
|
|
fe15d863ce | ||
|
|
299da593d5 | ||
|
|
32e3a79dcd | ||
|
|
ffb10843c9 | ||
|
|
5fb510290a | ||
|
|
932773773f | ||
|
|
6c7958c6d0 | ||
|
|
a449edc545 | ||
|
|
e88c23b8ee | ||
|
|
acad3b32c1 | ||
|
|
b443a333e0 | ||
|
|
66fa8f476c | ||
|
|
047363c2d3 | ||
|
|
1e8503bd46 | ||
|
|
135497b272 | ||
|
|
0c2a5dedea | ||
|
|
c1676cf26a | ||
|
|
c65cbcc96c | ||
|
|
01fe48de00 | ||
|
|
9baf533c90 | ||
|
|
563a850aac | ||
|
|
9e5ff0b8a0 | ||
|
|
3fe0f8c443 | ||
|
|
742b3279a2 | ||
|
|
9f77e3e932 | ||
|
|
083621342a | ||
|
|
4f20befd77 | ||
|
|
2aaabc48d6 | ||
|
|
b9bf210804 | ||
|
|
d6007dc444 | ||
|
|
54c5ca3840 | ||
|
|
57fc434d4f | ||
|
|
9530ee2e5a | ||
|
|
91f2c23539 | ||
|
|
ba32234b52 | ||
|
|
382afd2766 | ||
|
|
26303adfec |
@@ -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
|
||||
@@ -132,9 +132,17 @@ jobs:
|
||||
- name: Install candidate and baseline dependencies
|
||||
run: npm ci --prefix candidate && npm ci --prefix baseline
|
||||
|
||||
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
working-directory: candidate
|
||||
run: npx playwright install --with-deps chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
|
||||
@@ -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,6 +271,42 @@ jobs:
|
||||
Этап: ${{ needs.guard.outputs.stage }}
|
||||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||||
code — код-ревью (PROCESS.md §2.7)
|
||||
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
|
||||
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
перепроверял AC, которых правка не касалась: r2 по #150 стоил
|
||||
полного прогона ради одной строки в тестовой фикстуре.
|
||||
|
||||
Порядок для r2 и дальше:
|
||||
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
|
||||
на котором он получен. SHA в вердикте не назван — это находка;
|
||||
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
|
||||
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
|
||||
3. по каждой находке предыдущего раунда покажи, чем именно она
|
||||
закрыта: строка кода или текста, а не заявление автора;
|
||||
4. заново проверяй только те AC, чьё доказательство дельта
|
||||
задевает. Остальные наследуй;
|
||||
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
|
||||
принято без повторной проверки, со ссылкой на документ того
|
||||
раунда и SHA, на котором вывод получен. Без этого перечня
|
||||
сокращение — молчаливое доверие, а такой тихий успех уже
|
||||
дважды стоил дня (#171, #207).
|
||||
|
||||
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
|
||||
вперёд dev (после ребейза это другой код, §7.2), смена контракта
|
||||
поведения, задета новая подсистема, либо объём дельты сопоставим с
|
||||
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
|
||||
|
||||
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
|
||||
способна сломать AC, который предыдущий раунд признал выполненным —
|
||||
так появилась регрессия #102. Поэтому граница не «только находки», а
|
||||
«находки плюс всё, до чего дотягивается дельта».
|
||||
|
||||
Прочитай в этом порядке, прежде чем судить:
|
||||
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
|
||||
@@ -273,14 +350,29 @@ jobs:
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
Всегда, они дешёвые:
|
||||
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
|
||||
а стоят они минуты:
|
||||
`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` — если тронут
|
||||
@@ -299,27 +391,46 @@ jobs:
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
|
||||
Low либо правится, либо снимается с записью. Жёлтый вердикт
|
||||
допустим при полностью выполненных AC, если изменение не решает
|
||||
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
|
||||
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
|
||||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
|
||||
обслуживание issue дороже правки на месте); Low либо правится,
|
||||
либо снимается с записью. Жёлтый вердикт допустим и при полностью
|
||||
выполненных AC, если изменение не решает заявленный сценарий или
|
||||
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
|
||||
отменяет AC и не даёт права менять скоуп.
|
||||
|
||||
Каждую Medium-находку заведи отдельным issue со ссылкой на
|
||||
#${{ github.event.issue.number }} и метками: тип, приоритет,
|
||||
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
|
||||
запрещено §12.
|
||||
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
|
||||
поведения, который в этой ветке чинить нельзя) заведи отдельным
|
||||
issue со ссылкой на #${{ github.event.issue.number }} и метками:
|
||||
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
|
||||
считается и прямо запрещено §12.
|
||||
|
||||
Напиши полный документ ревью в файл
|
||||
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
|
||||
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
|
||||
находки с воспроизведением, что проверено и корректно, чего не
|
||||
проверял. Каталог 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 по схеме. Это последнее действие и оно обязательно:
|
||||
без него метка не переставится и конвейер встанет.
|
||||
@@ -337,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. Раньше шаг в этом случае молча
|
||||
@@ -350,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
|
||||
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
|
||||
@@ -367,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]" \
|
||||
|
||||
@@ -116,6 +116,98 @@ jobs:
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||||
# же входами уже завершилась успешно.
|
||||
#
|
||||
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
|
||||
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
|
||||
# здесь эквивалентность входов доказана хешем.
|
||||
#
|
||||
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
|
||||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
golden: ${{ steps.probe.outputs.golden }}
|
||||
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
|
||||
backend: ${{ steps.probe.outputs.backend }}
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
id: m_smoke
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер golden
|
||||
id: m_golden
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ steps.keys.outputs.golden }}
|
||||
lookup-only: true
|
||||
- name: Маркер performance_smoke
|
||||
id: m_perf
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ steps.keys.outputs.backend }}
|
||||
lookup-only: true
|
||||
- name: Что переиспользуем
|
||||
id: probe
|
||||
env:
|
||||
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
|
||||
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
|
||||
PERF: ${{ steps.m_perf.outputs.cache-hit }}
|
||||
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
|
||||
run: |
|
||||
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
|
||||
# успех, который уже дважды стоил нам дня (#171, #207).
|
||||
waive() {
|
||||
if [ "$2" = "true" ]; then
|
||||
echo "$1=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
|
||||
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$1=false" >> "$GITHUB_OUTPUT"
|
||||
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
|
||||
waive smoke "$SMOKE"
|
||||
waive golden "$GOLDEN"
|
||||
waive performance_smoke "$PERF"
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
@@ -160,7 +252,8 @@ jobs:
|
||||
|
||||
smoke:
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -169,8 +262,20 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#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 a fresh bundle for the smokes
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
@@ -194,11 +299,28 @@ jobs:
|
||||
with:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||||
|
||||
golden:
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -207,8 +329,20 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#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
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the exact source under review
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture or verify golden matrix
|
||||
@@ -227,13 +361,33 @@ jobs:
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "golden прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
|
||||
performance_smoke:
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -241,8 +395,20 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#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
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the exact candidate source
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture the heaviest Glow state
|
||||
@@ -257,10 +423,26 @@ jobs:
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "performance_smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
|
||||
backend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -273,3 +455,19 @@ jobs:
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
- name: Backend unit tests (pure + HA harness)
|
||||
run: python -m pytest tests_backend/ -q
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "backend прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
|
||||
|
||||
@@ -255,10 +255,15 @@ The exchange happens in **issue comments** — there is no local message bus. Ve
|
||||
format:
|
||||
|
||||
```text
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
|
||||
```
|
||||
|
||||
High blocks. Medium must become its own issue. Low is fixed or waived with a note
|
||||
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
|
||||
with no High findings the verdict is yellow, the author fixes it and the fix
|
||||
passes another review cycle — no separate issue (owner's decision 2026-08-19,
|
||||
#202: filing and servicing an issue costs far more than fixing in place). Only
|
||||
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
|
||||
never patched from this branch. Low is fixed or waived with a note
|
||||
in the review document. A yellow verdict is legitimate even when every acceptance
|
||||
criterion passes, if the change does not solve the stated scenario or degrades a
|
||||
neighbouring one.
|
||||
@@ -353,14 +358,22 @@ 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
|
||||
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
|
||||
an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
|
||||
@@ -131,10 +131,14 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
|
||||
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
|
||||
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
|
||||
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
|
||||
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
|
||||
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
|
||||
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
|
||||
Low либо правится, либо снимается решением ревьюера с записью.
|
||||
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
|
||||
4 циклов (§4).
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
### 2.5 Готово к разработке (DoR)
|
||||
|
||||
@@ -187,9 +191,11 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **High блокируют.** Medium **обязаны** превратиться в issue.
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4).
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
### 2.8 Закрытие после выпуска беты
|
||||
|
||||
@@ -210,6 +216,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
|
||||
оправдана). Тихое закрытие без причины запрещено.
|
||||
|
||||
### 2.10 Повторный раунд ревью — объём по дельте
|
||||
|
||||
Решение владельца 2026-08-19 (issue #214). Относится и к ревью ТЗ, и к
|
||||
код-ревью, начиная со второго цикла.
|
||||
|
||||
**Предмет повторного раунда — дельта, а не задача целиком.** Раньше объём
|
||||
разбора не был оговорён, промпт ревьюера для всех раундов был одинаковым, и
|
||||
повторный цикл заново выводил продуктовую рамку и перепроверял AC, которых
|
||||
правка не касалась: r2 по #150 стоил полного прогона конвейера ради одной
|
||||
строки в тестовой фикстуре.
|
||||
|
||||
Порядок:
|
||||
|
||||
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
|
||||
вердикте не назван — это находка;
|
||||
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
|
||||
issue для этапа ТЗ;
|
||||
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
|
||||
строкой кода или текста, а не заявлением автора;
|
||||
4. заново проверять только те AC, чьё доказательство дельта задевает;
|
||||
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
|
||||
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
|
||||
превращается в молчаливое доверие.
|
||||
|
||||
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
|
||||
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
|
||||
|
||||
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
|
||||
`dev` (после ребейза это другой код, §7.2), смена контракта поведения, задета
|
||||
новая подсистема, либо объём дельты сопоставим с исходной задачей.
|
||||
|
||||
Сокращается объём **разбора, а не строгость**: правка по замечанию способна
|
||||
сломать AC, который предыдущий раунд признал выполненным — так появилась
|
||||
регрессия #102. Граница не «только находки», а «находки плюс всё, до чего
|
||||
дотягивается дельта».
|
||||
|
||||
---
|
||||
|
||||
## 3. Правила
|
||||
@@ -231,8 +273,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
ревью-гейт.
|
||||
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
|
||||
отклонить или арбитраж (§4).
|
||||
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
|
||||
решением ревьюера с записью в документе.
|
||||
8. **High блокирует. Medium в скоупе чинится в текущем issue** (без High —
|
||||
жёлтый вердикт и повторный цикл); Medium вне скоупа становится отдельным
|
||||
issue (#202). Low либо правится, либо снимается решением ревьюера с записью
|
||||
в документе.
|
||||
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
@@ -263,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 меньших с
|
||||
@@ -444,8 +501,12 @@ issue #NN
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
|
||||
High: N · Medium: N → #… · Документ: docs/reviews/…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
|
||||
блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… ·
|
||||
Документ: docs/reviews/…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору.
|
||||
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
|
||||
его не тратят, поэтому заход и K расходятся, #227)
|
||||
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
|
||||
|
||||
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
|
||||
@@ -477,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`, коммит вместе с задачей.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
@@ -686,8 +768,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
```
|
||||
|
||||
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
|
||||
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
|
||||
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
|
||||
вердикту, а не модель.**
|
||||
|
||||
@@ -811,7 +893,8 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
|
||||
- закрытие issue до выпуска беты с зелёным CI;
|
||||
- переоткрытие закрытого issue вместо нового бага;
|
||||
- Medium-находки, оставленные как TODO в документе ревью;
|
||||
- Medium-находки, оставленные как TODO в документе ревью: в скоупе — чинятся
|
||||
в текущем issue, вне скоупа — становятся отдельным (#202);
|
||||
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
|
||||
- ревью-документы вне репозитория;
|
||||
- попутные правки «раз уж я здесь»;
|
||||
|
||||
@@ -46,12 +46,12 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.65.0-beta.1"
|
||||
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
|
||||
# user have different compatibility lifecycles.
|
||||
PLAN_MODEL_VERSION = 6
|
||||
PLAN_MODEL_VERSION = 7
|
||||
EXPORT_VERSION = 1
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,9 +49,11 @@ from .validation import (
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
)
|
||||
|
||||
FORMAT = "houseplan-export"
|
||||
@@ -264,7 +267,8 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
_pick_fields(
|
||||
opening,
|
||||
("id", "type", "x", "y", "angle", "length")
|
||||
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v")),
|
||||
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
|
||||
+ (("host",) if opening.get("host") else ()),
|
||||
)
|
||||
for opening in space.get("openings") or []
|
||||
]
|
||||
@@ -345,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 + "/"
|
||||
@@ -800,6 +822,56 @@ def _orphan_marker(key: str, manifest: dict[str, Any] | None, space_id: str, use
|
||||
}
|
||||
|
||||
|
||||
def _repair_target_space_refs(
|
||||
current_config: dict[str, Any],
|
||||
current_layout: dict[str, Any],
|
||||
old_space_id: str,
|
||||
new_space_id: str,
|
||||
old_room_ids: dict[str, str],
|
||||
) -> tuple[dict[str, Any], dict[str, Any], int]:
|
||||
"""Apply the exact map of this import to pre-existing orphan target refs."""
|
||||
config = _json_copy(current_config)
|
||||
layout = _json_copy(current_layout)
|
||||
if any(str(space.get("id")) == old_space_id for space in config.get("spaces") or []):
|
||||
return config, layout, 0
|
||||
|
||||
repaired = 0
|
||||
for marker in config.get("markers") or []:
|
||||
if marker.get("space") == old_space_id:
|
||||
marker["space"] = new_space_id
|
||||
repaired += 1
|
||||
room_id = marker.get("room_id")
|
||||
if room_id is not None and str(room_id) in old_room_ids:
|
||||
marker["room_id"] = old_room_ids[str(room_id)]
|
||||
repaired += 1
|
||||
vacuum = marker.get("vacuum")
|
||||
segment_map = vacuum.get("segment_map") if isinstance(vacuum, dict) else None
|
||||
if isinstance(segment_map, dict):
|
||||
for key, room_id in list(segment_map.items()):
|
||||
mapped = old_room_ids.get(str(room_id))
|
||||
if mapped is not None:
|
||||
segment_map[key] = mapped
|
||||
repaired += 1
|
||||
|
||||
# Rewrite ownership before rekeying labels. A valid destination wins a
|
||||
# collision inside the target; the imported document itself is overlaid
|
||||
# later and has final priority for its own label position.
|
||||
for position in layout.values():
|
||||
if isinstance(position, dict) and position.get("s") == old_space_id:
|
||||
position["s"] = new_space_id
|
||||
repaired += 1
|
||||
for old_room_id, new_room_id in old_room_ids.items():
|
||||
old_key = "rl_" + old_room_id
|
||||
if old_key not in layout:
|
||||
continue
|
||||
new_key = "rl_" + new_room_id
|
||||
if new_key not in layout:
|
||||
layout[new_key] = layout[old_key]
|
||||
del layout[old_key]
|
||||
repaired += 1
|
||||
return config, layout, repaired
|
||||
|
||||
|
||||
def build_space_merge(
|
||||
document: dict[str, Any],
|
||||
current_config: dict[str, Any],
|
||||
@@ -837,9 +909,22 @@ def build_space_merge(
|
||||
old_room_ids = {
|
||||
old: new for old, new in id_map.items() if old != old_space_id and new.startswith("room_")
|
||||
}
|
||||
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
|
||||
current_config, current_layout, old_space_id, new_space_id, old_room_ids,
|
||||
)
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("open_to"):
|
||||
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
|
||||
# Opening ownership is part of the same space-local id graph. Remap the
|
||||
# nested reference together with the partition itself; otherwise the
|
||||
# invariant validator correctly rejects a copied space whose host.id still
|
||||
# names the source partition.
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host") if isinstance(opening, dict) else None
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
old_host_id = str(host.get("id"))
|
||||
if old_host_id in id_map:
|
||||
host["id"] = id_map[old_host_id]
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
@@ -852,7 +937,7 @@ def build_space_merge(
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
incoming_bindings = _binding_inventory(incoming, incoming_layout, manifest)
|
||||
current_bindings = _binding_inventory(current_config, current_layout)
|
||||
current_bindings = _binding_inventory(target_config, target_layout)
|
||||
duplicate = incoming_bindings & current_bindings
|
||||
marker_map: dict[str, str] = {}
|
||||
output_markers: list[dict[str, Any]] = []
|
||||
@@ -959,14 +1044,14 @@ def build_space_merge(
|
||||
virtualized += 1
|
||||
output_layout[new_key] = {**pos, "s": new_space_id}
|
||||
|
||||
merged_config = _json_copy(current_config)
|
||||
merged_config = target_config
|
||||
merged_config.setdefault("spaces", []).append(space)
|
||||
merged_config.setdefault("markers", []).extend(output_markers)
|
||||
dropped_marker_links += _drop_invalid_import_marker_links(
|
||||
merged_config,
|
||||
clean_ids={str(marker.get("id")) for marker in output_markers},
|
||||
)
|
||||
merged_layout = {**_json_copy(current_layout), **output_layout}
|
||||
merged_layout = {**target_layout, **output_layout}
|
||||
if len(merged_config.get("spaces") or []) > MAX_SPACES \
|
||||
or len(merged_config.get("markers") or []) > MAX_MARKERS \
|
||||
or len(merged_layout) > MAX_LAYOUT:
|
||||
@@ -980,7 +1065,11 @@ def build_space_merge(
|
||||
validate_marker_light_entities(merged_config, current_config)
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
validate_opening_passages(merged_config, current_config)
|
||||
except (MarkerControlError, OpeningPassageError) as err:
|
||||
validate_partition_opening_hosts(merged_config, current_config)
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
try:
|
||||
merged_layout = LAYOUT_SCHEMA(merged_layout)
|
||||
@@ -998,6 +1087,7 @@ def build_space_merge(
|
||||
"virtualized": virtualized,
|
||||
"orphan_markers": len(output_markers) - len(marker_map),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
"repaired_target_refs": repaired_target_refs,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.65.0-beta.1"
|
||||
"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,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -24,11 +25,33 @@ import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
|
||||
"""Whether an ended current run may be reopened for this point.
|
||||
|
||||
Store timestamps are untrusted persisted data. Only finite JSON-number
|
||||
timestamps and a non-negative inclusive grace interval are accepted;
|
||||
malformed values and wall-clock rollback fail closed into a new run.
|
||||
"""
|
||||
if not isinstance(run, dict) or run.get("map_id") != map_id:
|
||||
return False
|
||||
ended = run.get("ended")
|
||||
if (
|
||||
isinstance(ended, bool)
|
||||
or not isinstance(ended, (int, float))
|
||||
or isinstance(now, bool)
|
||||
or not isinstance(now, (int, float))
|
||||
):
|
||||
return False
|
||||
elapsed = now - ended
|
||||
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
@@ -64,7 +87,10 @@ class TrailBook:
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
|
||||
if resumed:
|
||||
cur["ended"] = None
|
||||
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
@@ -73,7 +99,9 @@ class TrailBook:
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
# Clearing ended is observable state even if the source repeats
|
||||
# the dock point: it must still reach Store and live cards.
|
||||
return resumed
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
@@ -85,7 +113,7 @@ class TrailBook:
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
if cur and cur.get("ended") is None:
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -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
|
||||
@@ -46,6 +52,112 @@ class OpeningPassageError(ValueError):
|
||||
)
|
||||
|
||||
|
||||
class PartitionOpeningHostError(ValueError):
|
||||
"""A write tried to strip explicit host identity from a surviving opening."""
|
||||
|
||||
code = "invalid_partition_opening_host"
|
||||
|
||||
|
||||
class PartitionOpeningJambMarginError(ValueError):
|
||||
"""A direct geometry write leaves no physical jamb at a wall endpoint."""
|
||||
|
||||
code = "invalid_partition_opening_jamb_margin"
|
||||
|
||||
def __init__(
|
||||
self, space_id: str, opening_id: str, margin: float, margin_cm: float
|
||||
) -> None:
|
||||
self.space_id = space_id
|
||||
self.opening_id = opening_id
|
||||
self.margin = margin
|
||||
self.margin_cm = margin_cm
|
||||
super().__init__(
|
||||
f"space={space_id}; opening={opening_id}; "
|
||||
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
|
||||
)
|
||||
|
||||
|
||||
# One normalized canvas width contains this many physical grid cells. Keep in
|
||||
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
|
||||
# user setting.
|
||||
NORMALIZED_CANVAS_CELLS = 240.0
|
||||
|
||||
|
||||
def validate_partition_opening_hosts(
|
||||
config: dict, previous: dict | None = None
|
||||
) -> None:
|
||||
"""Validate hosted-opening write deltas without rejecting legacy reads.
|
||||
|
||||
Deleting the opening together with its partition remains valid. Surviving
|
||||
records keep their host, while new/direct geometry changes reserve half the
|
||||
actual wall depth at both endpoints. Rigid host translation and unrelated
|
||||
edits round-trip existing near-end records unchanged.
|
||||
"""
|
||||
old_spaces = {
|
||||
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
|
||||
}
|
||||
for space in config.get("spaces") or []:
|
||||
space_id = str(space.get("id", ""))
|
||||
old_space = old_spaces.get(space_id)
|
||||
old_openings = {
|
||||
str(opening.get("id")): opening
|
||||
for opening in (old_space or {}).get("openings") or []
|
||||
}
|
||||
partitions = {
|
||||
str(partition.get("id")): partition
|
||||
for partition in space.get("partitions") or []
|
||||
}
|
||||
old_partitions = {
|
||||
str(partition.get("id")): partition
|
||||
for partition in (old_space or {}).get("partitions") or []
|
||||
}
|
||||
for opening in space.get("openings") or []:
|
||||
opening_id = str(opening.get("id", ""))
|
||||
old = old_openings.get(opening_id)
|
||||
if old and old.get("host") is not None and opening.get("host") is None:
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host removed"
|
||||
)
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
partition = partitions.get(str(host.get("id", "")))
|
||||
if partition is None:
|
||||
# SPACE_SCHEMA owns missing-host diagnostics.
|
||||
continue
|
||||
old_host = (old or {}).get("host")
|
||||
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
old_span = None
|
||||
if old_partition is not None:
|
||||
old_ax, old_ay = old_partition["a"]
|
||||
old_bx, old_by = old_partition["b"]
|
||||
old_span = ((old_bx - old_ax) ** 2 + (old_by - old_ay) ** 2) ** 0.5
|
||||
strict = (
|
||||
old is None
|
||||
or old_host is None
|
||||
or old_host.get("id") != host.get("id")
|
||||
or old_host.get("t") != host.get("t")
|
||||
or old.get("length") != opening.get("length")
|
||||
or old_partition is None
|
||||
or old_partition.get("cm") != partition.get("cm")
|
||||
or abs(old_span - span) > 1e-9
|
||||
)
|
||||
if not strict:
|
||||
continue
|
||||
cell_cm = float(space.get("cell_cm", 5))
|
||||
margin_cm = float(partition["cm"]) / 2
|
||||
margin = margin_cm / cell_cm / NORMALIZED_CANVAS_CELLS
|
||||
along = float(host["t"]) * span
|
||||
half = float(opening["length"]) / 2
|
||||
if (along - half < margin - 1e-9
|
||||
or along + half > span - margin + 1e-9):
|
||||
raise PartitionOpeningJambMarginError(
|
||||
space_id, opening_id, margin, margin_cm
|
||||
)
|
||||
|
||||
|
||||
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
|
||||
|
||||
|
||||
@@ -416,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
|
||||
@@ -767,6 +886,15 @@ WALL_COLUMN_SCHEMA = vol.All(
|
||||
_strict_wall_column,
|
||||
)
|
||||
|
||||
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("kind"): vol.Equal("partition"),
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
@@ -784,6 +912,29 @@ def _space_geometry_invariants(value: dict) -> dict:
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
partitions = {
|
||||
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
|
||||
}
|
||||
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
|
||||
for opening in value.get("openings", []):
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
partition = partitions.get(host["id"])
|
||||
if partition is None:
|
||||
raise vol.Invalid("partition opening host must exist in the same space")
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
length = float(opening["length"])
|
||||
along = float(host["t"]) * span
|
||||
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
|
||||
raise vol.Invalid("partition opening must fit inside its host")
|
||||
lo, hi = along - length / 2, along + length / 2
|
||||
occupied = hosted_intervals.setdefault(host["id"], [])
|
||||
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
|
||||
raise vol.Invalid("partition openings must not overlap")
|
||||
occupied.append((lo, hi))
|
||||
return value
|
||||
|
||||
|
||||
@@ -849,6 +1000,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Optional("invert"): bool,
|
||||
vol.Optional("flip_h"): bool,
|
||||
vol.Optional("flip_v"): bool,
|
||||
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -985,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,
|
||||
@@ -57,8 +61,9 @@ from .virtual_lights import (
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, sanitize_filename,
|
||||
validate_opening_passages,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
)
|
||||
@@ -137,6 +142,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
websocket_api.async_register_command(hass, ws_space_delete)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
@@ -425,8 +431,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,
|
||||
@@ -443,16 +453,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,
|
||||
@@ -495,6 +509,7 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"layout_rev": new_layout_rev,
|
||||
"counts": details.get("counts", {}),
|
||||
"space_id": details.get("space_id"),
|
||||
"repaired_target_refs": details.get("repaired_target_refs", 0),
|
||||
"can_undo": kind == "full",
|
||||
})
|
||||
|
||||
@@ -565,6 +580,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,
|
||||
@@ -626,7 +644,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)
|
||||
@@ -734,7 +755,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",),
|
||||
)
|
||||
@@ -1256,7 +1280,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
except (MarkerControlError, OpeningPassageError) as err:
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
@@ -1276,6 +1304,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,
|
||||
@@ -1315,6 +1354,193 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# ---------------- whole-plan maintenance ----------------
|
||||
|
||||
|
||||
def _space_marker_dependencies(
|
||||
config: dict[str, Any], layout: dict[str, Any], space_id: str,
|
||||
) -> list[str]:
|
||||
"""Active marker ids that make deleting a space unsafe (deduplicated)."""
|
||||
space = next(
|
||||
(item for item in config.get("spaces") or [] if item.get("id") == space_id),
|
||||
None,
|
||||
)
|
||||
room_ids = {
|
||||
str(room.get("id")) for room in (space or {}).get("rooms") or []
|
||||
if room.get("id") is not None
|
||||
}
|
||||
dependencies = {
|
||||
str(marker.get("id"))
|
||||
for marker in config.get("markers") or []
|
||||
if marker.get("removed") is not True
|
||||
and marker.get("id") is not None
|
||||
and (
|
||||
marker.get("space") == space_id
|
||||
or (
|
||||
marker.get("room_id") is not None
|
||||
and str(marker.get("room_id")) in room_ids
|
||||
)
|
||||
or (layout.get(str(marker.get("id"))) or {}).get("s") == space_id
|
||||
)
|
||||
}
|
||||
return sorted(dependencies)
|
||||
|
||||
|
||||
def _space_delete_candidate(
|
||||
config: dict[str, Any], layout: dict[str, Any], space_id: str,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], list[str], int]:
|
||||
"""Return a pure exact pair; blockers leave both inputs unchanged."""
|
||||
candidate_config = json.loads(json.dumps(config))
|
||||
candidate_layout = json.loads(json.dumps(layout))
|
||||
dependencies = _space_marker_dependencies(candidate_config, candidate_layout, space_id)
|
||||
spaces = candidate_config.get("spaces") or []
|
||||
deleting_last_space = len(spaces) == 1 and spaces[0].get("id") == space_id
|
||||
if dependencies and not deleting_last_space:
|
||||
return candidate_config, candidate_layout, dependencies, 0
|
||||
space = next(
|
||||
(item for item in candidate_config.get("spaces") or []
|
||||
if item.get("id") == space_id),
|
||||
None,
|
||||
)
|
||||
room_ids = {
|
||||
str(room.get("id")) for room in (space or {}).get("rooms") or []
|
||||
if room.get("id") is not None
|
||||
}
|
||||
candidate_config["spaces"] = [
|
||||
item for item in candidate_config.get("spaces") or []
|
||||
if item.get("id") != space_id
|
||||
]
|
||||
for marker in candidate_config.get("markers") or []:
|
||||
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
|
||||
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
|
||||
references_deleted_space = (
|
||||
marker.get("space") == space_id
|
||||
or (
|
||||
marker.get("room_id") is not None
|
||||
and str(marker.get("room_id")) in room_ids
|
||||
)
|
||||
or (
|
||||
isinstance(marker_position, dict)
|
||||
and marker_position.get("s") == space_id
|
||||
)
|
||||
)
|
||||
if deleting_last_space and references_deleted_space:
|
||||
marker.pop("space", None)
|
||||
marker.pop("room_id", None)
|
||||
continue
|
||||
if marker.get("removed") is not True:
|
||||
continue
|
||||
if marker.get("space") == space_id:
|
||||
marker.pop("space", None)
|
||||
if (marker.get("room_id") is not None
|
||||
and str(marker.get("room_id")) in room_ids):
|
||||
marker.pop("room_id", None)
|
||||
removed_layout = 0
|
||||
for key in list(candidate_layout):
|
||||
position = candidate_layout.get(key)
|
||||
if isinstance(position, dict) and position.get("s") == space_id:
|
||||
del candidate_layout[key]
|
||||
removed_layout += 1
|
||||
return candidate_config, candidate_layout, dependencies, removed_layout
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/space/delete",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete one space as a crash-recoverable config/layout pair."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only editors may delete spaces")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "Invalid space id")
|
||||
return
|
||||
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if (msg["expected_config_rev"] != config_rev
|
||||
or msg["expected_layout_rev"] != layout_rev):
|
||||
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
|
||||
return
|
||||
current_config = config_data.get("config") or DEFAULT_CONFIG
|
||||
current_layout = layout_data.get("layout") or {}
|
||||
if not any(
|
||||
item.get("id") == space_id for item in current_config.get("spaces") or []
|
||||
):
|
||||
connection.send_error(msg["id"], "space_not_found", "Space no longer exists")
|
||||
return
|
||||
target_config, target_layout, dependencies, removed_layout = (
|
||||
_space_delete_candidate(current_config, current_layout, space_id)
|
||||
)
|
||||
spaces = current_config.get("spaces") or []
|
||||
deleting_last_space = (
|
||||
len(spaces) == 1 and spaces[0].get("id") == space_id
|
||||
)
|
||||
if dependencies and not deleting_last_space:
|
||||
connection.send_error(
|
||||
msg["id"], "space_in_use",
|
||||
f"Space is still used by {len(dependencies)} active marker(s)",
|
||||
)
|
||||
return
|
||||
target_config = CONFIG_SCHEMA(target_config)
|
||||
target_layout = LAYOUT_SCHEMA(target_layout)
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
original_metadata = _layout_metadata(layout_data)
|
||||
final_metadata = {
|
||||
key: value for key, value in original_metadata.items()
|
||||
if key not in {_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"}
|
||||
}
|
||||
pending = {
|
||||
"kind": "space_delete",
|
||||
"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": "space_delete_rollback",
|
||||
"config": canonicalize_config_geometry(current_config),
|
||||
"layout": canonicalize_layout_geometry(current_layout),
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
await _commit_import_pair(rt, pending, rollback)
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_config", str(err))
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan space delete failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
|
||||
return
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"removed_layout": removed_layout,
|
||||
})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/optimize",
|
||||
@@ -1367,7 +1593,11 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
except (MarkerControlError, OpeningPassageError) as err:
|
||||
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
|
||||
@@ -1388,15 +1618,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,
|
||||
@@ -1466,8 +1700,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mdiLightbulbSpot } from '@mdi/js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const artifactDir = resolve('artifacts/device-icon-reference');
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
|
||||
const referenceAsset = (theme, file, coreSize) => {
|
||||
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
|
||||
if (file === 'Lock.svg') {
|
||||
const old = theme === 'Dark' ? '#252525' : 'black';
|
||||
source = source.replaceAll(old, '#66D17A');
|
||||
if (theme === 'Dark') source = source.replaceAll('fill="white"', 'fill="#252525"');
|
||||
}
|
||||
if (file === 'Unlock.svg') {
|
||||
source = source.replaceAll(theme === 'Dark' ? '#1DC21D' : '#F0A00C', '#F0410C');
|
||||
}
|
||||
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
|
||||
return {
|
||||
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
|
||||
displayWidth: nativeWidth * coreSize / 80,
|
||||
};
|
||||
};
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
|
||||
);
|
||||
|
||||
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const marker = (id, patch) => ({
|
||||
...(c._serverCfg.markers || []).find((item) => item.id === id),
|
||||
id, binding: `device:${id}`, ...patch,
|
||||
});
|
||||
const replacements = new Map([
|
||||
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
|
||||
['d_tv', marker('d_tv', { display: 'value' })],
|
||||
['d_temp', marker('d_temp', {
|
||||
display: 'badge',
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
|
||||
position: 'right',
|
||||
},
|
||||
})],
|
||||
]);
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
|
||||
...replacements.values(),
|
||||
];
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'sensor.living_temp': {
|
||||
...c.hass.states['sensor.living_temp'],
|
||||
state: '23',
|
||||
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
|
||||
},
|
||||
'media_player.tv': {
|
||||
...c.hass.states['media_player.tv'],
|
||||
state: 'Working',
|
||||
},
|
||||
},
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._cfgEpoch++;
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const qaStyle = document.createElement('style');
|
||||
qaStyle.textContent = '.devtip{display:none!important}';
|
||||
(c.renderRoot || c.shadowRoot).append(qaStyle);
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
|
||||
});
|
||||
|
||||
const selector = (id) => `.dev[data-id="${id}"]`;
|
||||
|
||||
async function runtimePng(theme, row, size) {
|
||||
await page.mouse.move(1, 1);
|
||||
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
|
||||
const node = (window.__card.renderRoot || window.__card.shadowRoot)
|
||||
.querySelector(`.dev[data-id="${id}"]`);
|
||||
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
|
||||
marker.style.visibility = marker === node ? 'visible' : 'hidden';
|
||||
node.classList.remove(...[
|
||||
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
|
||||
'sel', 'lock-locked', 'lock-unlocked',
|
||||
]);
|
||||
node.classList.add(`theme-${themeName}`, ...classes);
|
||||
node.style.setProperty('--device-base-size', `${px}px`);
|
||||
node.style.setProperty('--dev-scale', '1');
|
||||
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
|
||||
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
|
||||
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
|
||||
node.blur();
|
||||
}, {
|
||||
id: row.id,
|
||||
themeName: theme.toLowerCase(),
|
||||
classes: row.classes || [],
|
||||
px: size,
|
||||
clearValues: row.clearValues || false,
|
||||
});
|
||||
if (row.hover) {
|
||||
await page.hover(selector(row.id));
|
||||
await page.waitForTimeout(180);
|
||||
}
|
||||
if (row.focus) {
|
||||
await page.$eval(selector(row.id), (node) => node.focus());
|
||||
}
|
||||
await page.$eval(selector(row.id), (node) => {
|
||||
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
|
||||
tooltip.style.setProperty('display', 'none', 'important');
|
||||
node.querySelector('.lqi')?.style.setProperty('display', 'none');
|
||||
});
|
||||
const clip = await page.$eval(selector(row.id), (node) => {
|
||||
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
|
||||
const pad = 22;
|
||||
return {
|
||||
x: Math.max(0, shell.left - pad),
|
||||
y: Math.max(0, shell.top - pad),
|
||||
width: shell.width + pad * 2,
|
||||
height: shell.height + pad * 2,
|
||||
};
|
||||
});
|
||||
return (await page.screenshot({ clip })).toString('base64');
|
||||
}
|
||||
|
||||
const rows = [
|
||||
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
|
||||
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
|
||||
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
|
||||
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
|
||||
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
|
||||
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
|
||||
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
|
||||
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
|
||||
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
|
||||
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
|
||||
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
|
||||
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
|
||||
];
|
||||
|
||||
const matrix = [];
|
||||
for (const theme of ['Light', 'Dark']) {
|
||||
for (const row of rows) {
|
||||
matrix.push({
|
||||
theme,
|
||||
row,
|
||||
size: 56,
|
||||
runtime: await runtimePng(theme, row, 56),
|
||||
});
|
||||
}
|
||||
for (const size of [32, 96]) {
|
||||
const row = rows[0];
|
||||
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
|
||||
}
|
||||
const textRow = rows.find((row) => row.label === 'Text');
|
||||
matrix.push({ theme, row: textRow, size: 96, runtime: await runtimePng(theme, textRow, 96) });
|
||||
}
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
const body = matrix.map(({ theme, row, size, runtime }) => {
|
||||
const reference = referenceAsset(theme, row.file, size);
|
||||
return `
|
||||
<tr>
|
||||
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
|
||||
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
|
||||
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const html = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
|
||||
<style>
|
||||
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
|
||||
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
|
||||
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
|
||||
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
|
||||
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
|
||||
</style></head><body>
|
||||
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
|
||||
<p>Issues #211/#217. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default covers 32/56/96 px and Text has an additional large 96 px row so its outer stadium curvature is reviewable. Dark Unlock is evaluated using the owner's amber override from #179.</p>
|
||||
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
|
||||
<tbody>${body}</tbody></table></body></html>`;
|
||||
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
|
||||
writeFileSync(htmlPath, html);
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
await page.screenshot({
|
||||
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await browser.close();
|
||||
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
|
||||
@@ -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,72 +1,101 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 24,
|
||||
"acceptedAt": "2026-08-17T10:51:28.998Z",
|
||||
"sourceFingerprint": "b73e5020215967d2d13e2a08ce303916e2bdbcbd45c94c40c6e56336a8097656",
|
||||
"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",
|
||||
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
|
||||
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
|
||||
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
|
||||
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
|
||||
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
|
||||
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
|
||||
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
|
||||
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
|
||||
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
|
||||
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
|
||||
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
|
||||
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
|
||||
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
|
||||
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
|
||||
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
|
||||
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
|
||||
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
|
||||
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
|
||||
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
|
||||
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
|
||||
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
|
||||
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
|
||||
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
|
||||
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
|
||||
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
|
||||
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
|
||||
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
|
||||
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
|
||||
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
|
||||
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
|
||||
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
|
||||
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
|
||||
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
|
||||
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
|
||||
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
|
||||
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
|
||||
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
|
||||
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
|
||||
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
|
||||
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
|
||||
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
|
||||
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
|
||||
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
|
||||
"backup-plan-only-export-desktop-en": "2833ee45acac2936e76546e9ff5d0031932c02fbf66fe904c1be94dc3e33dc05",
|
||||
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"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": "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": "5b0499409dc7e0d04006c4f0cfec1b4a97a55151934294acb0f9040d62b7d445",
|
||||
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"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": "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": "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": "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: 100 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 336 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: 172 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 152 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: 44 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 150 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: 175 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 323 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: 47 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 200 KiB |