Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ebbb75f8 | ||
|
|
1d81890589 | ||
|
|
dd0936258c | ||
|
|
ac077d9b6d | ||
|
|
ab5e5ed96f | ||
|
|
ccf685e0f8 | ||
|
|
79aa0a90c4 | ||
|
|
1179a02122 | ||
|
|
801e49092f | ||
|
|
568eae3fa4 | ||
|
|
eef1ec844c | ||
|
|
58ebf8b4cd | ||
|
|
6a0a349b2e | ||
|
|
4cdd6d7aac | ||
|
|
37cb415100 | ||
|
|
5740324bed | ||
|
|
12cd8a1af3 | ||
|
|
7a0702adea | ||
|
|
dcbaea2c08 | ||
|
|
0c9cf9503e | ||
|
|
1e341c608c | ||
|
|
3c6b473c94 | ||
|
|
e3a4ac7655 | ||
|
|
c1b37f0dc5 | ||
|
|
c665c7d344 | ||
|
|
1ea460e467 | ||
|
|
b428eacca6 | ||
|
|
b573590a96 | ||
|
|
918fc9e2fe | ||
|
|
4ded9c0b7d | ||
|
|
783cc68b91 | ||
|
|
5a2dd333d2 | ||
|
|
b37a59874c | ||
|
|
9b79807b69 | ||
|
|
c88af61be2 | ||
|
|
81420a0d94 | ||
|
|
3cb9e2ba62 | ||
|
|
165dabc5d7 | ||
|
|
bd82a6709e | ||
|
|
9b519b0d4c | ||
|
|
37a5d14569 | ||
|
|
61d9952543 | ||
|
|
7c13b16afd | ||
|
|
542e54034b | ||
|
|
eedff24b29 | ||
|
|
e3f5c49502 | ||
|
|
2c65fcec03 | ||
|
|
5a703ef562 | ||
|
|
4485417027 | ||
|
|
7340622524 | ||
|
|
ff6e53a48a | ||
|
|
59ae6b1064 | ||
|
|
80a664bc23 | ||
|
|
508945c088 | ||
|
|
95fba6dbab | ||
|
|
550be251a6 | ||
|
|
0cdf85d9e2 | ||
|
|
58f3acbbde | ||
|
|
c231602a2b | ||
|
|
4423d28579 | ||
|
|
ebbfe4588f | ||
|
|
2d70354345 | ||
|
|
3de2a4a4b5 | ||
|
|
60e125e960 | ||
|
|
2d1ca74a7d | ||
|
|
d33aa0a7e5 | ||
|
|
90a3313d92 | ||
|
|
04bb54aef3 | ||
|
|
294d7047f3 | ||
|
|
85636a65bb | ||
|
|
ddfca3a865 | ||
|
|
4e8e00fa73 | ||
|
|
658c955553 | ||
|
|
7aaf5a72b0 | ||
|
|
5fb4ce328b | ||
|
|
c90f5bf052 | ||
|
|
92f476556f | ||
|
|
3f73eced95 | ||
|
|
802604e0c3 | ||
|
|
ccadb7779e | ||
|
|
eff786fafa | ||
|
|
c755a0efc4 | ||
|
|
d0d6cf57a7 | ||
|
|
6540474ff5 | ||
|
|
2d4ace1b58 | ||
|
|
0a203649fc | ||
|
|
60f41a53af | ||
|
|
21d47c52ba | ||
|
|
5d292831f3 | ||
|
|
fffe9fb1f7 | ||
|
|
aa9444491d | ||
|
|
361ec47856 | ||
|
|
cab8d128bf | ||
|
|
024866842c | ||
|
|
e04cf7e573 | ||
|
|
ff9ac33149 | ||
|
|
eeae6752d9 | ||
|
|
1866c0921e | ||
|
|
e63b03a0ee | ||
|
|
2b1964f973 | ||
|
|
4800c44e51 | ||
|
|
2c20f2dc35 | ||
|
|
2c4b505f1a | ||
|
|
273b0d5ecb | ||
|
|
f2c2084dd4 | ||
|
|
26c6e87079 | ||
|
|
0824e51014 | ||
|
|
574fea98ab | ||
|
|
8f0b6b97e8 | ||
|
|
0bb42caeff | ||
|
|
e1df015d93 | ||
|
|
7017896eb2 | ||
|
|
ad3f998147 | ||
|
|
8945e04fe4 | ||
|
|
7a74a37c6e | ||
|
|
2b1f03be2d | ||
|
|
f514fb27fe | ||
|
|
7d15bd06e9 | ||
|
|
002795f7c8 | ||
|
|
4758767e0c | ||
|
|
214355f424 | ||
|
|
6fc57f2ae8 | ||
|
|
806f7f784c | ||
|
|
3120924e26 | ||
|
|
8205897149 | ||
|
|
73b665ede6 | ||
|
|
d67bb11de0 | ||
|
|
5266d2f6be | ||
|
|
8161394178 | ||
|
|
118062bb8c | ||
|
|
8b0ad08db2 | ||
|
|
6efc315ba6 | ||
|
|
b87322ed5f | ||
|
|
c5e145269b | ||
|
|
2294d46f3f | ||
|
|
722f26a3c5 | ||
|
|
4a833c5e13 | ||
|
|
ba66698f55 | ||
|
|
ef32d2326f | ||
|
|
536b750d03 | ||
|
|
c46c714c58 | ||
|
|
6e1aea3ae7 | ||
|
|
a3e329950a | ||
|
|
78850a8733 | ||
|
|
1ac91e43fd | ||
|
|
6a3ac52258 | ||
|
|
1a8b480355 | ||
|
|
ec957a7c88 | ||
|
|
557c44c392 | ||
|
|
7181535dec | ||
|
|
fb162953a9 | ||
|
|
244fddeb3b | ||
|
|
ad73280b1b | ||
|
|
5ad0c1cdc3 | ||
|
|
b8976415ac | ||
|
|
296df95e32 | ||
|
|
acc30c2da7 | ||
|
|
ca383febef | ||
|
|
39eda1e136 | ||
|
|
caf6e6c355 | ||
|
|
4d9542b138 | ||
|
|
69e410fea3 | ||
|
|
1edf5cb3a7 | ||
|
|
9b7a700fd6 | ||
|
|
780b7a6c7a | ||
|
|
674607694f | ||
|
|
fb8fdf479c | ||
|
|
3b915bd077 | ||
|
|
69facb9918 | ||
|
|
343af61a61 | ||
|
|
59a23de81b | ||
|
|
87ff906545 | ||
|
|
298030d47e | ||
|
|
0c3aacdd34 | ||
|
|
134afb658e | ||
|
|
6a96254956 | ||
|
|
de7554e9e0 | ||
|
|
8210af6472 | ||
|
|
376a2db599 | ||
|
|
6b9f5eea96 | ||
|
|
59bf4665f6 | ||
|
|
3a562f2bd6 | ||
|
|
aaf7611490 | ||
|
|
3d4e5580fa | ||
|
|
1c598a287a | ||
|
|
05ec0a1de8 | ||
|
|
b29472a887 | ||
|
|
0f36ef55d5 | ||
|
|
1d92193c5d | ||
|
|
dff476ff35 | ||
|
|
ee672dcd1a | ||
|
|
ffb232398a | ||
|
|
33a5b46990 | ||
|
|
f353be3d76 | ||
|
|
295548f0fd | ||
|
|
0f6583b51c | ||
|
|
5886069c3a | ||
|
|
6205c8b2d7 | ||
|
|
b6de84e528 | ||
|
|
5b7d37e911 | ||
|
|
f3fe86371c | ||
|
|
3ab6fa9f8a | ||
|
|
3d31d65735 | ||
|
|
9ff464fe09 | ||
|
|
8bfd56eb59 | ||
|
|
26ad7651fb | ||
|
|
9134d86266 | ||
|
|
f15bdc678b | ||
|
|
db4e0ef999 | ||
|
|
a66fb7ef53 | ||
|
|
5e35612bdd | ||
|
|
fa9d9b05e9 | ||
|
|
916a42e33a | ||
|
|
799289e73b | ||
|
|
73cf8ed01a | ||
|
|
1857cb69cf | ||
|
|
1fcbb0de6d | ||
|
|
b8a9f9b289 | ||
|
|
c821dad436 | ||
|
|
1f6aefd2a7 | ||
|
|
7273a3ad9a | ||
|
|
b542f44bd6 | ||
|
|
43f91a214b | ||
|
|
822e6223f7 | ||
|
|
1cdd4ed6de | ||
|
|
a5abfe8bd6 | ||
|
|
83ec6fb879 | ||
|
|
2710dca7d1 | ||
|
|
114dcec27c | ||
|
|
93054823c3 | ||
|
|
2364d6dd43 | ||
|
|
64e9bf8f6b | ||
|
|
069c4867ea | ||
|
|
ce18012fb9 | ||
|
|
0e18038c1b | ||
|
|
2347e8df88 | ||
|
|
5da7812099 | ||
|
|
7a73bcbc2b | ||
|
|
98a0a24bd4 | ||
|
|
676610c0cd | ||
|
|
9726f2dfdb | ||
|
|
ccdd47a6bd | ||
|
|
ed126d6a43 | ||
|
|
d34e56ba78 | ||
|
|
5e5dad277c | ||
|
|
ffe0f0c788 | ||
|
|
a78deb7ce7 | ||
|
|
05622d155b | ||
|
|
b4cccfdf63 | ||
|
|
7518cb2d4d | ||
|
|
f37efdad8a | ||
|
|
fb6cc3ca2d | ||
|
|
95fa23ca79 | ||
|
|
063026bc0b | ||
|
|
ad54716d21 | ||
|
|
cfc1bb063e | ||
|
|
20f5c39699 | ||
|
|
c75c77d571 | ||
|
|
0f54daf37b | ||
|
|
07402d64cc | ||
|
|
71ac4a6639 | ||
|
|
fc9f513082 | ||
|
|
3fff527e22 | ||
|
|
e1059e2e29 | ||
|
|
2f155a83b9 | ||
|
|
ce01d095a8 | ||
|
|
b336eee996 | ||
|
|
245466d167 | ||
|
|
4b414e26f7 | ||
|
|
b72b56ea45 | ||
|
|
7a6331d359 | ||
|
|
5f0a7f5065 | ||
|
|
fd4bf0e95e | ||
|
|
0b782ee714 | ||
|
|
0d62a48b1e | ||
|
|
3a49b14274 | ||
|
|
0d9320fe1d | ||
|
|
55f7126afd | ||
|
|
bf52524de3 | ||
|
|
7d094fd2ea | ||
|
|
c684f178cb | ||
|
|
cbdc248987 | ||
|
|
34335d1e83 | ||
|
|
afb6e151e5 | ||
|
|
5c466b6c01 | ||
|
|
4c93f28e73 | ||
|
|
30698d6ec4 | ||
|
|
b1b8fb9fab | ||
|
|
89ac6024bc | ||
|
|
2e3874f10d | ||
|
|
0e6fbfb00b | ||
|
|
de0867b001 | ||
|
|
9ec3a77ffa | ||
|
|
8d41651b1e | ||
|
|
0d328f6c0c | ||
|
|
c8d56b6c4d | ||
|
|
f2460dc81d | ||
|
|
a3a75ccbf3 | ||
|
|
6c1534f170 | ||
|
|
e85bae70f0 | ||
|
|
101cf70952 | ||
|
|
787891c4e7 | ||
|
|
2ade43520b | ||
|
|
cee4718ffa | ||
|
|
f0ea376aa9 | ||
|
|
bbac40eb8e | ||
|
|
ff623cd49d | ||
|
|
dc68868bd8 | ||
|
|
1134345444 | ||
|
|
729e2d05af | ||
|
|
489f68eea9 | ||
|
|
31d7a70e34 | ||
|
|
96318fb94f | ||
|
|
d3927f2e56 | ||
|
|
b2b2640751 | ||
|
|
d8c7652d0e | ||
|
|
8a82189368 | ||
|
|
8eef4e9c5a | ||
|
|
cc937de0b1 | ||
|
|
d3ed299d2a | ||
|
|
9c396935dc | ||
|
|
ec8dc6fde1 | ||
|
|
58c096cef1 | ||
|
|
e88abcb138 | ||
|
|
9dbdbec03d | ||
|
|
c179a89876 | ||
|
|
df38c5c93b | ||
|
|
b6f80c56f0 | ||
|
|
aac501516e | ||
|
|
8af5ebefd2 | ||
|
|
ef22c2c6fd | ||
|
|
a6ef664bef | ||
|
|
76d60b3e1b | ||
|
|
a1361974a3 | ||
|
|
f1c76aa415 | ||
|
|
3df1dcf5a3 | ||
|
|
eef4e0d6d4 | ||
|
|
1575b1e890 | ||
|
|
e603c5496f | ||
|
|
33dcd1ce62 | ||
|
|
7310ce0134 | ||
|
|
a2b4d32b2d | ||
|
|
9c5e1e2749 | ||
|
|
2143e88824 | ||
|
|
1f83f35a66 | ||
|
|
e7f2f2c174 | ||
|
|
9f54cbab75 | ||
|
|
51810164c2 | ||
|
|
9c0b276f43 | ||
|
|
26af611af1 | ||
|
|
19f536f361 | ||
|
|
67bcce8efb | ||
|
|
ab2f37aa4d | ||
|
|
2cf47fc133 | ||
|
|
ca58f0e3eb | ||
|
|
93cd45569d | ||
|
|
36992d68bd | ||
|
|
0f71d869e3 | ||
|
|
56dbb48868 | ||
|
|
13219307d5 | ||
|
|
8b4730df05 | ||
|
|
19332a915d | ||
|
|
fd4fc801ef | ||
|
|
6d094ca01b | ||
|
|
65e55872e8 | ||
|
|
68498e526e | ||
|
|
4b8f17bae5 | ||
|
|
136e0cb773 | ||
|
|
641c4e199e | ||
|
|
8a3a115f42 | ||
|
|
5526447070 | ||
|
|
5696e274fe | ||
|
|
ba63fccbb7 | ||
|
|
6558728519 | ||
|
|
7b66b1e9b3 | ||
|
|
2805c2c8a0 | ||
|
|
1b7c150dda | ||
|
|
2523c38a4e | ||
|
|
5ecfebcf67 | ||
|
|
e13e8ff9e9 | ||
|
|
01992a9a87 | ||
|
|
2d19ce04ad | ||
|
|
390f77bd65 | ||
|
|
d5670ad141 | ||
|
|
3cf8ed74db | ||
|
|
b9db5d4750 | ||
|
|
2cad797d2c | ||
|
|
e4dfef19e1 | ||
|
|
8d165fd1c2 | ||
|
|
23d208f30c | ||
|
|
e4139da3e6 | ||
|
|
3fb2edc904 | ||
|
|
4969f46e0e | ||
|
|
ed28951b9d | ||
|
|
2b7c98e6bc | ||
|
|
8187dd9bda | ||
|
|
89ac96a8af | ||
|
|
033ad11bf7 | ||
|
|
f7a19a35f1 | ||
|
|
451932c8e9 | ||
|
|
ab9a83e755 | ||
|
|
55db16df6e | ||
|
|
8221ad9b84 | ||
|
|
45066631ae | ||
|
|
4feeebc355 | ||
|
|
158601d67d | ||
|
|
671d1af4c3 | ||
|
|
67777e47c0 | ||
|
|
953f6756bc | ||
|
|
578eb5ebd3 | ||
|
|
874838c954 | ||
|
|
0ac92cb551 | ||
|
|
142ec8d1c7 | ||
|
|
1816042a1a | ||
|
|
8c03656412 | ||
|
|
fc81bd8a32 | ||
|
|
74d306b510 | ||
|
|
80dc5860ca | ||
|
|
04110bf741 | ||
|
|
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 | ||
|
|
2f9348adf1 | ||
|
|
7b267fb632 | ||
|
|
8c7e8bc9d1 | ||
|
|
f5ec6718a2 | ||
|
|
ffb609d471 | ||
|
|
bfdeb493cb | ||
|
|
9d6a4e34fd | ||
|
|
d9f7861c05 | ||
|
|
6dd59a1925 | ||
|
|
b9e4be3799 | ||
|
|
86a0786e2d | ||
|
|
0dbc94739b | ||
|
|
074a9ee86d | ||
|
|
e383fc2989 | ||
|
|
4edb2bb2eb | ||
|
|
b56f119f7a | ||
|
|
36b5a8c36c | ||
|
|
a41035d185 | ||
|
|
eefe0fab94 | ||
|
|
759111beb3 | ||
|
|
63db3ae4c6 | ||
|
|
4b6331f8d8 | ||
|
|
10999a5c5c | ||
|
|
793a6486d8 | ||
|
|
2d1fca1dfa | ||
|
|
a952f5fd07 | ||
|
|
6d0fa3b819 | ||
|
|
b6c3c5ced3 | ||
|
|
7e69b4bb28 | ||
|
|
d27c3f023e | ||
|
|
74d2285d1b | ||
|
|
dc3c083352 | ||
|
|
735b98c8ca | ||
|
|
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 | ||
|
|
944a36ee67 | ||
|
|
5fa3733c7e | ||
|
|
839e3316c3 | ||
|
|
e50c01245a | ||
|
|
4799c6be0c | ||
|
|
99ceb1e57f | ||
|
|
cf3010cd0c | ||
|
|
bd860a7594 | ||
|
|
09ab9266c0 | ||
|
|
61c401b810 | ||
|
|
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 | ||
|
|
e246d32580 | ||
|
|
636d0a56fa | ||
|
|
9e71334cb4 | ||
|
|
142f7ec121 | ||
|
|
1391aeacfe | ||
|
|
3aba493b25 | ||
|
|
6bd197ffd7 | ||
|
|
55d5a560ad | ||
|
|
691cea074b | ||
|
|
8fc3ec783b | ||
|
|
2f968996b1 | ||
|
|
9a131cca25 | ||
|
|
e44b3c97b0 | ||
|
|
66ffd6fdda | ||
|
|
81f38689bb | ||
|
|
bff47f522b | ||
|
|
abfaae3e38 | ||
|
|
969847a4c4 | ||
|
|
bc8c368db9 | ||
|
|
0312104527 | ||
|
|
77fa698a27 | ||
|
|
7b0747888c | ||
|
|
dc9ced2fb4 | ||
|
|
61c874953a | ||
|
|
09b5d394a5 | ||
|
|
e4b4f33c6d | ||
|
|
5fd9716906 | ||
|
|
1fb3a5754e | ||
|
|
0df5db8b1c | ||
|
|
c8e9597228 | ||
|
|
67505f8343 | ||
|
|
b2024c6626 | ||
|
|
b7a9cc4edf | ||
|
|
f4098fcf09 | ||
|
|
05b2c74442 | ||
|
|
1e2eba95b1 | ||
|
|
6836561340 | ||
|
|
6811cc0908 | ||
|
|
d3468c1fde | ||
|
|
579d6e5f4e | ||
|
|
edf1cca8e3 | ||
|
|
115a7d4ccd | ||
|
|
44a35fde0f | ||
|
|
6872dc420d | ||
|
|
2396044bae | ||
|
|
619516806f | ||
|
|
fab0c38ca9 | ||
|
|
fe2fcef735 | ||
|
|
ed13b4a5ca | ||
|
|
10d5b5a288 | ||
|
|
50a00e41a1 | ||
|
|
e6be43b90f | ||
|
|
483c29cc39 | ||
|
|
1ecd267138 | ||
|
|
5c560df1a2 | ||
|
|
ecc3d6a88b | ||
|
|
5b8b6899da | ||
|
|
81210f7afc | ||
|
|
fd43a25746 | ||
|
|
33470306d2 | ||
|
|
35bb2b58b7 | ||
|
|
5fdedf39d6 | ||
|
|
82bb03cdf9 | ||
|
|
a8aeecc32c | ||
|
|
90a00670a7 | ||
|
|
8369c0e839 | ||
|
|
07f1b8c674 | ||
|
|
afd5d589d5 | ||
|
|
99c6cd09f3 | ||
|
|
6176f02430 | ||
|
|
01d7554607 | ||
|
|
30bf18f508 | ||
|
|
9848f4a0cb | ||
|
|
5ba46dfd97 | ||
|
|
48308209c2 | ||
|
|
e57e1d920c | ||
|
|
c3278ddd07 | ||
|
|
41325a852f | ||
|
|
0fd2331d4a | ||
|
|
7763af6b8e | ||
|
|
d486c64576 | ||
|
|
603bb3647f | ||
|
|
fb6d7ea693 | ||
|
|
73eb92257e | ||
|
|
62f398d359 | ||
|
|
38e6869c90 | ||
|
|
86778ed452 | ||
|
|
812e5ff391 | ||
|
|
810ad01ce9 | ||
|
|
2935c293e1 | ||
|
|
cb1e4cea64 | ||
|
|
4a84734b18 | ||
|
|
a0716dc404 | ||
|
|
442731e8eb | ||
|
|
723ebb8f61 | ||
|
|
8e0358e7dd | ||
|
|
0cb38e5562 | ||
|
|
ea50548b70 | ||
|
|
5f000cb6fb | ||
|
|
be0277f5f5 | ||
|
|
a20dd54ba6 | ||
|
|
3af04840a9 | ||
|
|
d68e958c0d | ||
|
|
e0b3c4715d | ||
|
|
4aa9d1b2bb | ||
|
|
d55e0dd298 | ||
|
|
ce400859f3 | ||
|
|
bc75e00c2d | ||
|
|
84fa434a64 | ||
|
|
16c8a28aaf | ||
|
|
6786c48a0e | ||
|
|
28d6b9ccd9 | ||
|
|
b56e122b07 | ||
|
|
3d11758e2b | ||
|
|
4c512fa59f | ||
|
|
72275de567 | ||
|
|
f9accc1198 | ||
|
|
c6ff34cea3 | ||
|
|
9e8393f874 | ||
|
|
39456dc44c | ||
|
|
c547586dc9 | ||
|
|
fe33deaa13 | ||
|
|
8ba840aa74 | ||
|
|
ea33edebf9 | ||
|
|
2b45086794 | ||
|
|
75ad20a1df | ||
|
|
cbaa7b0cb9 | ||
|
|
adc1d15b98 | ||
|
|
a14de0a122 | ||
|
|
0e5ee030fe | ||
|
|
c749d68a4d | ||
|
|
0c93be9e85 | ||
|
|
9f25f17c83 | ||
|
|
1521d3716a | ||
|
|
fbb426afc9 | ||
|
|
c5ba699cf1 | ||
|
|
b7bfc92d4e | ||
|
|
120d41317c | ||
|
|
5cae1fdf82 | ||
|
|
ec6f77b014 | ||
|
|
efcdb269e8 | ||
|
|
9f5ebec6f2 | ||
|
|
3490faae0d | ||
|
|
6063eead10 | ||
|
|
b89456ca81 | ||
|
|
6c3d376b2b | ||
|
|
c08d5a88ae | ||
|
|
0bd6094ca8 | ||
|
|
d197381894 | ||
|
|
4d1b62b57c | ||
|
|
303f710e03 | ||
|
|
270cf634e6 | ||
|
|
4e82976b4a | ||
|
|
6feb0189ee | ||
|
|
ce304646c8 | ||
|
|
4fc0f7bd32 | ||
|
|
93200eb56a | ||
|
|
aa97e7e0bc | ||
|
|
72eae1059c | ||
|
|
11abc0292f | ||
|
|
c447e41d4d | ||
|
|
c22b39e8fc | ||
|
|
842f9dc30e | ||
|
|
9daa2e91fd | ||
|
|
6fdb7dce1a | ||
|
|
7af6d742b9 | ||
|
|
040db9ad12 | ||
|
|
48bcdafab9 | ||
|
|
6731691ad8 | ||
|
|
2310e6d88a | ||
|
|
424e613c6f | ||
|
|
517a7101e7 | ||
|
|
a84338b042 | ||
|
|
2d5fec09d6 | ||
|
|
5ce3ceeca7 | ||
|
|
ef22b236f8 | ||
|
|
a797752c89 | ||
|
|
833e8e5472 | ||
|
|
9b05dd598d | ||
|
|
cd17a0b00f | ||
|
|
3a68efa62f | ||
|
|
2dd1731cc4 | ||
|
|
e158f8fdcc | ||
|
|
edf532e217 | ||
|
|
56e01148f8 | ||
|
|
d31ad3c562 | ||
|
|
5c09591ce9 | ||
|
|
5dc9016645 | ||
|
|
6f89002e3a | ||
|
|
4c8ba981e7 | ||
|
|
15dc8adc4f | ||
|
|
ad8e7a50cc | ||
|
|
f287bddd97 | ||
|
|
5ff80f3bdc | ||
|
|
e6016a966f | ||
|
|
142bd611b1 | ||
|
|
1290927f10 | ||
|
|
84e62dcd0f | ||
|
|
f7b811a621 | ||
|
|
950403debd | ||
|
|
009fed9bc0 | ||
|
|
10c0f3c95a | ||
|
|
f7abf14abd | ||
|
|
8b8b9ed90d | ||
|
|
7b759f316b | ||
|
|
b583d663e3 | ||
|
|
88a28775e7 | ||
|
|
cd029a0415 | ||
|
|
bf83246b7b | ||
|
|
4089c912c6 | ||
|
|
0b411dd802 | ||
|
|
07b0b3dec2 | ||
|
|
2ef32417bd | ||
|
|
016c75f539 | ||
|
|
71d369a295 | ||
|
|
f66e671b89 | ||
|
|
41e2cfffc6 | ||
|
|
a0c4c1cdd8 | ||
|
|
edfea67ddd | ||
|
|
57ba75b9da | ||
|
|
804b282f5f | ||
|
|
a356ec29ab | ||
|
|
67bf85e7d2 | ||
|
|
2453ec0d7f | ||
|
|
fb265282cc | ||
|
|
c9a83af50e | ||
|
|
f08c4adabe | ||
|
|
6e93aa705c | ||
|
|
dd2e0e9b08 | ||
|
|
d01d0926be | ||
|
|
74d19d4d14 | ||
|
|
c1dde9a0cf | ||
|
|
56f31dc199 | ||
|
|
db57180956 | ||
|
|
bd9409b33d | ||
|
|
b203e8faeb | ||
|
|
63249ffd97 | ||
|
|
327c35f606 | ||
|
|
fe7b28f3a7 | ||
|
|
19e92e0cc0 | ||
|
|
9bde4b1a6d | ||
|
|
1bf90ee0d8 | ||
|
|
fcee724638 | ||
|
|
f69ac71ef7 | ||
|
|
3540d24f18 | ||
|
|
25ea8fefab | ||
|
|
9ec3636a42 | ||
|
|
07d0c2ef86 | ||
|
|
053007414d | ||
|
|
fc22d9a6c5 | ||
|
|
fbbaed22de | ||
|
|
29ce5d9e65 | ||
|
|
3a8aae06d1 | ||
|
|
a75c729d87 | ||
|
|
a55ba3de8b | ||
|
|
2f0dc44f27 | ||
|
|
1c3404fdd0 | ||
|
|
3e335b8808 | ||
|
|
e2bb90b59b | ||
|
|
a80fa1fa5e | ||
|
|
0e53b1b0d6 | ||
|
|
fa015907d4 | ||
|
|
5d04e9b7c2 | ||
|
|
89789d8fa6 | ||
|
|
6846ffb828 | ||
|
|
c8755b7c51 | ||
|
|
fe15d863ce | ||
|
|
299da593d5 | ||
|
|
32e3a79dcd | ||
|
|
ffb10843c9 | ||
|
|
5fb510290a | ||
|
|
932773773f | ||
|
|
6c7958c6d0 | ||
|
|
a449edc545 | ||
|
|
e88c23b8ee | ||
|
|
acad3b32c1 | ||
|
|
b443a333e0 | ||
|
|
66fa8f476c | ||
|
|
047363c2d3 | ||
|
|
1e8503bd46 | ||
|
|
135497b272 | ||
|
|
0c2a5dedea | ||
|
|
c1676cf26a | ||
|
|
c65cbcc96c | ||
|
|
01fe48de00 | ||
|
|
9baf533c90 | ||
|
|
563a850aac | ||
|
|
9e5ff0b8a0 | ||
|
|
3fe0f8c443 | ||
|
|
742b3279a2 | ||
|
|
9f77e3e932 | ||
|
|
083621342a | ||
|
|
4f20befd77 | ||
|
|
2aaabc48d6 | ||
|
|
b9bf210804 | ||
|
|
d6007dc444 | ||
|
|
54c5ca3840 | ||
|
|
57fc434d4f | ||
|
|
9530ee2e5a | ||
|
|
58ba15ac89 | ||
|
|
f46be0e0e2 | ||
|
|
8086399aa6 | ||
|
|
034eb3f5f3 | ||
|
|
76f75f85aa | ||
|
|
7f1655c7f5 | ||
|
|
e46ef6f55c | ||
|
|
63eea47ac6 | ||
|
|
f54b9c0ccd | ||
|
|
aac2978359 | ||
|
|
fc1e7951e4 | ||
|
|
e206e8761c | ||
|
|
8a25b5224f | ||
|
|
1f11f8f410 | ||
|
|
f4b1a4766f | ||
|
|
4fda569f74 | ||
|
|
393ec62c61 | ||
|
|
dae2efc280 | ||
|
|
e727023bf4 | ||
|
|
91f2c23539 | ||
|
|
690f57aaba | ||
|
|
c4ba218ee8 | ||
|
|
df093c7e51 | ||
|
|
a9d999e2f2 | ||
|
|
6ca9aacebc | ||
|
|
ae7fb621e7 | ||
|
|
ba32234b52 | ||
|
|
4d71f57b4f | ||
|
|
0ee69ccb20 | ||
|
|
6cbf9fbdf0 | ||
|
|
fd0b52f3a9 | ||
|
|
2667f71bd6 | ||
|
|
c27185cfa4 | ||
|
|
382afd2766 | ||
|
|
a05aa5dc06 | ||
|
|
93a29cd79e | ||
|
|
260a994f5f | ||
|
|
c9a00b2a37 | ||
|
|
4a8f44210f | ||
|
|
91c8b01d7d | ||
|
|
bc478b1756 | ||
|
|
135a18a3ca | ||
|
|
c23b2d7a90 | ||
|
|
61978d4f1e | ||
|
|
141c657813 | ||
|
|
0040615a45 | ||
|
|
ff9f20f52e | ||
|
|
a1b8861eff | ||
|
|
2e47473619 | ||
|
|
b369eb5dcd | ||
|
|
7f397a6875 | ||
|
|
bf2937c1ed | ||
|
|
8c6ac30300 | ||
|
|
6df4722438 | ||
|
|
bdae05dafc | ||
|
|
4ed86b38bc | ||
|
|
9c0e9fab68 | ||
|
|
154c662a0b | ||
|
|
6beb40435f | ||
|
|
e8a5771a63 | ||
|
|
f7c8609ac5 | ||
|
|
8ce7d1f0bb | ||
|
|
d8e3b82da6 | ||
|
|
b94b1b93cd | ||
|
|
82cf3ad2db | ||
|
|
26303adfec |
@@ -31,6 +31,16 @@ fi
|
||||
|
||||
status=0
|
||||
|
||||
# Локальный набор гейтов (#343). Выключен по умолчанию намеренно: 20-45 секунд на
|
||||
# каждый пуш, включая пуши одной строки документации, — цена, которую стоит
|
||||
# платить осознанно. Документация: docs/TESTING.md.
|
||||
if [ "${HP_PREPUSH_GATE:-}" = "1" ] && [ -f "$repo_root/scripts/pre-push-gate.mjs" ]; then
|
||||
echo "pre-push-gate: HP_PREPUSH_GATE=1, прогоняю локальный набор" >&2
|
||||
if ! node "$repo_root/scripts/pre-push-gate.mjs" >&2; then
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
|
||||
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
# Deleting a remote branch pushes nothing to examine.
|
||||
if [ "$local_sha" = "$zero" ]; then
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Announce release
|
||||
name: Анонс релиза
|
||||
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
|
||||
# Stable releases are announced; prereleases are deliberately silent.
|
||||
# workflow_dispatch exists purely as a connectivity test button and therefore
|
||||
@@ -36,6 +36,7 @@ permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
telegram:
|
||||
name: Оповещение в Telegram (только стабильные)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Скриншоты документации снимаются здесь и только здесь (#246).
|
||||
#
|
||||
# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре:
|
||||
# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка
|
||||
# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять
|
||||
# целиком. Одно окружение убирает этот шум насовсем.
|
||||
#
|
||||
# Джоба ничего не коммитит: она публикует артефакт, который человек принимает
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
name: Скриншоты документации
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ветка или SHA, с которого снимать
|
||||
required: false
|
||||
default: dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
capture:
|
||||
name: Съёмка и сверка скриншот-индекса
|
||||
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
|
||||
# oxipng без потерь снимает с набора ~19% (замер в #345 на pyoxipng 9.1.1;
|
||||
# у 10.2.0 пресеты уровней перебалансированы, точная доля может отличаться,
|
||||
# но кадры остаются пиксельно идентичными в любой версии — это перепаковка).
|
||||
#
|
||||
# Пин версии и контрольной суммы намеренно, а не `apt-get install oxipng`:
|
||||
# пакет из образа раннера может пропасть или переехать, а падение шага
|
||||
# съёмки стоит целого цикла приёмки. Тот же урок, что с azure-зеркалом
|
||||
# Playwright (#175, #206).
|
||||
- name: Установить oxipng
|
||||
env:
|
||||
OXIPNG_VERSION: 10.2.0
|
||||
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
|
||||
run: |
|
||||
set -euo pipefail
|
||||
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL -o "$asset" \
|
||||
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
|
||||
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
|
||||
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/oxipng" --version
|
||||
- name: Capture
|
||||
run: node demo/docs/capture.mjs
|
||||
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
|
||||
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
|
||||
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
|
||||
# браузер: тот же Chromium и десять изменившихся картинок означают, что
|
||||
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
|
||||
# разницу рендеринга.
|
||||
- name: Вердикт
|
||||
run: |
|
||||
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
|
||||
# свежего. Читается одинаково для браузера и для упаковщика: оба
|
||||
# переписывают все кадры сразу, и различить их причины обязан вердикт,
|
||||
# а не человек по памяти (#345).
|
||||
field() {
|
||||
node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).$1||'')}catch{console.log('')}})"
|
||||
}
|
||||
git status --porcelain docs/images
|
||||
changed=$(git diff --name-only docs/images | grep -c png || true)
|
||||
before=$(git show HEAD:docs/images/screenshots.json | field chromium)
|
||||
after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)")
|
||||
packer_before=$(git show HEAD:docs/images/screenshots.json | field oxipng)
|
||||
packer_after=$(node -e "console.log(require('./docs/images/screenshots.json').oxipng || '')")
|
||||
echo "--- изменившихся PNG: $changed"
|
||||
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
|
||||
echo "--- oxipng: было «${packer_before:-не записан}», стало «${packer_after:-нет}»"
|
||||
if [ "$packer_before" != "$packer_after" ] && [ "$changed" -gt 0 ]; then
|
||||
echo "ВЕРДИКТ: изменился упаковщик, поэтому переписаны все кадры сразу."
|
||||
echo "Это перепаковка без потерь: пиксели те же, размер меньше на ~19%."
|
||||
echo "Ожидаемо один раз — при включении oxipng либо при смене его версии."
|
||||
echo "Проверить можно сравнением декодированных кадров, а не байтов."
|
||||
elif [ "$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
|
||||
@@ -1,18 +1,29 @@
|
||||
name: Mutation gate
|
||||
name: Мутационный гейт
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
|
||||
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
|
||||
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
|
||||
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
|
||||
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
|
||||
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
|
||||
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
|
||||
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
|
||||
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
|
||||
# test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
# tsc), а реестр режется на четыре чересполосных шарда — полный прогон
|
||||
# укладывается в десятки минут вместо часов. Локальный дифф-режим:
|
||||
# node scripts/mutation-gate.mjs --changed origin/dev..HEAD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref whose mutation guards must be proved
|
||||
required: false
|
||||
default: dev
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
@@ -26,14 +37,19 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)"
|
||||
runs-on: ubuntu-latest
|
||||
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
|
||||
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
# Шард ~64 мутантов × свой guard; бандл собирают только браузерные гварды.
|
||||
# Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -43,6 +59,13 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Установить backend test dependencies
|
||||
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
@@ -57,5 +80,8 @@ jobs:
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs
|
||||
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Full Performance
|
||||
name: Полные бенчмарки производительности
|
||||
|
||||
on:
|
||||
# Every main promotion is a stable-release candidate and must have an
|
||||
@@ -24,10 +24,21 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
performance:
|
||||
# Base and candidate stay sequential on one hosted runner. Splitting them
|
||||
# across runners would turn machine variance into a false regression.
|
||||
name: Бенчмарки рендера и геометрии
|
||||
# Every profile keeps base and candidate sequential on one hosted runner.
|
||||
# Independent profile pairs run in parallel: cross-profile timing is never
|
||||
# compared, while serialising all five pairs cannot fit the job timeout.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile:
|
||||
- large-house
|
||||
- isometric
|
||||
- plan-snap
|
||||
- blend
|
||||
- overlay
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v7
|
||||
@@ -132,47 +143,95 @@ jobs:
|
||||
- name: Install candidate and baseline dependencies
|
||||
run: npm ci --prefix candidate && npm ci --prefix baseline
|
||||
|
||||
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
working-directory: candidate
|
||||
run: npx playwright install --with-deps chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Capture base and candidate profiles
|
||||
working-directory: candidate
|
||||
run: |
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
if [ -f baseline/scripts/bundle-sync.mjs ]; then
|
||||
(cd baseline && node scripts/bundle-sync.mjs)
|
||||
else
|
||||
echo "::notice::The comparison base predates bundle-sync.mjs; materializing its freshly built bundle with the legacy copy path."
|
||||
mkdir -p baseline/custom_components/houseplan/frontend baseline/demo/srv/assets
|
||||
cp baseline/dist/houseplan-card.js baseline/custom_components/houseplan/frontend/houseplan-card.js
|
||||
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
|
||||
fi
|
||||
|
||||
- name: Enforce relative and absolute performance budgets
|
||||
- name: Capture base and candidate profile
|
||||
working-directory: candidate
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
mkdir -p ../artifacts/performance
|
||||
case "$PROFILE" in
|
||||
large-house)
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
;;
|
||||
overlay)
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown performance profile: $PROFILE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance reports
|
||||
- name: Enforce relative and absolute performance budget
|
||||
working-directory: candidate
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
case "$PROFILE" in
|
||||
large-house)
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
;;
|
||||
overlay)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: full-performance
|
||||
name: full-performance-${{ matrix.profile }}
|
||||
path: artifacts/performance
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Process
|
||||
name: Ревью-конвейер
|
||||
|
||||
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
|
||||
# сообщение: она порождает событие, событие запускает следующий шаг.
|
||||
@@ -35,10 +35,12 @@ permissions:
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: "Страж: ребейз на dev и предпосылки ревью"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
- id: decide
|
||||
@@ -63,19 +65,41 @@ jobs:
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
|
||||
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
|
||||
# код-ревью упиралось бы в review-4 после одной правки.
|
||||
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
|
||||
#
|
||||
# Этап опознаётся по имени документа в теле комментария. Если документа
|
||||
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
|
||||
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
|
||||
done_cycles=0
|
||||
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
|
||||
# только для имени документа и метки: два захода с одинаковым номером
|
||||
# означают, что второй документ перезапишет первый и артефакт ревью
|
||||
# исчезнет.
|
||||
#
|
||||
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
|
||||
# «отправка на ревью → вердикт с блокирующими находками → возврат
|
||||
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
|
||||
# Зелёный ничего на правки не вернул и цикла не образует.
|
||||
#
|
||||
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
|
||||
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
|
||||
# велит вернуть S7-code-review после ребейза, и этот заход добивал
|
||||
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
|
||||
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
|
||||
# зелёным CI: работа встала, хотя после вердикта не было ни одной
|
||||
# правки продуктового кода.
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
if [ -n "$stage" ]; then
|
||||
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
|
||||
--json comments \
|
||||
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
@@ -109,22 +133,37 @@ jobs:
|
||||
refuse "стоит blocked — конвейер не запускается" \
|
||||
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
|
||||
elif [ "$EXHAUSTED" = "true" ]; then
|
||||
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
|
||||
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
|
||||
# метка могла остаться от прежнего правила, когда бюджет тратил и
|
||||
# зелёный вердикт (#227).
|
||||
stale=""
|
||||
if [ "$spent" -lt "$limit" ]; then
|
||||
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
|
||||
fi
|
||||
refuse "стоит review-4 — решение за владельцем" \
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||||
elif [ "$done_cycles" -ge "$limit" ]; then
|
||||
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
|
||||
elif [ "$spent" -ge "$limit" ]; then
|
||||
echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан"
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
|
||||
# Перечень учтённого обязателен: иначе владельцу приходится читать
|
||||
# всю ленту, чтобы понять, из чего сложился счёт.
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||||
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
|
||||
|
||||
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
|
||||
$spent_list"
|
||||
stage=""
|
||||
else
|
||||
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
|
||||
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
|
||||
fi
|
||||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
name: "Ревью (Claude): вердикт в issue"
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
@@ -135,6 +174,26 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
|
||||
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
|
||||
# contents: read. Отказ обязан быть громким и правильным.
|
||||
persist-credentials: false
|
||||
|
||||
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
|
||||
# только на публикации документа — после сорока минут работы ревьюера.
|
||||
- name: Секрет HP_PROCESS_TOKEN жив
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
|
||||
exit 1
|
||||
fi
|
||||
if ! login=$(gh api user -q .login 2>/dev/null); then
|
||||
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
echo "токен жив, действует от: $login"
|
||||
|
||||
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
|
||||
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
|
||||
@@ -154,8 +213,15 @@ jobs:
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
|
||||
| head -1 | sed 's|.*refs/heads/||')
|
||||
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
|
||||
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
|
||||
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
|
||||
branches=$(git for-each-ref --sort=-committerdate \
|
||||
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
|
||||
branch=$(printf '%s\n' "$branches" | head -1)
|
||||
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
|
||||
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
|
||||
fi
|
||||
if [ -n "$branch" ]; then
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
@@ -165,27 +231,170 @@ 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"
|
||||
|
||||
# Материал ревью — конкретный SHA (#312). Вердикт применим только к
|
||||
# нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан
|
||||
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
|
||||
- name: Зафиксировать SHA материала ревью
|
||||
id: material
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "материал ревью: $(git rev-parse --short HEAD)"
|
||||
|
||||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||||
# метка меняется всегда» при этом держится: возврат в 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 (ревью не запускалось)"
|
||||
|
||||
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
|
||||
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
|
||||
# ревью тратится на повторение CI вместо чтения кода.
|
||||
#
|
||||
# Доказательство здесь такое же строгое, как у reuse-маркеров (#208): не
|
||||
# «недавно было зелено», а «completed success ровно на этом SHA». После
|
||||
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
|
||||
- name: Зелёные гейты на этом SHA
|
||||
id: validated
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
sha=$(git rev-parse HEAD)
|
||||
short=$(git rev-parse --short HEAD)
|
||||
row=$(gh run list --repo "${{ github.repository }}" --workflow validate.yml \
|
||||
--commit "$sha" --limit 5 \
|
||||
--json status,conclusion,url \
|
||||
--jq '[.[] | select(.status=="completed" and .conclusion=="success")][0] // empty')
|
||||
{
|
||||
echo 'note<<EOF_NOTE'
|
||||
if [ -n "$row" ]; then
|
||||
url=$(printf '%s' "$row" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))')
|
||||
echo "**Дешёвые гейты на этом SHA уже подтверждены** (#343). Validate на \`$short\` завершился success: $url"
|
||||
echo ""
|
||||
echo "Значит \`npx tsc --noEmit\`, \`npm test\` и \`npm run build\` со сверкой копий бандла перегонять не нужно — сошлись на этом прогоне, назвав его ссылкой. Бюджет раунда тратится на чтение кода."
|
||||
echo ""
|
||||
echo "Что Validate НЕ покрывает и остаётся за тобой: смоки, выбранные по диффу; golden, если diff трогает рендер; инварианты модели на конкретной конфигурации; и любой гейт, который требуют AC задачи."
|
||||
else
|
||||
echo "**Зелёного Validate на этом SHA (\`$short\`) нет** — прогон не найден, не завершён либо не success. Дешёвые гейты прогоняешь сам и называешь результат."
|
||||
fi
|
||||
echo 'EOF_NOTE'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: 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'
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
# уничтожать его собственный артефакт (#220).
|
||||
REVIEW_DOC: ${{ runner.temp }}/review-document.md
|
||||
with:
|
||||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||||
@@ -198,6 +407,44 @@ jobs:
|
||||
Этап: ${{ needs.guard.outputs.stage }}
|
||||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||||
code — код-ревью (PROCESS.md §2.7)
|
||||
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
|
||||
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
${{ steps.rebase.outputs.note }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
перепроверял AC, которых правка не касалась: r2 по #150 стоил
|
||||
полного прогона ради одной строки в тестовой фикстуре.
|
||||
|
||||
Порядок для r2 и дальше:
|
||||
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
|
||||
на котором он получен. SHA в вердикте не назван — это находка;
|
||||
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
|
||||
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
|
||||
3. по каждой находке предыдущего раунда покажи, чем именно она
|
||||
закрыта: строка кода или текста, а не заявление автора;
|
||||
4. заново проверяй только те AC, чьё доказательство дельта
|
||||
задевает. Остальные наследуй;
|
||||
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
|
||||
принято без повторной проверки, со ссылкой на документ того
|
||||
раунда и SHA, на котором вывод получен. Без этого перечня
|
||||
сокращение — молчаливое доверие, а такой тихий успех уже
|
||||
дважды стоил дня (#171, #207).
|
||||
|
||||
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
|
||||
вперёд dev (после ребейза это другой код, §7.2), смена контракта
|
||||
поведения, задета новая подсистема, либо объём дельты сопоставим с
|
||||
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
|
||||
|
||||
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
|
||||
способна сломать AC, который предыдущий раунд признал выполненным —
|
||||
так появилась регрессия #102. Поэтому граница не «только находки», а
|
||||
«находки плюс всё, до чего дотягивается дельта».
|
||||
|
||||
Прочитай в этом порядке, прежде чем судить:
|
||||
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
|
||||
@@ -241,14 +488,46 @@ jobs:
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
Всегда, они дешёвые:
|
||||
${{ steps.validated.outputs.note }}
|
||||
|
||||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||||
и в повторном раунде тоже: код изменился, а стоят они минуты:
|
||||
`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` — если тронут
|
||||
@@ -256,6 +535,14 @@ jobs:
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
**Одно число — один источник.** Если дифф добавляет или меняет
|
||||
величину, видимую пользователю, назови в отчёте прямо: какое число
|
||||
видно дважды (превью против записи, подпись против площади,
|
||||
подсветка инструмента против сохранённого значения) и один ли у него
|
||||
источник. Три дефекта подряд имели именно эту причину — #234, #233 и
|
||||
способ, которым #234 обнаружили. Механическая часть закреплена
|
||||
тестом `test/single-source-numbers.test.mjs`, смысловая — твоя.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
|
||||
@@ -267,27 +554,46 @@ jobs:
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
|
||||
Low либо правится, либо снимается с записью. Жёлтый вердикт
|
||||
допустим при полностью выполненных AC, если изменение не решает
|
||||
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
|
||||
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
|
||||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
|
||||
обслуживание issue дороже правки на месте); Low либо правится,
|
||||
либо снимается с записью. Жёлтый вердикт допустим и при полностью
|
||||
выполненных AC, если изменение не решает заявленный сценарий или
|
||||
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
|
||||
отменяет AC и не даёт права менять скоуп.
|
||||
|
||||
Каждую Medium-находку заведи отдельным issue со ссылкой на
|
||||
#${{ github.event.issue.number }} и метками: тип, приоритет,
|
||||
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
|
||||
запрещено §12.
|
||||
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
|
||||
поведения, который в этой ветке чинить нельзя) заведи отдельным
|
||||
issue со ссылкой на #${{ github.event.issue.number }} и метками:
|
||||
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
|
||||
считается и прямо запрещено §12.
|
||||
|
||||
Напиши полный документ ревью в файл
|
||||
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
|
||||
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
|
||||
находки с воспроизведением, что проверено и корректно, чего не
|
||||
проверял. Каталог docs/reviews/ создай, если его нет. Больше не
|
||||
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
|
||||
Напиши полный документ ревью в файл, путь которого лежит в
|
||||
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
|
||||
|
||||
Почему не в docs/reviews: документ там был некоммитнутым файлом того
|
||||
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
|
||||
#220 три раунда подряд документ исчезал — восстановление дерева
|
||||
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
|
||||
ревью, потому что он untracked. В репозиторий его положит шаг
|
||||
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
|
||||
|
||||
В самом репозитории не создавай файлов вообще: любые изменения в
|
||||
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
|
||||
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
|
||||
code, с номером issue и заходом.
|
||||
|
||||
Содержание документа: скоуп, как проверялось, находки с
|
||||
воспроизведением, что проверено и корректно, чего не проверял. Для
|
||||
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
|
||||
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
|
||||
что принято без повторной проверки, с документом и SHA.
|
||||
|
||||
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
|
||||
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
|
||||
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
|
||||
`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
|
||||
|
||||
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
|
||||
без него метка не переставится и конвейер встанет.
|
||||
@@ -299,10 +605,14 @@ jobs:
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
@@ -313,12 +623,49 @@ jobs:
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
|
||||
fi
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||||
# Рабочая копия отбрасывается ДО того, как документ попадёт в дерево:
|
||||
# ревьюер правит код, проверяя «умеет ли тест падать», и его правки
|
||||
# публиковаться не должны.
|
||||
git checkout -- . 2>/dev/null || true
|
||||
# docs/reviews исключён из уборки: ревьюер мог написать документ по
|
||||
# старому пути, и клин не должен его съесть до `git add` — ровно так
|
||||
# оба пути остаются работоспособными.
|
||||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||||
# Документ приезжает извне репозитория (#220). Три раунда подряд он
|
||||
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
|
||||
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
|
||||
# `git clean -fd` сносят собственный артефакт ревью, потому что он
|
||||
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
|
||||
# страшна.
|
||||
if [ -f "$SOURCE" ]; then
|
||||
mkdir -p docs/reviews
|
||||
cp "$SOURCE" "$doc"
|
||||
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
|
||||
else
|
||||
# Совместимость: ревьюер мог написать по старому пути, если промпт
|
||||
# ещё не обновился в этой ветке.
|
||||
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
|
||||
fi
|
||||
git add docs/reviews 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
echo "::warning::документ ревью не создан"
|
||||
exit 0
|
||||
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
|
||||
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
|
||||
# коммиттер GitHub). Провал — когда файла нет и на ветке.
|
||||
git fetch -q origin "$target"
|
||||
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||||
echo "документ уже опубликован ревьюером: $doc"
|
||||
exit 0
|
||||
fi
|
||||
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
|
||||
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
|
||||
# остались только комментариями, метки переставились, и пропажу
|
||||
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
|
||||
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
|
||||
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
|
||||
exit 1
|
||||
fi
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
@@ -338,16 +685,26 @@ jobs:
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase "origin/$target"; then
|
||||
git rebase --abort || true
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
|
||||
exit 0
|
||||
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
|
||||
exit 1
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"
|
||||
fi
|
||||
echo "документ опубликован в $target"
|
||||
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
|
||||
# документом, названным не по формату, — тот же вердикт без
|
||||
# артефакта, только дороже в обнаружении.
|
||||
git fetch -q origin "$target"
|
||||
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||||
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
|
||||
exit 1
|
||||
fi
|
||||
echo "документ опубликован в $target: $doc"
|
||||
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -393,14 +750,42 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git fetch -q origin dev
|
||||
git checkout -q -B merge-into-dev "origin/$BRANCH"
|
||||
git fetch -q origin dev "$BRANCH"
|
||||
# #312: сливается только проверенный код. Допустимые вершины ветки:
|
||||
# сам SHA материала либо он же плюс ровно один коммит публикации
|
||||
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
|
||||
# ветка уехала после ревью, вердикт к ней не применим: возврат в
|
||||
# S6-in-progress через merged=false, как при конфликте.
|
||||
actual=$(git rev-parse "origin/$BRANCH")
|
||||
reviewed="$MATERIAL_SHA"
|
||||
fresh=false
|
||||
if [ "$actual" = "$reviewed" ]; then
|
||||
fresh=true
|
||||
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
|
||||
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
|
||||
fresh=true
|
||||
fi
|
||||
if [ "$fresh" != true ]; then
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
|
||||
cat > /tmp/stale-verdict.md <<EOF
|
||||
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
|
||||
|
||||
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
|
||||
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
|
||||
exit 0
|
||||
fi
|
||||
git checkout -q -B merge-into-dev "$actual"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase origin/dev; then
|
||||
@@ -426,6 +811,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 }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish prerelease
|
||||
name: Публикация пре-релиза (ручная)
|
||||
run-name: Publish ${{ inputs.tag }}
|
||||
|
||||
on:
|
||||
@@ -19,6 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка и релизный контракт"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
@@ -61,6 +62,7 @@ jobs:
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
|
||||
publish:
|
||||
name: Публикация тега и релиза
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -80,18 +82,13 @@ jobs:
|
||||
set -euo pipefail
|
||||
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
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
npm run bundle:budget
|
||||
VERSION=${TAG#v}
|
||||
grep -Fq "$VERSION" dist/houseplan-card.js
|
||||
grep -RFq "$VERSION" dist
|
||||
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
|
||||
unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
|
||||
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
|
||||
test "$ZIP_VERSION" = "$VERSION" || {
|
||||
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
|
||||
exit 1
|
||||
}
|
||||
node scripts/verify-houseplan-zip.mjs houseplan.zip \
|
||||
custom_components/houseplan/frontend "$VERSION"
|
||||
test -s dist/houseplan-card.js
|
||||
test -s houseplan.zip
|
||||
- name: Create or verify the annotated tag
|
||||
@@ -199,6 +196,7 @@ jobs:
|
||||
# A manual step after a successful release is the worst kind: by the time it is
|
||||
# due, the work already looks finished, which is exactly why it gets forgotten.
|
||||
close-merged:
|
||||
name: Закрытие вошедших issue
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -246,6 +244,7 @@ jobs:
|
||||
}
|
||||
|
||||
announce:
|
||||
name: Комментарий о публикации
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
uses: ./.github/workflows/announce.yml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Attach HACS zip to release
|
||||
name: HACS-zip к релизу
|
||||
# hacs.json declares zip_release + filename=houseplan.zip, so every release
|
||||
# (prereleases included) must carry the asset — HACS installs from it and
|
||||
# GitHub's public download counter becomes a free per-version install metric
|
||||
@@ -19,6 +19,7 @@ permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
zip:
|
||||
name: Собрать houseplan.zip и приложить к релизу
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
@@ -33,7 +34,7 @@ jobs:
|
||||
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
|
||||
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
|
||||
- name: Sanity check
|
||||
run: unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
run: node scripts/verify-houseplan-zip.mjs houseplan.zip custom_components/houseplan/frontend
|
||||
- name: Upload asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release
|
||||
name: "Релиз: ассеты после зелёной проверки"
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
# Needs a push with a token that has the `workflow` scope (the ordinary
|
||||
# Personal Access Token used for `git push` refuses workflow file updates).
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка точного SHA тега"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -42,8 +43,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
|
||||
build:
|
||||
name: Сборка бандла и загрузка ассетов
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -57,7 +59,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 }}
|
||||
@@ -65,12 +67,14 @@ jobs:
|
||||
with:
|
||||
name: continuity-screencast
|
||||
path: artifacts/continuity-screencast
|
||||
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
- name: Verify the complete committed frontend tree
|
||||
run: node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
name: HACS-видимость пре-релиза (порядок бет)
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
# sorting SemVer. A valid asset can therefore be invisible to beta users
|
||||
# (beta.10 appeared after beta.9). Keep the release asset, but
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Validate
|
||||
name: Проверка (CI)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -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.
|
||||
@@ -15,23 +21,57 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
# Четыре секундных скрипта жили четырьмя job, каждая со своим checkout и
|
||||
# setup-node ради нескольких секунд работы (#336). Здесь они шаги одной job.
|
||||
#
|
||||
# Независимость при этом сохранена намеренно: у каждого шага
|
||||
# `continue-on-error`, поэтому упавший провенанс не прячет результат
|
||||
# процессного гейта, а вердикт в конце падает, если упал хоть один. Прежняя
|
||||
# запись «job независимый: краснеет сам и не роняет остальные» продолжает
|
||||
# действовать — на уровне шагов, с той же гранулярностью в логе.
|
||||
preflight:
|
||||
name: "Предполётные проверки: документация, провенанс, процесс"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# `blob:none` при полной истории (#345): этой job нужны сообщения
|
||||
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
|
||||
# ревизий. Полный клон стоит 215 МБ против 26 МБ, и 32% этого веса —
|
||||
# скриншоты документации, переснятые 196 раз.
|
||||
#
|
||||
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
|
||||
# работают как раньше. Единственная догрузка по требованию здесь —
|
||||
# `git show origin/main:.github/workflows/process.yml`: один блоб.
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Validate public documentation
|
||||
|
||||
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: node scripts/check-docs.mjs --external
|
||||
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Validate commit trailers and hook mode
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml идентичен в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
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
|
||||
|
||||
- name: "Провенанс коммитов: трейлеры и эталоны"
|
||||
id: provenance
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
@@ -42,19 +82,14 @@ jobs:
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
|
||||
|
||||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||||
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev
|
||||
# без PR — GitHub на своей стороне не блокирует ничего. Это последнее место,
|
||||
# где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и
|
||||
# не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим.
|
||||
process-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Process gate
|
||||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||||
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в
|
||||
# dev без PR — GitHub на своей стороне не блокирует ничего. Это последнее
|
||||
# место, где нарушение правила №1 ловится машиной. Откат — удалить шаг,
|
||||
# скрипт остаётся рабочим.
|
||||
- name: "Процессный гейт: диапазон, трейлеры, статусы issue"
|
||||
id: process_gate
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
@@ -68,6 +103,33 @@ jobs:
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/process-gate.mjs --github-range --issues
|
||||
|
||||
# Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе
|
||||
# слияние job стоило бы автору лишнего круга «починил одно — узнал о
|
||||
# втором».
|
||||
- name: Вердикт предполётных проверок
|
||||
env:
|
||||
DOCS: ${{ steps.docs.outcome }}
|
||||
WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }}
|
||||
PROVENANCE: ${{ steps.provenance.outcome }}
|
||||
PROCESS_GATE: ${{ steps.process_gate.outcome }}
|
||||
run: |
|
||||
fail=0
|
||||
check() {
|
||||
if [ "$2" = "success" ]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "FAIL $1 ($2)"
|
||||
echo "- **$1**: $2" >> "$GITHUB_STEP_SUMMARY"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
exit $fail
|
||||
|
||||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||||
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
|
||||
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
|
||||
@@ -76,14 +138,17 @@ jobs:
|
||||
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
|
||||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||||
changes:
|
||||
name: Классификация изменённых файлов
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
@@ -100,10 +165,28 @@ jobs:
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# #347: непустой before, которого больше не существует, — это
|
||||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в
|
||||
# этом случае НЕ угадывается: merge-base после переписанной истории
|
||||
# уже выдавал «изменились только два doc-файла» при реальном диффе
|
||||
# в custom_components/**, и тяжёлые job тихо скипались, а общий
|
||||
# статус рана оставался success — ровно класс тихого пропуска
|
||||
# #171/#207. Force-push редок, полный прогон дешевле ложного
|
||||
# «зелёного».
|
||||
if [ -n "$base" ] && [ "$base" != "$zero" ] \
|
||||
&& ! git cat-file -e "$base" 2>/dev/null; then
|
||||
echo "force-push: before=$base переписан — полный прогон без классификации"
|
||||
{
|
||||
echo "### Классификация изменённых файлов"
|
||||
echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
|
||||
# иначе классифицировалась бы вся история.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
|
||||
git fetch -q origin dev
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
|
||||
fi
|
||||
@@ -116,7 +199,101 @@ jobs:
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||||
# же входами уже завершилась успешно.
|
||||
#
|
||||
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
|
||||
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
|
||||
# здесь эквивалентность входов доказана хешем.
|
||||
#
|
||||
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
|
||||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
name: "Переиспользование: это дерево уже проверено"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
golden: ${{ steps.probe.outputs.golden }}
|
||||
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
|
||||
backend: ${{ steps.probe.outputs.backend }}
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
id: m_smoke
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер golden
|
||||
id: m_golden
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ steps.keys.outputs.golden }}
|
||||
lookup-only: true
|
||||
- name: Маркер performance_smoke
|
||||
id: m_perf
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ steps.keys.outputs.backend }}
|
||||
lookup-only: true
|
||||
- name: Что переиспользуем
|
||||
id: probe
|
||||
env:
|
||||
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
|
||||
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
|
||||
PERF: ${{ steps.m_perf.outputs.cache-hit }}
|
||||
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
|
||||
run: |
|
||||
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
|
||||
# успех, который уже дважды стоил нам дня (#171, #207).
|
||||
waive() {
|
||||
if [ "$2" = "true" ]; then
|
||||
echo "$1=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
|
||||
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$1=false" >> "$GITHUB_OUTPUT"
|
||||
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
|
||||
waive smoke "$SMOKE"
|
||||
waive golden "$GOLDEN"
|
||||
waive performance_smoke "$PERF"
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
name: "HACS: валидация репозитория"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -128,6 +305,7 @@ jobs:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
name: "Hassfest: манифест интеграции"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -137,6 +315,7 @@ jobs:
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
frontend:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -153,15 +332,49 @@ jobs:
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Card bundle snapshots in sync
|
||||
# Entry, manifest and every content-hashed chunk are one artifact (#337).
|
||||
- name: Card bundle trees 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
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
npm run bundle:budget
|
||||
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
|
||||
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
|
||||
# --noEmit` плюс rollup, около минуты на job.
|
||||
#
|
||||
# Подмену артефакта ловить не нужно отдельной проверкой: `assertFreshDemoBundle`
|
||||
# сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного
|
||||
# дерева, и каждая браузерная job делает это перед первым кадром.
|
||||
- name: Выложить бандл для браузерных job
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
# Несколько браузерных контрактов импортируют чистые функции из
|
||||
# скомпилированного тестового дерева. Оно создаётся `npm test`, но не
|
||||
# входит в production-бандл и потому едет отдельным артефактом (#336).
|
||||
- name: Выложить тестовые модули для смоков
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: card-test-build
|
||||
path: test-build/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
smoke:
|
||||
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||||
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -169,15 +382,50 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps 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
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Забрать тестовые модули
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-test-build
|
||||
path: test-build
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
- 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"
|
||||
@@ -187,18 +435,46 @@ 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
|
||||
|
||||
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
|
||||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||||
smoke_done:
|
||||
name: "Смоки: все шарды зелёные"
|
||||
needs: [smoke, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||||
|
||||
golden:
|
||||
name: Golden-кадры против принятых эталонов
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -207,10 +483,30 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact source under review
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
@@ -227,13 +523,34 @@ jobs:
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "golden прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
|
||||
performance_smoke:
|
||||
name: "Перф-смок: бюджет времени кадра"
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -241,26 +558,67 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact candidate source
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
- 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
|
||||
- name: Enforce absolute smoke ceilings
|
||||
run: |
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
run: npm run benchmark:junction-limits
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "performance_smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
|
||||
backend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -273,3 +631,19 @@ jobs:
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
- name: Backend unit tests (pure + HA harness)
|
||||
run: python -m pytest tests_backend/ -q
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "backend прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
|
||||
|
||||
@@ -7,3 +7,10 @@ __pycache__/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
.agents/
|
||||
|
||||
# Копия бандла для стенда: её собирает `npm run bundle:sync`, а в репозитории
|
||||
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
|
||||
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
|
||||
demo/srv/assets/houseplan-card.js
|
||||
demo/srv/assets/houseplan-assets.json
|
||||
demo/srv/assets/houseplan-assets/
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to
|
||||
the entry, manifest and hashed chunks under `dist/`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
@@ -40,10 +41,16 @@ task records: problem, scope, acceptance criteria and discussion.
|
||||
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
|
||||
issue. Labels are the whole of it: GitHub Projects is no longer used.
|
||||
|
||||
Two shortcuts exist for small work. `small` — the light track: the spec lives in
|
||||
the issue body and its review is a comment. `trivial` — the short track: no spec
|
||||
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
|
||||
issue body first. `trivial` requires a bug confined to one surface with no new UX
|
||||
**The light track is the default, not a shortcut** (owner's decision 2026-08-27,
|
||||
issue #338). `small` — the spec lives in the issue body and its review is a
|
||||
comment. Analysis names the `small` criterion the task *fails* when it takes the
|
||||
full track; "ordinary track" without a named criterion is not a justification.
|
||||
The threshold itself did not move — only which side carries the proof. The full
|
||||
track stays what it was for geometry, config migrations and public contracts,
|
||||
where a criterion is broken plainly and saying which one is easy.
|
||||
|
||||
`trivial` — the short track: no spec stage at all, `S2-analysis` straight to
|
||||
`S5-ready`, with the AC written into the issue body first. `trivial` requires a bug confined to one surface with no new UX
|
||||
contract, no migration, no i18n, no perf or touch impact, at most three checkable
|
||||
AC, **and expected behaviour already on record** — nothing left to decide. Code
|
||||
review is never skipped on either track; it is what stands in for testing.
|
||||
@@ -87,7 +94,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/**` is no longer committed (#255): build the complete tree 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`,
|
||||
@@ -255,10 +262,15 @@ The exchange happens in **issue comments** — there is no local message bus. Ve
|
||||
format:
|
||||
|
||||
```text
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
|
||||
```
|
||||
|
||||
High blocks. Medium must become its own issue. Low is fixed or waived with a note
|
||||
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
|
||||
with no High findings the verdict is yellow, the author fixes it and the fix
|
||||
passes another review cycle — no separate issue (owner's decision 2026-08-19,
|
||||
#202: filing and servicing an issue costs far more than fixing in place). Only
|
||||
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
|
||||
never patched from this branch. Low is fixed or waived with a note
|
||||
in the review document. A yellow verdict is legitimate even when every acceptance
|
||||
criterion passes, if the change does not solve the stated scenario or degrades a
|
||||
neighbouring one.
|
||||
@@ -269,7 +281,9 @@ the owner splits the task, rejects it, or arbitrates.
|
||||
|
||||
On the light track (`small`: complexity ≤3, one surface, no config migration, no
|
||||
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
|
||||
body and the spec review is a comment. Code review is never skipped.
|
||||
body and the spec review is a comment. Code review is never skipped. This track is
|
||||
the default: taking the full one means naming the criterion above that the task
|
||||
does not meet.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -312,12 +326,12 @@ npm run inventory # the only correct way to get test counts
|
||||
|
||||
Never copy test counts into documents by hand; they go stale in days.
|
||||
|
||||
After building, keep all three bundle snapshots in sync — CI compares them
|
||||
byte-for-byte:
|
||||
After building, keep the complete manifest-driven bundle trees in sync — CI
|
||||
verifies every listed file 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)
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
```
|
||||
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
@@ -353,14 +367,22 @@ complete Linux CI artifact; never accept a partial scenario or images merely to
|
||||
CI green. See `demo/golden/README.md`.
|
||||
|
||||
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
|
||||
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
|
||||
`assertFreshDemoBundle` before recording any result; a missing or mismatched
|
||||
fingerprint is a hard failure, not a warning.
|
||||
TypeScript and package-lock build inputs. Every browser check must verify it
|
||||
before trusting a result — benchmarks, golden runs and documentation captures
|
||||
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
|
||||
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
|
||||
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
|
||||
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
|
||||
assertions go red and part stay green, which reads as a logic defect.
|
||||
|
||||
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
|
||||
an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
|
||||
@@ -9,6 +9,44 @@ User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Translations
|
||||
|
||||
A shipped UI language has three matching parts:
|
||||
|
||||
1. `src/i18n/<code>.json` for the card;
|
||||
2. `custom_components/houseplan/translations/<code>.json` for the Home Assistant
|
||||
integration;
|
||||
3. one registry entry (code, native label and eager dictionary or lazy loader)
|
||||
in `src/i18n/registry.ts`.
|
||||
|
||||
Use the canonical Home Assistant/BCP 47 language tag as `<code>` (for example,
|
||||
`fr` or `pt-BR`) and use that exact spelling for both JSON filenames. Lookup is
|
||||
case-insensitive and also accepts `_` from legacy locale sources.
|
||||
|
||||
The registry drives language resolution, the visual-editor selector and parity
|
||||
tests. The tests reject missing or extra locale files; frontend dictionaries
|
||||
also fail on mismatched keys, empty values and changed placeholders.
|
||||
Placeholders such as `{name}` and `{n}` are a contract: do not translate, add
|
||||
or remove them.
|
||||
|
||||
English and Russian are synchronous fallback/legacy locales. German is the
|
||||
reference lazy third-locale implementation: its module carries the same build
|
||||
fingerprint as the entry bundle, has one content-hashed retry URL and is listed
|
||||
under `lazyLocaleFiles` in `houseplan-assets.json`. New sizeable locales should
|
||||
follow that path unless a measured initial-bundle budget explicitly justifies
|
||||
an eager import. Extend the runtime, manifest, file/key/placeholder parity and
|
||||
regional-locale tests together; never bypass the registry by importing a locale
|
||||
directly in a component.
|
||||
|
||||
The current `subst()` helper does not implement plural rules. Phrase strings so
|
||||
their grammar does not depend on the numeric value (for example, use a neutral
|
||||
label followed by `{n}` rather than an English singular/plural pair).
|
||||
|
||||
Adding a UI locale does not automatically create another full documentation
|
||||
set; maintain the existing English and Russian documentation according to the
|
||||
project's normal rules. Right-to-left layout is a separate product project,
|
||||
because the plan canvas and editors cannot be mirrored by translations alone.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
@@ -28,7 +66,7 @@ issue, but they do not replace it or maintain a separate checklist.
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Matysh/houseplan-card && cd houseplan-card
|
||||
git clone --filter=blob:none https://github.com/Matysh/houseplan-card && cd houseplan-card
|
||||
npm ci # frontend toolchain
|
||||
npm run typecheck # tsc --noEmit (strict)
|
||||
npm test # node:test — pure logic, i18n parity, tap-action security
|
||||
@@ -37,6 +75,24 @@ pip install pytest voluptuous && python -m pytest tests_backend -q # pure back
|
||||
npm install # also installs .githooks through the prepare script
|
||||
```
|
||||
|
||||
### Why `--filter=blob:none` (#345)
|
||||
|
||||
A full clone is **215 MB of `.git`**; a blobless one is **26 MB** — measured, not
|
||||
estimated. Both carry all 1611 commits and all 182 tags, so ranges, `merge-base`
|
||||
and `git diff` across history work identically; `git diff origin/dev~3..origin/dev`
|
||||
in a blobless clone takes about a second and grows `.git` by one megabyte.
|
||||
|
||||
The difference is that historical *file contents* are fetched only if something
|
||||
actually asks for them. That matters here because 32% of the pack is documentation
|
||||
screenshots — ten PNGs re-captured 196 times — and another sizeable share is the
|
||||
committed bundle, one 1.16 MB file per product change. Almost nobody ever reads an
|
||||
old revision of either.
|
||||
|
||||
Drop the flag if you work offline with history, or need `git log -p` over the whole
|
||||
tree repeatedly. Do **not** replace it with `--depth=1`: a shallow clone is about
|
||||
the same size but has no `merge-base`, so the process gate, `smoke-select` and
|
||||
every `origin/dev..HEAD` range stop working.
|
||||
|
||||
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
|
||||
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
|
||||
every push — locally they are skipped when `homeassistant` is not importable.
|
||||
@@ -45,8 +101,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
|
||||
|
||||
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
|
||||
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
|
||||
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
|
||||
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
|
||||
- Every UI string goes through `src/i18n/<lang>.json`; follow the
|
||||
[Translations](#translations) flow for registry and backend parity.
|
||||
- The built card must be committed in sync: `cp dist/houseplan-card.js
|
||||
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
|
||||
- Tap actions have a security model (locks/alarms never toggle from the plan) —
|
||||
|
||||
@@ -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 допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
|
||||
|
||||
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
|
||||
бандл» перестают быть лазейками.
|
||||
@@ -101,7 +101,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
|
||||
8. трек — **по умолчанию `small`** (§5). Если задача идёт полным треком,
|
||||
называется критерий §5, который она не проходит: «обычный трек» без
|
||||
названного критерия обоснованием не является.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
@@ -131,10 +133,14 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
|
||||
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
|
||||
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
|
||||
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
|
||||
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
|
||||
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
|
||||
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
|
||||
Low либо правится, либо снимается решением ревьюера с записью.
|
||||
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
|
||||
4 циклов (§4).
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
### 2.5 Готово к разработке (DoR)
|
||||
|
||||
@@ -187,9 +193,17 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **High блокируют.** Medium **обязаны** превратиться в issue.
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
- **Вердикт привязан к SHA (#312).** Все числа и факты отчёта сверяются с
|
||||
`git rev-parse HEAD` непосредственно перед подведением итогов, а не с SHA,
|
||||
зафиксированным в начале разбора: во время ревью в ветку может прилететь
|
||||
fix-up. Серверный стопор — шаг слияния конвейера сверяет вершину ветки с
|
||||
SHA материала ревью (допустим только собственный doc-коммит публикации
|
||||
поверх) и при расхождении отменяет слияние с возвратом в `S6-in-progress`.
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4).
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
### 2.8 Закрытие после выпуска беты
|
||||
|
||||
@@ -210,6 +224,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
|
||||
оправдана). Тихое закрытие без причины запрещено.
|
||||
|
||||
### 2.10 Повторный раунд ревью — объём по дельте
|
||||
|
||||
Решение владельца 2026-08-19 (issue #214). Относится и к ревью ТЗ, и к
|
||||
код-ревью, начиная со второго цикла.
|
||||
|
||||
**Предмет повторного раунда — дельта, а не задача целиком.** Раньше объём
|
||||
разбора не был оговорён, промпт ревьюера для всех раундов был одинаковым, и
|
||||
повторный цикл заново выводил продуктовую рамку и перепроверял AC, которых
|
||||
правка не касалась: r2 по #150 стоил полного прогона конвейера ради одной
|
||||
строки в тестовой фикстуре.
|
||||
|
||||
Порядок:
|
||||
|
||||
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
|
||||
вердикте не назван — это находка;
|
||||
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
|
||||
issue для этапа ТЗ;
|
||||
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
|
||||
строкой кода или текста, а не заявлением автора;
|
||||
4. заново проверять только те AC, чьё доказательство дельта задевает;
|
||||
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
|
||||
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
|
||||
превращается в молчаливое доверие.
|
||||
|
||||
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
|
||||
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
|
||||
|
||||
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
|
||||
`dev` (после ребейза это другой код, §7.2), смена контракта поведения, задета
|
||||
новая подсистема, либо объём дельты сопоставим с исходной задачей.
|
||||
|
||||
Сокращается объём **разбора, а не строгость**: правка по замечанию способна
|
||||
сломать AC, который предыдущий раунд признал выполненным — так появилась
|
||||
регрессия #102. Граница не «только находки», а «находки плюс всё, до чего
|
||||
дотягивается дельта».
|
||||
|
||||
---
|
||||
|
||||
## 3. Правила
|
||||
@@ -231,13 +281,17 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
ревью-гейт.
|
||||
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
|
||||
отклонить или арбитраж (§4).
|
||||
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
|
||||
решением ревьюера с записью в документе.
|
||||
8. **High блокирует. Medium в скоупе чинится в текущем issue** (без High —
|
||||
жёлтый вердикт и повторный цикл); Medium вне скоупа становится отдельным
|
||||
issue (#202). Low либо правится, либо снимается решением ревьюера с записью
|
||||
в документе.
|
||||
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
|
||||
том же коммите.
|
||||
том же коммите. После `cherry-pick -x` служебная строка `(cherry picked
|
||||
from ...)` должна оставаться выше финального блока трейлеров: перед push
|
||||
проверяем порядок через `git show -s --format=full HEAD`.
|
||||
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
|
||||
коммитом документация не бывает.
|
||||
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
|
||||
@@ -263,11 +317,24 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
## 4. Лимит циклов ревью: 4
|
||||
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
|
||||
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**.
|
||||
|
||||
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
|
||||
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
|
||||
- **Зелёный вердикт цикла не образует** и бюджет не тратит (решение владельца
|
||||
2026-08-20, issue #227): он ничего не вернул на правки. Практический случай —
|
||||
зелёное ревью, слияние которого не удалось: конвейер сам предписывает ребейз и
|
||||
возврат метки, и этот заход не должен наказываться. Раньше счётчик считал все
|
||||
вердикты подряд, и на #225 последовательность жёлтый → зелёный → ребейз дала
|
||||
`review-4` на задаче с зелёным ревью и зелёным CI.
|
||||
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
|
||||
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
|
||||
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
|
||||
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
|
||||
циклов (шесть документов = четыре цикла плюс два ребейза).
|
||||
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
|
||||
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
|
||||
лимита, конвейер сообщает пересчёт, но метку не трогает.
|
||||
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
|
||||
решение одно из трёх:
|
||||
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
|
||||
@@ -283,9 +350,26 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
---
|
||||
|
||||
## 5. Лёгкий трек (метка `small`)
|
||||
## 5. Лёгкий трек (метка `small`) — путь по умолчанию
|
||||
|
||||
**Критерии — все одновременно:**
|
||||
Умолчание изменено решением владельца 2026-08-27, issue #338. Прежде полный трек
|
||||
был бесплатен, а выбор лёгкого требовал обоснования. Фактическая цена: **2.9
|
||||
ревью-документа на задачу** в среднем и до шести на одну issue (#329, #316,
|
||||
#290) — при том что Medium-находки всё равно чинятся в той же задаче, без
|
||||
отдельного цикла.
|
||||
|
||||
**Порог не изменился.** Критерии ниже те же и по-прежнему обязательны все
|
||||
одновременно. Изменилась сторона доказательства: теперь обосновывается не выбор
|
||||
лёгкого трека, а отказ от него — в `S2-analysis` называется критерий, который
|
||||
задача не проходит. Полный трек остаётся тем, чем был, для геометрии, миграций
|
||||
конфига и публичных контрактов: там критерии нарушаются сами, и назвать
|
||||
нарушенный несложно.
|
||||
|
||||
Инверсия умолчания не отменяет ничего из §5 ниже и ничего из §4: бюджет четырёх
|
||||
циклов, арбитраж владельца, обязательность ТЗ на полном треке и правило «ревью
|
||||
до мержа» остаются как были. Меняется только стоимость пути по умолчанию.
|
||||
|
||||
**Критерии — все одновременно; нарушенный называется явно:**
|
||||
|
||||
- сложность и риск ≤ 3;
|
||||
- одна поверхность (один диалог, один модуль, один эндпоинт);
|
||||
@@ -444,8 +528,12 @@ issue #NN
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
|
||||
High: N · Medium: N → #… · Документ: docs/reviews/…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
|
||||
блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… ·
|
||||
Документ: docs/reviews/…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору.
|
||||
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
|
||||
его не тратят, поэтому заход и K расходятся, #227)
|
||||
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
|
||||
|
||||
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
|
||||
@@ -476,23 +564,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 тега.
|
||||
|
||||
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
|
||||
@@ -686,8 +809,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
```
|
||||
|
||||
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
|
||||
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
|
||||
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
|
||||
вердикту, а не модель.**
|
||||
|
||||
@@ -717,6 +840,28 @@ Medium-находку, кладёт документ в `docs/reviews/` ветк
|
||||
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
|
||||
ошибки.
|
||||
|
||||
**Ветка приводится к `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`: работа действительно вернулась к автору, только осталась не
|
||||
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
|
||||
@@ -811,7 +956,8 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
|
||||
- закрытие issue до выпуска беты с зелёным CI;
|
||||
- переоткрытие закрытого issue вместо нового бага;
|
||||
- Medium-находки, оставленные как TODO в документе ревью;
|
||||
- Medium-находки, оставленные как TODO в документе ревью: в скоупе — чинятся
|
||||
в текущем issue, вне скоупа — становятся отдельным (#202);
|
||||
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
|
||||
- ревью-документы вне репозитория;
|
||||
- попутные правки «раз уж я здесь»;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🏠 House Plan — a live home map for Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
@@ -88,10 +88,11 @@ The [Background editor contract](docs/DECOR-EDITOR.md) and
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
1. In HACS open **⋮ → Custom repositories**.
|
||||
2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
|
||||
3. Install House Plan and restart Home Assistant.
|
||||
4. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
House Plan is in the HACS default catalog — no custom repository needed.
|
||||
|
||||
1. In HACS search for **House Plan** and install it.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
@@ -107,8 +108,10 @@ serve that path as a JavaScript module.
|
||||
|
||||
### Manual installation
|
||||
|
||||
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
|
||||
Assistant, and add the House Plan integration.
|
||||
Copy the complete `custom_components/houseplan` release folder to
|
||||
`config/custom_components`, restart Home Assistant, and add the House Plan
|
||||
integration. Do not copy only `houseplan-card.js`: the card also uses an
|
||||
internal manifest and content-hashed modules from the same release.
|
||||
|
||||
### Add the card
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🏠 House Plan — живой план дома для Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
@@ -91,10 +91,12 @@ Assistant — и устройства появятся на плане авто
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
1. В HACS откройте **⋮ → Пользовательские репозитории**.
|
||||
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
|
||||
3. Установите House Plan и перезапустите Home Assistant.
|
||||
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
House Plan входит в основной каталог HACS — пользовательский репозиторий
|
||||
добавлять не нужно.
|
||||
|
||||
1. Найдите **House Plan** в поиске HACS и установите.
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
@@ -110,8 +112,10 @@ resources:
|
||||
|
||||
### Вручную
|
||||
|
||||
Скопируйте `custom_components/houseplan` в `config/custom_components`,
|
||||
перезапустите Home Assistant и добавьте интеграцию House Plan.
|
||||
Скопируйте всю папку `custom_components/houseplan` из релиза в
|
||||
`config/custom_components`, перезапустите Home Assistant и добавьте интеграцию
|
||||
House Plan. Одного `houseplan-card.js` недостаточно: карточке также нужны
|
||||
внутренний манифест и хешированные модули из того же релиза.
|
||||
|
||||
### Добавление карточки
|
||||
|
||||
|
||||
@@ -39,10 +39,12 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
|
||||
from .frontend_assets import HouseplanFrontendAssetView
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
hass.http.register_view(HouseplanImportPreviewView())
|
||||
hass.http.register_view(HouseplanFrontendAssetView())
|
||||
return True
|
||||
|
||||
|
||||
@@ -98,7 +100,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the card bundle stays public — Lovelace resources must be.
|
||||
# Only the entry and manifest-gated JS chunks stay public —
|
||||
# Lovelace modules must be loadable without an auth header.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
@@ -236,12 +239,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
|
||||
if recovered_import:
|
||||
await recorder.async_refresh()
|
||||
current = (await data.config_store.async_load() or {}).get("config") or {}
|
||||
live_ids = {str(marker.get("id")) for marker in current.get("markers") or []}
|
||||
for marker_id in list(recorder.book.data):
|
||||
if marker_id not in live_ids:
|
||||
await recorder.async_delete(marker_id)
|
||||
await recorder.async_purge_orphans(current)
|
||||
await recorder.async_refresh()
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 2
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
FRONTEND_ASSETS_URL = "/houseplan_files/houseplan-assets"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
@@ -46,12 +47,12 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.64.0"
|
||||
VERSION = "1.69.0-beta.1"
|
||||
|
||||
# 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 = 9
|
||||
EXPORT_VERSION = 1
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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 segment in _records(space.get("wall_segments")):
|
||||
_lattice_point(segment.get("a"))
|
||||
_lattice_point(segment.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
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "420f1f87e66a879316f23c4dd573991c8cf2bfcf5ef013191e55e4216031f3be",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-BarpA18i.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 256012,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/de-L64iNsLu.js",
|
||||
"houseplan-assets/editor-B45l6syb.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-D6vKsLjO.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BUDnRMK5.js",
|
||||
"houseplan-assets/space-deletion-05RPnoUh.js"
|
||||
],
|
||||
"lazyGzipBytes": 160359,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/editor-B45l6syb.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-D6vKsLjO.js",
|
||||
"houseplan-assets/space-deletion-05RPnoUh.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 131837,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BUDnRMK5.js",
|
||||
"houseplan-assets/space-deletion-05RPnoUh.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 7198,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-L64iNsLu.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 21878,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/de-L64iNsLu.js",
|
||||
"sha256": "472ff7eb5679b16bacedce870ac7a23379c8a03ee3470b660c0c996a76f29b7e",
|
||||
"rawBytes": 71477,
|
||||
"gzipBytes": 21878,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-B45l6syb.js",
|
||||
"sha256": "2df6678b9adc8c3423d6e250b111461f7f3a75f4377a36ed72e5cbee51ce4bf2",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BarpA18i.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-BarpA18i.js",
|
||||
"sha256": "b3276d12591e3face8174420b981e9d7626ca9ab02667982ad83943d93194ef1",
|
||||
"rawBytes": 910025,
|
||||
"gzipBytes": 255830,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-L64iNsLu.js",
|
||||
"houseplan-assets/editor-B45l6syb.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-D6vKsLjO.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BUDnRMK5.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-D6vKsLjO.js",
|
||||
"sha256": "8cf01ac6ca2eb3ee4f8f1191ab15eb92884ee25013356ea4e12bfffdeb0b1fd2",
|
||||
"rawBytes": 504959,
|
||||
"gzipBytes": 129700,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BarpA18i.js",
|
||||
"houseplan-assets/space-deletion-05RPnoUh.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-BUDnRMK5.js",
|
||||
"sha256": "2439e9cdf2c7873b737d46261a8f489c398c5434bc1fecd71a481ca80519443d",
|
||||
"rawBytes": 26356,
|
||||
"gzipBytes": 6644,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BarpA18i.js",
|
||||
"houseplan-assets/space-deletion-05RPnoUh.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-deletion-05RPnoUh.js",
|
||||
"sha256": "2cf11df1b1bcd463ef90c1089b35ae89be7ce4d92370bb6a9ee3e94987a3d65f",
|
||||
"rawBytes": 911,
|
||||
"gzipBytes": 554,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "aa89ff5b93e00716f50c19cbb8efd81900451bca43220ddf3a74d9de7bf6a57e",
|
||||
"rawBytes": 190,
|
||||
"gzipBytes": 182,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BarpA18i.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="420f1f87e66a879316f23c4dd573991c8cf2bfcf5ef013191e55e4216031f3be";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-BarpA18i.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -0,0 +1 @@
|
||||
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="420f1f87e66a879316f23c4dd573991c8cf2bfcf5ef013191e55e4216031f3be";export{t as a,e as c,o as i,r as s,s as t};
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Pure manifest/path resolver for generated frontend assets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_MANIFEST = "houseplan-assets.json"
|
||||
_ASSET_DIR = "houseplan-assets"
|
||||
|
||||
|
||||
def resolve_frontend_asset(frontend_root: Path, filename: object) -> Path | None:
|
||||
"""Resolve one generated JS asset only when the current manifest lists it."""
|
||||
if not isinstance(filename, str) or not filename or filename != Path(filename).name:
|
||||
return None
|
||||
if "/" in filename or "\\" in filename or filename in {".", ".."}:
|
||||
return None
|
||||
if not filename.endswith(".js"):
|
||||
return None
|
||||
try:
|
||||
manifest = json.loads((frontend_root / _MANIFEST).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(manifest, dict) or manifest.get("schema") != 1:
|
||||
return None
|
||||
expected = f"{_ASSET_DIR}/{filename}"
|
||||
listed = {
|
||||
item.get("path")
|
||||
for item in manifest.get("files", [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if expected not in listed:
|
||||
return None
|
||||
asset_root = (frontend_root / _ASSET_DIR).resolve()
|
||||
candidate = (asset_root / filename).resolve()
|
||||
if candidate.parent != asset_root or not candidate.is_file():
|
||||
return None
|
||||
return candidate
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Public, manifest-gated JavaScript chunks for the Lovelace card."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
try:
|
||||
from homeassistant.components.http import KEY_HASS
|
||||
except ImportError: # pragma: no cover - old HA compatibility
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
|
||||
from .const import FRONTEND_ASSETS_URL
|
||||
from .frontend_asset_manifest import resolve_frontend_asset
|
||||
|
||||
_FRONTEND_ROOT = Path(__file__).parent / "frontend"
|
||||
|
||||
|
||||
class HouseplanFrontendAssetView(HomeAssistantView):
|
||||
"""Serve only generated chunks named by the current build manifest."""
|
||||
|
||||
url = f"{FRONTEND_ASSETS_URL}/{{filename}}"
|
||||
name = "api:houseplan:frontend-asset"
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request: web.Request, filename: str) -> web.StreamResponse:
|
||||
hass = request.app[KEY_HASS]
|
||||
path = await hass.async_add_executor_job(
|
||||
resolve_frontend_asset, _FRONTEND_ROOT, filename
|
||||
)
|
||||
if path is None:
|
||||
raise web.HTTPNotFound()
|
||||
return web.FileResponse(
|
||||
path,
|
||||
headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Issue #329 — wall junction limits, Python mirror of src/junction-limits.ts.
|
||||
|
||||
The card refuses a WRITE that would ADD a junction violation; this module is
|
||||
the backend half of that contract for `config/set` and `plan/optimize` (#333)
|
||||
— the two commands a client can use to write arbitrary geometry. Import and
|
||||
backup restore are DELIBERATELY outside the gate: #329 §3 promises that a
|
||||
restore is never blocked, so a crafted import can still persist violations —
|
||||
they are then inherited, never legalised as new ones. It mirrors П1–П4 exactly, including the two legal
|
||||
incidences of П4 (a shared node and a T-joint into the middle of a foreign
|
||||
wall) and the П3 rule that measures the collinear same-thickness WALL RUN
|
||||
rather than one catalogue atom.
|
||||
|
||||
П5 (the room keeps at least 25 cm² of interior) is deliberately NOT mirrored:
|
||||
it is a statement about the rendered wall bodies, and reproducing the mitre /
|
||||
inset pipeline in Python would be a second geometry implementation whose drift
|
||||
is a larger risk than the rule it guards. A document that violates only П5 is
|
||||
ugly, not corrupt — see docs/specs/329-junction-limits.md §5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import logging
|
||||
|
||||
from .wall_segment_model import (
|
||||
WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MIN_JUNCTION_ANGLE_DEG = 15.0
|
||||
MAX_JUNCTION_VALENCE = 6
|
||||
MIN_SEGMENT_LENGTH_CM = 20.0
|
||||
MIN_NODE_DISTANCE_CM = 5.0
|
||||
|
||||
GRID_STEP_N = 1 / 240
|
||||
|
||||
_EPS = 1e-9
|
||||
# #331 §2.1: below this two points are ONE node / a node is ON the wall —
|
||||
# floating debris of pre-canonicalisation arithmetic, not a near miss.
|
||||
_INCIDENT_EPS = 2e-7
|
||||
_KEY_FACTOR = 1e7
|
||||
|
||||
|
||||
class JunctionLimitError(ValueError):
|
||||
"""A write introduced a wall-junction violation (#329)."""
|
||||
|
||||
def __init__(self, space_id: str, rule: str, subject: str,
|
||||
actual: float, limit: float) -> None:
|
||||
self.space_id = space_id
|
||||
self.rule = rule
|
||||
self.subject = subject
|
||||
self.actual = actual
|
||||
self.limit = limit
|
||||
self.code = f"junction_limit_{rule}"
|
||||
super().__init__(
|
||||
f"space={space_id}; rule={rule}; subject={subject}; "
|
||||
f"actual={actual:.12g}; limit={limit:.12g}"
|
||||
)
|
||||
|
||||
|
||||
def _finite_point(point: object) -> bool:
|
||||
return (
|
||||
isinstance(point, (list, tuple))
|
||||
and len(point) >= 2
|
||||
and all(isinstance(value, (int, float)) and math.isfinite(value)
|
||||
for value in point[:2])
|
||||
)
|
||||
|
||||
|
||||
def _quantize_key_coord(value: float) -> float:
|
||||
"""#331 §2.1: the canonicalisation formula — sign*floor(|v|*f+0.5)/f.
|
||||
|
||||
Native round() is banker's rounding and parts ways with JS Math-style
|
||||
rounding on .5 ticks; coordinate canonicalisation already encodes this
|
||||
parity lesson, and node keys must follow it. -0 normalises to 0.
|
||||
"""
|
||||
rounded = math.copysign(
|
||||
math.floor(abs(value) * _KEY_FACTOR + 0.5), value,
|
||||
) / _KEY_FACTOR
|
||||
return 0.0 if rounded == 0 else rounded
|
||||
|
||||
|
||||
def _key(point) -> str:
|
||||
return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}"
|
||||
|
||||
|
||||
def _length(a, b) -> float:
|
||||
return math.hypot(b[0] - a[0], b[1] - a[1])
|
||||
|
||||
|
||||
def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> float:
|
||||
return (cm / (cell_cm or 1)) * grid_pitch
|
||||
|
||||
|
||||
def limit_segments(space: dict) -> list[dict]:
|
||||
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
|
||||
segments: list[dict] = []
|
||||
for segment in space.get("wall_segments") or []:
|
||||
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
|
||||
segments.append({
|
||||
"id": str(segment.get("id") or ""),
|
||||
"a": segment["a"], "b": segment["b"],
|
||||
"cm": float(segment.get("cm") or 0),
|
||||
})
|
||||
for partition in space.get("partitions") or []:
|
||||
if _finite_point(partition.get("a")) and _finite_point(partition.get("b")):
|
||||
segments.append({
|
||||
"id": str(partition.get("id") or ""),
|
||||
"a": partition["a"], "b": partition["b"],
|
||||
"cm": float(partition.get("cm") or 0),
|
||||
})
|
||||
for draft in space.get("room_drafts") or []:
|
||||
points = draft.get("points") or []
|
||||
drafted = draft.get("segments") or []
|
||||
for index in range(max(len(points) - 1, 0)):
|
||||
if not (_finite_point(points[index]) and _finite_point(points[index + 1])):
|
||||
continue
|
||||
piece = drafted[index] if index < len(drafted) else {}
|
||||
segments.append({
|
||||
"id": str((piece or {}).get("id")
|
||||
or f"{draft.get('id') or 'draft'}-{index}"),
|
||||
"a": points[index], "b": points[index + 1],
|
||||
"cm": float((piece or {}).get("cm") or 0),
|
||||
})
|
||||
return [segment for segment in segments
|
||||
if _length(segment["a"], segment["b"]) > _EPS]
|
||||
|
||||
|
||||
def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
|
||||
"""П1 + П2: valence of a node and the narrowest wedge in it."""
|
||||
rays: dict[str, list[float]] = {}
|
||||
for segment in segments:
|
||||
for start, end in ((segment["a"], segment["b"]), (segment["b"], segment["a"])):
|
||||
rays.setdefault(_key(start), []).append(
|
||||
math.atan2(end[1] - start[1], end[0] - start[0])
|
||||
)
|
||||
violations: list[tuple[str, str, float, float]] = []
|
||||
for node, angles in rays.items():
|
||||
if len(angles) > MAX_JUNCTION_VALENCE:
|
||||
violations.append(
|
||||
("valence", node, float(len(angles)), float(MAX_JUNCTION_VALENCE))
|
||||
)
|
||||
if len(angles) < 2:
|
||||
continue
|
||||
ordered = sorted(angles)
|
||||
smallest = math.inf
|
||||
for index, value in enumerate(ordered):
|
||||
delta = ordered[(index + 1) % len(ordered)] - value
|
||||
if index == len(ordered) - 1:
|
||||
delta += math.pi * 2
|
||||
degrees = (delta * 180) / math.pi
|
||||
# A ~0° pair is NOT a violation — and cannot be (#331 revision 4,
|
||||
# learned in the field): a shared wall of two adjacent rooms is
|
||||
# two co-located owner atoms ON ONE LINE, so every shared-wall
|
||||
# node carries a legitimate 0° pair by construction. An exact
|
||||
# duplicate stays a KNOWN LIMITATION of П1 at this level.
|
||||
if _EPS < degrees < smallest:
|
||||
smallest = degrees
|
||||
if smallest < MIN_JUNCTION_ANGLE_DEG - 1e-9:
|
||||
violations.append(("angle", node, smallest, MIN_JUNCTION_ANGLE_DEG))
|
||||
return violations
|
||||
|
||||
|
||||
def _axis_degrees(segment: dict) -> float:
|
||||
degrees = math.degrees(math.atan2(
|
||||
segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0]
|
||||
))
|
||||
return (degrees % 180 + 180) % 180
|
||||
|
||||
|
||||
def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
|
||||
delta = abs(_axis_degrees(left) - _axis_degrees(right))
|
||||
return min(delta, 180 - delta) <= tolerance_deg
|
||||
|
||||
|
||||
def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
|
||||
by_node: dict[str, list[dict]] = {}
|
||||
for item in segments:
|
||||
for point in (item["a"], item["b"]):
|
||||
by_node.setdefault(_key(point), []).append(item)
|
||||
return by_node
|
||||
|
||||
|
||||
def collinear_run_length_units(
|
||||
segment: dict, segments: list[dict],
|
||||
by_node_index: dict[str, list[dict]] | None = None,
|
||||
) -> float:
|
||||
"""Length of the WALL a segment belongs to, not of the atom.
|
||||
|
||||
#331 §2.3/§2.4: an iterative edge walk over the collinear component — no
|
||||
recursion (a 10 000-atom chain must answer, not overflow), no
|
||||
combinatorial DFS (every atom joins at most once, O(E)), no silently
|
||||
dropped fork. Collinearity is measured against the BASE segment's axis,
|
||||
so an arc of small per-atom turns cannot pose as one straight wall.
|
||||
|
||||
#330 §4.3: the per-check caller builds the node index once.
|
||||
"""
|
||||
by_node = by_node_index if by_node_index is not None else _build_node_index(segments)
|
||||
visited = {id(segment)}
|
||||
total = _length(segment["a"], segment["b"])
|
||||
frontier = [segment["a"], segment["b"]]
|
||||
base_cm = float(segment.get("cm") or 0)
|
||||
while frontier:
|
||||
node = frontier.pop()
|
||||
for candidate in by_node.get(_key(node), ()):
|
||||
if id(candidate) in visited:
|
||||
continue
|
||||
if not _collinear(candidate, segment):
|
||||
continue
|
||||
if float(candidate.get("cm") or 0) != base_cm:
|
||||
continue
|
||||
visited.add(id(candidate))
|
||||
total += _length(candidate["a"], candidate["b"])
|
||||
frontier.append(candidate["a"])
|
||||
frontier.append(candidate["b"])
|
||||
return total
|
||||
|
||||
def check_segment_lengths(
|
||||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
) -> list[tuple[str, str, float, float]]:
|
||||
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
|
||||
violations: list[tuple[str, str, float, float]] = []
|
||||
by_node = _build_node_index(segments)
|
||||
for segment in segments:
|
||||
units = collinear_run_length_units(segment, segments, by_node)
|
||||
cm = (units / grid_pitch) * (cell_cm or 1)
|
||||
thickness = float(segment.get("cm") or 0)
|
||||
limit = max(MIN_SEGMENT_LENGTH_CM, thickness if thickness > 0 else 0.0)
|
||||
if cm < limit - 1e-9:
|
||||
violations.append((
|
||||
"length", str(segment.get("id") or _key(segment["a"])), cm, limit,
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def _distance_to_segment(point, a, b) -> float:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length_sq = dx * dx + dy * dy
|
||||
t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, (
|
||||
(point[0] - a[0]) * dx + (point[1] - a[1]) * dy
|
||||
) / length_sq))
|
||||
return math.hypot(point[0] - (a[0] + dx * t), point[1] - (a[1] + dy * t))
|
||||
|
||||
|
||||
def check_node_distances(
|
||||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
) -> list[tuple[str, str, float, float]]:
|
||||
"""П4: non-incident nodes and node-to-foreign-wall clearance.
|
||||
|
||||
#330 §4.5: the all-pairs form cost 372 ms on 576 atoms. Nodes and padded
|
||||
segment bounding boxes are hashed into a grid with the threshold as cell
|
||||
size; each node is compared only against its 9-cell neighbourhood.
|
||||
Verdicts are identical — pinned by the equivalence unit and the parity
|
||||
suite. Pair order inside the subject follows the lexicographic key order,
|
||||
which replaces the i<j of the all-pairs loop.
|
||||
"""
|
||||
nodes: dict[str, list] = {}
|
||||
for segment in segments:
|
||||
nodes[_key(segment["a"])] = segment["a"]
|
||||
nodes[_key(segment["b"])] = segment["b"]
|
||||
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
|
||||
size = min_units if min_units > _EPS else 1.0
|
||||
|
||||
node_grid: dict[tuple[int, int], list] = {}
|
||||
for node_key, point in nodes.items():
|
||||
node_grid.setdefault(
|
||||
(int(point[0] // size), int(point[1] // size)), []
|
||||
).append((node_key, point))
|
||||
segment_grid: dict[tuple[int, int], list] = {}
|
||||
for segment in segments:
|
||||
x0 = min(segment["a"][0], segment["b"][0]) - min_units
|
||||
x1 = max(segment["a"][0], segment["b"][0]) + min_units
|
||||
y0 = min(segment["a"][1], segment["b"][1]) - min_units
|
||||
y1 = max(segment["a"][1], segment["b"][1]) + min_units
|
||||
for cx in range(int(x0 // size), int(x1 // size) + 1):
|
||||
for cy in range(int(y0 // size), int(y1 // size) + 1):
|
||||
segment_grid.setdefault((cx, cy), []).append(segment)
|
||||
|
||||
violations: list[tuple[str, str, float, float]] = []
|
||||
for node_key, point in nodes.items():
|
||||
cx, cy = int(point[0] // size), int(point[1] // size)
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
for other_key, other in node_grid.get((cx + dx, cy + dy), ()):
|
||||
if node_key >= other_key:
|
||||
continue
|
||||
distance = _length(point, other)
|
||||
# #331 §2.1: raw debris within the incidence quantum is ONE
|
||||
# node on two neighbouring keys — never a near miss.
|
||||
if distance <= _INCIDENT_EPS:
|
||||
continue
|
||||
if distance < min_units - 1e-9:
|
||||
violations.append((
|
||||
"distance", f"{node_key} ↔ {other_key}",
|
||||
(distance / grid_pitch) * (cell_cm or 1),
|
||||
MIN_NODE_DISTANCE_CM,
|
||||
))
|
||||
for segment in segment_grid.get((cx, cy), ()):
|
||||
# A node that belongs to the wall is a legal corner or T-joint.
|
||||
if _key(segment["a"]) == node_key or _key(segment["b"]) == node_key:
|
||||
continue
|
||||
distance = _distance_to_segment(point, segment["a"], segment["b"])
|
||||
# Sitting exactly ON the wall is the other legal incidence.
|
||||
if distance <= _INCIDENT_EPS:
|
||||
continue
|
||||
if distance < min_units - 1e-9:
|
||||
violations.append((
|
||||
"distance",
|
||||
f"{node_key} → {segment.get('id') or _key(segment['a'])}",
|
||||
(distance / grid_pitch) * (cell_cm or 1),
|
||||
MIN_NODE_DISTANCE_CM,
|
||||
))
|
||||
return violations
|
||||
|
||||
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
|
||||
"""П1–П4 over one space, in the frontend's order."""
|
||||
segments = limit_segments(space)
|
||||
cell_cm = float(space.get("cell_cm") or 1)
|
||||
return [
|
||||
*check_nodes(segments),
|
||||
*check_segment_lengths(segments, cell_cm),
|
||||
*check_node_distances(segments, cell_cm),
|
||||
]
|
||||
|
||||
|
||||
def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]:
|
||||
"""Spaces of one document AFTER the wall-segment migration, by id.
|
||||
|
||||
The limits read `wall_segments`, so a document that predates the catalogue
|
||||
reports NO walls at all — a legacy space would answer "no violations"
|
||||
regardless of its geometry. Comparing such a baseline against a candidate
|
||||
the client already migrated counts every inherited violation as new and
|
||||
refuses an unrelated edit (spec §3 forbids exactly that). Both sides are
|
||||
therefore judged after the SAME migration, mirroring the frontend barrier.
|
||||
|
||||
A document that cannot be migrated is not a reason to refuse the write:
|
||||
the wall-model barrier owns that verdict and reports it with its own code.
|
||||
Here it simply means there is no baseline to inherit from.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
# #330 §4.6: a document that already carries the current catalogue is
|
||||
# used as-is. The migration's purpose here is to never compare a
|
||||
# catalogue-less legacy baseline against a migrated candidate; a
|
||||
# current-version document has the catalogue, so the purpose is met with
|
||||
# zero work — while a no-op re-migration costs 0.8 s of _atomize on a
|
||||
# 576-atom plan. Equivalence "as-is == migrated" for current-version
|
||||
# documents is pinned by a parity case.
|
||||
if int(config.get("model_version") or 0) >= WALL_SEGMENT_MODEL_VERSION:
|
||||
migrated = config
|
||||
else:
|
||||
try:
|
||||
migrated, _ = commit_wall_segment_model(config)
|
||||
except (WallSegmentMigrationError, ValueError) as error:
|
||||
# The wall-model barrier owns this verdict and reports it with its
|
||||
# own code; here it merely means "no catalogue to read".
|
||||
_LOGGER.debug("junction limits: %s migration fell back: %s", side, error)
|
||||
migrated = config
|
||||
except Exception:
|
||||
# #331 §2.6 (AC6): a genuine migration bug on the CANDIDATE side
|
||||
# must surface as an honest WS error, not a silent "no
|
||||
# violations". The PREVIOUS side keeps the wide fallback — an
|
||||
# unprovable inheritance is no reason to block an unrelated write
|
||||
# (the exact P1 symptom this task fixes).
|
||||
if side == "candidate":
|
||||
raise
|
||||
_LOGGER.debug("junction limits: previous-side migration bug swallowed by design")
|
||||
migrated = config
|
||||
return {
|
||||
str(space.get("id")): space
|
||||
for space in (migrated or {}).get("spaces") or []
|
||||
if isinstance(space, dict)
|
||||
}
|
||||
|
||||
|
||||
def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]:
|
||||
"""Violation counts per space per rule — the shape the barrier consumes
|
||||
and the rev cache (#330 §4.2) stores. Documents are NOT retained."""
|
||||
result: dict[str, dict[str, int]] = {}
|
||||
for space_id, space in spaces.items():
|
||||
counts: dict[str, int] = {}
|
||||
for rule, _subject, _actual, _limit in space_violations(space):
|
||||
counts[rule] = counts.get(rule, 0) + 1
|
||||
result[space_id] = counts
|
||||
return result
|
||||
|
||||
|
||||
def validate_junction_limits(
|
||||
config: dict, previous: dict | None = None, *,
|
||||
baseline_counts: dict[str, dict[str, int]] | None = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Refuse a write that ADDS a junction violation; inherit the rest.
|
||||
|
||||
Counted per RULE, not per subject: a structural write re-atomises and
|
||||
re-keys contour segments, so subject identity is not stable across the
|
||||
barrier and matching by it would report an inherited violation as new
|
||||
(the mistake that refused legitimate resizes on the frontend). Spec §3:
|
||||
a write may keep existing violations, it may never add one.
|
||||
|
||||
A LEGACY document still goes through `commit_wall_segment_model` first,
|
||||
so a catalogue-less baseline is compared in the same terms as the
|
||||
candidate; a current-version document is used as-is (#330 §4.6).
|
||||
|
||||
`baseline_counts` (#330 §4.2) replaces the previous document entirely:
|
||||
the caller caches the counts of the stored document by config_rev, so a
|
||||
repeated write does not even touch `previous`. Returns the candidate's
|
||||
counts — the value to cache after a successful write.
|
||||
"""
|
||||
if baseline_counts is not None:
|
||||
old_counts = baseline_counts
|
||||
else:
|
||||
old_counts = space_violation_counts(_migrated_spaces(previous))
|
||||
new_spaces = _migrated_spaces(config, side="candidate")
|
||||
candidate_counts: dict[str, dict[str, int]] = {}
|
||||
for space_id, space in new_spaces.items():
|
||||
# A brand-new space has nothing to inherit from — but neither is a
|
||||
# first write allowed to arrive already broken.
|
||||
before = old_counts.get(space_id) or {}
|
||||
after: dict[str, list[tuple[str, str, float, float]]] = {}
|
||||
for violation in space_violations(space):
|
||||
after.setdefault(violation[0], []).append(violation)
|
||||
candidate_counts[space_id] = {
|
||||
rule: len(items) for rule, items in after.items()
|
||||
}
|
||||
for rule, items in after.items():
|
||||
if len(items) > before.get(rule, 0):
|
||||
_, subject, actual, limit = items[0]
|
||||
raise JunctionLimitError(space_id, rule, subject, actual, limit)
|
||||
return candidate_counts
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.64.0"
|
||||
"version": "1.69.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -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__)
|
||||
@@ -97,6 +101,11 @@ class HouseplanData:
|
||||
# Parsed import candidates are short-lived, user-bound and memory-only.
|
||||
# dict keeps insertion order, which lets the preview service evict oldest.
|
||||
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# #330 §4.2: junction-limit violation counts of the STORED document,
|
||||
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
|
||||
# memory-only, invalidated by a rev mismatch; the previous document is
|
||||
# not re-judged on every write. Counts only — documents are never held.
|
||||
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
@@ -157,7 +166,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 +214,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 +226,7 @@ async def async_save_config_state(
|
||||
try:
|
||||
await async_reconcile_virtual_lights(
|
||||
runtime.virtual_light_store,
|
||||
config,
|
||||
canonical_config,
|
||||
rev,
|
||||
previous_config_rev=previous_rev,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -24,11 +25,33 @@ import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
|
||||
"""Whether an ended current run may be reopened for this point.
|
||||
|
||||
Store timestamps are untrusted persisted data. Only finite JSON-number
|
||||
timestamps and a non-negative inclusive grace interval are accepted;
|
||||
malformed values and wall-clock rollback fail closed into a new run.
|
||||
"""
|
||||
if not isinstance(run, dict) or run.get("map_id") != map_id:
|
||||
return False
|
||||
ended = run.get("ended")
|
||||
if (
|
||||
isinstance(ended, bool)
|
||||
or not isinstance(ended, (int, float))
|
||||
or isinstance(now, bool)
|
||||
or not isinstance(now, (int, float))
|
||||
):
|
||||
return False
|
||||
elapsed = now - ended
|
||||
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
@@ -64,7 +87,10 @@ class TrailBook:
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
|
||||
if resumed:
|
||||
cur["ended"] = None
|
||||
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
@@ -73,7 +99,9 @@ class TrailBook:
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
# Clearing ended is observable state even if the source repeats
|
||||
# the dock point: it must still reach Store and live cards.
|
||||
return resumed
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
@@ -85,7 +113,7 @@ class TrailBook:
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
if cur and cur.get("ended") is None:
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
@@ -159,8 +187,11 @@ class TrailRecorder:
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
# next state change — otherwise the first seconds of the path are
|
||||
# lost.
|
||||
now = time.time()
|
||||
changed = False
|
||||
for src in self.pairs:
|
||||
self._sample(src, time.time())
|
||||
changed |= self._sample(src, now)
|
||||
self._handle_sample_change(changed, now)
|
||||
|
||||
def _source_failure_reason(self, source: str) -> str | None:
|
||||
"""Classify only refresh-time health evidence.
|
||||
@@ -216,25 +247,70 @@ class TrailRecorder:
|
||||
|
||||
async def async_delete(self, marker: str) -> bool:
|
||||
"""Stop and erase one marker without racing subscription refresh/save."""
|
||||
return bool(await self._async_delete_many({marker}))
|
||||
|
||||
async def async_purge_orphans(self, config: dict[str, Any]) -> int:
|
||||
"""Erase trails whose marker is absent or a removal tombstone.
|
||||
|
||||
A tombstone deliberately stays in config so discovery cannot resurrect
|
||||
a deleted device. For live tracking and trail ownership it is absent:
|
||||
this is the same boundary used by ``async_refresh`` above.
|
||||
"""
|
||||
live_marker_ids = {
|
||||
str(marker.get("id"))
|
||||
for marker in config.get("markers") or []
|
||||
if marker.get("id") is not None and marker.get("removed") is not True
|
||||
}
|
||||
orphan_ids = set(self.book.data) - live_marker_ids
|
||||
if not orphan_ids:
|
||||
return 0
|
||||
try:
|
||||
return await self._async_delete_many(orphan_ids)
|
||||
except Exception: # noqa: BLE001 — config commit already succeeded
|
||||
_LOGGER.exception(
|
||||
"House Plan: removing orphan vacuum trails failed: markers=%s",
|
||||
sorted(orphan_ids),
|
||||
)
|
||||
return 0
|
||||
|
||||
async def _async_delete_many(self, markers: set[str]) -> int:
|
||||
"""Delete one or more books with one subscription/store transaction."""
|
||||
async with self._refresh_lock:
|
||||
# The trail book owns deletion. When it has no such marker, this
|
||||
# is a no-op and must not silently damage the live tracking graph.
|
||||
removed = self.book.delete(marker)
|
||||
removed = {
|
||||
marker: self.book.data.pop(marker)
|
||||
for marker in markers
|
||||
if marker in self.book.data
|
||||
}
|
||||
if not removed:
|
||||
return False
|
||||
for src in list(self.pairs):
|
||||
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
|
||||
if kept:
|
||||
self.pairs[src] = kept
|
||||
else:
|
||||
del self.pairs[src]
|
||||
self._resubscribe()
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
return 0
|
||||
previous_pairs = {src: list(pairs) for src, pairs in self.pairs.items()}
|
||||
had_pending_save = self._unsub_save is not None
|
||||
try:
|
||||
for src in list(self.pairs):
|
||||
kept = [pair for pair in self.pairs[src] if pair[0] not in removed]
|
||||
if kept:
|
||||
self.pairs[src] = kept
|
||||
else:
|
||||
del self.pairs[src]
|
||||
self._resubscribe()
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
except Exception:
|
||||
# The store is the durable authority. Restore the in-memory
|
||||
# owner graph so the next successful config sync can retry
|
||||
# instead of leaving an orphan on disk forever (#335).
|
||||
self.book.data.update(removed)
|
||||
self.pairs = previous_pairs
|
||||
self._resubscribe()
|
||||
if had_pending_save:
|
||||
self._schedule_save()
|
||||
raise
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
return True
|
||||
return len(removed)
|
||||
|
||||
def _resubscribe(self) -> None:
|
||||
"""Replace the state subscription for the current pair graph."""
|
||||
@@ -316,6 +392,10 @@ class TrailRecorder:
|
||||
for src, pair_list in self.pairs.items():
|
||||
if eid == src or any(eid == vac for _, vac in pair_list):
|
||||
changed |= self._sample(src, now)
|
||||
self._handle_sample_change(changed, now)
|
||||
|
||||
def _handle_sample_change(self, changed: bool, now: float) -> None:
|
||||
"""Persist and announce one logical sampling pass when it changed."""
|
||||
if changed:
|
||||
self._schedule_save()
|
||||
if now - self._last_fire >= FIRE_THROTTLE_S:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
"admin_only": "Nur Administratoren dürfen den Grundriss bearbeiten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"admin_only": "Nur Administratoren dürfen den Grundriss bearbeiten"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"broken_plan": {
|
||||
"title": "Grundrissbild fehlt",
|
||||
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
"""Deterministic persisted contour-wall identity (model v9, issues #282/#306).
|
||||
|
||||
This is the backend twin of ``src/wall-segment-model.ts``. Import preview is
|
||||
a server-side structural writer, so it cannot depend on a browser being open
|
||||
to upgrade v7 backups before validating/remapping them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import math
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from .coordinate_canonicalization import canonicalize_config_geometry
|
||||
|
||||
|
||||
WALL_SEGMENT_MODEL_VERSION = 9
|
||||
GRID_STEP_N = 1 / 240
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
class WallSegmentMigrationError(ValueError):
|
||||
"""The candidate cannot be upgraded without guessing ownership."""
|
||||
|
||||
code = "wall_model_migration_blocked"
|
||||
|
||||
def __init__(self, reason: str, detail: str = "") -> None:
|
||||
self.reason = reason
|
||||
super().__init__(f"{reason}: {detail}" if detail else reason)
|
||||
|
||||
|
||||
def _point_key(point: list[float]) -> str:
|
||||
return f"{float(point[0]):.12f},{float(point[1]):.12f}"
|
||||
|
||||
|
||||
def _span_key(a: list[float], b: list[float]) -> str:
|
||||
ka, kb = _point_key(a), _point_key(b)
|
||||
return f"{ka}|{kb}" if ka < kb else f"{kb}|{ka}"
|
||||
|
||||
|
||||
def _canonical_span(a: list[float], b: list[float]) -> tuple[list[float], list[float]]:
|
||||
left, right = ([float(a[0]), float(a[1])], [float(b[0]), float(b[1])])
|
||||
return (left, right) if _point_key(left) <= _point_key(right) else (right, left)
|
||||
|
||||
|
||||
def _length(a: list[float], b: list[float]) -> float:
|
||||
return math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1]))
|
||||
|
||||
|
||||
def _project_t(point: list[float], a: list[float], b: list[float]) -> float:
|
||||
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
|
||||
denominator = dx * dx + dy * dy
|
||||
if denominator <= EPS * EPS:
|
||||
return 0
|
||||
return ((float(point[0]) - float(a[0])) * dx
|
||||
+ (float(point[1]) - float(a[1])) * dy) / denominator
|
||||
|
||||
|
||||
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
|
||||
t = max(0.0, min(1.0, _project_t(point, a, b)))
|
||||
return math.hypot(
|
||||
float(point[0]) - (float(a[0]) + (float(b[0]) - float(a[0])) * t),
|
||||
float(point[1]) - (float(a[1]) + (float(b[1]) - float(a[1])) * t),
|
||||
)
|
||||
|
||||
|
||||
def _collinear_overlap(
|
||||
a: list[float], b: list[float], c: list[float], d: list[float], epsilon: float = EPS,
|
||||
) -> float:
|
||||
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
|
||||
length = math.hypot(dx, dy)
|
||||
if length <= epsilon:
|
||||
return 0
|
||||
cross_c = abs((float(c[0]) - float(a[0])) * dy
|
||||
- (float(c[1]) - float(a[1])) * dx) / length
|
||||
cross_d = abs((float(d[0]) - float(a[0])) * dy
|
||||
- (float(d[1]) - float(a[1])) * dx) / length
|
||||
if cross_c > epsilon or cross_d > epsilon:
|
||||
return 0
|
||||
tc, td = _project_t(c, a, b), _project_t(d, a, b)
|
||||
return max(0.0, min(1.0, max(tc, td)) - max(0.0, min(tc, td))) * length
|
||||
|
||||
|
||||
def _room_poly(room: dict[str, Any]) -> list[list[float]]:
|
||||
poly = room.get("poly")
|
||||
if isinstance(poly, list) and len(poly) >= 3:
|
||||
return [[float(point[0]), float(point[1])] for point in poly]
|
||||
if all(isinstance(room.get(key), (int, float)) and not isinstance(room.get(key), bool)
|
||||
for key in ("x", "y", "w", "h")):
|
||||
x, y, width, height = (float(room[key]) for key in ("x", "y", "w", "h"))
|
||||
return [[x, y], [x + width, y], [x + width, y + height], [x, y + height]]
|
||||
return []
|
||||
|
||||
|
||||
def _shared_boundaries(first: list[list[float]], second: list[list[float]]) -> list[list[float]]:
|
||||
result: list[list[float]] = []
|
||||
epsilon = GRID_STEP_N * 0.04
|
||||
for index, start in enumerate(first):
|
||||
end = first[(index + 1) % len(first)]
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length < epsilon:
|
||||
continue
|
||||
ux, uy = dx / length, dy / length
|
||||
for other_index, other_start in enumerate(second):
|
||||
other_end = second[(other_index + 1) % len(second)]
|
||||
tolerance = max(epsilon, length * 1e-6)
|
||||
distances = [
|
||||
abs((point[0] - start[0]) * uy - (point[1] - start[1]) * ux)
|
||||
for point in (other_start, other_end)
|
||||
]
|
||||
if max(distances) > tolerance:
|
||||
continue
|
||||
t1 = (other_start[0] - start[0]) * ux + (other_start[1] - start[1]) * uy
|
||||
t2 = (other_end[0] - start[0]) * ux + (other_end[1] - start[1]) * uy
|
||||
lo, hi = max(0.0, min(t1, t2)), min(length, max(t1, t2))
|
||||
if hi - lo > epsilon:
|
||||
result.append([
|
||||
start[0] + ux * lo, start[1] + uy * lo,
|
||||
start[0] + ux * hi, start[1] + uy * hi,
|
||||
])
|
||||
return result
|
||||
|
||||
|
||||
def _wall_direction(a: list[float], b: list[float]) -> tuple[float, float]:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 1e-12:
|
||||
return 1.0, 0.0
|
||||
dx, dy = dx / length, dy / length
|
||||
if dx < -1e-12 or (abs(dx) <= 1e-12 and dy < 0):
|
||||
dx, dy = -dx, -dy
|
||||
return dx, dy
|
||||
|
||||
|
||||
def _js_round(value: float) -> int:
|
||||
return math.floor(value + 0.5)
|
||||
|
||||
|
||||
def _quantize(value: float, pitch: float = GRID_STEP_N) -> float:
|
||||
return _js_round(value / pitch) * pitch
|
||||
|
||||
|
||||
def _wall_key(a: list[float], b: list[float]) -> str:
|
||||
midpoint_x = _quantize((float(a[0]) + float(b[0])) / 2)
|
||||
midpoint_y = _quantize((float(a[1]) + float(b[1])) / 2)
|
||||
dx, dy = _wall_direction(a, b)
|
||||
angle = math.atan2(dy, dx)
|
||||
if angle < 0:
|
||||
angle += math.pi
|
||||
angle = _js_round(angle * 1800) / 1800
|
||||
return f"{midpoint_x:.6f},{midpoint_y:.6f}@{angle:.4f}"
|
||||
|
||||
|
||||
def deterministic_wall_segment_id(
|
||||
space_id: str, a: list[float], b: list[float], owners: list[str], salt: str = "",
|
||||
) -> str:
|
||||
ca, cb = _canonical_span(a, b)
|
||||
seed = (f"{space_id}|{_point_key(ca)}|{_point_key(cb)}|"
|
||||
f"{','.join(sorted(owners))}{salt}")
|
||||
encoded = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
|
||||
return f"wall-{encoded[:20]}"
|
||||
|
||||
|
||||
def _translated_segment_delta(
|
||||
a: list[float], b: list[float], previous: dict[str, Any],
|
||||
) -> tuple[float, float] | None:
|
||||
def matches(pa: list[float], pb: list[float]) -> tuple[float, float] | None:
|
||||
dx, dy = a[0] - pa[0], a[1] - pa[1]
|
||||
if abs((b[0] - pb[0]) - dx) <= EPS and abs((b[1] - pb[1]) - dy) <= EPS:
|
||||
return dx, dy
|
||||
return None
|
||||
|
||||
return matches(previous["a"], previous["b"]) or matches(previous["b"], previous["a"])
|
||||
|
||||
|
||||
def _atomize(
|
||||
space: dict[str, Any], old: dict[str, dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
rooms = space.get("rooms") or []
|
||||
room_polys = {str(room.get("id", "")): _room_poly(room) for room in rooms}
|
||||
global_breaks: list[list[float]] = []
|
||||
explicit_spans = space.get("open_spans") or []
|
||||
legacy_segments: list[list[float]] = []
|
||||
for span in explicit_spans:
|
||||
if isinstance(span, dict) and isinstance(span.get("a"), list) and isinstance(span.get("b"), list):
|
||||
global_breaks.extend((span["a"], span["b"]))
|
||||
legacy_segments.append([
|
||||
float(span["a"][0]), float(span["a"][1]),
|
||||
float(span["b"][0]), float(span["b"][1]),
|
||||
])
|
||||
if not legacy_segments:
|
||||
linked = lambda first, second: (
|
||||
str(second.get("id", "")) in (first.get("open_to") or [])
|
||||
or str(first.get("id", "")) in (second.get("open_to") or [])
|
||||
)
|
||||
for first_index, first in enumerate(rooms):
|
||||
for second in rooms[first_index + 1:]:
|
||||
if not linked(first, second):
|
||||
continue
|
||||
for shared in _shared_boundaries(
|
||||
room_polys.get(str(first.get("id", "")), []),
|
||||
room_polys.get(str(second.get("id", "")), []),
|
||||
):
|
||||
legacy_segments.append(shared)
|
||||
global_breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
|
||||
canonical_zero_segments: list[list[float]] = []
|
||||
for room in rooms:
|
||||
poly = room_polys.get(str(room.get("id", ""))) or []
|
||||
wall_ids = room.get("wall_ids") or []
|
||||
if len(wall_ids) != len(poly):
|
||||
continue
|
||||
for index, segment_id in enumerate(wall_ids):
|
||||
previous = old.get(str(segment_id)) if isinstance(segment_id, str) else None
|
||||
if previous is None or float(previous.get("cm", 0)) != 0:
|
||||
continue
|
||||
following = (index + 1) % len(poly)
|
||||
canonical_zero_segments.append([
|
||||
float(poly[index][0]), float(poly[index][1]),
|
||||
float(poly[following][0]), float(poly[following][1]),
|
||||
])
|
||||
for segment in canonical_zero_segments:
|
||||
global_breaks.extend((segment[:2], segment[2:]))
|
||||
zero_segments = canonical_zero_segments + legacy_segments
|
||||
|
||||
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
|
||||
# carries an existing contour opening; its edges become atom boundaries so
|
||||
# the zero run continues on both sides of the opening.
|
||||
legacy_era_openings: list[dict[str, Any]] = []
|
||||
if legacy_segments:
|
||||
for opening in space.get("openings") or []:
|
||||
if not isinstance(opening, dict):
|
||||
continue
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
continue
|
||||
try:
|
||||
centre = [float(opening["x"]), float(opening["y"])]
|
||||
angle = float(opening["angle"])
|
||||
half = float(opening["length"]) / 2
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if half <= 0:
|
||||
continue
|
||||
# Only an opening that actually stands on a legacy cut changes the
|
||||
# atomization; unrelated openings must not churn the catalogue.
|
||||
if not any(
|
||||
_distance_to_segment(centre, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in legacy_segments
|
||||
):
|
||||
continue
|
||||
legacy_era_openings.append(
|
||||
{"centre": centre, "angle": angle, "half": half}
|
||||
)
|
||||
direction = (math.cos(math.radians(angle)), math.sin(math.radians(angle)))
|
||||
global_breaks.extend((
|
||||
[centre[0] - direction[0] * half, centre[1] - direction[1] * half],
|
||||
[centre[0] + direction[0] * half, centre[1] + direction[1] * half],
|
||||
))
|
||||
|
||||
def _atom_carries_opening(a: list[float], b: list[float]) -> bool:
|
||||
for entry in legacy_era_openings:
|
||||
if not _angle_matches(a, b, entry["angle"]):
|
||||
continue
|
||||
if _distance_to_segment(entry["centre"], a, b) > GRID_STEP_N * 0.02:
|
||||
continue
|
||||
span = _length(a, b)
|
||||
tc = _project_t(entry["centre"], a, b) * span
|
||||
if tc + entry["half"] >= -EPS and tc - entry["half"] <= span + EPS:
|
||||
return True
|
||||
return False
|
||||
|
||||
for wall in space.get("walls") or []:
|
||||
if isinstance(wall, dict) and isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
|
||||
global_breaks.extend((wall["a"], wall["b"]))
|
||||
|
||||
atoms_by_key: dict[str, dict[str, Any]] = {}
|
||||
next_rooms: list[dict[str, Any]] = []
|
||||
epsilon = GRID_STEP_N * 0.04
|
||||
for raw_room in rooms:
|
||||
room_id = str(raw_room.get("id", ""))
|
||||
original = room_polys.get(room_id) or []
|
||||
if not room_id or len(original) < 3:
|
||||
raise WallSegmentMigrationError("invalid-room", room_id)
|
||||
breaks = list(global_breaks)
|
||||
for other_id, other_poly in room_polys.items():
|
||||
if other_id == room_id:
|
||||
continue
|
||||
for shared in _shared_boundaries(original, other_poly):
|
||||
breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
|
||||
|
||||
poly: list[list[float]] = []
|
||||
parents: list[int] = []
|
||||
for index, a in enumerate(original):
|
||||
b = original[(index + 1) % len(original)]
|
||||
poly.append(list(a))
|
||||
parents.append(index)
|
||||
length = _length(a, b)
|
||||
if length < epsilon * 2:
|
||||
raise WallSegmentMigrationError("zero-length", room_id)
|
||||
gap = min(0.499, epsilon * 2 / length)
|
||||
positions: list[float] = []
|
||||
for breakpoint in breaks:
|
||||
if _distance_to_segment(breakpoint, a, b) > epsilon:
|
||||
continue
|
||||
t = _project_t(breakpoint, a, b)
|
||||
if t <= gap or t >= 1 - gap:
|
||||
continue
|
||||
if any(abs(existing - t) * length <= epsilon * 2 for existing in positions):
|
||||
continue
|
||||
positions.append(t)
|
||||
for t in sorted(positions):
|
||||
poly.append([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
|
||||
parents.append(index)
|
||||
|
||||
old_ids = raw_room.get("wall_ids") if isinstance(raw_room.get("wall_ids"), list) else []
|
||||
indexed_lineage = len(old_ids) == len(original)
|
||||
rigid_delta: tuple[float, float] | None = None
|
||||
rigid_indexed_lineage = indexed_lineage
|
||||
if rigid_indexed_lineage:
|
||||
for index, a in enumerate(original):
|
||||
previous = old.get(old_ids[index])
|
||||
delta = _translated_segment_delta(
|
||||
a, original[(index + 1) % len(original)], previous,
|
||||
) if previous else None
|
||||
if delta is None or (rigid_delta is not None and (
|
||||
abs(delta[0] - rigid_delta[0]) > EPS
|
||||
or abs(delta[1] - rigid_delta[1]) > EPS
|
||||
)):
|
||||
rigid_indexed_lineage = False
|
||||
break
|
||||
rigid_delta = delta
|
||||
wall_keys: list[str] = []
|
||||
for index, a in enumerate(poly):
|
||||
b = poly[(index + 1) % len(poly)]
|
||||
key = _span_key(a, b)
|
||||
atom = atoms_by_key.setdefault(key, {
|
||||
"key": key, "a": _canonical_span(a, b)[0], "b": _canonical_span(a, b)[1],
|
||||
"owners": set(), "preferred": set(), "positional": set(),
|
||||
"preferred_carriers": {},
|
||||
"parent_keys": set(), "zero_wall": False,
|
||||
})
|
||||
atom["owners"].add(room_id)
|
||||
if len(atom["owners"]) > 2:
|
||||
raise WallSegmentMigrationError("third-owner", key)
|
||||
parent_index = parents[index]
|
||||
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
|
||||
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
covered_by = lambda cuts: any(
|
||||
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in cuts
|
||||
)
|
||||
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
|
||||
# that carries an opening (#316 §3.1).
|
||||
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
|
||||
covered_by(legacy_segments) and not _atom_carries_opening(a, b)
|
||||
)
|
||||
if indexed_lineage and isinstance(old_ids[parent_index], str) and old_ids[parent_index]:
|
||||
previous = old.get(old_ids[parent_index])
|
||||
if rigid_indexed_lineage or previous is None or _collinear_overlap(
|
||||
a, b, previous["a"], previous["b"]
|
||||
) > EPS:
|
||||
atom["preferred"].add(old_ids[parent_index])
|
||||
else:
|
||||
atom["positional"].add(old_ids[parent_index])
|
||||
atom["preferred_carriers"][old_ids[parent_index]] = {
|
||||
"a": list(original[parent_index]),
|
||||
"b": list(original[(parent_index + 1) % len(original)]),
|
||||
}
|
||||
wall_keys.append(key)
|
||||
next_room = {key: copy.deepcopy(value) for key, value in raw_room.items() if key != "wall_ids"}
|
||||
next_room["poly"] = poly
|
||||
next_room["wall_ids"] = wall_keys
|
||||
next_rooms.append(next_room)
|
||||
return sorted(atoms_by_key.values(), key=lambda atom: atom["key"]), next_rooms
|
||||
|
||||
|
||||
def _thickness(space: dict[str, Any], atom: dict[str, Any], previous: dict[str, Any] | None) -> float:
|
||||
if atom.get("zero_wall"):
|
||||
return 0.0
|
||||
candidates: list[float] = []
|
||||
query_key = _wall_key(atom["a"], atom["b"])
|
||||
query_length = _length(atom["a"], atom["b"])
|
||||
for wall in space.get("walls") or []:
|
||||
try:
|
||||
cm = float(wall.get("cm", 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if cm <= 0:
|
||||
continue
|
||||
exact_key = wall.get("key") == query_key or wall.get("key") in atom["parent_keys"]
|
||||
covers = False
|
||||
if isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
|
||||
overlap = _collinear_overlap(atom["a"], atom["b"], wall["a"], wall["b"])
|
||||
covers = overlap >= query_length - EPS
|
||||
if exact_key or covers:
|
||||
candidates.append(max(1.0, min(100.0, cm)))
|
||||
unique = {round(value, 9) for value in candidates}
|
||||
if len(unique) > 1:
|
||||
raise WallSegmentMigrationError("thickness-conflict", atom["key"])
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
if previous is not None and float(previous.get("cm", 0)) > 0:
|
||||
return float(previous["cm"])
|
||||
return 0.0
|
||||
|
||||
|
||||
def _non_catalog_ids(space: dict[str, Any]) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for name in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
|
||||
for item in space.get(name) or []:
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]:
|
||||
result.add(item["id"])
|
||||
for draft in space.get("room_drafts") or []:
|
||||
for segment in draft.get("segments") or []:
|
||||
if isinstance(segment, dict) and isinstance(segment.get("id"), str) and segment["id"]:
|
||||
result.add(segment["id"])
|
||||
return result
|
||||
|
||||
|
||||
def _fresh_wall_segment_id(used: set[str]) -> str:
|
||||
for _attempt in range(1000):
|
||||
segment_id = f"wall-{uuid.uuid4()}"
|
||||
if segment_id not in used:
|
||||
return segment_id
|
||||
raise WallSegmentMigrationError("duplicate-id", "id factory exhausted")
|
||||
|
||||
|
||||
def _assign_lineage(
|
||||
space: dict[str, Any], atoms: list[dict[str, Any]], old: dict[str, dict],
|
||||
initial_migration: bool,
|
||||
) -> None:
|
||||
old_by_key = {_span_key(segment["a"], segment["b"]): segment for segment in old.values()}
|
||||
host_counts: dict[str, int] = {}
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host") if isinstance(opening, dict) else None
|
||||
if isinstance(host, dict) and host.get("kind") == "wall":
|
||||
host_counts[str(host.get("id"))] = host_counts.get(str(host.get("id")), 0) + 1
|
||||
proposals: dict[str, dict[str, Any]] = {}
|
||||
for atom in atoms:
|
||||
if len(atom["preferred"]) > 1:
|
||||
raise WallSegmentMigrationError("duplicate-id", ",".join(sorted(atom["preferred"])))
|
||||
preferred_id = next(iter(atom["preferred"]), None)
|
||||
preferred = old.get(preferred_id) if preferred_id else None
|
||||
if preferred:
|
||||
proposals[atom["key"]] = preferred
|
||||
continue
|
||||
carrier = atom["preferred_carriers"].get(preferred_id) if preferred_id else None
|
||||
if preferred_id and carrier:
|
||||
proposals[atom["key"]] = {
|
||||
"id": preferred_id, "a": carrier["a"], "b": carrier["b"], "cm": 0,
|
||||
}
|
||||
continue
|
||||
if atom["key"] in old_by_key:
|
||||
proposals[atom["key"]] = old_by_key[atom["key"]]
|
||||
continue
|
||||
overlaps = [segment for segment in old.values()
|
||||
if _collinear_overlap(atom["a"], atom["b"], segment["a"], segment["b"]) > EPS]
|
||||
overlaps.sort(key=lambda segment: (
|
||||
-host_counts.get(str(segment["id"]), 0),
|
||||
-_length(segment["a"], segment["b"]), str(segment["id"]),
|
||||
))
|
||||
if overlaps:
|
||||
proposals[atom["key"]] = overlaps[0]
|
||||
elif len(atom["positional"]) == 1:
|
||||
positional = old.get(next(iter(atom["positional"])))
|
||||
if positional:
|
||||
proposals[atom["key"]] = positional
|
||||
|
||||
by_id: dict[str, list[dict[str, Any]]] = {}
|
||||
for atom in atoms:
|
||||
proposal = proposals.get(atom["key"])
|
||||
if proposal:
|
||||
by_id.setdefault(str(proposal["id"]), []).append(atom)
|
||||
for segment_id, candidates in by_id.items():
|
||||
old_segment = old.get(segment_id) or proposals.get(candidates[0]["key"])
|
||||
if old_segment is None:
|
||||
raise WallSegmentMigrationError("duplicate-id", segment_id)
|
||||
midpoint = [
|
||||
(old_segment["a"][0] + old_segment["b"][0]) / 2,
|
||||
(old_segment["a"][1] + old_segment["b"][1]) / 2,
|
||||
]
|
||||
candidates.sort(key=lambda atom: (
|
||||
0 if _distance_to_segment(midpoint, atom["a"], atom["b"]) <= EPS else 1,
|
||||
0 if _distance_to_segment(old_segment["a"], atom["a"], atom["b"]) <= EPS else 1,
|
||||
atom["key"],
|
||||
))
|
||||
candidates[0]["id"] = segment_id
|
||||
|
||||
used = _non_catalog_ids(space)
|
||||
for atom in atoms:
|
||||
if not atom.get("id"):
|
||||
continue
|
||||
if atom["id"] in used:
|
||||
raise WallSegmentMigrationError("duplicate-id", atom["id"])
|
||||
used.add(atom["id"])
|
||||
unassigned = [atom for atom in atoms if not atom.get("id")]
|
||||
if initial_migration:
|
||||
seeds: list[tuple[str, str, str, dict[str, Any]]] = []
|
||||
for atom in unassigned:
|
||||
ca, cb = _canonical_span(atom["a"], atom["b"])
|
||||
seed = (f"{space.get('id', '')}|{_point_key(ca)}|{_point_key(cb)}|"
|
||||
f"{','.join(sorted(atom['owners']))}")
|
||||
digest = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
|
||||
seeds.append((digest, atom["key"], seed, atom))
|
||||
full_digests: dict[str, str] = {}
|
||||
for digest, _key, seed, atom in sorted(seeds):
|
||||
if digest in full_digests and full_digests[digest] != seed:
|
||||
raise WallSegmentMigrationError("duplicate-id", digest)
|
||||
full_digests[digest] = seed
|
||||
base = f"wall-{digest[:20]}"
|
||||
suffix, segment_id = 1, base
|
||||
while segment_id in used:
|
||||
suffix += 1
|
||||
segment_id = f"{base}-{suffix}"
|
||||
atom["id"] = segment_id
|
||||
used.add(segment_id)
|
||||
else:
|
||||
for atom in unassigned:
|
||||
atom["id"] = _fresh_wall_segment_id(used)
|
||||
used.add(atom["id"])
|
||||
|
||||
|
||||
def _angle_matches(a: list[float], b: list[float], angle: float) -> bool:
|
||||
dx, dy = _wall_direction(a, b)
|
||||
wall_angle = math.degrees(math.atan2(dy, dx))
|
||||
difference = abs((wall_angle - angle + 90) % 180 - 90)
|
||||
return difference <= 8
|
||||
|
||||
|
||||
def _host_openings(
|
||||
space: dict[str, Any], segments: list[dict[str, Any]], initial_migration: bool,
|
||||
) -> None:
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
continue
|
||||
try:
|
||||
centre = [float(opening["x"]), float(opening["y"])]
|
||||
angle, half = float(opening["angle"]), float(opening["length"]) / 2
|
||||
except (KeyError, TypeError, ValueError):
|
||||
if initial_migration:
|
||||
# #316 §3.4: the initial migration never throws over an opening.
|
||||
opening.pop("host", None)
|
||||
continue
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
|
||||
|
||||
def eligible(segment: dict[str, Any]) -> bool:
|
||||
if float(segment.get("cm", 0)) <= 0:
|
||||
return False
|
||||
t = _project_t(centre, segment["a"], segment["b"])
|
||||
span = _length(segment["a"], segment["b"])
|
||||
return (-EPS <= t <= 1 + EPS
|
||||
and _distance_to_segment(centre, segment["a"], segment["b"])
|
||||
<= GRID_STEP_N * 0.02
|
||||
and _angle_matches(segment["a"], segment["b"], angle)
|
||||
and half >= 0 and t * span - half >= -EPS
|
||||
and t * span + half <= span + EPS)
|
||||
|
||||
def materialize(carrier: dict[str, Any]) -> None:
|
||||
opening["host"] = {
|
||||
"kind": "wall", "id": carrier["id"],
|
||||
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
|
||||
}
|
||||
|
||||
current = None
|
||||
if isinstance(host, dict) and host.get("kind") == "wall":
|
||||
current = next((segment for segment in segments if segment["id"] == host.get("id")), None)
|
||||
candidates = [current] if current is not None and eligible(current) else [
|
||||
segment for segment in segments if eligible(segment)
|
||||
]
|
||||
if len(candidates) == 1:
|
||||
materialize(candidates[0])
|
||||
continue
|
||||
# #316 §3.3: an unhosted opening is a valid degraded v9 state. A later
|
||||
# write keeps it, may self-heal it, and never fails over it.
|
||||
if host is None and not initial_migration:
|
||||
continue
|
||||
if not initial_migration:
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
|
||||
|
||||
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
if not pool:
|
||||
return None
|
||||
if current is not None and any(item is current for item in pool):
|
||||
return current
|
||||
return sorted(pool, key=lambda segment: (
|
||||
_distance_to_segment(centre, segment["a"], segment["b"]),
|
||||
-float(segment.get("cm", 0)),
|
||||
str(segment.get("id", "")),
|
||||
))[0]
|
||||
|
||||
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
|
||||
# H1): a host away from the opening's own x/y would violate the
|
||||
# geometry-match invariant of CONFIG_SCHEMA and wedge the write on the
|
||||
# schema layer; without an in-place carrier the opening goes straight
|
||||
# to the unhosted degraded state.
|
||||
carrier = pick([segment for segment in segments if eligible(segment)])
|
||||
if carrier is not None:
|
||||
materialize(carrier)
|
||||
else:
|
||||
opening.pop("host", None)
|
||||
|
||||
|
||||
def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
old: dict[str, dict[str, Any]] = {}
|
||||
for segment in space.get("wall_segments") or []:
|
||||
segment_id = str(segment.get("id", ""))
|
||||
if not segment_id or segment_id in old:
|
||||
raise WallSegmentMigrationError("duplicate-id", segment_id)
|
||||
old[segment_id] = segment
|
||||
atoms, rooms = _atomize(space, old)
|
||||
_assign_lineage(space, atoms, old, initial_migration)
|
||||
segments = []
|
||||
for atom in atoms:
|
||||
previous = old.get(atom["id"])
|
||||
segment = copy.deepcopy(previous) if previous else {}
|
||||
segment.update({
|
||||
"id": atom["id"], "a": list(atom["a"]), "b": list(atom["b"]),
|
||||
"cm": _thickness(space, atom, previous),
|
||||
})
|
||||
segments.append(segment)
|
||||
id_by_key = {atom["key"]: atom["id"] for atom in atoms}
|
||||
for room in rooms:
|
||||
room["wall_ids"] = [id_by_key[key] for key in room["wall_ids"]]
|
||||
space["rooms"] = rooms
|
||||
space["wall_segments"] = segments
|
||||
walls = [{
|
||||
"key": _wall_key(segment["a"], segment["b"]),
|
||||
"cm": segment["cm"], "a": list(segment["a"]), "b": list(segment["b"]),
|
||||
} for segment in segments if float(segment["cm"]) > 0]
|
||||
if walls:
|
||||
space["walls"] = walls
|
||||
else:
|
||||
space.pop("walls", None)
|
||||
space.pop("open_spans", None)
|
||||
for room in space.get("rooms") or []:
|
||||
room.pop("open_to", None)
|
||||
draft_used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "room_drafts", "partitions",
|
||||
"wall_columns", "wall_segments")
|
||||
for item in space.get(name) or []
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
|
||||
}
|
||||
for draft in space.get("room_drafts") or []:
|
||||
for index, segment in enumerate(draft.get("segments") or []):
|
||||
if isinstance(segment.get("id"), str) and segment["id"]:
|
||||
if segment["id"] in draft_used:
|
||||
raise WallSegmentMigrationError("duplicate-id", segment["id"])
|
||||
draft_used.add(segment["id"])
|
||||
continue
|
||||
try:
|
||||
a, b = draft["points"][index], draft["points"][index + 1]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
raise WallSegmentMigrationError("zero-length", str(draft.get("id", ""))) from None
|
||||
if initial_migration:
|
||||
base = deterministic_wall_segment_id(
|
||||
str(space.get("id", "")), a, b, [f"draft:{draft.get('id', '')}"],
|
||||
)
|
||||
suffix, segment["id"] = 1, base
|
||||
while segment["id"] in draft_used:
|
||||
suffix += 1
|
||||
segment["id"] = f"{base}-{suffix}"
|
||||
else:
|
||||
segment["id"] = _fresh_wall_segment_id(draft_used)
|
||||
draft_used.add(segment["id"])
|
||||
_host_openings(space, segments, initial_migration)
|
||||
return sum(1 for segment in segments if segment["id"] not in old)
|
||||
|
||||
|
||||
def commit_wall_segment_model(config: Any) -> tuple[Any, int]:
|
||||
"""Return one migrated deep copy and the count of newly assigned wall ids."""
|
||||
candidate = canonicalize_config_geometry(copy.deepcopy(config))
|
||||
if not isinstance(candidate, dict):
|
||||
raise WallSegmentMigrationError("invalid-room")
|
||||
migrated = 0
|
||||
initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION
|
||||
for space in candidate.get("spaces") or []:
|
||||
migrated += _migrate_space(space, initial_migration)
|
||||
candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION
|
||||
return canonicalize_config_geometry(candidate), migrated
|
||||
@@ -24,6 +24,11 @@ from .const import (
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
@@ -55,11 +60,21 @@ from .virtual_lights import (
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .projection import project_config, project_layout
|
||||
from .wall_segment_model import (
|
||||
WALL_SEGMENT_MODEL_VERSION,
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -136,6 +151,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)
|
||||
@@ -263,6 +279,7 @@ async def _commit_import_pair(
|
||||
vol.Required("type"): "houseplan/export/create",
|
||||
vol.Required("kind"): vol.In(["full", "space"]),
|
||||
vol.Optional("space_id"): str,
|
||||
vol.Optional("plan_only", default=False): bool,
|
||||
vol.Optional("card_version", default=""): str,
|
||||
}
|
||||
)
|
||||
@@ -287,6 +304,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
layout_data,
|
||||
kind=msg["kind"],
|
||||
space_id=msg.get("space_id"),
|
||||
plan_only=msg.get("plan_only", False),
|
||||
card_version=msg.get("card_version", ""),
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
@@ -422,8 +440,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,
|
||||
@@ -440,16 +462,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,
|
||||
@@ -467,24 +493,14 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
|
||||
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)
|
||||
if kind == "full":
|
||||
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
live_marker_ids = {
|
||||
str(marker.get("id")) for marker in target_config.get("markers") or []
|
||||
}
|
||||
if recorder is not None:
|
||||
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
|
||||
if marker_id not in live_marker_ids:
|
||||
try:
|
||||
await recorder.async_delete(marker_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan: removing orphan import trail failed")
|
||||
await _purge_trail_recorder(hass, target_config)
|
||||
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))
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"kind": kind,
|
||||
@@ -492,6 +508,9 @@ 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),
|
||||
"preserved_unresolved_refs": details.get("preserved_unresolved_refs", 0),
|
||||
"reference_report": details.get("reference_report", {}),
|
||||
"can_undo": kind == "full",
|
||||
})
|
||||
|
||||
@@ -511,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
|
||||
@@ -522,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),
|
||||
@@ -562,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,
|
||||
@@ -623,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)
|
||||
@@ -731,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",),
|
||||
)
|
||||
@@ -1054,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.
|
||||
@@ -1062,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:
|
||||
@@ -1085,12 +1138,20 @@ 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)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
|
||||
"undo_kind": _undo_kind(data, layout_data),
|
||||
# #295: the card compares this against its own version to decide
|
||||
# whether the «update House Plan» preflight hint can actually help.
|
||||
"integration_version": VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1199,7 +1260,9 @@ def _missing_internal_attachments(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
vol.Required("config"): CONFIG_SCHEMA,
|
||||
# Semantic stale-client detection needs the stored v8 document and
|
||||
# therefore runs inside the write lock before CONFIG_SCHEMA.
|
||||
vol.Required("config"): dict,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@@ -1248,13 +1311,41 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# Marker-to-marker light links are a semantic graph layered on top of
|
||||
# the lossless controls array. Validate only edges introduced by this
|
||||
# write so an unrelated edit can still round-trip a legacy broken ref.
|
||||
try:
|
||||
# #330 §4.1/§4.2: the CPU part of validation runs in the executor —
|
||||
# schema + semantic validators cost seconds on a large plan and the
|
||||
# event loop must not carry that (HA guideline: >50 ms). write_lock
|
||||
# is still held across the await: writes stay serialised, only the
|
||||
# loop is freed. The junction baseline of the STORED document is
|
||||
# cached by rev, so a repeated write does not re-judge `previous`.
|
||||
baseline = rt.junction_baseline
|
||||
baseline_counts = baseline[1] if baseline and baseline[0] == current_rev else None
|
||||
|
||||
def _validate_config_cpu():
|
||||
validate_wall_model_transition(msg["config"], data.get("config"))
|
||||
checked = CONFIG_SCHEMA(msg["config"])
|
||||
msg["config"].clear()
|
||||
msg["config"].update(checked)
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
except MarkerControlError as err:
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
return validate_junction_limits(
|
||||
msg["config"], data.get("config"),
|
||||
baseline_counts=baseline_counts,
|
||||
)
|
||||
try:
|
||||
candidate_counts = await hass.async_add_executor_job(_validate_config_cpu)
|
||||
except (
|
||||
JunctionLimitError, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_format", str(err))
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
# plan and then delete it from the same dialog, and two clients can do
|
||||
# the same thing in either order — the lock serialises them but says
|
||||
@@ -1272,6 +1363,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,
|
||||
@@ -1279,6 +1381,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
new_rev,
|
||||
previous_rev=int(current_rev),
|
||||
)
|
||||
# The candidate just became the stored document: its counts are the
|
||||
# next write's baseline (#330 §4.2).
|
||||
rt.junction_baseline = (int(new_rev), candidate_counts)
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
|
||||
@@ -1297,6 +1402,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
await hass.async_add_executor_job(_collect)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||
# The config is already durable, so trail cleanup is best-effort and
|
||||
# cannot turn this accepted write into a retryable client failure.
|
||||
# Keep it under write_lock: a later config/set must not resurrect a
|
||||
# marker between this commit and the ownership decision (#335).
|
||||
await _purge_trail_recorder(hass, msg["config"])
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
@@ -1311,10 +1421,197 @@ 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",
|
||||
vol.Required("config"): CONFIG_SCHEMA,
|
||||
vol.Required("config"): dict,
|
||||
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
@@ -1358,13 +1655,74 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# Optimization is a normal configuration write with an additional
|
||||
# layout transaction. It must enforce the same marker-link semantics
|
||||
# as config/set; otherwise a crafted client can persist a new cycle.
|
||||
try:
|
||||
# #330 §4.1: the same executor treatment as config/set — Optimize
|
||||
# carries schema + a possible full migration, the costliest CPU path
|
||||
# of all writers, and it used to run on the event loop.
|
||||
def _validate_optimize_cpu():
|
||||
validate_wall_model_transition(msg["config"], config_data.get("config"))
|
||||
try:
|
||||
submitted_model = int(msg["config"].get("model_version", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
submitted_model = WALL_SEGMENT_MODEL_VERSION
|
||||
# Optimize is an explicit structural writer and therefore the
|
||||
# server-side v7 -> v8 barrier as well. Current v8 candidates are
|
||||
# validated verbatim: the backend must never invent lineage for a
|
||||
# graph already authored by a current client.
|
||||
candidate_config = CONFIG_SCHEMA(msg["config"])
|
||||
# Keep the established public validation contract ahead of the
|
||||
# structural migration barrier. A malformed passage can also make
|
||||
# wall-host materialisation impossible, but callers must still get
|
||||
# the actionable `invalid_passage_fields` code rather than the
|
||||
# generic wall-model blocker.
|
||||
validate_opening_passages(candidate_config, config_data.get("config"))
|
||||
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
|
||||
candidate_config, _ = commit_wall_segment_model(candidate_config)
|
||||
checked = CONFIG_SCHEMA(candidate_config)
|
||||
migrated_size = len(json.dumps(checked, separators=(",", ":")))
|
||||
if migrated_size > MAX_CONFIG_BYTES:
|
||||
return None, migrated_size
|
||||
msg["config"].clear()
|
||||
msg["config"].update(checked)
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
except MarkerControlError as err:
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(
|
||||
msg["config"], config_data.get("config"),
|
||||
allow_optimize_rehost=True,
|
||||
)
|
||||
# #333: Optimize is a normal configuration write for the junction
|
||||
# limits too — an honest client's optimization never ADDS a
|
||||
# violation (#329 AC10), so this gate is a no-op for legitimate
|
||||
# flows and refuses only a crafted payload that used this command
|
||||
# as a side door around config/set. Inheritance is counted per
|
||||
# rule exactly as in config/set: repairing a legacy plan that
|
||||
# already carries violations still passes.
|
||||
return validate_junction_limits(
|
||||
msg["config"], config_data.get("config"),
|
||||
), None
|
||||
try:
|
||||
optimize_counts, oversize = await hass.async_add_executor_job(
|
||||
_validate_optimize_cpu,
|
||||
)
|
||||
if oversize is not None:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {oversize // 1024} KB, "
|
||||
f"the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
except (
|
||||
JunctionLimitError, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
WallSegmentMigrationError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_format", str(err))
|
||||
return
|
||||
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
@@ -1383,15 +1741,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,
|
||||
@@ -1409,6 +1771,11 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
# The optimized candidate is now the stored document: its junction
|
||||
# counts are the next write's baseline (#333 AC3, symmetric with
|
||||
# config/set — otherwise the next save re-judges `previous` for
|
||||
# nothing and the #330 cache loses its point).
|
||||
rt.junction_baseline = (int(new_config_rev), optimize_counts or {})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, msg["layout"], new_layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup},
|
||||
@@ -1461,8 +1828,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 = {
|
||||
@@ -1491,24 +1862,14 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
|
||||
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)
|
||||
if restored_kind == "import":
|
||||
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
live_marker_ids = {
|
||||
str(marker.get("id")) for marker in restored_config.get("markers") or []
|
||||
}
|
||||
if recorder is not None:
|
||||
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
|
||||
if marker_id not in live_marker_ids:
|
||||
try:
|
||||
await recorder.async_delete(marker_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan: removing orphan undo trail failed")
|
||||
await _purge_trail_recorder(hass, restored_config)
|
||||
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))
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
@@ -1592,6 +1953,12 @@ def _refresh_trail_recorder(hass: HomeAssistant) -> None:
|
||||
hass.async_create_task(rec.async_refresh())
|
||||
|
||||
|
||||
async def _purge_trail_recorder(hass: HomeAssistant, config: dict[str, Any]) -> int:
|
||||
"""Reconcile durable trails with the live marker set after a config commit."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
return await rec.async_purge_orphans(config) if rec else 0
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
|
||||
@@ -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;
|
||||
@@ -87,7 +87,7 @@ const viewport = { width: 1280, height: 900 };
|
||||
const { page, browser } = await launch(
|
||||
viewport, 1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{}, resolve(targetRoot, 'demo/srv'),
|
||||
{}, resolve(targetRoot, 'demo/srv'), targetRoot,
|
||||
);
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* #330 §5: the performance contract of the junction limits (#329).
|
||||
*
|
||||
* Guards the CLASS of regression — a quadratic path returning — not runner
|
||||
* noise: budgets are 2-3x the measured post-fix numbers. Measures the exact
|
||||
* code the write barrier runs: the pure П1-П5 checks over a 576-atom plan
|
||||
* (the S2 reproduction grid of #330), for both mirrors. The python half runs
|
||||
* through a child process with the real custom_components module.
|
||||
*
|
||||
* Runs on test-build/ like benchmark:wall-model — no browser, no bundle.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
checkNodes, checkSegmentLengths, checkNodeDistances, checkRoomClearance,
|
||||
cmToUnits,
|
||||
} from '../test-build/junction-limits.js';
|
||||
import { commitWallSegmentModel } from '../test-build/wall-segment-model.js';
|
||||
import { GRID_STEP_N } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const CELL = 5;
|
||||
const GRID_N = 12; // 576 contour atoms — the #330 S2 grid
|
||||
const WARMUPS = 2;
|
||||
const SAMPLES = 5;
|
||||
|
||||
// Budgets are calibrated from the SLOWEST machine observed, not the
|
||||
// author's: the CI review runner measured tsFullCandidateMs at 169-171 ms
|
||||
// where the dev sandbox saw 88 (code-review 330-r1 H2). Each budget keeps
|
||||
// the spec's 2-3x allowance over that worst observation, so the bench turns
|
||||
// red for the O(n²) class (which costs seconds), not for a slower runner.
|
||||
const BUDGETS = {
|
||||
tsSegmentLengthsMs: 60,
|
||||
tsNodeDistancesMs: 80,
|
||||
tsFullCandidateMs: 400,
|
||||
pyWarmValidateMs: 300,
|
||||
pyColdValidateMs: 5000,
|
||||
};
|
||||
|
||||
const u = (cm) => cmToUnits(cm, CELL, GRID_STEP_N);
|
||||
const rooms = []; const walls = []; let k = 0;
|
||||
for (let i = 0; i < GRID_N; i++) {
|
||||
for (let j = 0; j < GRID_N; j++) {
|
||||
const x0 = u(310) * i; const y0 = u(310) * j;
|
||||
const poly = [[x0, y0], [x0 + u(300), y0], [x0 + u(300), y0 + u(300)], [x0, y0 + u(300)]];
|
||||
rooms.push({ id: `r${i}-${j}`, name: `r${i}${j}`, area: null, poly });
|
||||
for (let e = 0; e < 4; e++) {
|
||||
walls.push({ key: `w${k++}`, a: poly[e], b: poly[(e + 1) % 4], cm: 15 });
|
||||
}
|
||||
}
|
||||
}
|
||||
const legacy = {
|
||||
spaces: [{
|
||||
id: 's', title: 's', cell_cm: CELL, view_box: [0, 0, 1, 1],
|
||||
rooms, walls, openings: [], room_drafts: [], partitions: [], wall_columns: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const { config: v9 } = commitWallSegmentModel(JSON.parse(JSON.stringify(legacy)));
|
||||
const space = v9.spaces[0];
|
||||
const segments = (space.wall_segments || []).map((item) => ({
|
||||
id: item.id, a: item.a, b: item.b, cm: Number(item.cm),
|
||||
}));
|
||||
|
||||
/** The same П1-П5 set the card's write barrier computes for one candidate. */
|
||||
const fullCandidate = () => {
|
||||
const violations = [
|
||||
...checkNodes(segments),
|
||||
...checkSegmentLengths(segments, CELL, GRID_STEP_N),
|
||||
...checkNodeDistances(segments, CELL, GRID_STEP_N),
|
||||
];
|
||||
// #330 §4.7: shared passes exactly as the card computes them.
|
||||
const sharedNodes = multiWallNodesForGeometry(
|
||||
space.rooms || [], space.walls || [], [], GRID_STEP_N, CELL, GRID_STEP_N, 1,
|
||||
);
|
||||
let sharedRoomGeometry;
|
||||
if (sharedNodes.nodes.length) {
|
||||
const geometry = wallBodiesGeometry(
|
||||
space.rooms || [], space.walls || [], [], [], GRID_STEP_N, CELL, GRID_STEP_N, 1,
|
||||
);
|
||||
sharedRoomGeometry = geometry?.status === 'ok' || geometry?.status === 'degraded-extra'
|
||||
? geometry.roomGeom : undefined;
|
||||
}
|
||||
for (const room of space.rooms || []) {
|
||||
let inner = null;
|
||||
try {
|
||||
inner = innerContourForRoom(
|
||||
space.rooms || [], String(room.id), space.walls || [], [],
|
||||
GRID_STEP_N, CELL, GRID_STEP_N, 1, sharedRoomGeometry, sharedNodes,
|
||||
);
|
||||
} catch { inner = null; }
|
||||
violations.push(...checkRoomClearance(String(room.id), inner, CELL, GRID_STEP_N));
|
||||
}
|
||||
return violations.length;
|
||||
};
|
||||
|
||||
const median = (fn) => {
|
||||
for (let i = 0; i < WARMUPS; i++) fn();
|
||||
const times = [];
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
const start = performance.now();
|
||||
fn();
|
||||
times.push(performance.now() - start);
|
||||
}
|
||||
times.sort((left, right) => left - right);
|
||||
return times[Math.floor(times.length / 2)];
|
||||
};
|
||||
|
||||
const tsSegmentLengthsMs = median(() => checkSegmentLengths(segments, CELL, GRID_STEP_N));
|
||||
const tsNodeDistancesMs = median(() => checkNodeDistances(segments, CELL, GRID_STEP_N));
|
||||
const tsFullCandidateMs = median(fullCandidate);
|
||||
|
||||
// Python mirror: cold (legacy both sides) and warm (v9 + baseline counts).
|
||||
const pyScript = `
|
||||
import importlib.util, sys, types, os, json, time
|
||||
root = ${JSON.stringify(REPO_ROOT)}
|
||||
pr = os.path.join(root, 'custom_components'); hr = os.path.join(pr, 'houseplan')
|
||||
pkg = types.ModuleType('custom_components'); pkg.__path__ = [pr]; sys.modules['custom_components'] = pkg
|
||||
p2 = types.ModuleType('custom_components.houseplan'); p2.__path__ = [hr]; sys.modules['custom_components.houseplan'] = p2
|
||||
spec = importlib.util.spec_from_file_location('custom_components.houseplan.junction_limits', os.path.join(hr, 'junction_limits.py'))
|
||||
jl = importlib.util.module_from_spec(spec); sys.modules[spec.name] = jl; spec.loader.exec_module(jl)
|
||||
legacy = json.load(sys.stdin)
|
||||
from custom_components.houseplan.wall_segment_model import commit_wall_segment_model
|
||||
v9, _ = commit_wall_segment_model(json.loads(json.dumps(legacy)))
|
||||
prev = json.loads(json.dumps(v9)); cand = json.loads(json.dumps(v9))
|
||||
cand['spaces'][0]['rooms'][0]['name'] = 'x'
|
||||
t0 = time.perf_counter()
|
||||
jl.validate_junction_limits(json.loads(json.dumps(legacy)), json.loads(json.dumps(legacy)))
|
||||
cold = 1000 * (time.perf_counter() - t0)
|
||||
counts = jl.validate_junction_limits(json.loads(json.dumps(cand)), json.loads(json.dumps(prev)))
|
||||
best = None
|
||||
for _ in range(3):
|
||||
t0 = time.perf_counter()
|
||||
jl.validate_junction_limits(json.loads(json.dumps(cand)), None, baseline_counts=counts)
|
||||
warm = 1000 * (time.perf_counter() - t0)
|
||||
best = warm if best is None else min(best, warm)
|
||||
print(json.dumps({'pyColdValidateMs': cold, 'pyWarmValidateMs': best}))
|
||||
`;
|
||||
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
|
||||
const pyRun = spawnSync(python, ['-c', pyScript], {
|
||||
input: JSON.stringify(legacy), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (pyRun.status !== 0) {
|
||||
console.error('python mirror failed:', (pyRun.stderr || '').slice(-2000));
|
||||
process.exit(1);
|
||||
}
|
||||
const py = JSON.parse(pyRun.stdout.trim().split('\n').pop());
|
||||
|
||||
const results = {
|
||||
tsSegmentLengthsMs, tsNodeDistancesMs, tsFullCandidateMs,
|
||||
pyWarmValidateMs: py.pyWarmValidateMs, pyColdValidateMs: py.pyColdValidateMs,
|
||||
};
|
||||
const pass = Object.entries(BUDGETS).every(([name, budget]) => results[name] <= budget);
|
||||
console.log(JSON.stringify({
|
||||
issue: 330,
|
||||
fixture: { atoms: segments.length, grid: `${GRID_N}x${GRID_N}` },
|
||||
warmups: WARMUPS, samples: SAMPLES,
|
||||
results: Object.fromEntries(Object.entries(results).map(
|
||||
([name, value]) => [name, Math.round(value * 10) / 10],
|
||||
)),
|
||||
budgets: BUDGETS,
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -20,6 +20,7 @@ const isometric = profile === 'large-house-isometric-v1';
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
@@ -42,6 +43,7 @@ const { page, browser } = await launch(
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{},
|
||||
resolve(targetRoot, 'demo/srv'),
|
||||
targetRoot,
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addStyleTag({
|
||||
@@ -65,6 +67,7 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -122,6 +125,7 @@ try {
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
|
||||
});
|
||||
|
||||
window.__card?.remove?.();
|
||||
@@ -175,6 +179,13 @@ try {
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
if (requiresIsometric) {
|
||||
if (typeof card._onLabsSnapshot !== 'function')
|
||||
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
|
||||
// The product flag expires at 1.65.0. Performance keeps exercising
|
||||
// the dormant renderer without changing the public registry contract.
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
@@ -232,6 +243,7 @@ try {
|
||||
const cacheValue = card._planSnapGeometryCache?.value ?? null;
|
||||
const configBefore = JSON.stringify(card._serverCfg);
|
||||
const callsBefore = wsCalls;
|
||||
const wallFaceCacheBeforePointer = card._wallFaceGraphCache?.length ?? 0;
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const fromPlan = (x, y) => ({
|
||||
@@ -284,13 +296,39 @@ try {
|
||||
=== staticNodes,
|
||||
configStable: JSON.stringify(card._serverCfg) === configBefore,
|
||||
wsWrites: wsCalls - callsBefore,
|
||||
wallFaceCacheStableOnPointer:
|
||||
(card._wallFaceGraphCache?.length ?? 0) === wallFaceCacheBeforePointer,
|
||||
};
|
||||
if (requiresPlanSnap && (
|
||||
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|
||||
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|
||||
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|
||||
|| !planSnapDiagnostics.wallFaceCacheStableOnPointer
|
||||
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
|
||||
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
if (requiresWallFace) {
|
||||
const oldPath = card._path;
|
||||
const oldDraftId = card._activeDraftId;
|
||||
const oldCms = card._draftSegmentCms;
|
||||
const beforePath = [[10, 10]];
|
||||
card._path = [[10, 10], [20, 10]];
|
||||
card._activeDraftId = 'perf-face-draft';
|
||||
card._draftSegmentCms = [15];
|
||||
const acceptedStarted = performance.now();
|
||||
card._offerWallFaces(beforePath);
|
||||
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
|
||||
planSnapDiagnostics.wallFaceCacheEntries = card._wallFaceGraphCache?.length ?? 0;
|
||||
card._wallFaceBatch = null;
|
||||
card._roomDialog = false;
|
||||
card._path = oldPath;
|
||||
card._activeDraftId = oldDraftId;
|
||||
card._draftSegmentCms = oldCms;
|
||||
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
|
||||
throw new Error(`wall-face accepted-click contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
}
|
||||
}
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
@@ -308,7 +346,7 @@ try {
|
||||
target: null,
|
||||
};
|
||||
card._rszEdgeDown(quietEvent, room.id, 1);
|
||||
const plan = card._rszDrag?.plan;
|
||||
const plan = card._resize?.plan;
|
||||
if (!plan) throw new Error('large-house resize plan was not created');
|
||||
const target = [
|
||||
plan.a[0] + plan.n[0] * card._gridPitch,
|
||||
@@ -410,7 +448,7 @@ try {
|
||||
return result;
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
|
||||
|
||||
@@ -13,19 +14,70 @@ const fingerprintForTree = async (root) => {
|
||||
return module.sourceFingerprint(root);
|
||||
};
|
||||
|
||||
const verifyManifestTree = (root, expected) => {
|
||||
const assetRoot = resolve(root, 'demo/srv/assets');
|
||||
const manifestPath = resolve(assetRoot, 'houseplan-assets.json');
|
||||
// Comparative benchmarks can target releases from before the multi-asset
|
||||
// contract. Their own embedded fingerprint remains the legacy authority.
|
||||
if (!existsSync(manifestPath)) return;
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest?.schema !== 1 || manifest.fingerprint !== expected
|
||||
|| !Array.isArray(manifest.files)) {
|
||||
throw new Error('demo bundle manifest is stale or malformed; run npm run bundle:sync');
|
||||
}
|
||||
for (const file of manifest.files) {
|
||||
const path = resolve(assetRoot, String(file?.path || ''));
|
||||
const rel = relative(assetRoot, path);
|
||||
if (!rel || rel.startsWith('..') || rel.includes(':') || !existsSync(path)) {
|
||||
throw new Error(`demo bundle manifest asset is missing or unsafe: ${file?.path}`);
|
||||
}
|
||||
const hash = createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
if (hash !== file.sha256) throw new Error(`demo bundle asset hash mismatch: ${file.path}`);
|
||||
}
|
||||
};
|
||||
|
||||
/** Refuse measurements/screenshots made by a committed bundle from old source. */
|
||||
export async function assertFreshDemoBundle(page, root = process.cwd()) {
|
||||
// A comparative performance run may load an older tree whose fingerprint
|
||||
// contract is intentionally different from the candidate's. Validate that
|
||||
// tree with the implementation that built it, not with today's algorithm.
|
||||
const expected = await fingerprintForTree(root);
|
||||
verifyManifestTree(root, expected);
|
||||
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
|
||||
if (loaded !== expected) {
|
||||
throw new Error(
|
||||
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
|
||||
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
|
||||
'demo/srv/assets bundle tree is stale. Run npm run bundle:sync first. '
|
||||
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/** Env switch that lets a debugging session run against a stale bundle. */
|
||||
export const ALLOW_STALE_BUNDLE = 'HP_ALLOW_STALE_BUNDLE';
|
||||
|
||||
/**
|
||||
* The freshness gate for every browser check, escape hatch included (#236).
|
||||
*
|
||||
* The smoke launcher had no freshness check at all, while golden runs and
|
||||
* benchmarks did. A smoke against a stale `demo/srv/assets/houseplan-card.js`
|
||||
* does not fail cleanly: on #234 three assertions went red and a fourth went
|
||||
* GREEN, because the old code was wrong in two places that agreed with each
|
||||
* other. A partly-red partly-green result looks like a logic defect and sends
|
||||
* the reader hunting in the wrong file.
|
||||
*
|
||||
* Skipping is allowed for debugging, but never silently: a skipped guard that
|
||||
* says nothing is the same silent success this project keeps digging out.
|
||||
*/
|
||||
export async function assertFreshDemoBundleUnlessAllowed(
|
||||
page, root = process.cwd(), env = process.env,
|
||||
) {
|
||||
if (env[ALLOW_STALE_BUNDLE]) {
|
||||
console.warn(
|
||||
`[houseplan] ${ALLOW_STALE_BUNDLE} is set — bundle freshness NOT verified. `
|
||||
+ 'A red result may mean a stale bundle rather than a defect (#236).',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return assertFreshDemoBundle(page, root);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mdiLightbulbSpot } from '@mdi/js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const artifactDir = resolve('artifacts/device-icon-reference');
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
|
||||
const referenceAsset = (theme, file, coreSize) => {
|
||||
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
|
||||
if (file === 'Lock.svg') {
|
||||
const old = theme === 'Dark' ? '#252525' : 'black';
|
||||
source = source.replaceAll(old, '#66D17A');
|
||||
if (theme === 'Dark') source = source.replaceAll('fill="white"', 'fill="#252525"');
|
||||
}
|
||||
if (file === 'Unlock.svg') {
|
||||
source = source.replaceAll(theme === 'Dark' ? '#1DC21D' : '#F0A00C', '#F0410C');
|
||||
}
|
||||
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
|
||||
return {
|
||||
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
|
||||
displayWidth: nativeWidth * coreSize / 80,
|
||||
};
|
||||
};
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
|
||||
);
|
||||
|
||||
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const marker = (id, patch) => ({
|
||||
...(c._serverCfg.markers || []).find((item) => item.id === id),
|
||||
id, binding: `device:${id}`, ...patch,
|
||||
});
|
||||
const replacements = new Map([
|
||||
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
|
||||
['d_tv', marker('d_tv', { display: 'value' })],
|
||||
['d_temp', marker('d_temp', {
|
||||
display: 'badge',
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
|
||||
position: 'right',
|
||||
},
|
||||
})],
|
||||
]);
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
|
||||
...replacements.values(),
|
||||
];
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'sensor.living_temp': {
|
||||
...c.hass.states['sensor.living_temp'],
|
||||
state: '23',
|
||||
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
|
||||
},
|
||||
'media_player.tv': {
|
||||
...c.hass.states['media_player.tv'],
|
||||
state: 'Working',
|
||||
},
|
||||
},
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._cfgEpoch++;
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const qaStyle = document.createElement('style');
|
||||
qaStyle.textContent = '.devtip{display:none!important}';
|
||||
(c.renderRoot || c.shadowRoot).append(qaStyle);
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
|
||||
});
|
||||
|
||||
const selector = (id) => `.dev[data-id="${id}"]`;
|
||||
|
||||
async function runtimePng(theme, row, size) {
|
||||
await page.mouse.move(1, 1);
|
||||
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
|
||||
const node = (window.__card.renderRoot || window.__card.shadowRoot)
|
||||
.querySelector(`.dev[data-id="${id}"]`);
|
||||
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
|
||||
marker.style.visibility = marker === node ? 'visible' : 'hidden';
|
||||
node.classList.remove(...[
|
||||
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
|
||||
'sel', 'lock-locked', 'lock-unlocked',
|
||||
]);
|
||||
node.classList.add(`theme-${themeName}`, ...classes);
|
||||
node.style.setProperty('--device-base-size', `${px}px`);
|
||||
node.style.setProperty('--dev-scale', '1');
|
||||
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
|
||||
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
|
||||
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
|
||||
node.blur();
|
||||
}, {
|
||||
id: row.id,
|
||||
themeName: theme.toLowerCase(),
|
||||
classes: row.classes || [],
|
||||
px: size,
|
||||
clearValues: row.clearValues || false,
|
||||
});
|
||||
if (row.hover) {
|
||||
await page.hover(selector(row.id));
|
||||
await page.waitForTimeout(180);
|
||||
}
|
||||
if (row.focus) {
|
||||
await page.$eval(selector(row.id), (node) => node.focus());
|
||||
}
|
||||
await page.$eval(selector(row.id), (node) => {
|
||||
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
|
||||
tooltip.style.setProperty('display', 'none', 'important');
|
||||
node.querySelector('.lqi')?.style.setProperty('display', 'none');
|
||||
});
|
||||
const clip = await page.$eval(selector(row.id), (node) => {
|
||||
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
|
||||
const pad = 22;
|
||||
return {
|
||||
x: Math.max(0, shell.left - pad),
|
||||
y: Math.max(0, shell.top - pad),
|
||||
width: shell.width + pad * 2,
|
||||
height: shell.height + pad * 2,
|
||||
};
|
||||
});
|
||||
return (await page.screenshot({ clip })).toString('base64');
|
||||
}
|
||||
|
||||
const rows = [
|
||||
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
|
||||
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
|
||||
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
|
||||
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
|
||||
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
|
||||
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
|
||||
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
|
||||
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
|
||||
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
|
||||
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
|
||||
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
|
||||
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
|
||||
];
|
||||
|
||||
const matrix = [];
|
||||
for (const theme of ['Light', 'Dark']) {
|
||||
for (const row of rows) {
|
||||
matrix.push({
|
||||
theme,
|
||||
row,
|
||||
size: 56,
|
||||
runtime: await runtimePng(theme, row, 56),
|
||||
});
|
||||
}
|
||||
for (const size of [32, 96]) {
|
||||
const row = rows[0];
|
||||
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
|
||||
}
|
||||
const textRow = rows.find((row) => row.label === 'Text');
|
||||
matrix.push({ theme, row: textRow, size: 96, runtime: await runtimePng(theme, textRow, 96) });
|
||||
}
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
const body = matrix.map(({ theme, row, size, runtime }) => {
|
||||
const reference = referenceAsset(theme, row.file, size);
|
||||
return `
|
||||
<tr>
|
||||
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
|
||||
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
|
||||
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const html = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
|
||||
<style>
|
||||
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
|
||||
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
|
||||
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
|
||||
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
|
||||
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
|
||||
</style></head><body>
|
||||
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
|
||||
<p>Issues #211/#217. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default covers 32/56/96 px and Text has an additional large 96 px row so its outer stadium curvature is reviewable. Dark Unlock is evaluated using the owner's amber override from #179.</p>
|
||||
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
|
||||
<tbody>${body}</tbody></table></body></html>`;
|
||||
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
|
||||
writeFileSync(htmlPath, html);
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
await page.screenshot({
|
||||
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await browser.close();
|
||||
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
|
||||
@@ -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 }));
|
||||
@@ -1,90 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
// #337: a clean CI checkout has no ignored demo bundle. Materialize the whole
|
||||
// manifest-owned tree before launching Chromium; copying only the stable entry
|
||||
// leaves every content-hashed import at 404.
|
||||
import '../../scripts/bundle-sync.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');
|
||||
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
|
||||
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
|
||||
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
|
||||
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,
|
||||
},
|
||||
]);
|
||||
/**
|
||||
* Перепаковка кадра без потерь (#345).
|
||||
*
|
||||
* Замер на этом наборе: 2096 КБ превращаются в 1689 КБ, минус 19.4%, и все
|
||||
* десять кадров остаются ПИКСЕЛЬНО идентичными — декодированные RGBA совпадают
|
||||
* по sha256. Это выбор фильтров строки и уровня сжатия, а не квантование:
|
||||
* визуального решения здесь нет вовсе.
|
||||
*
|
||||
* Почему внутри съёмки, а не отдельным проходом по закоммиченным файлам.
|
||||
* Манифест хранит `imageSha256` каждого кадра, поэтому оптимизировать файлы в
|
||||
* репозитории руками нельзя — `check-docs` покраснеет; а если жать после
|
||||
* подсчёта хешей, следующая же съёмка вернёт неоптимизированные байты.
|
||||
*
|
||||
* Отсутствие инструмента не ошибка: локальная съёмка и без него полезна для
|
||||
* глаз, а приёмка всё равно идёт только из артефакта CI, где `oxipng` стоит
|
||||
* пином (`.github/workflows/docs-screenshots.yml`). Но молчать об этом нельзя —
|
||||
* байты кадра зависят от того, был ли инструмент, поэтому его версия попадает
|
||||
* в манифест рядом с версией браузера, по той же причине.
|
||||
*/
|
||||
const oxipngVersion = (() => {
|
||||
const probe = spawnSync('oxipng', ['--version'], { encoding: 'utf8' });
|
||||
if (probe.status !== 0) {
|
||||
console.log('oxipng не найден: кадры пишутся как есть, без перепаковки');
|
||||
return null;
|
||||
}
|
||||
return String(probe.stdout || '').trim().split('\n')[0];
|
||||
})();
|
||||
|
||||
/** Пожать файл на месте и вернуть его новые байты. */
|
||||
const shrinkPng = (path, before) => {
|
||||
if (!oxipngVersion) return before;
|
||||
const run = spawnSync('oxipng', ['-o', '4', '--strip', 'safe', '--quiet', path]);
|
||||
if (run.status !== 0) {
|
||||
throw new Error(`oxipng не смог обработать ${path}: код ${run.status}`
|
||||
+ `${run.stderr ? ` · ${run.stderr}` : ''}`);
|
||||
}
|
||||
const after = readFileSync(path);
|
||||
console.log(` ${(before.length / 1024).toFixed(0)} КБ -> ${(after.length / 1024).toFixed(0)} КБ`);
|
||||
return after;
|
||||
};
|
||||
|
||||
|
||||
const roomCardClip = (page) => page.evaluate(() => {
|
||||
const card = window.__goldenCard;
|
||||
@@ -171,15 +146,18 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
}, scenario);
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
copyFileSync(BUNDLE, DEMO_BUNDLE);
|
||||
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
|
||||
|
||||
const { page, browser } = await launch();
|
||||
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);
|
||||
@@ -192,14 +170,18 @@ try {
|
||||
const image = await page.screenshot({
|
||||
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
|
||||
});
|
||||
writeFileSync(resolve(OUTPUT, scenario.file), image);
|
||||
const imagePath = resolve(OUTPUT, scenario.file);
|
||||
writeFileSync(imagePath, image);
|
||||
// Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты,
|
||||
// которые лежат на диске, иначе приёмка отвергнет свой же кандидат.
|
||||
const stored = shrinkPng(imagePath, image);
|
||||
scenarios[scenario.id] = {
|
||||
file: scenario.file,
|
||||
viewport: scenario.viewport,
|
||||
theme: scenario.theme,
|
||||
language: scenario.language,
|
||||
sourceSha256: fingerprint,
|
||||
imageSha256: sha256(image),
|
||||
imageSha256: sha256(stored),
|
||||
};
|
||||
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
|
||||
}
|
||||
@@ -207,6 +189,11 @@ try {
|
||||
const manifest = {
|
||||
version: DOC_SCREENSHOT_VERSION,
|
||||
fixture: 'synthetic-only',
|
||||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||||
chromium: browser.version(),
|
||||
// Чем жали — тоже часть доказательства: без инструмента байты другие.
|
||||
oxipng: oxipngVersion,
|
||||
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,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,59 @@
|
||||
// AC-13 for #157. Usage:
|
||||
// node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/dist/houseplan-card.js
|
||||
// The v1.64.0 frontend does not understand `passage`; this executable fixture
|
||||
// pins its documented best-effort fallback (door symbol) and, critically,
|
||||
// rejects any pageerror/unhandled exception while reading the newer literal.
|
||||
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const value = process.argv.find((arg) => arg.startsWith('--bundle='))?.slice('--bundle='.length);
|
||||
if (!value) {
|
||||
console.error('usage: node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/houseplan-card.js');
|
||||
process.exit(2);
|
||||
}
|
||||
const bundle = isAbsolute(value) ? value : resolve(value);
|
||||
if (!existsSync(bundle)) {
|
||||
console.error(`v1.64.0 bundle not found: ${bundle}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const currentDemo = fileURLToPath(new URL('./srv', import.meta.url));
|
||||
const serveRoot = mkdtempSync(join(tmpdir(), 'hp-157-downgrade-'));
|
||||
let browser;
|
||||
try {
|
||||
cpSync(currentDemo, serveRoot, { recursive: true });
|
||||
cpSync(bundle, join(serveRoot, 'assets', 'houseplan-card.js'));
|
||||
const launched = await launch(undefined, 1, [], {}, serveRoot);
|
||||
browser = launched.browser;
|
||||
const out = await launched.page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
|
||||
space.openings = [{
|
||||
id: 'future-passage', type: 'passage', x: 0.3, y: 0.14,
|
||||
angle: 0, length: 0.09, future_material: 'stone',
|
||||
}];
|
||||
card._setMode('plan');
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
|
||||
const opening = root().querySelector('[data-hp="opening"][data-id="future-passage"]');
|
||||
const stored = space.openings[0];
|
||||
return {
|
||||
newerLiteralLoads: !!opening,
|
||||
documentedDoorFallback: !!opening?.querySelector('.op-leaf,.op-arc'),
|
||||
readDoesNotRewriteConfig: stored.type === 'passage'
|
||||
&& stored.future_material === 'stone' && space.openings.length === 1,
|
||||
};
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
browser = undefined;
|
||||
} finally {
|
||||
await browser?.close?.();
|
||||
rmSync(serveRoot, { recursive: true, force: 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),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* #314 anonymised dense-plan fixture.
|
||||
*
|
||||
* It preserves the structural shape of the owner's report without names,
|
||||
* markers, layout, or original coordinates: 13 rooms, 44 catalogue walls,
|
||||
* 24 intentional partitions, and one pre-existing two-point draft. The last
|
||||
* object is known debt and must remain the only invariant finding before and
|
||||
* after drawing another room.
|
||||
*/
|
||||
import { WALL_KEY_PITCH } from './wall-key.mjs';
|
||||
|
||||
export function makeV8DraftRegressionFixture() {
|
||||
const step = WALL_KEY_PITCH;
|
||||
const point = (x, y) => [x * step, y * step];
|
||||
const segmentIds = new Map();
|
||||
const wallSegments = [];
|
||||
const keyOf = (a, b) => {
|
||||
const left = `${a[0]},${a[1]}`;
|
||||
const right = `${b[0]},${b[1]}`;
|
||||
return left < right ? `${left}|${right}` : `${right}|${left}`;
|
||||
};
|
||||
const wallId = (a, b) => {
|
||||
const key = keyOf(a, b);
|
||||
let id = segmentIds.get(key);
|
||||
if (!id) {
|
||||
id = `wall-314-${String(wallSegments.length + 1).padStart(3, '0')}`;
|
||||
segmentIds.set(key, id);
|
||||
wallSegments.push({ id, a: [...a], b: [...b], cm: 15 });
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const rooms = Array.from({ length: 13 }, (_, index) => {
|
||||
const x0 = 4 + index * 12;
|
||||
const x1 = x0 + 12;
|
||||
const y0 = 4;
|
||||
const y1 = 16;
|
||||
const poly = [point(x0, y0), point(x1, y0), point(x1, y1)];
|
||||
// Four split top edges make the catalogue population exactly 44 while
|
||||
// keeping all rooms valid and non-overlapping.
|
||||
if (index < 4) poly.push(point(x0 + 6, y1));
|
||||
poly.push(point(x0, y1));
|
||||
return {
|
||||
id: `room-314-${String(index + 1).padStart(2, '0')}`,
|
||||
name: `Room ${index + 1}`,
|
||||
poly,
|
||||
wall_ids: poly.map((a, edge) => wallId(a, poly[(edge + 1) % poly.length])),
|
||||
};
|
||||
});
|
||||
|
||||
const partitions = Array.from({ length: 24 }, (_, index) => {
|
||||
const row = Math.floor(index / 8);
|
||||
const x = 4 + (index % 8) * 18;
|
||||
const y = 28 + row * 8;
|
||||
return {
|
||||
id: `partition-314-${String(index + 1).padStart(2, '0')}`,
|
||||
a: point(x, y),
|
||||
b: point(x + 8, y + 2),
|
||||
cm: 15 + (index % 3) * 5,
|
||||
};
|
||||
});
|
||||
|
||||
if (wallSegments.length !== 44) {
|
||||
throw new Error(`fixture catalogue drifted: expected 44, got ${wallSegments.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
model_version: 8,
|
||||
spaces: [{
|
||||
id: 'space-314-dense',
|
||||
title: 'Dense regression fixture',
|
||||
cell_cm: 5,
|
||||
view_box: [0, 0, 400, 400],
|
||||
rooms,
|
||||
wall_segments: wallSegments,
|
||||
partitions,
|
||||
room_drafts: [{
|
||||
id: 'draft-314-known-debt',
|
||||
points: [point(4, 56), point(14, 64)],
|
||||
segments: [{ id: 'wall-314-known-draft', cm: 15 }],
|
||||
}],
|
||||
}],
|
||||
markers: [],
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -57,6 +48,11 @@ const lightingRooms = [
|
||||
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
|
||||
];
|
||||
|
||||
const applianceRooms = [
|
||||
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
|
||||
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
|
||||
];
|
||||
|
||||
const geometrySpace = {
|
||||
id: 'golden-geometry',
|
||||
title: 'Geometry matrix',
|
||||
@@ -122,7 +118,25 @@ const lightingSpace = {
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = () => {
|
||||
const applianceSpace = {
|
||||
id: 'golden-appliance',
|
||||
title: 'Appliance lifecycle',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
|
||||
sun_rays: false, bg_mode: 'static',
|
||||
},
|
||||
rooms: applianceRooms,
|
||||
walls: wallsFor('appliance', applianceRooms, 15),
|
||||
openings: [],
|
||||
partitions: [],
|
||||
wall_columns: [],
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = (includeAppliance = false) => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
@@ -136,7 +150,8 @@ const runtime = () => {
|
||||
// The production projection must preserve them.
|
||||
const layout = {};
|
||||
const areas = Object.fromEntries(
|
||||
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
||||
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
|
||||
.map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
||||
);
|
||||
const add = (id, domain, area, x, y, state, attributes = {}) => {
|
||||
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
|
||||
@@ -171,6 +186,44 @@ const runtime = () => {
|
||||
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
|
||||
if (includeAppliance) {
|
||||
const washerId = 'golden-washer';
|
||||
devices[washerId] = {
|
||||
id: washerId,
|
||||
name: 'Golden washing machine',
|
||||
model: 'GOLDEN-WASHER-COMPOSITE',
|
||||
area_id: 'golden_appliance',
|
||||
identifiers: [['houseplan_golden', washerId]],
|
||||
config_entries: ['golden_entry'],
|
||||
entry_type: null,
|
||||
via_device_id: null,
|
||||
disabled_by: null,
|
||||
};
|
||||
const addWasherEntity = (entityId, state, attributes = {}, registry = {}) => {
|
||||
entities[entityId] = {
|
||||
entity_id: entityId,
|
||||
device_id: washerId,
|
||||
platform: 'houseplan_golden',
|
||||
config_entry_id: 'golden_entry',
|
||||
disabled_by: null,
|
||||
...registry,
|
||||
};
|
||||
states[entityId] = {
|
||||
entity_id: entityId,
|
||||
state,
|
||||
attributes: { friendly_name: registry.original_name || entityId, ...attributes },
|
||||
};
|
||||
};
|
||||
addWasherEntity('switch.golden_washer_power', 'on', {}, { original_name: 'Power' });
|
||||
addWasherEntity('switch.golden_washer_child_lock', 'off', {}, { original_name: 'Child lock' });
|
||||
addWasherEntity('sensor.golden_washer_status', 'done', {}, {
|
||||
original_name: 'Status', translation_key: 'status',
|
||||
});
|
||||
addWasherEntity('sensor.golden_washer_stage', 'Rinse', {}, { original_name: 'Stage' });
|
||||
addWasherEntity('sensor.golden_washer_program', 'mixed_wash', {}, { original_name: 'Program' });
|
||||
layout[washerId] = { s: 'golden-appliance', x: 0.5, y: 0.5 };
|
||||
}
|
||||
return { devices, entities, states, layout, areas };
|
||||
};
|
||||
|
||||
@@ -182,9 +235,12 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = () => ({
|
||||
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
|
||||
config: {
|
||||
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
|
||||
spaces: [
|
||||
structuredClone(geometrySpace), structuredClone(lightingSpace),
|
||||
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
|
||||
],
|
||||
// A persisted marker is part of the fixture contract for scenarios that
|
||||
// override per-source Glow controls. The device/layout alone are not a
|
||||
// saved marker configuration and must not be silently treated as one.
|
||||
@@ -201,6 +257,10 @@ export const makeVisualMatrixFixture = () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(),
|
||||
counts: VISUAL_MATRIX_COUNTS,
|
||||
...runtime(applianceLifecycle),
|
||||
counts: applianceLifecycle ? {
|
||||
...VISUAL_MATRIX_COUNTS,
|
||||
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
|
||||
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
|
||||
} : VISUAL_MATRIX_COUNTS,
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -28,6 +28,20 @@ radial spokes visible instead of hiding them under a translucent room fill.
|
||||
- `accept` requires `--reviewed`, a complete candidate report and current
|
||||
source fingerprint. It validates the whole set before copying anything and
|
||||
is the only command allowed to update baselines.
|
||||
- `scripts/golden-accept.mjs` wraps `accept` and additionally requires the
|
||||
reviewer to declare intent, with two separate flags because they assert two
|
||||
different things. `--expect-change=<id,id>` means "I know why this existing
|
||||
baseline moved"; `--expect-new=<id,id>` means "I have looked at this new
|
||||
frame". Anything that differs, or arrives without a baseline, and is not named
|
||||
refuses the whole acceptance before a single file is copied; naming a scenario
|
||||
under the wrong flag refuses it too. Only the named scenarios are written:
|
||||
everything else keeps its reviewed bytes and its manifest hash, because
|
||||
`passed` means "within threshold", not "byte-identical", and copying every
|
||||
candidate let sub-threshold drift ratchet the baselines to the newest
|
||||
environment unseen (#351). The first flag is what makes a local
|
||||
capture admissible (see below) and blocks the one-command "accept everything so
|
||||
CI turns green"; the second stops an empty or clipped frame from becoming the
|
||||
contract unseen (#350).
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -35,7 +49,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
|
||||
```
|
||||
|
||||
@@ -43,17 +57,57 @@ Review `artifacts/golden/actual/` and, when existing references are present,
|
||||
`artifacts/golden/diff/`. If every image is intentional:
|
||||
|
||||
```bash
|
||||
npm run golden:accept -- --reviewed
|
||||
node scripts/golden-accept.mjs --reviewed --expect-change=wall-junctions-plan-t-dark
|
||||
npm run golden:verify
|
||||
```
|
||||
|
||||
Never accept images merely to make CI green. A matrix/framing change increments
|
||||
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
|
||||
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
|
||||
Future updates must still use the `golden-images` artifact produced by the Linux
|
||||
CI job as the review set: desktop font rasterisation can differ from the CI
|
||||
environment even with the same pinned Chromium. Pass its unpacked root via
|
||||
`--from=...` when accepting it locally.
|
||||
|
||||
## Capturing candidates without a second CI round trip (#334)
|
||||
|
||||
Accepting from the `golden-images` CI artifact still works and is still the
|
||||
safest route: unpack it and pass `--from=...`. It costs two full CI runs per
|
||||
visual fix, though — one to produce the artifact and one to verify the accepted
|
||||
baseline — and at matrix version 48 that toll is paid often.
|
||||
|
||||
A local capture is admissible instead, because admissibility is now *proved*
|
||||
rather than assumed. Desktop font rasterisation can differ from the runner, but
|
||||
it cannot differ quietly: it would move every text-bearing scenario, not only
|
||||
the ones under edit. So the rule is simply that the capture must reproduce every
|
||||
accepted baseline the reviewer did not intend to change:
|
||||
|
||||
```bash
|
||||
node scripts/golden-container.mjs # capture in the pinned image
|
||||
node scripts/golden-accept.mjs --reviewed --expect-change=<the scenarios you changed>
|
||||
```
|
||||
|
||||
If the environment is not pixel-equivalent, unrelated scenarios come out
|
||||
`different`, the wrapper names them and refuses. A wrong container tag or a
|
||||
mismatched font set therefore cannot corrupt baselines — it can only fail.
|
||||
|
||||
`scripts/golden-container.mjs` derives the image tag from the `playwright`
|
||||
version locked in `package-lock.json`, so the container Chromium equals the
|
||||
runner's; `--image=` overrides it when a distro-specific tag is needed
|
||||
(`...:v1.62.0-jammy`). The host `node_modules` is shadowed by an anonymous
|
||||
volume: the repository copy may be built for Windows, and `npm ci` inside the
|
||||
container would otherwise replace it with Linux binaries. The run does write
|
||||
`dist/` and the three bundle copies, exactly as a local `npm run bundle:sync`
|
||||
would.
|
||||
|
||||
Docker is not a requirement of the rule, only a convenience: any Linux
|
||||
environment that satisfies the parity condition qualifies, WSL included. The
|
||||
`chromium` string recorded in the manifest keeps the browser build itself
|
||||
pinned, and `golden:verify` rejects a manifest captured by a different build.
|
||||
|
||||
`scripts/golden-accept.mjs` deliberately wraps `demo/golden/accept.mjs` instead
|
||||
of replacing its checks: every `.mjs` under `demo/golden` belongs to
|
||||
`sourceFingerprint`, so editing the acceptance tool itself would declare the
|
||||
committed bundle, the documentation screenshot manifest and the baseline
|
||||
manifest stale — the very double round trip this change removes. Narrowing that
|
||||
corpus is worthwhile but separate: `scripts/source-fingerprint.mjs` is itself a
|
||||
build input, so any change to it forces one bundle rebuild.
|
||||
|
||||
Scenarios may also declare a semantic pixel region (for example, a receiving
|
||||
room that must contain warm light). `golden:capture` and `golden:verify` reject
|
||||
|
||||
@@ -6,11 +6,21 @@ import { fileURLToPath } from 'node:url';
|
||||
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
||||
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
|
||||
import {
|
||||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||||
} from '../../scripts/golden-acceptance.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const reviewed = process.argv.includes('--reviewed');
|
||||
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
|
||||
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
|
||||
const list = (name) => {
|
||||
const found = process.argv.find((arg) => arg.startsWith(`--${name}=`));
|
||||
return (found ? found.slice(name.length + 3) : '')
|
||||
.split(',').map((id) => id.trim()).filter(Boolean);
|
||||
};
|
||||
const declared = list('expect-change');
|
||||
const declaredNew = list('expect-new');
|
||||
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
|
||||
|
||||
const reportPath = resolve(from, 'golden-report.json');
|
||||
@@ -24,11 +34,29 @@ if (typeof report.chromium !== 'string' || !report.chromium)
|
||||
throw new Error('candidate report does not identify its Chromium build');
|
||||
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
|
||||
|
||||
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
|
||||
if (refusal) throw new Error(refusal);
|
||||
|
||||
const byId = new Map(report.results.map((result) => [result.id, result]));
|
||||
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
||||
mkdirSync(baselineRoot, { recursive: true });
|
||||
const hashes = {};
|
||||
const candidates = [];
|
||||
/**
|
||||
* Прежний индекс: источник хешей для сцен, которые остаются как были (#351).
|
||||
*
|
||||
* `passed` не значит «байт в байт» — он значит «в пределах порога». Прежняя
|
||||
* версия копировала кандидата поверх КАЖДОГО эталона, поэтому подпороговый
|
||||
* дрейф уезжал в контракт молча, и накапливался: каждая приёмка подтягивала
|
||||
* эталон к последней среде, порог не пересекался никогда, а эталон уходил.
|
||||
* Так `1e341c60` заменил 22 картинки, объявив четыре. Владелец делал эту работу
|
||||
* руками (`ad3f9981`: «nine unrelated baselines … were restored to their
|
||||
* reviewed versions»); теперь её делает инструмент.
|
||||
*/
|
||||
const manifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
||||
const previous = existsSync(manifestPath)
|
||||
? JSON.parse(readFileSync(manifestPath, 'utf8')).scenarios || {}
|
||||
: {};
|
||||
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
|
||||
// не имеет права оставить каталог эталонов половинным.
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
const result = byId.get(scenario.id);
|
||||
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
|
||||
@@ -36,16 +64,20 @@ for (const scenario of GOLDEN_SCENARIOS) {
|
||||
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
|
||||
if (!result?.actualSha256 || !existsSync(candidate))
|
||||
throw new Error(`review candidate missing: ${scenario.id}`);
|
||||
const bytes = readFileSync(candidate);
|
||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||
const digest = createHash('sha256').update(readFileSync(candidate)).digest('hex');
|
||||
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
|
||||
candidates.push({ scenario, candidate });
|
||||
hashes[scenario.id] = digest;
|
||||
}
|
||||
// Validate the complete set first: a broken report must never leave a half-
|
||||
// updated baseline directory behind.
|
||||
for (const { scenario, candidate } of candidates)
|
||||
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
|
||||
const plan = goldenAcceptancePlan({
|
||||
scenarioIds: GOLDEN_SCENARIOS.map((scenario) => scenario.id),
|
||||
results: report.results,
|
||||
previousHashes: previous,
|
||||
declared,
|
||||
declaredNew,
|
||||
});
|
||||
const hashes = plan.hashes;
|
||||
for (const id of plan.replace) {
|
||||
copyFileSync(resolve(from, 'actual', `${id}.png`), resolve(baselineRoot, `${id}.png`));
|
||||
}
|
||||
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
|
||||
schema: 1,
|
||||
matrixVersion: GOLDEN_MATRIX_VERSION,
|
||||
@@ -54,4 +86,11 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
|
||||
chromium: report.chromium,
|
||||
scenarios: hashes,
|
||||
}, null, 2)}\n`, 'utf8');
|
||||
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
|
||||
const silent = goldenSilentDeclarations(report.results, declared);
|
||||
if (silent.length) {
|
||||
console.log(`Объявлены как изменённые, но совпали с эталоном: ${silent.join(', ')}.`);
|
||||
}
|
||||
console.log(`Заменено эталонов: ${plan.replace.length}`
|
||||
+ `${plan.replace.length ? ` (${[...plan.replace].sort().join(', ')})` : ''}.`);
|
||||
console.log(`Сохранено без изменений: ${plan.keep.length}.`);
|
||||
console.log(`Индекс перезаписан на ${GOLDEN_SCENARIOS.length} сцен.`);
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,69 +1,145 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 22,
|
||||
"acceptedAt": "2026-08-14T16:03:22.017Z",
|
||||
"sourceFingerprint": "24117bcfb4c94d5421e0304612089743615233eab3911f065e8cfa003f48fa7d",
|
||||
"matrixVersion": 49,
|
||||
"acceptedAt": "2026-08-28T09:41:50.717Z",
|
||||
"sourceFingerprint": "b6d9eb672c772bca7fa08bf25b4525cc8bbf3084320f383f16ab86a535090444",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"hidden-wall-diagnostics-plan-light": "37ba151aa6b1ea7b1b270d461ccca348444c5eeb14a5592bf045a0e69b0fb83e",
|
||||
"hidden-wall-diagnostics-plan-dark": "565d5481f744704a2a6f18658b9b6fa65e7cdbfcd11b50a2bedbc0daae2d8488",
|
||||
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
|
||||
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
|
||||
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
|
||||
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
|
||||
"span-over-door-migrated-dark": "a817f5baacf5ff80d1f236ffaa176cb6331b75423393dae5b16e54f67f37c7e0",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
|
||||
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
|
||||
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
|
||||
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
|
||||
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
|
||||
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
|
||||
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
|
||||
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
|
||||
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
|
||||
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
|
||||
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
|
||||
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
|
||||
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
|
||||
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
|
||||
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
|
||||
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
|
||||
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
|
||||
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
|
||||
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
|
||||
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
|
||||
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
|
||||
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
|
||||
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
|
||||
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
|
||||
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
|
||||
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
|
||||
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
|
||||
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
|
||||
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
|
||||
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
|
||||
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
|
||||
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
|
||||
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
|
||||
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
|
||||
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
|
||||
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
|
||||
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
|
||||
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
|
||||
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
|
||||
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
|
||||
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
|
||||
"backup-space-preview-mobile-ru": "16d06859ea6aac6cbed586f0d6918da972e9c95206fffd771fe43b6084f79877"
|
||||
"split-corner-wall-thin-dark": "48062a49e12728c377a7c4352148381b1be2a5606b6792d754ae055b04c22be1",
|
||||
"split-corner-wall-thick-dark": "a5d795eec4561e3a1aa9c37843fadddcb04d888d5a49e6ffacf29c17b7798568",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
|
||||
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
|
||||
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
|
||||
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
|
||||
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
|
||||
"isometric-large-warm-remount-dark": "299401bd4b499876d40678593308b42ac8aa4d3328113f27ad5940fc3621184a",
|
||||
"geometry-view-dark-fit": "2d96e776ea9fc8a136896b58df30abb13a8a53e6c40e5b4f4e12c6dba522380f",
|
||||
"geometry-view-light-fit": "60e8a908fc1c3be3ca5c9b82bf432120d808d913f743c04683b4a2c501d84659",
|
||||
"room-label-parity-view-dark": "b35675251b85144bca1bc15ed7bf27a4dd1eaa0bfc288e3e3b0d1cdefac79ae5",
|
||||
"room-label-parity-plan-dark": "34c1010481e2799b8598e3c6f659ca7ec8e695a064eecacf3562000639f44d6a",
|
||||
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
|
||||
"room-label-parity-plan-light": "272dc35d85b1f07efc4c2a05c31f6adbf7d10c648e3c678e7235e35167f83a7d",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "be3b891520637857b7f89dc88284753a15601ebae4db7db750714b9535348d33",
|
||||
"day-cycle-day-dark": "1b791b8a773a5910a71338351ab12fdc36c22742a358b647ff4f295431fa4cfd",
|
||||
"day-cycle-dusk-dark": "14182ad89301cb260ba21ee5077134ed408f038ea878ed4539b84f987182f910",
|
||||
"day-cycle-night-dark": "407632fc54e13e6362b7e583d6e2b7bdd31b4093823846e4e630de0c8f542120",
|
||||
"geometry-plan-editor-dark": "40a0083511c1ff9d0b8f1ec419af63e2a28f1f9a0ec3768387a10c89efe73905",
|
||||
"safe-resize-handles-clamp-light": "f47a5bb09fa4415416d01ca5f4164014cc1d8dc7be54ea888e5399277104e94c",
|
||||
"safe-resize-handles-clamp-dark": "3efd0ca2db8a06aa475e81e3635b878c3038d4763f840e6e856661b04a4fed96",
|
||||
"space-tab-drop-before-light": "7024186821b13feb63ba254aee841379788bca1a5c01bfd07c42e15fe6b81179",
|
||||
"space-tab-drop-after-dark": "5a2199e8b8364da9e85e0887334144e426f43b8022f2918bf130ea13cd099f5d",
|
||||
"plan-snap-endpoint-light": "2185d5ec6726e07097ba1a911984a11d5ca9b460a1cd1e7b2a719255b7d0ed0b",
|
||||
"plan-snap-line-gaps-dark": "276eb10f32be938640bd68a1cf837494f11cce8d10703721831360f3a1100d23",
|
||||
"wall-junctions-plan-preview-light": "965860eb4302b60864b5e3191514c60b97bbcd6073960c437a973b4a880ecddb",
|
||||
"wall-junctions-plan-t-dark": "258869fb3c5af9a223d63518bc6127bfde78c2c039d39583a965d68b225338ba",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "90acdf0658772c2883e775dd6972141ad699e165afcd9bf55382fbd99c203717",
|
||||
"junction-patch-resilience-view-dark": "afd83241d9a1e1f19ab59c3f1e6fa7af058c3e42a7fafa5ba271ff92ccea8f91",
|
||||
"wall-union-isolation-view-light": "106f8ac97b82107fed36aa4456c0b9f7a39eeb44be86864b1d4f083497cdbd6e",
|
||||
"wall-union-isolation-view-dark": "914451a813546189bdc9ac22dcf158bbc822b4403080a3c2fd4c6b02bb7780a4",
|
||||
"multiwall-junction-bevel-view-dark": "b80c4584a649722897c206d242a9df88d80169806a73db579b45595173b698b8",
|
||||
"orthogonal-strip-cell-5-view-dark": "66a288f7e7fc6228c3c63f10e1df8df0656ca0cd36681d998b126b03acb70b5a",
|
||||
"orthogonal-strip-cell-1-view-dark": "c23e6d0410b8f5c2b7fb0707f6bea40408730af08f9e7a0b166aa8c0a3d6c85f",
|
||||
"wall-key-roundtrip-view-dark": "d7b0749d0f6c0cfa74ad4d9f76d8a7b7962fadf4e2648260b5647548a988341c",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "c21c2af812b847e7fd9a3a5b9df832db95266f81eba3d7b48c97d2b32619e7fe",
|
||||
"opening-placement-passage-thick-wall-dark": "90f9a837898169c2d9ff9ec3b44b2a3d247ea034fd84b1753519da7b0c89ebdb",
|
||||
"opening-placement-passage-thick-wall-light": "dd17884027d85f5727450cd08f3fe6daaed290770dbd04fe5ce10c4c2b9d43db",
|
||||
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "acc779a2a21d5c2a26f40d1a23495cafaffc460c57c1100660fb3cde554303f1",
|
||||
"device-inbox-desktop-en-light": "c04254626f613b11e1b9165abf6bfad4b04a2670acab2887e38062d7d16b4533",
|
||||
"device-inbox-desktop-ru-dark": "2e7e7175ed770fea657828beda68b62d6eb657a4b72222b1977df3d7d692b0d6",
|
||||
"device-inbox-narrow-ru-dark": "5263f518d4a415caedbb7917c79f9acbb03de234feec11e3128c235d2bd5b39f",
|
||||
"geometry-decor-editor-dark": "2a0d4368e49b51fc4fcf5ee72679efb7b7e094fd8b6f47682b0696ec909c423e",
|
||||
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
|
||||
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
|
||||
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
|
||||
"tray-medium-selection-ru": "bd97bee66778a65ebb849331724a307d0b089d6cdf5f3b85c6c905c413d5290f",
|
||||
"tray-narrow-palette-en": "10073be9fdf658275f77643505c3523bcddce6a1890deda5b1820958b90613d1",
|
||||
"tray-narrow-tool-ru": "95fa0f072efb595e1f9fe3f3287e7ce346fdee640e90dd70e4f5e5b31049ef29",
|
||||
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
|
||||
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
|
||||
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
|
||||
"decor-over-opaque-hover-light": "eae79026256367a4cfaa5f99393ec9c66dbf23908c50ccc0f541c139d1baeda0",
|
||||
"decor-over-glow-base-dark": "72a83f08f832c219d8e0e1b6f94d689c250342aa95d30e8c1f79cf9f9548ccab",
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
|
||||
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
|
||||
"device-icon-state-table-light": "85c785824ccafc2d5f50e834ad2d746319d64ecb292d1b9cf7fab8661d5d410d",
|
||||
"device-icon-state-table-dark": "03a90523132c3dbe6c78d44d01fe89df93e2d0474f91ef84e7107e6e4bc7d337",
|
||||
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
|
||||
"device-text-shell-long-dark": "c8fe32aced704c7e7562f0c8499183fad848a977063ab731401cc01e7f20ef38",
|
||||
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
|
||||
"lighting-fill-light-axis-split-dark": "e45d44496c83ad92f8d5bd058821e6d71c97c48a2b66c1933206535aea2655c1",
|
||||
"lighting-fill-temp-axis-split-dark": "895e1e0beee38bf659b1313ed9951353a7792d2e8ae3a8620c4cf7a4f6a84a4d",
|
||||
"lighting-fill-lqi-axis-split-dark": "4a029b17419aaf60feab23f7f0cb385d97353e3aec0e39ed67e9ac94f6c08029",
|
||||
"lighting-temp-glow-dark": "3ce504d83fda2ba5d772c25f8cbb47c379b335addd8650f6933b58f22f5655a7",
|
||||
"lighting-temp-glow-light": "e9f2db0a686a9e20bf71564c10bab826a2a473c73d572a6b67ef02a735d61e8e",
|
||||
"lighting-custom-glow-dark": "94cc6135573aa9462de9e7f309dea62e236668b84970bae35d48f1aad338f8f4",
|
||||
"lighting-opaque-glow-two-doorways-dark": "ee3c53a408f6b62aa6b770925fa6948934e0c1c6e73877e379695bc260b0ceac",
|
||||
"lighting-custom-glow-light": "aa9477aaff4d137abfcb6d67a5cf62b91f41b3631383ee8edb7499802a5c9b87",
|
||||
"lighting-temp-glow-no-sources-dark": "cd992af54d1c9dcc58bbd0b21b15b475be6f19eaabcf9ea866bfa82f205460c9",
|
||||
"lighting-temp-glow-room-override-dark": "66a418829b51379d024c542f8b9a12bd42d72afa81a6bf66561d6498ac4cdb45",
|
||||
"lighting-manual-auto-spill-overlap-dark": "0dc780335317d6dd577c8d5539f33223228074e1c95e936e4666f50a8db09739",
|
||||
"hover-over-glow-dark": "49136f6073662482ebf49a28f8f22b698d2c20a9cb64b5667d111b9835717968",
|
||||
"hover-nested-room-dark": "b546f923fd31d28d2881f074e3a67c5d9d3f9450169f9118502ecafc98c4467d",
|
||||
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
|
||||
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
|
||||
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
|
||||
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
|
||||
"junction-x-45-alternating-dark": "49192516aabc14e84a2da4d1361c1be57d24e5c947b199cdf8b6c55fd7318504",
|
||||
"junction-star5-equal15-dark": "2fd822f73f486173700405208168300d27d4e360b17cb278c7ee7bc1cda3769e",
|
||||
"junction-y-60-equal50-dark": "7a61dbfc36437674c2c2bf5f450a64797e21a124f42c0e45bfb56e3236e0b53e",
|
||||
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
|
||||
"junction-acute15-equal50-dark": "e7e657137e8dcf6294d400f3114497318c055405dbc0d867603514b56c42b907",
|
||||
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
|
||||
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
|
||||
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
|
||||
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
|
||||
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
|
||||
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
|
||||
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a",
|
||||
"sharp-apex-legacy-dark": "93a46c66f7263a0325d0c4d50fa0677f3c902647ea5880960b99dc8fa04a01a5",
|
||||
"junction-owner-repro-dark": "f5407d420449ebf897f94fcfcdaaf4630e818c043d3076b090f7d1944df5296b",
|
||||
"large-house-zoom-040-dark": "736af1c340646c4c1cbab3d2270f67a535995e1ea1e2abe49c91d2c396052097",
|
||||
"large-house-zoom-250-dark": "0a74099e265b38f376bb7bfd86d4a57c5d493ac3870478a7d55c48cf99e8f892",
|
||||
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
|
||||
"device-dialog-desktop-en": "e142521eae1bd40861c1d92eadbcfd6d88aefa689cd5ee9b4c1b6378c8ce712c",
|
||||
"device-dialog-mobile-ru": "7921b7d5e2c082fc4dd5053607a8228c2e63e0e72a401d86153effd54c2c6ca0",
|
||||
"device-dialog-desktop-de": "ac3f657e072006f5c1686d89da61df922991a04f69d2d7994a03d1ba93532681",
|
||||
"german-view-mobile-light": "9496071b11f510f1c851a9c60583a96bfe768c1fd1e12a2b1b9183dee7e453a8",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "77e8fc03817a784888993490cdfba675482b8c56483199c1efa512db8531516b",
|
||||
"device-help-popover-light-ru": "259a50065b7f09d07db656b6cfe79d1de34397665ff8dd8a8e361f6f29a7ea6a",
|
||||
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
|
||||
"decor-color-popover-desktop-en": "98d769f5393afb2bad3fb5143a562da5b8bb9a5c8049f4c707706bbcb4f99f8d",
|
||||
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
|
||||
"device-ripple-color-popover-mobile-ru": "4f2365818f46fe1e2b401e5835f5d29ea9c41b288d894025d7512db92f579762",
|
||||
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
|
||||
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
|
||||
"backup-plan-only-export-desktop-en": "805fb7cd15959a7eee0b17150c4417e335f1b1030a438f7051c2b006a5f7d4f4",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "0473d7f16dd830aaddbec5c33f2b423fde48e2053a1094dbab052e6a2cbad9d4",
|
||||
"optimize-preflight-dialog-light-ru": "a4eb6ef9930652bd7e55fbb725fd1bcc5a0e5b71e8060dbf6f772dd62cec5d36",
|
||||
"optimize-orphan-references-dark-en": "f40417298e126fa804ddafb91d62b728c86a56c5a28b4fad12b732070faa4ad6",
|
||||
"optimize-orphan-references-light-ru": "2d0319f681cfbe472ad5dede05d145a995dedee577d8d816e2fe46a519e019f5",
|
||||
"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: 100 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 347 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 332 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 |
|
After Width: | Height: | Size: 37 KiB |