Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55fed02d24 | ||
|
|
2fda6fa086 | ||
|
|
cd88633535 | ||
|
|
314b498e12 | ||
|
|
e03238ec1c | ||
|
|
474dbd62ca | ||
|
|
e1f0d2310a | ||
|
|
1d4bf9b018 | ||
|
|
0a52b6757e | ||
|
|
97acb0b415 | ||
|
|
e2c1dbec0d | ||
|
|
08dce8603d | ||
|
|
c4694acea9 | ||
|
|
6c5163c3c9 | ||
|
|
f2e8fe5e93 | ||
|
|
8750708c5b | ||
|
|
e6e80934e0 | ||
|
|
8eb351baa3 | ||
|
|
a4ef0e2088 | ||
|
|
3c754729f2 | ||
|
|
3f6338889e | ||
|
|
35ab1dc0c3 | ||
|
|
4ccf4a3ccc | ||
|
|
c8a3878568 | ||
|
|
0146d5787e | ||
|
|
feff436942 | ||
|
|
374081b490 | ||
|
|
e9bf11a8e3 | ||
|
|
66193c5862 | ||
|
|
894a4e5803 | ||
|
|
1d220628ff | ||
|
|
52c74301f9 | ||
|
|
01512081db | ||
|
|
1245beaaf6 | ||
|
|
d33ae88da4 | ||
|
|
f6cb957bba | ||
|
|
f44df5d05d | ||
|
|
6983f2b088 | ||
|
|
f6cf2c93ae | ||
|
|
edf8b068ed | ||
|
|
6551c5d53d | ||
|
|
e102f34156 | ||
|
|
7fd9ca0f6a | ||
|
|
6e1007a363 | ||
|
|
10fa0dc7fb | ||
|
|
f2c0721a9d | ||
|
|
e8a4db7972 | ||
|
|
406e46ecb1 | ||
|
|
4201644d33 | ||
|
|
17086d3cf2 | ||
|
|
74fdf2182c | ||
|
|
2d8ade3b2b | ||
|
|
6f67d53665 | ||
|
|
e333f0b2f1 | ||
|
|
f472af88a3 | ||
|
|
a7618151cc | ||
|
|
10ca913fad | ||
|
|
849b3e5f76 | ||
|
|
bca3bfd52c | ||
|
|
2ad20dca2b | ||
|
|
0bca6dfb14 | ||
|
|
8877e8c6d6 | ||
|
|
6a4e665d33 | ||
|
|
5272287671 | ||
|
|
c14dcecb98 | ||
|
|
523190d8f4 | ||
|
|
d5659478aa | ||
|
|
c654e0ec42 | ||
|
|
d8696315eb | ||
|
|
ed606c0f4a | ||
|
|
89658c20fe | ||
|
|
a988f7c6f1 | ||
|
|
d73790aaab | ||
|
|
07bd1a6781 | ||
|
|
0963f5b4dd | ||
|
|
a3e5f30a3c | ||
|
|
38ea8a9b9e | ||
|
|
93fad8cfb4 | ||
|
|
c0c30d645a | ||
|
|
cd9916d72a | ||
|
|
8aa7fc970c | ||
|
|
bb81ede47c | ||
|
|
79285ab1d2 | ||
|
|
c123757f50 | ||
|
|
495043791e | ||
|
|
598e7437f2 | ||
|
|
df1a8330f4 | ||
|
|
8156f80140 | ||
|
|
930a911a36 | ||
|
|
4dd074d9a3 | ||
|
|
bdd4f38cb9 | ||
|
|
4412f905bd | ||
|
|
e68b494555 | ||
|
|
a548d4f17d | ||
|
|
9c5b66c3e4 | ||
|
|
448ceea399 | ||
|
|
241821c40e | ||
|
|
b02a37123d | ||
|
|
03b45f2e37 | ||
|
|
a5a0f6da2c | ||
|
|
2e22ed7938 | ||
|
|
cd0a682ad7 | ||
|
|
6335b0e519 | ||
|
|
8b227962b1 | ||
|
|
1bbd53ee52 | ||
|
|
7be7f8cd99 | ||
|
|
9e35650470 | ||
|
|
29a0305558 | ||
|
|
ff2f2063cf | ||
|
|
b5519e997b | ||
|
|
82fe98bcd3 | ||
|
|
57fdfdbd83 | ||
|
|
496e79a6df | ||
|
|
434995beb2 | ||
|
|
a605611105 | ||
|
|
ec315ad7c1 | ||
|
|
cfc8f2114d | ||
|
|
d5a0452fb5 | ||
|
|
ca5166430b | ||
|
|
5c2c2578e1 | ||
|
|
a06c94b967 | ||
|
|
240316a639 | ||
|
|
678b1996fc | ||
|
|
ca8390366f | ||
|
|
8c4dd16754 | ||
|
|
7cef3014a2 | ||
|
|
7bcc5b285b | ||
|
|
29f6ad2969 | ||
|
|
3bf5a1db26 | ||
|
|
423c20cd23 | ||
|
|
62dd02d823 | ||
|
|
e04e168ac4 | ||
|
|
762e9f4b32 | ||
|
|
1daa852622 | ||
|
|
8d2a0505e3 | ||
|
|
b05eb68c3a | ||
|
|
22d0f05650 | ||
|
|
4266b625fe | ||
|
|
57a967b9a5 | ||
|
|
ad1ee9d13d | ||
|
|
c9b8497f58 | ||
|
|
7b23035e21 | ||
|
|
9b764277db | ||
|
|
d83b0c512f | ||
|
|
5fd4e2249a | ||
|
|
3fedcaf1d5 | ||
|
|
a28158c041 | ||
|
|
21b04ed0c2 | ||
|
|
f3d4a79787 | ||
|
|
dd4ebb7401 | ||
|
|
67863d68a4 | ||
|
|
c0b231fa06 | ||
|
|
c190a698c5 | ||
|
|
8bd910ed84 | ||
|
|
dc94e016ca | ||
|
|
e3b635aba2 | ||
|
|
ffbbb217b5 | ||
|
|
154af2692f | ||
|
|
d68e876f78 | ||
|
|
3ab3cdc1e4 | ||
|
|
105a8f70eb | ||
|
|
3e8733ad27 | ||
|
|
e2de0dbcc4 | ||
|
|
6e2247aa3d | ||
|
|
b5fbe633a6 | ||
|
|
b86da0d419 | ||
|
|
1d0e0c5ab8 | ||
|
|
3e3fb9ceaa | ||
|
|
5fa2dc2893 | ||
|
|
2d6b6006fe | ||
|
|
8cdf6b4894 | ||
|
|
a0b39b626f | ||
|
|
7b3ea3769a | ||
|
|
98dce8d0b4 | ||
|
|
0d0a9dd87a | ||
|
|
4eda889922 | ||
|
|
2e53cb0e05 | ||
|
|
0dc6c39784 | ||
|
|
61b437fd3f | ||
|
|
9f6efd0c11 | ||
|
|
18d504631c | ||
|
|
9f63163339 | ||
|
|
61bf537b58 | ||
|
|
aa85a95b32 | ||
|
|
28eaf86662 | ||
|
|
46a83de181 | ||
|
|
b9a5f6c4e4 | ||
|
|
5e95a28406 | ||
|
|
2970b8091b | ||
|
|
7b267fb632 | ||
|
|
8c7e8bc9d1 | ||
|
|
f5ec6718a2 | ||
|
|
ffb609d471 | ||
|
|
bfdeb493cb | ||
|
|
9d6a4e34fd | ||
|
|
d9f7861c05 | ||
|
|
6dd59a1925 | ||
|
|
b9e4be3799 | ||
|
|
86a0786e2d | ||
|
|
0dbc94739b | ||
|
|
074a9ee86d | ||
|
|
e383fc2989 | ||
|
|
4edb2bb2eb | ||
|
|
b56f119f7a | ||
|
|
36b5a8c36c | ||
|
|
a41035d185 | ||
|
|
eefe0fab94 | ||
|
|
759111beb3 | ||
|
|
63db3ae4c6 | ||
|
|
4b6331f8d8 | ||
|
|
10999a5c5c | ||
|
|
2d1fca1dfa | ||
|
|
a952f5fd07 | ||
|
|
6d0fa3b819 | ||
|
|
7e69b4bb28 | ||
|
|
74d2285d1b | ||
|
|
dc3c083352 | ||
|
|
59be468506 | ||
|
|
2c74b12101 | ||
|
|
5cf52707c4 | ||
|
|
c8d7d282a4 | ||
|
|
4d5f7f50f1 | ||
|
|
527081919a | ||
|
|
f12418ba1c | ||
|
|
bde9a40ef4 | ||
|
|
cba2b5ab50 | ||
|
|
4b19376a81 | ||
|
|
0108b26655 | ||
|
|
fbc689095b | ||
|
|
2ae523a8b1 | ||
|
|
2cb7c73cad | ||
|
|
e5bda27cc3 | ||
|
|
903bc0a345 | ||
|
|
6ef814cea7 | ||
|
|
21db2b2898 | ||
|
|
ee0a1f82f2 | ||
|
|
29904df6ee | ||
|
|
56e3da9b8a | ||
|
|
cb745c2035 | ||
|
|
d44d2e8830 | ||
|
|
5276cfdb15 | ||
|
|
0a1465d20c | ||
|
|
e0b613e94f | ||
|
|
9c5eacfd02 | ||
|
|
4e13d71d78 | ||
|
|
ed7ea29dc9 | ||
|
|
7180eee0ee | ||
|
|
7269550bbb | ||
|
|
0eb9ffe480 | ||
|
|
2534916c94 | ||
|
|
2c776b0c6d | ||
|
|
a3166eb1b0 | ||
|
|
e468414c24 | ||
|
|
120b491eba | ||
|
|
5ad4280ef7 | ||
|
|
cbdd5a7e73 | ||
|
|
56fe4240d1 | ||
|
|
4d73d031a4 | ||
|
|
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 |
@@ -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
|
||||
@@ -147,9 +147,9 @@ jobs:
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
npm --prefix candidate run build
|
||||
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
|
||||
(cd candidate && node scripts/bundle-sync.mjs)
|
||||
npm --prefix baseline run build
|
||||
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
|
||||
(cd baseline && node scripts/bundle-sync.mjs)
|
||||
|
||||
- name: Capture base and candidate profiles
|
||||
working-directory: candidate
|
||||
|
||||
@@ -229,22 +229,110 @@ jobs:
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
|
||||
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
|
||||
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
|
||||
# ребейз упирается в конфликт и это видно; смысловое расхождение git
|
||||
# склеивает молча, и в dev уезжает комбинация, которую никто не читал.
|
||||
# Именно так пришёл регресс #234.
|
||||
#
|
||||
# Заодно снимается плата за конфликт: он обнаруживался ПОСЛЕ сорока минут
|
||||
# ревью и потраченных лимитов подписки, хотя виден за пять секунд до них.
|
||||
#
|
||||
# Этап spec не затрагивается: ветку ТЗ в dev никто не сливает, и трогать
|
||||
# чужую ветку без нужды — лишний риск.
|
||||
- name: Привести ветку к dev
|
||||
id: rebase
|
||||
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
# «unable to auto-detect email address».
|
||||
GIT_AUTHOR_NAME: claude[bot]
|
||||
GIT_AUTHOR_EMAIL: 209825114+claude[bot]@users.noreply.github.com
|
||||
GIT_COMMITTER_NAME: claude[bot]
|
||||
GIT_COMMITTER_EMAIL: 209825114+claude[bot]@users.noreply.github.com
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
if git merge-base --is-ancestor origin/dev HEAD; then
|
||||
echo "ветка содержит весь dev — ребейз не нужен"
|
||||
exit 0
|
||||
fi
|
||||
behind=$(git rev-list --count "HEAD..origin/dev")
|
||||
before=$(git rev-parse "origin/$BRANCH")
|
||||
echo "dev впереди на $behind коммит(ов) — привожу ветку"
|
||||
if ! git rebase origin/dev; then
|
||||
git rebase --abort || true
|
||||
echo "conflict=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается"
|
||||
exit 0
|
||||
fi
|
||||
# --force-with-lease с явным ожидаемым значением обязателен: между
|
||||
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
|
||||
# его молча. Расхождение lease — падение прогона, а не предупреждение:
|
||||
# ревью пошло бы по коду, которого на ветке уже нет.
|
||||
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
|
||||
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:refs/heads/$BRANCH"; then
|
||||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||||
exit 1
|
||||
fi
|
||||
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
|
||||
# и без этого он ребейзил бы заново уже приведённое.
|
||||
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
|
||||
short_before=$(git rev-parse --short "$before")
|
||||
short_after=$(git rev-parse --short HEAD)
|
||||
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
|
||||
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
|
||||
|
||||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||||
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
|
||||
# тоже смена метки, и автор не ждёт впустую.
|
||||
- name: Конфликт с dev — вернуть автору без ревью
|
||||
if: steps.rebase.outputs.conflict == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
run: |
|
||||
cat > /tmp/stale.md <<EOF
|
||||
**Ревью не запускалось:** ветка \`$BRANCH\` не ребейзится на \`dev\` без конфликта. Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Проверка стоит до ревью намеренно: конфликт всё равно вернул бы задачу, но уже после сорока минут работы ревьюера и потраченных лимитов.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||||
|
||||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||||
2. запушить ветку;
|
||||
3. вернуть метку \`S7-code-review\`.
|
||||
|
||||
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
@@ -254,6 +342,7 @@ jobs:
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
@@ -277,6 +366,8 @@ jobs:
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
${{ steps.rebase.outputs.note }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
@@ -353,12 +444,41 @@ jobs:
|
||||
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
|
||||
а стоят они минуты:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла.
|
||||
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
|
||||
`src/**`: отпечаток скриншотов документации считается по всему
|
||||
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
|
||||
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
|
||||
с красным job `docs` до следующей задачи (#237).
|
||||
|
||||
Если diff трогает геометрию или ссылки на неё — рёбра комнат,
|
||||
записи толщины, `layout`, `marker.space`, `open_spans` — обязательны
|
||||
инварианты модели (#254): `npm test` уже гоняет их на всех моделях
|
||||
проекта, а на конкретной конфигурации они проверяются командой
|
||||
`npm run invariants -- --config <экспорт или ответ config/get>`.
|
||||
Три вопроса, на которые они отвечают, и все три уже стоили
|
||||
продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли
|
||||
каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу
|
||||
решёточного ребра (#258, #259). Последний сравнивает строки без
|
||||
допусков: сдвиг ключа на один шаг решётки равен допуску первых двух,
|
||||
поэтому они на нём промахиваются. Если задача меняет геометрию, а
|
||||
инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь.
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
|
||||
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
|
||||
только когда задача действительно задевает всё;
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
|
||||
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
|
||||
Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое
|
||||
в этот текст число трижды расходилось с деревом, поэтому его
|
||||
здесь больше нет. Прогон всех уместен только когда задача
|
||||
действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
|
||||
`smoke_wall_junctions`, который по названию про стыки стен, а не
|
||||
про толщину отрезка. Инструмент печатает три вида ответа, и они
|
||||
разные: «прямое совпадение» — смок называет изменённый символ,
|
||||
«зарегистрированная связь» — смок проверяет следствие контракта,
|
||||
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
|
||||
разрешение ничего не прогонять. Вывод инструмента прикладывается
|
||||
к комментарию ревью вместе с решением по каждой строке: прогнал
|
||||
либо не прогнал и почему. Слабые связи (одно распространённое
|
||||
имя) — повод посмотреть, а не обязанность прогонять;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
@@ -366,6 +486,14 @@ jobs:
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
**Одно число — один источник.** Если дифф добавляет или меняет
|
||||
величину, видимую пользователю, назови в отчёте прямо: какое число
|
||||
видно дважды (превью против записи, подпись против площади,
|
||||
подсветка инструмента против сохранённого значения) и один ли у него
|
||||
источник. Три дефекта подряд имели именно эту причину — #234, #233 и
|
||||
способ, которым #234 обнаружили. Механическая часть закреплена
|
||||
тестом `test/single-source-numbers.test.mjs`, смысловая — твоя.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
|
||||
@@ -428,6 +556,7 @@ jobs:
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
@@ -526,6 +655,7 @@ jobs:
|
||||
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -604,6 +734,7 @@ jobs:
|
||||
echo "слито в dev: $(git rev-parse --short HEAD)"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
|
||||
@@ -81,7 +81,6 @@ jobs:
|
||||
npm ci
|
||||
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
|
||||
VERSION=${TAG#v}
|
||||
grep -Fq "$VERSION" dist/houseplan-card.js
|
||||
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
node scripts/bundle-sync.mjs
|
||||
npm run continuity:screencast
|
||||
- name: Upload failed continuity frames
|
||||
if: ${{ failure() && !github.event.release.prerelease }}
|
||||
|
||||
@@ -6,6 +6,12 @@ on:
|
||||
# to the same SHA and must not duplicate the browser validation jobs.
|
||||
branches:
|
||||
- '**'
|
||||
# Документы ревью конвейер пишет пачками — 340 коммитов за месяц, и каждый
|
||||
# гонял лёгкую половину Validate впустую (≈15 часов раннера в месяц).
|
||||
# Релизного кандидата это не затрагивает: тег всегда стоит на коммите,
|
||||
# который меняет версию и бандл, а не только `docs/reviews/**`.
|
||||
paths-ignore:
|
||||
- 'docs/reviews/**'
|
||||
pull_request:
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
@@ -24,6 +30,28 @@ jobs:
|
||||
- name: Validate public documentation
|
||||
run: node scripts/check-docs.mjs --external
|
||||
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
process-workflow-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: process.yml идентичен в main и dev
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||||
<(git show origin/dev:.github/workflows/process.yml); then
|
||||
echo "main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
|
||||
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -245,16 +273,22 @@ jobs:
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
# Копия стенда больше не коммитится (#255): сверяются две обязательные.
|
||||
- name: Card bundle snapshots in sync
|
||||
run: |
|
||||
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
|
||||
smoke:
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||||
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -277,12 +311,22 @@ jobs:
|
||||
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
|
||||
run: npm run bundle:sync
|
||||
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: '3'
|
||||
run: |
|
||||
fail=0
|
||||
index=0
|
||||
ran=0
|
||||
mkdir -p /tmp/smoke-logs
|
||||
# Деление по порядковому номеру файла: список отсортирован, поэтому
|
||||
# разбиение детерминировано и не зависит от времени прогона.
|
||||
for f in demo/smoke_*.mjs; do
|
||||
index=$((index + 1))
|
||||
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
|
||||
ran=$((ran + 1))
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
@@ -292,18 +336,27 @@ jobs:
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
echo "--- шард ${SHARD}/${SHARDS}: прогнано ${ran} из ${index}"
|
||||
# Пустой шард — признак, что деление сломалось, а не что работы нет.
|
||||
if [ "$ran" -eq 0 ]; then echo "шард пуст: проверьте деление"; exit 1; fi
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: smoke-logs
|
||||
name: smoke-logs-${{ matrix.shard }}
|
||||
path: /tmp/smoke-logs
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
|
||||
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
|
||||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||||
smoke_done:
|
||||
needs: [smoke, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "smoke прогнана успешно" \
|
||||
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
@@ -344,7 +397,7 @@ jobs:
|
||||
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
|
||||
run: npm run bundle:sync
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
@@ -410,7 +463,7 @@ jobs:
|
||||
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
|
||||
run: npm run bundle:sync
|
||||
- name: Capture the heaviest Glow state
|
||||
run: |
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
|
||||
@@ -7,3 +7,8 @@ __pycache__/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
.agents/
|
||||
|
||||
# Копия бандла для стенда: её собирает `npm run bundle:sync`, а в репозитории
|
||||
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
|
||||
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
|
||||
demo/srv/assets/houseplan-card.js
|
||||
|
||||
@@ -87,7 +87,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
|
||||
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
|
||||
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
|
||||
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
|
||||
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | never changes on its own |
|
||||
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/houseplan-card.js` is no longer committed (#255): build it with `npm run bundle:sync` |
|
||||
|
||||
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
|
||||
configuration files this one omits — `package.json`, `package-lock.json`,
|
||||
@@ -322,7 +322,7 @@ byte-for-byte:
|
||||
|
||||
```
|
||||
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
```
|
||||
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
@@ -358,9 +358,13 @@ complete Linux CI artifact; never accept a partial scenario or images merely to
|
||||
CI green. See `demo/golden/README.md`.
|
||||
|
||||
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
|
||||
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
|
||||
`assertFreshDemoBundle` before recording any result; a missing or mismatched
|
||||
fingerprint is a hard failure, not a warning.
|
||||
TypeScript and package-lock build inputs. Every browser check must verify it
|
||||
before trusting a result — benchmarks, golden runs and documentation captures
|
||||
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
|
||||
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
|
||||
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
|
||||
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
|
||||
assertions go red and part stay green, which reads as a logic defect.
|
||||
|
||||
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
|
||||
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
|
||||
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
|
||||
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
|
||||
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (копия стенда `demo/srv/assets/houseplan-card.js` с #255 не коммитится вовсе) | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
|
||||
|
||||
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
|
||||
бандл» перестают быть лазейками.
|
||||
@@ -537,23 +537,58 @@ issue #NN
|
||||
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
|
||||
# копия стенда собирается `npm run bundle:sync`, в репозитории её нет (#255)
|
||||
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
|
||||
node demo/smoke_<целевые>.mjs
|
||||
npm run golden:verify # если менялся визуал
|
||||
node scripts/check-docs.mjs # если менялся src/**
|
||||
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
|
||||
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: браузерные смоки (сколько их —
|
||||
считает `ls demo/smoke_*.mjs | wc -l`, вшитое число здесь трижды отставало от
|
||||
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
|
||||
диффу, печатает
|
||||
`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`, коммит вместе с задачей.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
**Одно число — один источник.** Любая величина, которую пользователь видит
|
||||
дважды — превью против записи, подпись против площади, подсветка инструмента
|
||||
против сохранённого значения, — обязана считаться в одном месте. Три дефекта
|
||||
подряд имели ровно эту причину: #234 (резинка показывала 12 см, запись хранила
|
||||
24), #233 (подпись мерила по осевым линиям, площадь рядом — по полу) и способ,
|
||||
которым #234 нашли (подсветка «Толщины» врала согласованно с записью). Ревьюер
|
||||
отвечает на вопрос прямо: какое число в этом диффе видно дважды и один ли у него
|
||||
источник. Механическая часть правила закреплена тестом
|
||||
`test/single-source-numbers.test.mjs` — строку с единицей измерения собирает
|
||||
только канонический форматтер; смысловая часть остаётся за ревью.
|
||||
|
||||
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
|
||||
|
||||
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
|
||||
@@ -778,6 +813,28 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
|
||||
ошибки.
|
||||
|
||||
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
|
||||
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
|
||||
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
|
||||
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
|
||||
которую ревьюер не читал. Именно так пришёл регресс #234. Шаг перед ревью делает
|
||||
одно из трёх:
|
||||
|
||||
- ветка уже содержит весь `dev` — ничего;
|
||||
- отстала и ребейзится чисто — ребейз, `push --force-with-lease`, ревью по
|
||||
приведённому состоянию. Факт ребейза передаётся в промпт, чтобы сработало
|
||||
правило §7.2 о полном разборе вместо дельты;
|
||||
- конфликт — возврат в `S6-in-progress` **до** запуска ревью. Цикл при этом не
|
||||
расходуется: код никто не читал, вердикта нет.
|
||||
|
||||
Проверка стоит до ревью не только ради совпадения SHA. Конфликт всё равно вернул бы
|
||||
задачу, но обнаруживался он после сорока пяти минут работы ревьюера и потраченных
|
||||
лимитов подписки, хотя виден за пять секунд до них.
|
||||
|
||||
`--force-with-lease` здесь обязателен с явным ожидаемым значением: между чтением
|
||||
ветки и пушем автор мог запушить коммит, и слепой `--force` потерял бы его молча.
|
||||
Расхождение lease — падение прогона, а не предупреждение.
|
||||
|
||||
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
|
||||
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
|
||||
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
|
||||
|
||||
@@ -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.66.0"
|
||||
VERSION = "1.67.0-beta.9"
|
||||
|
||||
# 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,176 @@
|
||||
"""Lossless, allow-listed canonicalisation of persisted geometry.
|
||||
|
||||
The frontend mirrors this module in src/coordinate-canonicalization.ts.
|
||||
Keep the precision, lattice 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
|
||||
LATTICE_GRID_N = 240
|
||||
LATTICE_NOISE_STEPS = 1e-4
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
|
||||
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 canonicalize_lattice_coordinate(value: Any) -> Any:
|
||||
"""Collapse near-node noise while preserving authored off-grid values."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return value
|
||||
scaled = number * LATTICE_GRID_N
|
||||
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
|
||||
nearest = math.floor(scaled + 0.5)
|
||||
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
|
||||
result = nearest / LATTICE_GRID_N
|
||||
return 0.0 if result == 0 else result
|
||||
return canonicalize_number(number)
|
||||
|
||||
|
||||
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 _scalar_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_number(record[name])
|
||||
|
||||
|
||||
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_lattice_coordinate(record[name])
|
||||
|
||||
|
||||
def _lattice_point(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for index in range(min(2, len(value))):
|
||||
value[index] = canonicalize_lattice_coordinate(value[index])
|
||||
|
||||
|
||||
def _lattice_points(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for point in value:
|
||||
_lattice_point(point)
|
||||
|
||||
|
||||
def canonicalize_position(position: Any) -> Any:
|
||||
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
|
||||
result = copy.deepcopy(position)
|
||||
record = _record(result)
|
||||
if record is not None:
|
||||
_lattice_fields(record, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||
"""Canonicalise lattice x/y in every layout record."""
|
||||
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:
|
||||
_lattice_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")):
|
||||
_scalar_fields(
|
||||
space,
|
||||
(
|
||||
"plan_x",
|
||||
"plan_y",
|
||||
"plan_scale",
|
||||
"plan_scale_x",
|
||||
"plan_scale_y",
|
||||
"plan_angle",
|
||||
),
|
||||
)
|
||||
|
||||
for room in _records(space.get("rooms")):
|
||||
_lattice_fields(room, ("x", "y", "w", "h"))
|
||||
_lattice_points(room.get("poly"))
|
||||
|
||||
for wall in _records(space.get("walls")):
|
||||
_lattice_point(wall.get("a"))
|
||||
_lattice_point(wall.get("b"))
|
||||
|
||||
for opening in _records(space.get("openings")):
|
||||
_lattice_fields(opening, ("x", "y"))
|
||||
_scalar_fields(opening, ("angle", "length"))
|
||||
host = _record(opening.get("host"))
|
||||
if host is not None:
|
||||
_scalar_fields(host, ("t",))
|
||||
|
||||
for decor in _records(space.get("decor")):
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
_lattice_fields(decor, ("x", "y", "w", "h"))
|
||||
_scalar_fields(decor, ("angle",))
|
||||
elif kind == "text":
|
||||
_lattice_fields(decor, ("x", "y"))
|
||||
_scalar_fields(decor, ("scale", "angle"))
|
||||
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
_lattice_points(draft.get("points"))
|
||||
|
||||
for partition in _records(space.get("partitions")):
|
||||
_lattice_point(partition.get("a"))
|
||||
_lattice_point(partition.get("b"))
|
||||
|
||||
for column in _records(space.get("wall_columns")):
|
||||
_lattice_point(column.get("center"))
|
||||
if column.get("shape") == "square":
|
||||
_scalar_fields(column, ("angle",))
|
||||
|
||||
for span in _records(space.get("open_spans")):
|
||||
_lattice_point(span.get("a"))
|
||||
_lattice_point(span.get("b"))
|
||||
|
||||
for marker in _records(root.get("markers")):
|
||||
_scalar_fields(marker, ("angle",))
|
||||
|
||||
return result
|
||||
@@ -822,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],
|
||||
@@ -859,6 +909,9 @@ 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"]]
|
||||
@@ -884,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]] = []
|
||||
@@ -991,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:
|
||||
@@ -1034,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.66.0"
|
||||
"version": "1.67.0-beta.9"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Read-only projections of the stored plan (#256).
|
||||
|
||||
A full configuration is 70 KB on a real installation: three spaces and 139
|
||||
markers. Every diagnostic question — "which space does this marker point at",
|
||||
"how many markers are hidden", "what is on the first floor" — used to require
|
||||
downloading all of it, because `houseplan/config/get` had no way to ask for
|
||||
less.
|
||||
|
||||
The functions here are pure and deliberately unaware of Home Assistant: the
|
||||
websocket handlers stay thin, and the interesting part is covered by tests that
|
||||
run without the HA harness.
|
||||
|
||||
Two rules shape everything below.
|
||||
|
||||
* Absent parameter means *no projection*. The response must stay byte-for-byte
|
||||
what it was before this module existed; no existing client may notice it.
|
||||
* A projection never invents or repairs data. An unknown field name simply adds
|
||||
nothing, and an unknown space yields an empty list rather than an error — the
|
||||
caller distinguishes "no such thing" from "broken" by content, not by an
|
||||
error code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def _names(value: Any) -> list[str] | None:
|
||||
"""Normalise a field list; anything unusable means "no projection"."""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
names = [str(item) for item in value if isinstance(item, str) and item]
|
||||
return names or None
|
||||
|
||||
|
||||
def project_markers(markers: Any, marker_fields: Iterable[str] | None) -> Any:
|
||||
"""Keep only the requested marker fields, plus `id`.
|
||||
|
||||
`id` is added unconditionally: a marker without it cannot be matched to
|
||||
anything, so a projection that drops it produces an answer nobody can use.
|
||||
"""
|
||||
names = _names(marker_fields)
|
||||
if names is None or not isinstance(markers, list):
|
||||
return markers
|
||||
keep = {"id", *names}
|
||||
out = []
|
||||
for marker in markers:
|
||||
if not isinstance(marker, dict):
|
||||
out.append(marker)
|
||||
continue
|
||||
out.append({key: value for key, value in marker.items() if key in keep})
|
||||
return out
|
||||
|
||||
|
||||
def project_config(
|
||||
config: Any,
|
||||
*,
|
||||
space_id: str | None = None,
|
||||
fields: Iterable[str] | None = None,
|
||||
marker_fields: Iterable[str] | None = None,
|
||||
) -> Any:
|
||||
"""Return a narrowed copy of the configuration.
|
||||
|
||||
The original object is never mutated: the caller hands us the store's
|
||||
document, and a projection that edited it in place would corrupt the very
|
||||
thing it was asked to read.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
field_names = _names(fields)
|
||||
if space_id is None and field_names is None and _names(marker_fields) is None:
|
||||
return config
|
||||
|
||||
projected: dict[str, Any] = dict(config)
|
||||
if space_id is not None:
|
||||
spaces = projected.get("spaces")
|
||||
projected["spaces"] = [
|
||||
space for space in spaces
|
||||
if isinstance(space, dict) and str(space.get("id", "")) == str(space_id)
|
||||
] if isinstance(spaces, list) else spaces
|
||||
if marker_fields is not None:
|
||||
projected["markers"] = project_markers(projected.get("markers"), marker_fields)
|
||||
if field_names is not None:
|
||||
projected = {key: value for key, value in projected.items() if key in set(field_names)}
|
||||
return projected
|
||||
|
||||
|
||||
def project_layout(layout: Any, *, space_id: str | None = None) -> Any:
|
||||
"""Keep only the positions of one space."""
|
||||
if space_id is None or not isinstance(layout, dict):
|
||||
return layout
|
||||
wanted = str(space_id)
|
||||
return {
|
||||
key: position for key, position in layout.items()
|
||||
if isinstance(position, dict) and str(position.get("s", "")) == wanted
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -5,10 +5,17 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import math
|
||||
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
|
||||
@@ -74,10 +81,173 @@ class PartitionOpeningJambMarginError(ValueError):
|
||||
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
|
||||
# user setting.
|
||||
NORMALIZED_CANVAS_CELLS = 240.0
|
||||
_DEFAULT_ROOM_WALL_CM = 15.0
|
||||
_OPTIMIZE_REHOST_EPSILON = 1e-8
|
||||
|
||||
|
||||
def _room_polygon(room: dict) -> list[list[float]]:
|
||||
poly = room.get("poly")
|
||||
if isinstance(poly, list) and len(poly) >= 3:
|
||||
return poly
|
||||
if all(key in room for key in ("x", "y", "w", "h")):
|
||||
x, y = float(room["x"]), float(room["y"])
|
||||
w, h = float(room["w"]), float(room["h"])
|
||||
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
|
||||
return []
|
||||
|
||||
|
||||
def _segment_metrics(a: list, b: list) -> tuple[float, float, float]:
|
||||
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
|
||||
return dx, dy, math.hypot(dx, dy)
|
||||
|
||||
|
||||
def _line_distance(point: list, a: list, b: list) -> float:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return math.inf
|
||||
return abs((float(point[0]) - float(a[0])) * dy
|
||||
- (float(point[1]) - float(a[1])) * dx) / length
|
||||
|
||||
|
||||
def _projection(point: list, a: list, b: list) -> float:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return math.inf
|
||||
return ((float(point[0]) - float(a[0])) * dx
|
||||
+ (float(point[1]) - float(a[1])) * dy) / length
|
||||
|
||||
|
||||
def _segment_covers(a: list, b: list, target_a: list, target_b: list) -> bool:
|
||||
"""Return whether one exact collinear segment covers the target."""
|
||||
_, _, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
if (_line_distance(target_a, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(target_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
for point in (target_a, target_b):
|
||||
along = _projection(point, a, b)
|
||||
if along < -_OPTIMIZE_REHOST_EPSILON or along > length + _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _segments_overlap_on_axis(a: list, b: list, other_a: list, other_b: list) -> bool:
|
||||
if (_line_distance(other_a, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(other_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
_, _, length = _segment_metrics(a, b)
|
||||
lo, hi = sorted((_projection(other_a, a, b), _projection(other_b, a, b)))
|
||||
return min(length, hi) - max(0.0, lo) > _OPTIMIZE_REHOST_EPSILON
|
||||
|
||||
|
||||
def _angle_delta_mod_180(first: float, second: float) -> float:
|
||||
return abs((first - second + 90.0) % 180.0 - 90.0)
|
||||
|
||||
|
||||
def _safe_optimize_partition_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Independently prove the exact #276 partition-to-room-wall transition."""
|
||||
old_host = old_opening.get("host")
|
||||
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
|
||||
return False
|
||||
partition_id = str(old_host.get("id", ""))
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if old_partition is None or any(
|
||||
str(item.get("id", "")) == partition_id
|
||||
for item in space.get("partitions") or []
|
||||
):
|
||||
return False
|
||||
a, b = old_partition.get("a"), old_partition.get("b")
|
||||
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
|
||||
return False
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
|
||||
owners: set[str] = set()
|
||||
collinear_rooms: set[str] = set()
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
room_id = str(room.get("id", ""))
|
||||
edges = [(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))]
|
||||
if any(_segment_covers(edge_a, edge_b, a, b) for edge_a, edge_b in edges):
|
||||
owners.add(room_id)
|
||||
if any(_segments_overlap_on_axis(a, b, edge_a, edge_b)
|
||||
for edge_a, edge_b in edges):
|
||||
collinear_rooms.add(room_id)
|
||||
if len(owners) not in (1, 2) or collinear_rooms != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(a, b, span["a"], span["b"])
|
||||
for span in space.get("open_spans") or []):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
wall_a, wall_b = wall.get("a"), wall.get("b")
|
||||
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
|
||||
continue
|
||||
if _segment_covers(wall_a, wall_b, a, b):
|
||||
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
|
||||
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
|
||||
return False
|
||||
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
|
||||
new_stable = {key: value for key, value in opening.items() if key not in ignored}
|
||||
if old_stable != new_stable:
|
||||
return False
|
||||
try:
|
||||
t = float(old_host["t"])
|
||||
x, y = float(opening["x"]), float(opening["y"])
|
||||
opening_angle = float(opening["angle"])
|
||||
opening_length = float(opening["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if not (0 <= t <= 1 and opening_length > 0):
|
||||
return False
|
||||
expected_x, expected_y = float(a[0]) + dx * t, float(a[1]) + dy * t
|
||||
expected_angle = math.degrees(math.atan2(dy, dx))
|
||||
if expected_angle >= 90:
|
||||
expected_angle -= 180
|
||||
elif expected_angle < -90:
|
||||
expected_angle += 180
|
||||
if (abs(x - expected_x) > _OPTIMIZE_REHOST_EPSILON
|
||||
or abs(y - expected_y) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _angle_delta_mod_180(opening_angle, expected_angle) > 1e-7):
|
||||
return False
|
||||
along = t * length
|
||||
if (along - opening_length / 2 < -_OPTIMIZE_REHOST_EPSILON
|
||||
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
|
||||
for other in space.get("openings") or []:
|
||||
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
|
||||
continue
|
||||
try:
|
||||
center = [float(other["x"]), float(other["y"])]
|
||||
other_angle = float(other["angle"])
|
||||
other_length = float(other["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if (_line_distance(center, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _angle_delta_mod_180(other_angle, expected_angle) > 1e-7):
|
||||
continue
|
||||
other_along = _projection(center, a, b)
|
||||
if (min(along + opening_length / 2, other_along + other_length / 2)
|
||||
- max(along - opening_length / 2, other_along - other_length / 2)
|
||||
> _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_partition_opening_hosts(
|
||||
config: dict, previous: dict | None = None
|
||||
config: dict, previous: dict | None = None, *, allow_optimize_rehost: bool = False
|
||||
) -> None:
|
||||
"""Validate hosted-opening write deltas without rejecting legacy reads.
|
||||
|
||||
@@ -108,9 +278,13 @@ def validate_partition_opening_hosts(
|
||||
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"
|
||||
)
|
||||
if not (allow_optimize_rehost and old_space
|
||||
and _safe_optimize_partition_rehost(
|
||||
space, old_space, opening, old
|
||||
)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host removed"
|
||||
)
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
@@ -522,11 +696,18 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
POS_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
),
|
||||
canonicalize_position,
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(
|
||||
vol.Schema({str: POS_SCHEMA}),
|
||||
vol.Length(max=MAX_LAYOUT),
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
@@ -1124,38 +1305,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,
|
||||
@@ -55,6 +59,7 @@ from .virtual_lights import (
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .projection import project_config, project_layout
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
@@ -138,6 +143,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)
|
||||
@@ -426,8 +432,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if kind == "full":
|
||||
backup = {
|
||||
"kind": "import",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
@@ -444,16 +454,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,
|
||||
@@ -496,6 +510,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",
|
||||
})
|
||||
|
||||
@@ -515,10 +530,15 @@ def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, An
|
||||
return live_layout(config, layout)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/layout/get",
|
||||
vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the saved layout."""
|
||||
"""Return the saved layout, optionally narrowed to one space (#256)."""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
@@ -526,7 +546,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
connection.send_result(
|
||||
msg["id"], {
|
||||
"layout": data.get("layout", {}),
|
||||
"layout": project_layout(
|
||||
data.get("layout", {}), space_id=msg.get("space_id"),
|
||||
),
|
||||
"rev": int(data.get("rev", 0)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
|
||||
"undo_kind": _undo_kind(config_data, data),
|
||||
@@ -566,6 +588,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
||||
if layout == data.get("layout", {}):
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
@@ -627,7 +652,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
if layout.get(msg["device_id"]) == msg["pos"]:
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
|
||||
return
|
||||
layout = {**layout, msg["device_id"]: msg["pos"]}
|
||||
# keep the revision: a point-wise write used to drop it, which made the
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
@@ -735,7 +763,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",),
|
||||
)
|
||||
@@ -1058,7 +1089,20 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# ---------------- space configuration ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
|
||||
_PROJECTION_FIELDS = vol.All([vol.All(str, vol.Length(min=1, max=100))], vol.Length(max=50))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/get",
|
||||
# Проекция ответа (#256). Все параметры необязательны, и без них ответ
|
||||
# прежний — это главный инвариант: ни один существующий клиент не
|
||||
# должен заметить появление этой возможности.
|
||||
vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("fields"): _PROJECTION_FIELDS,
|
||||
vol.Optional("marker_fields"): _PROJECTION_FIELDS,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the configuration, its revision, and whether this user may write.
|
||||
@@ -1066,6 +1110,11 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
`can_write` is the single source of truth for the card's editor chrome
|
||||
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
|
||||
check that drifted from the integration option.
|
||||
|
||||
Optional `space_id`/`fields`/`marker_fields` narrow ONLY the returned
|
||||
document (#256). Revisions and capability flags are computed from the whole
|
||||
stored configuration: a caller that asked for one floor must not receive a
|
||||
revision that describes only that floor.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
@@ -1089,7 +1138,12 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": config,
|
||||
"config": project_config(
|
||||
config,
|
||||
space_id=msg.get("space_id"),
|
||||
fields=msg.get("fields"),
|
||||
marker_fields=msg.get("marker_fields"),
|
||||
),
|
||||
"rev": config_rev,
|
||||
"virtual_lights": virtual_lights,
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
@@ -1281,6 +1335,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,
|
||||
@@ -1320,6 +1385,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",
|
||||
@@ -1372,7 +1624,10 @@ 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"))
|
||||
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(
|
||||
msg["config"], config_data.get("config"),
|
||||
allow_optimize_rehost=True,
|
||||
)
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
@@ -1397,15 +1652,19 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = {
|
||||
"kind": "optimize",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout", {})
|
||||
),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
pending = {
|
||||
"config": msg["config"],
|
||||
"layout": msg["layout"],
|
||||
"config": canonicalize_config_geometry(msg["config"]),
|
||||
"layout": canonicalize_layout_geometry(msg["layout"]),
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": False,
|
||||
@@ -1475,8 +1734,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 = {
|
||||
|
||||
@@ -8,7 +8,7 @@ public materials.
|
||||
fake `hass` (registries, states, `callWS`, `callService`, floors).
|
||||
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
|
||||
needs the repo's devDependencies). The card bundle is copied from `dist/`:
|
||||
`cp dist/houseplan-card.js demo/srv/assets/`.
|
||||
`npm run bundle:sync` (копия стенда не коммитится, #255).
|
||||
- `serve.mjs` — playwright launcher (route interception, no web server).
|
||||
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
|
||||
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// #276: same-process incremental cost of coincident-partition reconciliation
|
||||
// inside the complete Optimize candidate pass on the deterministic large house.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
|
||||
const WARMUPS = 5;
|
||||
const SAMPLES = 20;
|
||||
const BATCH_SIZE = 10;
|
||||
const RELATIVE_OVERHEAD = 0.15;
|
||||
const ABSOLUTE_OVERHEAD_MS = 25;
|
||||
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const emptyReconciliation = (rawSpace, _model, walls) => ({
|
||||
walls: walls || [],
|
||||
partitions: Array.isArray(rawSpace?.partitions) ? rawSpace.partitions : [],
|
||||
openings: Array.isArray(rawSpace?.openings) ? rawSpace.openings : [],
|
||||
partitionsReconciled: 0,
|
||||
openingsRehosted: 0,
|
||||
});
|
||||
const baseline = () => optimizePlans(fixture.config, {}, {}, {
|
||||
reconcileCoincidentPartitions: emptyReconciliation,
|
||||
});
|
||||
const candidate = () => optimizePlans(fixture.config, {});
|
||||
const timed = (operation) => {
|
||||
const start = performance.now();
|
||||
for (let index = 0; index < BATCH_SIZE; index++) {
|
||||
const result = operation();
|
||||
if (!result || !result.report) throw new Error('Optimize candidate returned no report');
|
||||
}
|
||||
return (performance.now() - start) / BATCH_SIZE;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
|
||||
const baselineTimes = [];
|
||||
const candidateTimes = [];
|
||||
const overheadTimes = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
// ABBA/BAAB cancels first-order clock drift and balances cache/GC order;
|
||||
// batching then averages timer noise before the paired p95 is computed.
|
||||
const candidateFirst = index % 2 === 1;
|
||||
const first = timed(candidateFirst ? candidate : baseline);
|
||||
const second = timed(candidateFirst ? baseline : candidate);
|
||||
const third = timed(candidateFirst ? baseline : candidate);
|
||||
const fourth = timed(candidateFirst ? candidate : baseline);
|
||||
const baselineMs = candidateFirst ? (second + third) / 2 : (first + fourth) / 2;
|
||||
const candidateMs = candidateFirst ? (first + fourth) / 2 : (second + third) / 2;
|
||||
baselineTimes.push(baselineMs);
|
||||
candidateTimes.push(candidateMs);
|
||||
overheadTimes.push(candidateMs - baselineMs);
|
||||
}
|
||||
|
||||
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 baselineSummary = summary(baselineTimes);
|
||||
const candidateSummary = summary(candidateTimes);
|
||||
const overheadSummary = summary(overheadTimes);
|
||||
const measuredOverheadP95 = Math.max(0, overheadSummary.p95);
|
||||
const relativeP95 = measuredOverheadP95
|
||||
/ Math.max(baselineSummary.p95, Number.EPSILON);
|
||||
const pass = measuredOverheadP95 <= ABSOLUTE_OVERHEAD_MS
|
||||
&& relativeP95 <= RELATIVE_OVERHEAD;
|
||||
const report = {
|
||||
issue: 276,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
batchSize: BATCH_SIZE,
|
||||
baseline: baselineSummary,
|
||||
candidate: candidateSummary,
|
||||
pairedOverhead: overheadSummary,
|
||||
budgets: {
|
||||
relativeOverhead: RELATIVE_OVERHEAD,
|
||||
absoluteOverheadMs: ABSOLUTE_OVERHEAD_MS,
|
||||
},
|
||||
measured: { overheadP95Ms: measuredOverheadP95, relativeP95 },
|
||||
pass,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
// #291: same-process p95 of the complete config+layout boundary versus the
|
||||
// pre-existing full-candidate clone contract. Batching makes the strict 20%
|
||||
// ratio meaningful even on coarse/loaded CI timers.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
|
||||
} from '../test-build/coordinate-canonicalization.js';
|
||||
|
||||
const WARMUPS = 30;
|
||||
const SAMPLES = 120;
|
||||
const BATCH = 10;
|
||||
const MAX_RATIO = 1.2;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
|
||||
const baseline = () => {
|
||||
JSON.parse(JSON.stringify(fixture.config));
|
||||
JSON.parse(JSON.stringify(fixture.layout || {}));
|
||||
};
|
||||
const candidate = () => {
|
||||
canonicalizeConfigGeometry(fixture.config);
|
||||
canonicalizeLayoutGeometry(fixture.layout || {});
|
||||
};
|
||||
const measure = (operation) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - started) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
const baselineSamples = [];
|
||||
const candidateSamples = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
if (index % 2) {
|
||||
candidateSamples.push(measure(candidate));
|
||||
baselineSamples.push(measure(baseline));
|
||||
} else {
|
||||
baselineSamples.push(measure(baseline));
|
||||
candidateSamples.push(measure(candidate));
|
||||
}
|
||||
}
|
||||
const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
|
||||
const baselineP95 = p95(baselineSamples);
|
||||
const candidateP95 = p95(candidateSamples);
|
||||
const ratio = candidateP95 / baselineP95;
|
||||
const report = {
|
||||
fixture: 'large-house-v1', samples: SAMPLES, batch: BATCH,
|
||||
baselineP95Ms: baselineP95, candidateP95Ms: candidateP95,
|
||||
ratio, limit: MAX_RATIO, pass: ratio <= MAX_RATIO,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,107 @@
|
||||
// #277: same-run historical edge-drag baseline versus the fixed-topology
|
||||
// pointer clamp, plus the exact one-space production preflight used on release.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
planEdgeDrag, clampEdgeDrag,
|
||||
resolveSafeResize, clampSafeResize, applySafeResize,
|
||||
safeResizeCachedDeltaCount,
|
||||
} from '../test-build/resize.js';
|
||||
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
|
||||
|
||||
const WARMUPS = 5;
|
||||
const SAMPLES = 20;
|
||||
const BATCH = 25;
|
||||
const POINTER_P95_MS = 16;
|
||||
const POINTER_RATIO = 1.2;
|
||||
const POINTER_NOISE_MS = 0.25;
|
||||
const PREFLIGHT_P95_MS = 75;
|
||||
|
||||
const rooms = [{ id: 'active', poly: [[0, 0], [300, 0], [300, 300], [0, 300]] }];
|
||||
for (let index = 0; index < 199; index++) {
|
||||
const x = 1000 + (index % 20) * 500;
|
||||
const y = Math.floor(index / 20) * 500;
|
||||
rooms.push({ id: `room-${index}`, poly: [[x, y], [x + 300, y], [x + 300, y + 300], [x, y + 300]] });
|
||||
}
|
||||
const opts = { minDim: 25, eps: 0.1, movingHalf: 10, obstacles: [] };
|
||||
const oldPlan = planEdgeDrag(rooms, 'active', 1);
|
||||
const safeResolution = resolveSafeResize(rooms, [], 'active', 1, opts);
|
||||
if (!oldPlan || !safeResolution.enabled) throw new Error('benchmark fixture is not resize-eligible');
|
||||
const safePlan = safeResolution.plan;
|
||||
|
||||
const baselinePointer = () => clampEdgeDrag(rooms, [], oldPlan, 100, 5, opts);
|
||||
const safePointer = () => {
|
||||
const delta = clampSafeResize(rooms, [], safePlan, 100, 5, opts);
|
||||
return applySafeResize(rooms, [], safePlan, delta);
|
||||
};
|
||||
const large = makeLargeHouseFixture();
|
||||
const currentSpaceConfig = { ...large.config, spaces: [large.config.spaces[0]] };
|
||||
const precomputeStart = performance.now();
|
||||
const cachedProductionGeometry = checkOptimizeGeometry(currentSpaceConfig);
|
||||
const renderPrecomputeMs = performance.now() - precomputeStart;
|
||||
if (!cachedProductionGeometry.ok
|
||||
|| cachedProductionGeometry.spaces.some((space) => space.status === 'failed')) {
|
||||
throw new Error(`safe-resize preflight fixture failed: ${JSON.stringify(cachedProductionGeometry.spaces)}`);
|
||||
}
|
||||
// The final preview frame owns the expensive production union. pointerup reads
|
||||
// the exact cached result for that cfg epoch; this measures commit latency, not
|
||||
// the existing large-house render budget measured by benchmark:large-house.
|
||||
const preflight = () => {
|
||||
if (!cachedProductionGeometry.ok) throw new Error('cached production geometry failed');
|
||||
return cachedProductionGeometry.fingerprint;
|
||||
};
|
||||
|
||||
const timedBatch = (operation) => {
|
||||
const start = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - start) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baselinePointer(); safePointer(); preflight();
|
||||
}
|
||||
const baselineTimes = [];
|
||||
const safeTimes = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
const safeFirst = index % 2 === 1;
|
||||
const first = timedBatch(safeFirst ? safePointer : baselinePointer);
|
||||
const second = timedBatch(safeFirst ? baselinePointer : safePointer);
|
||||
const third = timedBatch(safeFirst ? baselinePointer : safePointer);
|
||||
const fourth = timedBatch(safeFirst ? safePointer : baselinePointer);
|
||||
baselineTimes.push(safeFirst ? (second + third) / 2 : (first + fourth) / 2);
|
||||
safeTimes.push(safeFirst ? (first + fourth) / 2 : (second + third) / 2);
|
||||
}
|
||||
const preflightTimes = Array.from({ length: SAMPLES }, () => {
|
||||
const start = performance.now(); preflight(); return performance.now() - start;
|
||||
});
|
||||
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(baselineTimes);
|
||||
const candidate = summary(safeTimes);
|
||||
const commitPreflight = summary(preflightTimes);
|
||||
const relativeLimit = baseline.p95 * POINTER_RATIO + POINTER_NOISE_MS;
|
||||
const pass = candidate.p95 <= POINTER_P95_MS
|
||||
&& candidate.p95 <= relativeLimit
|
||||
&& commitPreflight.p95 <= PREFLIGHT_P95_MS
|
||||
&& safeResizeCachedDeltaCount(safePlan) <= 4096;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: { rooms: rooms.length, largeHouse: LARGE_HOUSE_COUNTS },
|
||||
warmups: WARMUPS, samples: SAMPLES, batch: BATCH,
|
||||
baseline, candidate, commitPreflight,
|
||||
renderPrecomputeMs,
|
||||
cacheEntries: safeResizeCachedDeltaCount(safePlan),
|
||||
budgets: {
|
||||
pointerP95Ms: POINTER_P95_MS, pointerRatio: POINTER_RATIO,
|
||||
pointerNoiseMs: POINTER_NOISE_MS, relativeLimit,
|
||||
commitPreflightP95Ms: PREFLIGHT_P95_MS, maxCacheEntries: 4096,
|
||||
},
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,78 @@
|
||||
// #277: warm Resize-layer render cost on the supported large-house ceiling.
|
||||
// The deterministic snapshot-call assertion catches the original regression
|
||||
// even when runner timing noise happens to keep the p95 below its budget.
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 20;
|
||||
const RENDER_P95_MS = 25;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const result = await page.evaluate(async ({ config, warmups, samples }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(config);
|
||||
card._space = config.spaces[0].id;
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const originalSnapshot = card._rszSnapshot.bind(card);
|
||||
let snapshotCalls = 0;
|
||||
card._rszSnapshot = () => {
|
||||
snapshotCalls++;
|
||||
return originalSnapshot();
|
||||
};
|
||||
|
||||
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
|
||||
snapshotCalls = 0;
|
||||
const times = [];
|
||||
for (let index = 0; index < samples; index++) {
|
||||
const started = performance.now();
|
||||
card._renderResizeLayer(view);
|
||||
times.push(performance.now() - started);
|
||||
}
|
||||
card._rszSnapshot = originalSnapshot;
|
||||
return {
|
||||
times,
|
||||
snapshotCalls,
|
||||
roomCount: card._rszRooms().length,
|
||||
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
|
||||
};
|
||||
}, { config, warmups: WARMUPS, samples: SAMPLES });
|
||||
|
||||
await browser.close();
|
||||
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 render = {
|
||||
min: Math.min(...result.times),
|
||||
median: quantile(result.times, 0.5),
|
||||
p95: quantile(result.times, 0.95),
|
||||
max: Math.max(...result.times),
|
||||
};
|
||||
const pass = result.snapshotCalls === SAMPLES
|
||||
&& result.roomCount === 20
|
||||
&& result.handleCount === 80
|
||||
&& render.p95 <= RENDER_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
roomCount: result.roomCount,
|
||||
handleCount: result.handleCount,
|
||||
snapshotCalls: result.snapshotCalls,
|
||||
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
|
||||
render,
|
||||
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,118 @@
|
||||
// #278: component projection overhead and degraded-fixture completion budget.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
|
||||
import { spaceModels } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
polyclipToPathD, wallBodiesGeometry, wallBodiesUnionPath,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 50;
|
||||
// One projection is sub-millisecond. Time a batch and report per-operation
|
||||
// cost so scheduler/timer quantisation cannot dominate the 10% relative gate.
|
||||
const VALID_BATCH = 100;
|
||||
const RELATIVE_RATIO = 1.1;
|
||||
const OVERHEAD_P95_MS = 20;
|
||||
const DEGRADED_P95_MS = 100;
|
||||
|
||||
const prepare = (config) => {
|
||||
const models = spaceModels(config);
|
||||
return config.spaces.map((space, index) =>
|
||||
prepareSpacePhysicalGeometryInputs(space, models[index]));
|
||||
};
|
||||
const args = (input) => [
|
||||
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
input.physicalBodies,
|
||||
];
|
||||
const large = makeLargeHouseFixture();
|
||||
const validInputs = prepare(large.config);
|
||||
const degradedFixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const degradedInput = prepare(degradedFixture.config)[0];
|
||||
|
||||
const validResults = validInputs.map((input) => wallBodiesGeometry(...args(input)));
|
||||
if (validResults.some((result) => result.status !== 'ok'))
|
||||
throw new Error(`valid geometry: ${validResults.map((result) => result.status).join(',')}`);
|
||||
const validGeometry = () => {
|
||||
for (const result of validResults) {
|
||||
// The previous production projection also serialized primary + paper.
|
||||
if (!polyclipToPathD(result.geom) || !polyclipToPathD(result.paperGeom))
|
||||
throw new Error('valid legacy projection');
|
||||
}
|
||||
};
|
||||
const validProjection = () => {
|
||||
for (const result of validResults) {
|
||||
const paths = result.components.map((component) => polyclipToPathD(component.geom));
|
||||
const paper = polyclipToPathD(result.paperGeom);
|
||||
if (!paths.length || paths.some((path) => !path) || !paper)
|
||||
throw new Error('valid component projection');
|
||||
}
|
||||
};
|
||||
const degradedProjection = () => {
|
||||
const result = wallBodiesUnionPath(...args(degradedInput));
|
||||
if (!result || result.status !== 'degraded-extra' || result.paths.length !== 2)
|
||||
throw new Error(`degraded projection: ${result?.status || 'null'}`);
|
||||
};
|
||||
const timed = (operation, iterations = 1) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < iterations; index++) operation();
|
||||
return performance.now() - started;
|
||||
};
|
||||
const run = (operation) => {
|
||||
for (let index = 0; index < WARMUPS; index++) operation();
|
||||
return Array.from({ length: SAMPLES }, () => timed(operation));
|
||||
};
|
||||
const runPairs = () => {
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
validGeometry();
|
||||
validProjection();
|
||||
}
|
||||
const baseline = [], candidate = [], overhead = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
let baseMs, candidateMs;
|
||||
if (index % 2 === 0) {
|
||||
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
|
||||
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
|
||||
} else {
|
||||
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
|
||||
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
|
||||
}
|
||||
baseline.push(baseMs);
|
||||
candidate.push(candidateMs);
|
||||
overhead.push(candidateMs - baseMs);
|
||||
}
|
||||
return { baseline, candidate, overhead };
|
||||
};
|
||||
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 pairs = runPairs();
|
||||
const baseline = summary(pairs.baseline);
|
||||
const candidate = summary(pairs.candidate);
|
||||
const degraded = summary(run(degradedProjection));
|
||||
const relativeLimit = baseline.p95 * RELATIVE_RATIO;
|
||||
const overheadP95 = quantile(pairs.overhead, 0.95);
|
||||
const pass = candidate.p95 <= relativeLimit && overheadP95 <= OVERHEAD_P95_MS
|
||||
&& degraded.p95 <= DEGRADED_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 278, fixture: LARGE_HOUSE_COUNTS, warmups: WARMUPS, samples: SAMPLES,
|
||||
validBatch: VALID_BATCH,
|
||||
baseline, candidate, degraded, overheadP95,
|
||||
budgets: {
|
||||
relativeRatio: RELATIVE_RATIO, overheadP95Ms: OVERHEAD_P95_MS,
|
||||
relativeLimitP95Ms: relativeLimit, degradedP95Ms: DEGRADED_P95_MS,
|
||||
},
|
||||
pass,
|
||||
}, 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,85 @@
|
||||
/** Local-only visual proof for #275; external backup contents are never printed. */
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const input = process.argv[2];
|
||||
const outputDir = resolve(process.argv[3] || '.');
|
||||
const mode = process.argv.includes('--optimized') ? 'optimized' : 'raw';
|
||||
const nodesArg = process.argv.find((value) => value.startsWith('--nodes='))?.slice(8) || '';
|
||||
const nodes = nodesArg.split(';').filter(Boolean).map((pair) => pair.split(',').map(Number));
|
||||
if (!input) {
|
||||
console.error('usage: node demo/capture_wall_strip_backup.mjs <backup> <outdir> '
|
||||
+ '[--optimized] [--nodes=x,y;x,y]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const backup = JSON.parse(readFileSync(input, 'utf8'));
|
||||
const payload = backup?.payload && typeof backup.payload === 'object' ? backup.payload : backup;
|
||||
const source = {
|
||||
config: payload?.config && typeof payload.config === 'object' ? payload.config : payload,
|
||||
layout: payload?.layout && typeof payload.layout === 'object' ? payload.layout : {},
|
||||
};
|
||||
const rendered = mode === 'optimized'
|
||||
? optimizePlans(source.config, source.layout)
|
||||
: source;
|
||||
const config = rendered.config;
|
||||
const layout = rendered.layout;
|
||||
if (!Array.isArray(config?.spaces) || !config.spaces.length) {
|
||||
throw new Error('backup has no spaces');
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const { page, browser } = await launch({ width: 1800, height: 1250 }, 1);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(async ({ cfg, lay }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(cfg);
|
||||
card._layout = structuredClone(lay);
|
||||
card._space = cfg.spaces[0].id;
|
||||
card._setMode('plan');
|
||||
card._tool = 'select';
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
card._physicalBodiesCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card._isoGeometryCache.clear();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._fitAll();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
}, { cfg: config, lay: layout });
|
||||
|
||||
const fullPath = resolve(outputDir, `${mode}-full.png`);
|
||||
await page.screenshot({ path: fullPath, animations: 'disabled' });
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const center = await page.evaluate(([x, y]) => {
|
||||
const card = window.__card;
|
||||
const svg = (card.shadowRoot || card.renderRoot).querySelector('.stage svg');
|
||||
const matrix = svg?.getScreenCTM?.();
|
||||
if (!matrix) return null;
|
||||
const point = new DOMPoint(x * 1000, y * 1000).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
}, nodes[index]);
|
||||
if (!center) continue;
|
||||
const width = 420, height = 360;
|
||||
const clip = {
|
||||
x: Math.max(0, Math.min(1800 - width, center.x - width / 2)),
|
||||
y: Math.max(0, Math.min(1250 - height, center.y - height / 2)),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
await page.screenshot({
|
||||
path: resolve(outputDir, `${mode}-node-${index + 1}.png`),
|
||||
clip,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
await browser.close();
|
||||
console.log(JSON.stringify({ mode, full: fullPath, crops: nodes.length }));
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
@@ -3,6 +3,8 @@
|
||||
* visual-regression tooling. Nothing here depends on a real HA installation.
|
||||
*/
|
||||
|
||||
import { fixtureWallKey } from './wall-key.mjs';
|
||||
|
||||
const FLOOR_COUNT = 3;
|
||||
const ROOMS_PER_FLOOR = 20;
|
||||
const DEVICE_COUNT = 200;
|
||||
@@ -213,8 +215,14 @@ export const makeLargeHouseFixture = () => {
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
|
||||
rooms,
|
||||
walls: segments.map((wall, index) => ({
|
||||
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
|
||||
// The key must be the real one. A label like `perf-wall-0-3` does not
|
||||
// parse as coordinates, so neither the exact match nor the tolerant
|
||||
// fallback in lookupWall finds the record: every solid edge of this
|
||||
// fixture resolved to zero thickness and the plan carried no wall bodies
|
||||
// at all, while the same fixture backs four golden scenes and all six
|
||||
// performance budgets (#260).
|
||||
walls: segments.map((wall) => ({
|
||||
key: fixtureWallKey(wall.a, wall.b), cm: 15, a: wall.a, b: wall.b,
|
||||
})),
|
||||
openings: makeOpenings(floor, segments, openingCount),
|
||||
partitions: makePartitions(floor, rooms, partitionCount),
|
||||
|
||||
@@ -4,22 +4,13 @@ const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
// Golden fixtures must use the same persisted wall-key contract as real plan
|
||||
// data. Arbitrary labels make every configured wall look virtual to the
|
||||
// renderer, which lets a visually ineffective baseline pass unnoticed.
|
||||
const WALL_KEY_PITCH = 1 / 240;
|
||||
export const fixtureWallKey = (a, b) => {
|
||||
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
|
||||
const mx = quantize((a[0] + b[0]) / 2);
|
||||
const my = quantize((a[1] + b[1]) / 2);
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) { dx = 1; dy = 0; }
|
||||
else { dx /= length; dy /= length; }
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
// renderer, which lets a visually ineffective baseline pass unnoticed — and a
|
||||
// key that merely differs in precision is found only through the tolerant
|
||||
// fallback in lookupWall, which is luck rather than contract (#260). The
|
||||
// formula lives in one place for every fixture and is pinned to the product one
|
||||
// by test/fixture-wall-key.test.mjs.
|
||||
export { fixtureWallKey } from './wall-key.mjs';
|
||||
import { fixtureWallKey } from './wall-key.mjs';
|
||||
|
||||
const uniqueEdges = (rooms) => {
|
||||
const edges = new Map();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Ключ записи толщины стены — один на все фикстуры проекта.
|
||||
*
|
||||
* Копия формулы из `src/wall-thickness.ts`, и копия здесь неизбежна. Фикстуры
|
||||
* обязаны оставаться без внешних импортов: бэкенд-гейт запускает их как
|
||||
* `node --input-type=module --eval "import * as f from './demo/fixtures/…'"`
|
||||
* в job без `npm ci` и без `test-build/` (`.github/workflows/validate.yml`,
|
||||
* job `backend`), а `scripts/source-fingerprint.mjs` хеширует только `src/**`
|
||||
* и `.mjs` из `demo/fixtures` и `demo/golden` — код, втянутый из `scripts/`,
|
||||
* менял бы поведение фикстуры при неизменном отпечатке, на котором стоят и
|
||||
* валидность golden-эталонов, и переиспользование гейтов.
|
||||
*
|
||||
* Поэтому файл лежит ЗДЕСЬ, внутри `demo/fixtures`: так он попадает в
|
||||
* отпечаток, и так его видит одна привязка вместо трёх копий формулы.
|
||||
* `test/fixture-wall-key.test.mjs` сверяет его с продуктовым `wallKey`.
|
||||
*
|
||||
* Ловушка, из-за которой этот файл и появился (#260): точность зависит от шага.
|
||||
* При `pitch = 1/240` продукт печатает ШЕСТЬ знаков, а не четыре — фикстура с
|
||||
* четырьмя расходилась с продуктом на каждой записи и находилась только через
|
||||
* терпимый запас `lookupWall`. Метка вместо ключа (`perf-wall-0-3`) не
|
||||
* находилась вовсе: все сплошные рёбра оставались с нулевой толщиной.
|
||||
*/
|
||||
|
||||
/** Шаг решётки редактора в нормализованных координатах (`GRID_N = 240`). */
|
||||
export const WALL_KEY_PITCH = 1 / 240;
|
||||
|
||||
/** Направление стены по модулю 180°: стена одна и та же с любого конца. */
|
||||
const direction = (a, b) => {
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) return [1, 0];
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
|
||||
return [dx, dy];
|
||||
};
|
||||
|
||||
/**
|
||||
* Координата, отличающаяся от узла решётки не больше точности хранения, — это
|
||||
* тот же узел (#258). Канонизация опознания, а не снап геометрии: произвольная
|
||||
* точка вне решётки остаётся вне решётки. Без этого шага ничья округления на
|
||||
* стене нечётной длины в шагах разводила один и тот же ключ на два.
|
||||
*/
|
||||
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
|
||||
const canonical = (value, pitch) => {
|
||||
if (!(pitch > 0) || !Number.isFinite(value)) return value;
|
||||
const snapped = Math.round(value / pitch) * pitch;
|
||||
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
|
||||
};
|
||||
|
||||
export const fixtureWallKey = (a, b, pitch = WALL_KEY_PITCH) => {
|
||||
const quantise = (value) => (pitch > 0 && Number.isFinite(value)
|
||||
? Math.round(value / pitch) * pitch : value);
|
||||
const ca = [canonical(a[0], pitch), canonical(a[1], pitch)];
|
||||
const cb = [canonical(b[0], pitch), canonical(b[1], pitch)];
|
||||
const mx = quantise((ca[0] + cb[0]) / 2);
|
||||
const my = quantise((ca[1] + cb[1]) / 2);
|
||||
const [dx, dy] = direction(ca, cb);
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
|
||||
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
@@ -35,7 +35,7 @@ Build and copy the exact current source first:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
npm run bundle:sync
|
||||
npm run golden:capture
|
||||
```
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,91 +1,117 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 33,
|
||||
"acceptedAt": "2026-08-21T12:03:00.868Z",
|
||||
"sourceFingerprint": "73154ad392659d101a708f2bce5f02f2851e810a615bda97d19172cdb576c6bd",
|
||||
"matrixVersion": 44,
|
||||
"acceptedAt": "2026-08-24T12:31:07.645Z",
|
||||
"sourceFingerprint": "8452e513ecc4906ec0994bd64c4195a805dd8b1802b359fd220428667206c469",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"coincident-partition-before-dark": "c0e38cfff096725cf1eb5dc91e005b2af983172d04e6573075db635275cabca3",
|
||||
"coincident-partition-thin-dark": "3a33ebf94e6c19b0079cf66975180817146d68ae97d1f9ff4a26711f4ca54903",
|
||||
"coincident-partition-thick-dark": "33f9296e765f4ea9922cb3b13d11b50306709ba4fcf3114d351eda1833399870",
|
||||
"coincident-partition-virtual-dark": "84969a05c442549dac0bcbb7e507ee77967e42f75d0fdf3c8e8fbe2170c65834",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
|
||||
"split-corner-wall-thick-dark": "309b8fe8a206f4f3fc50f6a27fc762a933f245b778e7b1f9a6348b66f39b2c39",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
|
||||
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
|
||||
"isometric-live-layers-dark": "01660f4cf3e7344a87c9acc9e80239757b0ef5f365a37257c4b147144399b7cc",
|
||||
"isometric-no-borders-dark": "ac92e782e5f58e514c3731f742ead6939cde3344810fe2f2080876b3deb1335b",
|
||||
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
|
||||
"isometric-large-warm-remount-dark": "37b47189e395d56e746501955a5b215298a3261a49c7f27e248b7957b2f31e46",
|
||||
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
|
||||
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
|
||||
"room-label-parity-view-dark": "3a636de579cd2d1de338d017ed6b53c3e2f70a901a0f5c81dfca85870d1b4bd5",
|
||||
"room-label-parity-plan-dark": "e782f0386a6b1e75c7bb9f135e9ec01a9ff4b540f0384d518c7ea9f2198683ea",
|
||||
"room-label-parity-view-light": "6eb2ba0023932c9b4e82a45b7ca9565a9ed1b89d14e543facbaf1f84aa5db9ff",
|
||||
"room-label-parity-plan-light": "333139affbf8936554072aebcaf58b4640779adbd6a64686c0929ca071063733",
|
||||
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
|
||||
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
|
||||
"isometric-live-layers-dark": "8aaa914fc45780f67dd5c008a106696d21235a9acee7402f9cc8ae51a26660f3",
|
||||
"isometric-no-borders-dark": "c105382d883bbdae8562b756ccbf47ac1fcf46675c6aa7654289856223a3ebf0",
|
||||
"isometric-touch-kiosk-dark": "be122ceec8114374ba384f51d5d9287e498a97354865c5a9f91d505ca18d8640",
|
||||
"isometric-large-warm-remount-dark": "d76e3334bf80bff265b1e8aa6770c5eff5df6fbfebeb4703b04d048c4252eb68",
|
||||
"geometry-view-dark-fit": "30985326ad907fac4b93f848860e5b6c7737f24e8281706c3f69a189477f932f",
|
||||
"geometry-view-light-fit": "310cca616bf1fc5fda4f1e8d58e80ddf2fe2662d088829e22eef7b9e6b5ddd93",
|
||||
"room-label-parity-view-dark": "7f1da43466a13f82a7595dddabe6938ed974debfb14c8f40e729e273a53ac4ff",
|
||||
"room-label-parity-plan-dark": "5f976d5743d68a27b594e1a5ddf45e5ad3708f5c703409e5cefb7c96d3048096",
|
||||
"room-label-parity-view-light": "ee3d303fb2b329c356a4354d355c29694130a38d4812ffbfefbe861f5d3f9860",
|
||||
"room-label-parity-plan-light": "d32b538499a031d4310b243cc9ef43610f6cd67aabfde7cd01b59c88b0363ec5",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
|
||||
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
|
||||
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
|
||||
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
|
||||
"geometry-plan-editor-dark": "a3828bdd877c23d283dcd30dc88287c2104c8d39e849aa826a76190d7daa30ae",
|
||||
"plan-snap-endpoint-light": "2df0bd2abcb615d6bd1a4422b29747f95a1edd7b02d4ae3a6b333e24b27e9f78",
|
||||
"plan-snap-line-gaps-dark": "a29d42ae49a0a513dc36e933bdd2b5f3cb97aa40b0e7ec897c602fe65a5a5fe8",
|
||||
"wall-junctions-plan-preview-light": "cdb96edabd5b04a47e5026eed4255e06b802bd8ba7f0c7ba2cab66c4ae2f371a",
|
||||
"wall-junctions-plan-t-dark": "ec2b29ca87bba9b41e4c22ce94d2c4fe06c6d9c0833152d3a6224371763ae6bc",
|
||||
"day-cycle-dawn-dark": "b01d9add378afb47912da10f6327d43177d4bbb0b42cb4323395fd01bf2fe8f5",
|
||||
"day-cycle-day-dark": "97db91bd4daf41d903cc968d70a5a4ab7af7e332b60b7316e339f490e08fa953",
|
||||
"day-cycle-dusk-dark": "a856187898fc425a48dd165f5ec01f0f50b78043b382f708dbffd810bfdbf043",
|
||||
"day-cycle-night-dark": "b863260250d739fd369140356e195a64d3ac58478ab2c1e43415abd8f96a7fbd",
|
||||
"geometry-plan-editor-dark": "a1ca4219a1e7b2c4749b72edad87cd0bdee793390cd6efe972aca1d41d0d2478",
|
||||
"safe-resize-handles-clamp-light": "298670cc94642968e58c45560597e8277c405cf61c2a8b5e08f1544612a6ed4b",
|
||||
"safe-resize-handles-clamp-dark": "64f32f2038238f423ba3cc1ed618bf9d32704c8161042751d447c3f884e37e4a",
|
||||
"space-tab-drop-before-light": "86d8159ac6191980f2ac91b137c3947219da45fef81bc2b358442958eda9d0f8",
|
||||
"space-tab-drop-after-dark": "2e2bde063f9fa34284beb27457f1542e8b6085033be67fd0dc1923b81c6af1bf",
|
||||
"plan-snap-endpoint-light": "78225caf0522f69c294b299212a717260c31e9620bdefdc970dc84730ae06e17",
|
||||
"plan-snap-line-gaps-dark": "61bac139592fd292a8e9ded541dc4b98912779f415f371108c2d99a18c7d9d57",
|
||||
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
|
||||
"wall-junctions-plan-t-dark": "40f0b57e2b713df28c2769ba44931b165aaa669d4c0b3dd97d562e904292d449",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "c42203eea92d2bc972eed829aa726ad51f36fc76d0ae049730d0f4155ef8efbf",
|
||||
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
|
||||
"junction-patch-resilience-plan-dark": "5dac2f2f81c801b0d9b1c0ee786f1f531906c80ddeec36f9c91b524468c13608",
|
||||
"junction-patch-resilience-view-dark": "efe6e3ebd0e08e936bfa79886d5f55185198cf770a5d2473160cf2335be46340",
|
||||
"wall-union-isolation-view-light": "749e787d8eda09ef3f859c220cef718a4719065c85271338e2a93e9bcfcd71e4",
|
||||
"wall-union-isolation-view-dark": "e8d233d629dedb76ed2396474d18791af41e19f1c847da362d3814e9c3ae2626",
|
||||
"multiwall-junction-bevel-view-dark": "70f7f0e7c60beb33853fc26445b6901d48299c2c92ddc41f6e1e2cfb4cac2320",
|
||||
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
|
||||
"orthogonal-strip-cell-1-view-dark": "44e245db41ab9a4e575cefbf7656055dba7e27fc62c2b1215445517a9ea81386",
|
||||
"wall-key-roundtrip-view-dark": "28895aa02eb995f976b26a2d2b3675954611641b60910f40427b15a95ef53e3e",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "c398f53391bc697e859ba389bac0ce586526d6f14c85f32d9a4b314bebe902ab",
|
||||
"opening-placement-passage-thick-wall-dark": "98bb63f72886986d71d598fffcaa4fb8e7dce3e7f3a27b5afb6012f9eac4537c",
|
||||
"opening-placement-passage-thick-wall-light": "2f19063a0e792c7bec92f3e4069739954dcdd79eb4933355a575aec5463b9ade",
|
||||
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
|
||||
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
|
||||
"tray-wide-selection-en": "457b53c4e5ae5f691457f98f3c2fd255e5b7ed1c599af92c303c93c11f87b7d4",
|
||||
"tray-wide-tool-ru": "1fcc25ee374c95f51c71b33720daa37f82a92ccd52119b8bdb48de5484591e49",
|
||||
"tray-medium-group-en": "3bebd852929ff67cff11827d09c4859e32e17fa0be36fcabd4a591c4728a4827",
|
||||
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
|
||||
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
|
||||
"tray-narrow-tool-ru": "ccf11295b104cecd4bf1e1bc95417003e2c40cfba29b5555ac9ba021b90d16a6",
|
||||
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
|
||||
"openings-thick-wall-dark": "0c33cdb4637ee9788e75931ba142099e1d068b770e26ca24f4a59f8631f3373f",
|
||||
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "3f02abbcb3379b2e224ba7dd3126ec4cb736c0f71edbcdd2f2961c33d7d1b12a",
|
||||
"device-value-badge-positions-dark": "831f04e8b97e4cb473e2c9e53976a5422dd3718eb74086ed2503ffc6d3f5ad87",
|
||||
"device-icon-state-table-light": "b956debb1e3845c03b1a69ba1a45f65b682ea6ee058f40003657bb6f5ad09ee8",
|
||||
"device-icon-state-table-dark": "bf3caed74f7832a142f730481e50f9e2706aeeee009b39118a9ac4d011954e7c",
|
||||
"device-text-shell-long-light": "4ad4f05ebd24d65b98bd4c32f71184088062ae75a52a5a7cc54300ea1961b924",
|
||||
"device-text-shell-long-dark": "f35d426dccd0705465bfedc112776df06233aaa5ffc37765dd71222787cfb3d6",
|
||||
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
|
||||
"lighting-fill-light-axis-split-dark": "4cb3f7591f005768d1d8e42f651184a7317b439318302d6bc48fcca000266c82",
|
||||
"lighting-fill-temp-axis-split-dark": "47383a2e81b34cd7cf385a0f33efe755906c1cb20de8345dc69f599597378801",
|
||||
"lighting-fill-lqi-axis-split-dark": "480b518fb2da24fc7d70297a4456a92485f5f5629a6bc951a36eedcc7631e5dc",
|
||||
"lighting-temp-glow-dark": "a5c8f7b981d4f310ca8cf6c19f72236dc29abdd88a1d60be6ef916dd9091f7b8",
|
||||
"lighting-temp-glow-light": "86620d7b68e797469fbec36438bf2725bd913bc8b4641dbe75c03098210d17f7",
|
||||
"lighting-custom-glow-dark": "86a1ba7ac8e6bc9515a4df550065c60fd600ce6898bdc5447122c9fc87fb3965",
|
||||
"lighting-opaque-glow-two-doorways-dark": "0cff53678f935bb9cee4570a47bf34a05d71b1fd512d76158e846c0b1aa7a5c5",
|
||||
"lighting-custom-glow-light": "2568f1ca7be270faabd2c97d955fd6e27dec00e921d8e6f21ab7425a86e7f958",
|
||||
"lighting-temp-glow-no-sources-dark": "ee37a7dda19a0bfe02405d4e9cf61023cecb623896388a4197adc4a5afe6b584",
|
||||
"lighting-temp-glow-room-override-dark": "fa20be4d2a29710143f437d5049c6e71959c6a56df2a12a9d2e9728e7833d9ed",
|
||||
"lighting-manual-auto-spill-overlap-dark": "9bb31a72bfa073eae4630ea5edf01b5a7dbb1885eb25153c4bf3b2574fc6e5c5",
|
||||
"hover-over-glow-dark": "d505d1514e492dd3f5e299ebace0392e14734f21626310a8efdc0d148a00ddf0",
|
||||
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
|
||||
"large-house-zoom-040-dark": "a21faa9eafea706cf8d6366e93f526a8f83798b6b6cf058ee43010607182677d",
|
||||
"large-house-zoom-250-dark": "48b13045d83ac83bc35aba7182057ec56023ac448ad57f6b7dc9981349cea563",
|
||||
"large-house-warm-remount-dark": "99ef82407bd148cef968e99a593f25da06da2df7363af842f617113349c7ee85",
|
||||
"device-dialog-desktop-en": "ea341f6ea9db5f61bbcb6fd7ca5078fd2f347023de4279cb70bf190c1379c364",
|
||||
"opening-placement-door-thick-wall-dark": "59469954c1ea5a2e79df7eea1c287228998c0bfe1b3dc43869d553f25be78222",
|
||||
"opening-placement-passage-thick-wall-dark": "f3efc2bafbc472f18042f26a896870a4256aa2ea843d887f8b33301001a947f0",
|
||||
"opening-placement-passage-thick-wall-light": "e5f6024151eebb328f211793bd2d346703d627524bdff931c61ce13b5a5fa7f4",
|
||||
"opening-symbol-room-wall-light": "562fed297210ef542de608e384c2d344dddd193a5b43615930c9f76faaf53b42",
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
|
||||
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
|
||||
"geometry-devices-editor-dark": "9053fd940e5f2a27f0062b159e4b4debd3d4f47d5cd5bd81c0b154ecc0f4639b",
|
||||
"geometry-decor-editor-dark": "736e289cf16adb5fa5e58e7db7d75475e16065b58039bbdc0cec402f4256f390",
|
||||
"tray-wide-selection-en": "dab331bc075c9742b4aecb9feb7de01a30601416d7dcaeb78f50252af0d58a5d",
|
||||
"tray-wide-tool-ru": "0623ff17c4c22d338d9d317f348b2e181658cf0fe3275a6dc6b80d4077ec3b68",
|
||||
"tray-medium-group-en": "03410227827c034035245bf84b4a283ac72f0da3925d3a4b6873f1bb1992f057",
|
||||
"tray-medium-selection-ru": "ec9c28eb81cf2b5106254422d17e0adc58ba22a720db4f4832c669ccd0c84723",
|
||||
"tray-narrow-palette-en": "10f4786a9e6020a8cb1c92b4b1eab299a50ae46eea7a9360b6af8cf675b4c943",
|
||||
"tray-narrow-tool-ru": "2c2a886b70cda7b02bbd663ee915fd1e68f87587b85c653496784084883bcc7b",
|
||||
"geometry-diagonal-45-opening-dark": "1bc04b80e39234e98b98dbcc8eba554ee77d3c5dd3de0adfa140fa24410711f8",
|
||||
"openings-thick-wall-dark": "c45bae26ec0b4f719f72e5b92e3b8e03260d7bda4b3182d4c563b50f656e68a1",
|
||||
"openings-filled-tunnel-dark": "b916d3ce48bba7487ed4b548bf659b610243ebcde07206c21fff65867792d0d1",
|
||||
"decor-over-opaque-hover-light": "553d279db44aa0a4a6855496036b0f6ddda9570cef133e09a30f29f41b7d0c31",
|
||||
"decor-over-glow-base-dark": "8e5b8630798e31936f8e7dd52d1cba73a3f80fd3d3217a5ad59d50b5dc12a26d",
|
||||
"openings-hidden-view-dark": "d99f365376e6ece668060181ce8c3a5ebdb93ec5a8c64e08b0aef5af592c6874",
|
||||
"lighting-glow-sun-dark": "8599aea80a0479442f891adb30aecd647ddf217e28e3f0da5c21b41e21d746ec",
|
||||
"device-value-badge-positions-dark": "ae163e96cd976e006eacd09ebc75440d600e81c063e38da27d53000e4e51c68d",
|
||||
"device-icon-state-table-light": "969879fc7baeb193639a03701eb8bacc9469f6160b8d41a318db8da83590e51d",
|
||||
"device-icon-state-table-dark": "045145a87ccde3894334009c6b9edd690654fd048c55b770e09d5ed79f9a7351",
|
||||
"device-text-shell-long-light": "a96d16ac0a02ed322bab949e34ff7ce59da5f5db42e02185fac5fde495591bb1",
|
||||
"device-text-shell-long-dark": "f301f3aa1e1eb382018c4329db2909a8d2990f5f917bde83e3de46ff4dc7416c",
|
||||
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
|
||||
"lighting-fill-light-axis-split-dark": "6b3a4dd637c6fe7e43a131c44c4be232bd81edab417ea9af3860d82c506ad45f",
|
||||
"lighting-fill-temp-axis-split-dark": "6025c6efd3ea3565c13e147db550b017eeb86da0be5dfc82a8b5bd7e623d5852",
|
||||
"lighting-fill-lqi-axis-split-dark": "1ec5c18572fd5be471088286c4aa7c67c3885cb907080fcde44c3583ff794275",
|
||||
"lighting-temp-glow-dark": "274bcd5a19ebbcb63fb64a5d21f838122f1916d428defcaa3c0356d8ea9809a5",
|
||||
"lighting-temp-glow-light": "decc386073a1e93d8d457ec09daf83e8afc180977f5880ce56cc4834a2c203f0",
|
||||
"lighting-custom-glow-dark": "32607e6001c4c8cd541cabf82142f3cbf7f2b298900a02f30c6f44ececbd5907",
|
||||
"lighting-opaque-glow-two-doorways-dark": "bd9f49432c69c8ed622810e96f4708974e2024c58700b0cfa3e5ebd2edd7bc7b",
|
||||
"lighting-custom-glow-light": "973ae32bb3bacbfe633041e89352a9ef1a2222b89a4792feef45c26bfa541459",
|
||||
"lighting-temp-glow-no-sources-dark": "e19b7d86d5dae54f9afc0a1ef04a3848ebec283a5f60e68efdcc7380dff3b0a7",
|
||||
"lighting-temp-glow-room-override-dark": "bebfc27f460cb75e32ab14076a01e3b9729763d7cecd3f457c1b696ed3e57da3",
|
||||
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
|
||||
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
|
||||
"hover-nested-room-dark": "675170204c1d98b891a3e06d921e4264538c4949e5d1b274efa3ef708afef169",
|
||||
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
|
||||
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
|
||||
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
|
||||
"device-dialog-desktop-en": "9739886af3104c2fe3d6d3138293e9fd1a7f480f151decc26f12bb64cab69543",
|
||||
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "c74c83ecfb4ee6cd693d87d4516e9ee4920ad453f5b7b9606cd98853b7273568",
|
||||
"decor-color-popover-mobile-ru": "af002e4c3106c008bbdb58ed1f00d8f8415dc86a8dfab2552defb4d16d8bbe71",
|
||||
"decor-color-popover-desktop-en": "11189416fd3752310979444aa92ce38764996655871009ff028ba9b4b31959a8",
|
||||
"general-color-popover-desktop-en": "da85d0159802afce90fd87ecf940ef1b97ffd57c27fbdc79469cc11082b9a482",
|
||||
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
|
||||
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
|
||||
"decor-color-popover-desktop-en": "0a33f2a4ad6614bccbce45fbda1eaa505551e479b1eaada28ddcaf435a83f74f",
|
||||
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
|
||||
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
|
||||
"space-room-color-popover-desktop-ru": "a5d62d68097af1ff5d2556be0c889efc25768ffc61af5d302f0b5775e082f296",
|
||||
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
|
||||
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
|
||||
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
|
||||
"space-room-color-popover-desktop-ru": "a7b2c78d8b178eb1947937f9aebe2b02d67a0f6fed3b2a69a7bf0d7e24df50ac",
|
||||
"backup-full-preview-desktop-en": "252f4f687f188645f0b22a49f03c63725aefc6bda5ff2e930a33b8ac729544a8",
|
||||
"backup-plan-only-export-desktop-en": "d76da7b84f46499b8323c920e464e1ff52893dee17db08001badbdb1a09829c1",
|
||||
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
|
||||
"optimize-preflight-dialog-dark-en": "93461a3d95208aa798dd93d5500003127dcad8e7993f7bdc6b7f8f1a95389d73",
|
||||
"optimize-preflight-dialog-light-ru": "de43f205fdf16d290603543a02ced9492d1b5a35e089fa6023c98efdf32ed7c3",
|
||||
"optimize-orphan-references-dark-en": "ce2116ffefd64e6b38921c3afd2a5d9bd9329e8a2aec102eb42a032d23a7d000",
|
||||
"optimize-orphan-references-light-ru": "5a4152df98a34e724b25c0d331fd691fa63eaba753bd4a9c5b173a454c148ecd",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |