Skip to main content

atmos/os_lib/css/
selftest.rs

1// 分割: css.rs より機械的に移動(2026-07-16 リファクタ フェーズ3)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5/// CSS セレクタの自己テスト。各ケースで HTML をパースし、`css` を適用したとき
6/// `id` の要素にマーカープロパティ `zz` が付くか(= セレクタが一致したか)を確認する。
7pub fn selftest() -> (usize, usize) {
8    let cases: &[(&str, &str, &str, bool)] = &[
9        // 属性セレクタ
10        (
11            "<input id='t' type='text'>",
12            "input[type=text]{zz:1}",
13            "t",
14            true,
15        ),
16        (
17            "<input id='t' type='text'>",
18            "input[type=checkbox]{zz:1}",
19            "t",
20            false,
21        ),
22        // 属性セレクタの大文字小文字無視フラグ `i`(Selectors Level 4 §6.4.1)。
23        // 以前は末尾の ` i` がクォート付き値の一部として値へ混入し
24        // (`"checkbox" i` → 値 `checkbox" i`)常に不一致になる静かな破壊バグだった。
25        (
26            "<input id='t' type='CHECKBOX'>",
27            "input[type=\"checkbox\" i]{zz:1}",
28            "t",
29            true,
30        ),
31        // 値がクォート無しでもフラグは正しく分離される。
32        (
33            "<input id='t' type='CHECKBOX'>",
34            "input[type=checkbox i]{zz:1}",
35            "t",
36            true,
37        ),
38        // フラグ無し(既定)は引き続き大文字小文字を区別する。
39        (
40            "<input id='t' type='CHECKBOX'>",
41            "input[type=checkbox]{zz:1}",
42            "t",
43            false,
44        ),
45        // `s` フラグ(明示的に区別。既定と同じ動作)。
46        (
47            "<input id='t' type='CHECKBOX'>",
48            "input[type=\"checkbox\" s]{zz:1}",
49            "t",
50            false,
51        ),
52        (
53            "<div id='t' data-role='nav main'></div>",
54            "[data-role~=main]{zz:1}",
55            "t",
56            true,
57        ),
58        (
59            "<a id='t' href='https://x'></a>",
60            "[href^='https']{zz:1}",
61            "t",
62            true,
63        ),
64        (
65            "<a id='t' href='/page.html'></a>",
66            "[href$='.html']{zz:1}",
67            "t",
68            true,
69        ),
70        (
71            "<a id='t' href='/img/a.png'></a>",
72            "[href*='img']{zz:1}",
73            "t",
74            true,
75        ),
76        ("<div id='t' class='box'></div>", "[class]{zz:1}", "t", true),
77        ("<div id='t'></div>", "[class]{zz:1}", "t", false),
78        // :checked / :disabled(DOM 属性の有無で判定する簡易実装)
79        (
80            "<input id='t' type='checkbox' checked>",
81            "input:checked{zz:1}",
82            "t",
83            true,
84        ),
85        (
86            "<input id='t' type='checkbox'>",
87            "input:checked{zz:1}",
88            "t",
89            false,
90        ),
91        (
92            "<button id='t' disabled>x</button>",
93            "button:disabled{zz:1}",
94            "t",
95            true,
96        ),
97        (
98            "<button id='t'>x</button>",
99            "button:disabled{zz:1}",
100            "t",
101            false,
102        ),
103        // `:enabled`(`:disabled` の対)が丸ごと未対応だった。
104        (
105            "<button id='t' disabled>x</button>",
106            "button:enabled{zz:1}",
107            "t",
108            false,
109        ),
110        (
111            "<button id='t'>x</button>",
112            "button:enabled{zz:1}",
113            "t",
114            true,
115        ),
116        // `:indeterminate`(checkbox の不確定表示状態。`indeterminate` は本来
117        // HTML 属性として存在しない JS 専用プロパティのため、内部専用の
118        // `_indeterminate` 属性キー経由で判定される。フィクスチャは JS の
119        // `checkbox.indeterminate = true` が書き込む内部キーを直接模擬する)。
120        (
121            "<input id='t' type='checkbox' _indeterminate>",
122            "input:indeterminate{zz:1}",
123            "t",
124            true,
125        ),
126        // `:user-valid`/`:user-invalid`(CSS Selectors Level 4)が丸ごと未対応
127        // だった。`_indeterminate`と同じ方針で、`interp.rs`の`dispatch_event`が
128        // `input`/`change`発火時に書き込む内部専用属性`_user_interacted`を
129        // フィクスチャで直接模擬する。ユーザー未操作なら`_user_interacted`が
130        // 無いため`:user-invalid`にも`:user-valid`にも一致しない。
131        (
132            "<input id='t' type='text' required _user_interacted>",
133            "input:user-invalid{zz:1}",
134            "t",
135            true,
136        ),
137        (
138            "<input id='t' type='text' required value='x' _user_interacted>",
139            "input:user-valid{zz:1}",
140            "t",
141            true,
142        ),
143        (
144            "<input id='t' type='text' required>",
145            "input:user-invalid{zz:1}",
146            "t",
147            false,
148        ),
149        // `:defined`(Selectors Level 4)が丸ごと未対応だった。`customElements`
150        // 自体が未実装のため、パースされるタグは全て標準組み込み要素のみ
151        // (常に「定義済み」)という正直な簡略実装。
152        (
153            "<div id='t'></div>",
154            "div:defined{zz:1}",
155            "t",
156            true,
157        ),
158        (
159            "<span id='t'></span>",
160            ":defined{zz:1}",
161            "t",
162            true,
163        ),
164        // `:fullscreen`が丸ごと未対応だった。`element.requestFullscreen()`が
165        // 書き込む内部専用属性`_fullscreen`をフィクスチャで直接模擬する。
166        (
167            "<div id='t' _fullscreen></div>",
168            "div:fullscreen{zz:1}",
169            "t",
170            true,
171        ),
172        (
173            "<div id='t'></div>",
174            "div:fullscreen{zz:1}",
175            "t",
176            false,
177        ),
178        // `:modal`が丸ごと未対応だった。フルスクリーン要素、または
179        // `dialog.showModal()`が書き込む`_modal`のいずれかに一致する。
180        (
181            "<dialog id='t' open _modal></dialog>",
182            "dialog:modal{zz:1}",
183            "t",
184            true,
185        ),
186        (
187            "<dialog id='t' open></dialog>",
188            "dialog:modal{zz:1}",
189            "t",
190            false,
191        ),
192        (
193            "<div id='t' _fullscreen></div>",
194            "div:modal{zz:1}",
195            "t",
196            true,
197        ),
198        // `:popover-open`が丸ごと未対応だった。`element.showPopover()`が
199        // 書き込む内部専用属性`_popover_open`をフィクスチャで直接模擬する。
200        (
201            "<div id='t' popover _popover_open></div>",
202            "div:popover-open{zz:1}",
203            "t",
204            true,
205        ),
206        (
207            "<div id='t' popover></div>",
208            "div:popover-open{zz:1}",
209            "t",
210            false,
211        ),
212        (
213            "<input id='t' type='checkbox'>",
214            "input:indeterminate{zz:1}",
215            "t",
216            false,
217        ),
218        // nth-child
219        (
220            "<ul><li></li><li id='t'></li><li></li></ul>",
221            "li:nth-child(2){zz:1}",
222            "t",
223            true,
224        ),
225        (
226            "<ul><li id='t'></li><li></li></ul>",
227            "li:nth-child(odd){zz:1}",
228            "t",
229            true,
230        ),
231        (
232            "<ul><li></li><li id='t'></li></ul>",
233            "li:nth-child(odd){zz:1}",
234            "t",
235            false,
236        ),
237        (
238            "<ul><li></li><li id='t'></li><li></li><li></li></ul>",
239            "li:nth-child(even){zz:1}",
240            "t",
241            true,
242        ),
243        (
244            "<ul><li></li><li></li><li id='t'></li></ul>",
245            "li:nth-child(3){zz:1}",
246            "t",
247            true,
248        ),
249        (
250            "<ul><li></li><li></li><li id='t'></li></ul>",
251            "li:nth-last-child(1){zz:1}",
252            "t",
253            true,
254        ),
255        // `:nth-child(-n+3)` — 負の `a`(「先頭N個」の定番イディオム)。ロジック自体は
256        // 既に正しく実装済みだったが、負の `a` に対する専用の自己テストが無かったため
257        // 追加しておく(`nth_matches` の `diff % a == 0 && diff / a >= 0` が
258        // 負の `a` でも正しく機能することを保証する回帰防止)。
259        (
260            "<ul><li></li><li></li><li id='t'></li><li></li></ul>",
261            "li:nth-child(-n+3){zz:1}",
262            "t",
263            true,
264        ),
265        (
266            "<ul><li></li><li></li><li></li><li id='t'></li></ul>",
267            "li:nth-child(-n+3){zz:1}",
268            "t",
269            false,
270        ),
271        (
272            "<ul><li id='t'></li><li></li><li></li></ul>",
273            "li:nth-child(-n+3){zz:1}",
274            "t",
275            true,
276        ),
277        // :nth-child(An+B of S)(Selectors Level 4)。以前は `of S` が丸ごと非対応で
278        // `S` を無視した単なる `:nth-child(An+B)` と同じ結果になっていた
279        // (`of` 以降を切り捨てるだけで An+B 自体は正しく解析されていたが、フィルタが
280        // 一切効かなかった)。2026-07-09 に `S` に一致する兄弟だけを数える実装を追加。
281        // 自分自身が `S` に一致しない要素は常に不一致。
282        (
283            "<ul><li id='t'></li><li></li></ul>",
284            "li:nth-child(2n+1 of .foo){zz:1}",
285            "t",
286            false,
287        ),
288        // `.foo` に一致する兄弟だけを数えると t は絞り込み後1-based位置2(偶数)。
289        (
290            "<ul><li class='foo'></li><li></li><li class='foo' id='t'></li><li class='foo'></li></ul>",
291            "li:nth-child(2n+1 of .foo){zz:1}",
292            "t",
293            false,
294        ),
295        (
296            "<ul><li class='foo'></li><li></li><li class='foo' id='t'></li><li class='foo'></li></ul>",
297            "li:nth-child(2n of .foo){zz:1}",
298            "t",
299            true,
300        ),
301        // :nth-last-child(An+B of S) — 絞り込み後の末尾から数える。
302        (
303            "<ul><li class='foo'></li><li></li><li class='foo' id='t'></li><li class='foo'></li></ul>",
304            "li:nth-last-child(1 of .foo){zz:1}",
305            "t",
306            false,
307        ),
308        (
309            "<ul><li class='foo'></li><li></li><li class='foo'></li><li class='foo' id='t'></li></ul>",
310            "li:nth-last-child(1 of .foo){zz:1}",
311            "t",
312            true,
313        ),
314        // :nth-last-of-type — 同タグ名の兄弟内で末尾から1-based。
315        (
316            "<div><li></li><p></p><li></li><li id='t'></li></div>",
317            "li:nth-last-of-type(1){zz:1}",
318            "t",
319            true,
320        ),
321        (
322            "<div><li id='t'></li><p></p><li></li><li></li></div>",
323            "li:nth-last-of-type(1){zz:1}",
324            "t",
325            false,
326        ),
327        (
328            "<ul><li id='t'></li><li></li><li></li></ul>",
329            "li:nth-child(2n+1){zz:1}",
330            "t",
331            true,
332        ),
333        // :not
334        ("<p id='t' class='a'></p>", "p:not(.b){zz:1}", "t", true),
335        ("<p id='t' class='b'></p>", "p:not(.b){zz:1}", "t", false),
336        // universal
337        ("<div id='t'></div>", "*{zz:1}", "t", true),
338        // 複合(属性 + nth)
339        (
340            "<ul><li class='x'></li><li id='t' class='x'></li></ul>",
341            ".x:nth-child(2){zz:1}",
342            "t",
343            true,
344        ),
345        // 兄弟結合子
346        (
347            "<div><h1></h1><p id='t'></p></div>",
348            "h1 + p{zz:1}",
349            "t",
350            true,
351        ),
352        (
353            "<div><h1></h1><span></span><p id='t'></p></div>",
354            "h1 + p{zz:1}",
355            "t",
356            false,
357        ),
358        (
359            "<div><h1></h1><span></span><p id='t'></p></div>",
360            "h1 ~ p{zz:1}",
361            "t",
362            true,
363        ),
364        (
365            "<div><p id='t'></p><h1></h1></div>",
366            "h1 ~ p{zz:1}",
367            "t",
368            false,
369        ),
370        (
371            "<ul><li class='a'></li><li id='t' class='b'></li></ul>",
372            ".a + .b{zz:1}",
373            "t",
374            true,
375        ),
376        // :checked / :disabled / :required / :readonly — 要素自身の属性から静的判定。
377        (
378            "<input id='t' type='checkbox' checked>",
379            "input:checked{zz:1}",
380            "t",
381            true,
382        ),
383        (
384            "<input id='t' type='checkbox'>",
385            "input:checked{zz:1}",
386            "t",
387            false,
388        ),
389        (
390            "<input id='t' disabled>",
391            "input:disabled{zz:1}",
392            "t",
393            true,
394        ),
395        ("<input id='t'>", "input:disabled{zz:1}", "t", false),
396        (
397            "<input id='t' required>",
398            "input:required{zz:1}",
399            "t",
400            true,
401        ),
402        // :optional は :required の否定。
403        ("<input id='t'>", "input:optional{zz:1}", "t", true),
404        (
405            "<input id='t' required>",
406            "input:optional{zz:1}",
407            "t",
408            false,
409        ),
410        // `:valid`/`:invalid` が丸ごと未対応だった。
411        (
412            "<input id='t' required>",
413            "input:invalid{zz:1}",
414            "t",
415            true,
416        ),
417        (
418            "<input id='t' required value='x'>",
419            "input:invalid{zz:1}",
420            "t",
421            false,
422        ),
423        (
424            "<input id='t' required value='x'>",
425            "input:valid{zz:1}",
426            "t",
427            true,
428        ),
429        (
430            "<input id='t' min='1' max='10' value='20'>",
431            "input:invalid{zz:1}",
432            "t",
433            true,
434        ),
435        (
436            "<input id='t' min='1' max='10' value='5'>",
437            "input:valid{zz:1}",
438            "t",
439            true,
440        ),
441        // `:valid`/`:invalid`のcheckbox/radio対応(2026-07-17)。以前は他のinputと
442        // 同じ「value属性が空か」で判定しており、value属性を持たないchecked済み
443        // チェックボックスが常に`:invalid`になる等、チェック状態と無関係な結果に
444        // なっていた。
445        (
446            "<input id='t' type='checkbox' required>",
447            "input:invalid{zz:1}",
448            "t",
449            true,
450        ),
451        (
452            "<input id='t' type='checkbox' required checked>",
453            "input:valid{zz:1}",
454            "t",
455            true,
456        ),
457        // radioは同じ親を持つ兄弟の中に1つでもcheckedがあれば自身は未checkedでも
458        // valid(グループ判定は同一親の兄弟スコープに限定した簡略実装)。
459        (
460            "<div><input id='t' type='radio' name='g' required><input type='radio' name='g' checked></div>",
461            "input:valid{zz:1}",
462            "t",
463            true,
464        ),
465        (
466            "<div><input id='t' type='radio' name='g' required><input type='radio' name='g'></div>",
467            "input:invalid{zz:1}",
468            "t",
469            true,
470        ),
471        (
472            "<input id='t' readonly>",
473            "input:readonly{zz:1}",
474            "t",
475            true,
476        ),
477        // `:read-write`(`:read-only`の対)が丸ごと未対応だった。
478        (
479            "<input id='t' readonly>",
480            "input:read-write{zz:1}",
481            "t",
482            false,
483        ),
484        (
485            "<input id='t'>",
486            "input:read-write{zz:1}",
487            "t",
488            true,
489        ),
490        // :placeholder-shown は value 未指定/空 + placeholder あり のときのみマッチ。
491        (
492            "<input id='t' placeholder='name'>",
493            "input:placeholder-shown{zz:1}",
494            "t",
495            true,
496        ),
497        (
498            "<input id='t' placeholder='name' value='x'>",
499            "input:placeholder-shown{zz:1}",
500            "t",
501            false,
502        ),
503        ("<input id='t'>", "input:placeholder-shown{zz:1}", "t", false),
504        // :in-range / :out-of-range — min/max に対する value の範囲判定。
505        (
506            "<input id='t' type='number' min='1' max='10' value='5'>",
507            "input:in-range{zz:1}",
508            "t",
509            true,
510        ),
511        (
512            "<input id='t' type='number' min='1' max='10' value='50'>",
513            "input:in-range{zz:1}",
514            "t",
515            false,
516        ),
517        (
518            "<input id='t' type='number' min='1' max='10' value='50'>",
519            "input:out-of-range{zz:1}",
520            "t",
521            true,
522        ),
523        (
524            "<input id='t' type='number' min='1' max='10' value='5'>",
525            "input:out-of-range{zz:1}",
526            "t",
527            false,
528        ),
529        // min/max がどちらも無ければ範囲制約自体が無いので、in-range/out-of-range 両方不一致。
530        (
531            "<input id='t' type='number' value='5'>",
532            "input:in-range{zz:1}",
533            "t",
534            false,
535        ),
536        // :default — checkbox/radio は checked 属性、option は selected 属性の有無で判定。
537        (
538            "<input id='t' type='checkbox' checked>",
539            "input:default{zz:1}",
540            "t",
541            true,
542        ),
543        (
544            "<input id='t' type='checkbox'>",
545            "input:default{zz:1}",
546            "t",
547            false,
548        ),
549        (
550            "<input id='t' type='text' checked>",
551            "input:default{zz:1}",
552            "t",
553            false,
554        ),
555        (
556            "<option id='t' selected>x</option>",
557            "option:default{zz:1}",
558            "t",
559            true,
560        ),
561        (
562            "<option id='t'>x</option>",
563            "option:default{zz:1}",
564            "t",
565            false,
566        ),
567        // :link / :visited — href を持つ a/area 要素のみ静的にマッチする。
568        // 履歴を保持していないため :visited は常に不一致(プライバシー上安全側の簡略実装)。
569        ("<a id='t' href='https://example.com'>x</a>", "a:link{zz:1}", "t", true),
570        ("<a id='t'>x</a>", "a:link{zz:1}", "t", false),
571        (
572            "<a id='t' href='https://example.com'>x</a>",
573            "a:visited{zz:1}",
574            "t",
575            false,
576        ),
577        // 構造疑似クラス: :first-of-type / :last-of-type / :nth-of-type / :only-of-type
578        // / :only-child / :empty
579        (
580            "<div><p></p><span id='t'></span><span></span></div>",
581            "span:first-of-type{zz:1}",
582            "t",
583            true,
584        ),
585        (
586            "<div><span></span><p></p><span id='t'></span></div>",
587            "span:first-of-type{zz:1}",
588            "t",
589            false,
590        ),
591        (
592            "<div><span></span><span id='t'></span><p></p></div>",
593            "span:last-of-type{zz:1}",
594            "t",
595            true,
596        ),
597        (
598            "<div><p></p><span></span><span id='t'></span><span></span></div>",
599            "span:nth-of-type(2){zz:1}",
600            "t",
601            true,
602        ),
603        (
604            "<div><p id='t'></p><span></span></div>",
605            "p:only-of-type{zz:1}",
606            "t",
607            true,
608        ),
609        (
610            "<div><p id='t'></p><p></p></div>",
611            "p:only-of-type{zz:1}",
612            "t",
613            false,
614        ),
615        ("<div><p id='t'></p></div>", "p:only-child{zz:1}", "t", true),
616        (
617            "<div><p id='t'></p><span></span></div>",
618            "p:only-child{zz:1}",
619            "t",
620            false,
621        ),
622        ("<div id='t'></div>", "div:empty{zz:1}", "t", true),
623        (
624            "<div id='t'><span></span></div>",
625            "div:empty{zz:1}",
626            "t",
627            false,
628        ),
629        (
630            "<div id='t'>text</div>",
631            "div:empty{zz:1}",
632            "t",
633            false,
634        ),
635        // :is() / :where() — リスト中いずれかにマッチすれば真。
636        (
637            "<p id='t' class='a'></p>",
638            ":is(.a, .b){zz:1}",
639            "t",
640            true,
641        ),
642        (
643            "<p id='t' class='c'></p>",
644            ":is(.a, .b){zz:1}",
645            "t",
646            false,
647        ),
648        (
649            "<div><section><p id='t'></p></section></div>",
650            ":is(section, article) p{zz:1}",
651            "t",
652            true,
653        ),
654        (
655            "<span id='t' class='x'></span>",
656            ":where(.x, .y){zz:1}",
657            "t",
658            true,
659        ),
660        // :has() — 子孫(任意の深さ)にリスト中いずれかが一致すれば真。
661        (
662            "<div id='t'><span><img></span></div>",
663            "div:has(img){zz:1}",
664            "t",
665            true,
666        ),
667        (
668            "<div id='t'><span></span></div>",
669            "div:has(img){zz:1}",
670            "t",
671            false,
672        ),
673        (
674            "<div id='t'><p class='note'>x</p></div>",
675            "div:has(.note, .warn){zz:1}",
676            "t",
677            true,
678        ),
679        // :has(> child) — 直接子コンビネータ。孫要素は一致しない(従来は子コンビネータ
680        // 自体が非対応で常に不一致だった)。
681        (
682            "<div id='t'><img></div>",
683            "div:has(> img){zz:1}",
684            "t",
685            true,
686        ),
687        (
688            "<div id='t'><span><img></span></div>",
689            "div:has(> img){zz:1}",
690            "t",
691            false,
692        ),
693        // :has(+ sel) — 直後の兄弟コンビネータ(以前は丸ごと非対応で常に不一致だった)。
694        (
695            "<div><p id='t'></p><img></div>",
696            "p:has(+ img){zz:1}",
697            "t",
698            true,
699        ),
700        (
701            "<div><p id='t'></p><span></span><img></div>",
702            "p:has(+ img){zz:1}",
703            "t",
704            false,
705        ),
706        // :has(~ sel) — 以降の兄弟いずれか(直後でなくても良い)。
707        (
708            "<div><p id='t'></p><span></span><img></div>",
709            "p:has(~ img){zz:1}",
710            "t",
711            true,
712        ),
713        (
714            "<div><p id='t'></p><span></span></div>",
715            "p:has(~ img){zz:1}",
716            "t",
717            false,
718        ),
719        // CSS Nesting Module Level 1 — `&` を親セレクタへ置換(連結)してデシュガー。
720        (
721            "<div class='card'><span id='t'></span></div>",
722            ".card { & span { zz:1; } }",
723            "t",
724            true,
725        ),
726        // `&` を省略した場合も暗黙の子孫結合子として扱う。
727        (
728            "<div class='card'><span id='t'></span></div>",
729            ".card { span { zz:1; } }",
730            "t",
731            true,
732        ),
733        // `&` はコンパウンド(間にスペース無し)で連結できる。クラス不一致なら不一致。
734        (
735            "<div id='t' class='card'></div>",
736            ".card { &.active { zz:1; } }",
737            "t",
738            false,
739        ),
740        (
741            "<div id='t' class='card active'></div>",
742            ".card { &.active { zz:1; } }",
743            "t",
744            true,
745        ),
746        // ネストしたルールの後ろに続く宣言も、巻き添えで壊れずに正しくパースされる
747        // (旧実装は最初の `{` 以降を丸ごと非対応として捨てていたため、ネスト後に
748        // 続く宣言も消えてしまっていた)。
749        (
750            "<div id='t' class='card'></div>",
751            ".card { &:hover{color:blue;} zz:1; }",
752            "t",
753            true,
754        ),
755        // @supports: 葉の `(property: value)` は常に「対応している」扱いだが
756        // `not`/`and`/`or` の論理結合は実際に評価する。
757        (
758            "<div id='t'></div>",
759            "@supports (display: grid) { #t{zz:1} }",
760            "t",
761            true,
762        ),
763        (
764            "<div id='t'></div>",
765            "@supports not (display: grid) { #t{zz:1} }",
766            "t",
767            false,
768        ),
769        (
770            "<div id='t'></div>",
771            "@supports (display: grid) and (gap: 1rem) { #t{zz:1} }",
772            "t",
773            true,
774        ),
775        (
776            "<div id='t'></div>",
777            "@supports not (display: grid) or (gap: 1rem) { #t{zz:1} }",
778            "t",
779            true,
780        ),
781        // prefers-color-scheme: PREFERS_DARK 既定値は light(false)。
782        (
783            "<div id='t'></div>",
784            "@media (prefers-color-scheme: light) { #t{zz:1} }",
785            "t",
786            true,
787        ),
788        (
789            "<div id='t'></div>",
790            "@media (prefers-color-scheme: dark) { #t{zz:1} }",
791            "t",
792            false,
793        ),
794        // prefers-reduced-motion: この処理系は常に no-preference 扱い。
795        (
796            "<div id='t'></div>",
797            "@media (prefers-reduced-motion: no-preference) { #t{zz:1} }",
798            "t",
799            true,
800        ),
801        (
802            "<div id='t'></div>",
803            "@media (prefers-reduced-motion: reduce) { #t{zz:1} }",
804            "t",
805            false,
806        ),
807        // prefers-contrast: 丸ごと未対応で `more`/`less` が同時に真になるバグ
808        // だった(`pointer`/`hover` 等と同型)。この処理系は常に no-preference 扱い。
809        (
810            "<div id='t'></div>",
811            "@media (prefers-contrast: no-preference) { #t{zz:1} }",
812            "t",
813            true,
814        ),
815        (
816            "<div id='t'></div>",
817            "@media (prefers-contrast: more) { #t{zz:1} }",
818            "t",
819            false,
820        ),
821        (
822            "<div id='t'></div>",
823            "@media (prefers-contrast: less) { #t{zz:1} }",
824            "t",
825            false,
826        ),
827        // `update`/`grid`/`prefers-reduced-transparency`/`prefers-reduced-data` も
828        // 同じ「未知クエリ通過」フォールバックで相互排他の分岐が同時に真になる
829        // 同型バグだった。
830        (
831            "<div id='t'></div>",
832            "@media (update: fast) { #t{zz:1} }",
833            "t",
834            true,
835        ),
836        (
837            "<div id='t'></div>",
838            "@media (update: none) { #t{zz:1} }",
839            "t",
840            false,
841        ),
842        (
843            "<div id='t'></div>",
844            "@media (grid: 0) { #t{zz:1} }",
845            "t",
846            true,
847        ),
848        (
849            "<div id='t'></div>",
850            "@media (grid: 1) { #t{zz:1} }",
851            "t",
852            false,
853        ),
854        (
855            "<div id='t'></div>",
856            "@media (prefers-reduced-transparency: no-preference) { #t{zz:1} }",
857            "t",
858            true,
859        ),
860        (
861            "<div id='t'></div>",
862            "@media (prefers-reduced-transparency: reduce) { #t{zz:1} }",
863            "t",
864            false,
865        ),
866        (
867            "<div id='t'></div>",
868            "@media (prefers-reduced-data: no-preference) { #t{zz:1} }",
869            "t",
870            true,
871        ),
872        (
873            "<div id='t'></div>",
874            "@media (prefers-reduced-data: reduce) { #t{zz:1} }",
875            "t",
876            false,
877        ),
878        // `pointer`/`hover`(Media Queries Level 4)が丸ごと未対応で、末尾の
879        // 「未知のクエリは通過」フォールバックに落ちて matches/coarse の両方が
880        // 同時に真になってしまうバグだった。USB HID マウスを前提に fine/hover
881        // 固定で答える。
882        (
883            "<div id='t'></div>",
884            "@media (pointer: fine) { #t{zz:1} }",
885            "t",
886            true,
887        ),
888        (
889            "<div id='t'></div>",
890            "@media (pointer: coarse) { #t{zz:1} }",
891            "t",
892            false,
893        ),
894        (
895            "<div id='t'></div>",
896            "@media (hover: hover) { #t{zz:1} }",
897            "t",
898            true,
899        ),
900        (
901            "<div id='t'></div>",
902            "@media (hover: none) { #t{zz:1} }",
903            "t",
904            false,
905        ),
906        // `scripting`/`forced-colors`/`inverted-colors`/`color-gamut` も
907        // `pointer`/`hover` と同型のバグだった(相互排他のはずの分岐が
908        // フォールバックで両方 true になる)。
909        (
910            "<div id='t'></div>",
911            "@media (scripting: enabled) { #t{zz:1} }",
912            "t",
913            true,
914        ),
915        (
916            "<div id='t'></div>",
917            "@media (scripting: none) { #t{zz:1} }",
918            "t",
919            false,
920        ),
921        (
922            "<div id='t'></div>",
923            "@media (forced-colors: none) { #t{zz:1} }",
924            "t",
925            true,
926        ),
927        (
928            "<div id='t'></div>",
929            "@media (forced-colors: active) { #t{zz:1} }",
930            "t",
931            false,
932        ),
933        // `resolution`/`min-resolution`/`max-resolution`(HiDPI/retina 向け
934        // アセット出し分けの定番。丸ごと未対応で `min-width`/`max-width` と
935        // 同様の数値レンジ特性にもかかわらずフォールバックに落ちていた)。
936        // フレームバッファは等倍密度(1dppx)固定として扱う。
937        (
938            "<div id='t'></div>",
939            "@media (min-resolution: 2dppx) { #t{zz:1} }",
940            "t",
941            false,
942        ),
943        (
944            "<div id='t'></div>",
945            "@media (min-resolution: 1dppx) { #t{zz:1} }",
946            "t",
947            true,
948        ),
949        (
950            "<div id='t'></div>",
951            "@media (max-resolution: 2dppx) { #t{zz:1} }",
952            "t",
953            true,
954        ),
955        (
956            "<div id='t'></div>",
957            "@media (min-resolution: 96dpi) { #t{zz:1} }",
958            "t",
959            true,
960        ),
961        // `monochrome` も同型のバグだった。カラー表示なので常に0階調。
962        (
963            "<div id='t'></div>",
964            "@media (monochrome) { #t{zz:1} }",
965            "t",
966            false,
967        ),
968        (
969            "<div id='t'></div>",
970            "@media (monochrome: 0) { #t{zz:1} }",
971            "t",
972            true,
973        ),
974        (
975            "<div id='t'></div>",
976            "@media (min-monochrome: 1) { #t{zz:1} }",
977            "t",
978            false,
979        ),
980        // 複合 " and " 条件(VIEWPORT_HINT_W 既定値 800px 基準)。
981        // 旧実装は `screen and (...)` 直後の単一条件すら判定式まで届かず常に true へ
982        // フォールバックしていたため、`screen and (max-width: 700px)` が誤って true になっていた。
983        (
984            "<div id='t'></div>",
985            "@media screen and (min-width: 700px) { #t{zz:1} }",
986            "t",
987            true,
988        ),
989        (
990            "<div id='t'></div>",
991            "@media screen and (max-width: 700px) { #t{zz:1} }",
992            "t",
993            false,
994        ),
995        // レンジ指定(`(min-width: X) and (max-width: Y)`): 両方満たす場合のみマッチ。
996        (
997            "<div id='t'></div>",
998            "@media (min-width: 700px) and (max-width: 900px) { #t{zz:1} }",
999            "t",
1000            true,
1001        ),
1002        (
1003            "<div id='t'></div>",
1004            "@media (min-width: 700px) and (max-width: 750px) { #t{zz:1} }",
1005            "t",
1006            false,
1007        ),
1008        // :lang() — 要素自身の lang 属性を優先し、無ければ文書既定言語(DOCUMENT_LANG)へ
1009        // フォールバックする。ここでは DOCUMENT_LANG が空(テスト実行時は未設定)なので、
1010        // 要素自身の lang 属性のみで判定される。
1011        (
1012            "<p id='t' lang='en-US'>x</p>",
1013            ":lang(en){zz:1}",
1014            "t",
1015            true,
1016        ),
1017        (
1018            "<p id='t' lang='fr'>x</p>",
1019            ":lang(en){zz:1}",
1020            "t",
1021            false,
1022        ),
1023        (
1024            "<p id='t' lang='EN'>x</p>",
1025            ":lang(en){zz:1}",
1026            "t",
1027            true,
1028        ),
1029        // :dir(ltr|rtl) — 要素自身の dir 属性の値で判定。無指定/不正値は ltr 扱い
1030        // (祖先からの継承は非対応の簡略実装)。
1031        (
1032            "<p id='t' dir='rtl'>x</p>",
1033            ":dir(rtl){zz:1}",
1034            "t",
1035            true,
1036        ),
1037        (
1038            "<p id='t' dir='ltr'>x</p>",
1039            ":dir(rtl){zz:1}",
1040            "t",
1041            false,
1042        ),
1043        (
1044            "<p id='t'>x</p>",
1045            ":dir(ltr){zz:1}",
1046            "t",
1047            true,
1048        ),
1049        // :indeterminate — HTML属性直書き(簡略判定)と <progress value 未指定> の2パターン。
1050        (
1051            "<input id='t' indeterminate>",
1052            "input:indeterminate{zz:1}",
1053            "t",
1054            true,
1055        ),
1056        (
1057            "<input id='t' checked>",
1058            "input:indeterminate{zz:1}",
1059            "t",
1060            false,
1061        ),
1062        (
1063            "<progress id='t'></progress>",
1064            "progress:indeterminate{zz:1}",
1065            "t",
1066            true,
1067        ),
1068        (
1069            "<progress id='t' value='50'></progress>",
1070            "progress:indeterminate{zz:1}",
1071            "t",
1072            false,
1073        ),
1074    ];
1075
1076    fn find_marked(node: &StyledNode, id: &str) -> Option<bool> {
1077        if let NodeType::Element { id: nid, .. } = &node.node.node_type {
1078            if nid.as_deref() == Some(id) {
1079                return Some(node.specified_values.contains_key("zz"));
1080            }
1081        }
1082        for c in &node.children {
1083            if let Some(r) = find_marked(c, id) {
1084                return Some(r);
1085            }
1086        }
1087        None
1088    }
1089
1090    // カスケード優先順位(プロパティ値を検証): (html, css, id, prop, expected)
1091    let value_cases: &[(&str, &str, &str, &str, &str)] = &[
1092        (
1093            "<p id='t'></p>",
1094            "p{color:red} p{color:blue}",
1095            "t",
1096            "color",
1097            "blue",
1098        ), // ソース順(後勝ち)
1099        // @layer: 後で宣言されたレイヤーが、通常宣言では specificity に関わらず勝つ。
1100        // ここでは #t(高 specificity)が先のレイヤー、p(低 specificity)が後のレイヤー。
1101        (
1102            "<p id='t'></p>",
1103            "@layer a { #t{color:red} } @layer b { p{color:blue} }",
1104            "t",
1105            "color",
1106            "blue",
1107        ),
1108        // @layer: レイヤー無し(unlayered)の通常宣言は、どのレイヤーよりも常に勝つ
1109        // (specificity が低くても)。
1110        (
1111            "<p id='t'></p>",
1112            "@layer a { #t{color:red} } p{color:blue}",
1113            "t",
1114            "color",
1115            "blue",
1116        ),
1117        // @layer: 同一レイヤー内は通常どおりソース順(後勝ち)。
1118        (
1119            "<p id='t'></p>",
1120            "@layer a { p{color:red} } @layer a { p{color:green} }",
1121            "t",
1122            "color",
1123            "green",
1124        ),
1125        // @layer: 同名レイヤーは初出位置にマージされ、優先順位は初出位置のまま
1126        // (再宣言のたびに末尾へ繰り下がったりしない)。もしマージ位置がずれて
1127        // レイヤー a が b より後ろ扱いになってしまうと、この結果は誤って
1128        // green(レイヤー a 側)になってしまう。
1129        (
1130            "<p id='t'></p>",
1131            "@layer a { p{color:red} } @layer b { #t{color:blue} } @layer a { p{color:green} }",
1132            "t",
1133            "color",
1134            "blue",
1135        ),
1136        // カスケード優先順位: author の !important は inline style(通常)より勝つ。
1137        // 以前は inline style="" が常に無条件で上書きしており、author !important を
1138        // 無視してしまうバグだった。
1139        (
1140            "<p id='t' style='color:blue'></p>",
1141            "p{color:red !important}",
1142            "t",
1143            "color",
1144            "red",
1145        ),
1146        // inline style の !important は author 側の !important にも勝つ(最優先)。
1147        (
1148            "<p id='t' style='color:blue !important'></p>",
1149            "p{color:red !important}",
1150            "t",
1151            "color",
1152            "blue",
1153        ),
1154        // author が !important でなければ、従来通り inline style(通常)が勝つ。
1155        (
1156            "<p id='t' style='color:blue'></p>",
1157            "p{color:red}",
1158            "t",
1159            "color",
1160            "blue",
1161        ),
1162        // CSS Nesting(`&`)は非対応だが、ネストしたルールが同じブロック内の正当な宣言を
1163        // 巻き添えで破壊しないことを確認する(以前は `&:hover{...}` の `{`/`;` が混入して
1164        // 直前の `color:red` まで壊れてしまうバグだった)。
1165        (
1166            "<div id='t' class='a'></div>",
1167            ".a{color:green; &:hover{color:blue;}}",
1168            "t",
1169            "color",
1170            "green",
1171        ),
1172        // visibility は継承プロパティ。親の visibility:hidden が子孫へ伝播することを確認する
1173        // (以前は INHERITED_PROPS に含まれておらず、親に visibility:hidden を指定しても
1174        // 子孫は通常通り表示されてしまうバグだった)。
1175        (
1176            "<div style='visibility:hidden'><span id='t'></span></div>",
1177            "",
1178            "t",
1179            "visibility",
1180            "hidden",
1181        ),
1182        // 子孫が明示的に visibility:visible を指定すれば、継承された hidden を上書きできる。
1183        (
1184            "<div style='visibility:hidden'><span id='t' style='visibility:visible'></span></div>",
1185            "",
1186            "t",
1187            "visibility",
1188            "visible",
1189        ),
1190        // list-style-image は継承プロパティ(list-style-type と同様)。ul 側の指定が
1191        // 子孫の li まで伝播することを確認する(以前は INHERITED_PROPS に含まれておらず、
1192        // ul{list-style-image:...} が li に一切効かないバグだった)。
1193        (
1194            "<ul><li id='t'></li></ul>",
1195            "ul{list-style-image:url(bullet.png)}",
1196            "t",
1197            "list-style-image",
1198            "url(bullet.png)",
1199        ),
1200        // list-style-position も継承プロパティ(list-style-image と同種のバグが
1201        // INHERITED_PROPS に含まれていなかったために存在した。ul 側の指定が子孫の li まで
1202        // 伝播することを確認する)。
1203        (
1204            "<ul><li id='t'></li></ul>",
1205            "ul{list-style-position:inside}",
1206            "t",
1207            "list-style-position",
1208            "inside",
1209        ),
1210        // text-indent も継承プロパティ(同種のバグ)。通常の書き方(親要素へ指定し
1211        // テキストノード自身には指定しない)で子孫要素まで伝播することを確認する。
1212        (
1213            "<p><span id='t'></span></p>",
1214            "p{text-indent:2em}",
1215            "t",
1216            "text-indent",
1217            "2em",
1218        ),
1219        (
1220            "<p id='t' class='x'></p>",
1221            "p{color:red} .x{color:green}",
1222            "t",
1223            "color",
1224            "green",
1225        ), // 特異度(class>tag)
1226        // :is() の特異度はリスト中の最大値(この場合 id セレクタ #nomatch の (1,0,0))を
1227        // 採用する。ソース順では後に来る .c(class specificity (0,1,0))より先に書かれて
1228        // いても、id を含む方が勝つ(旧実装は :is() を一律 class 数1として計算しており、
1229        // この場合ソース順で .c が誤って勝ってしまっていた)。
1230        (
1231            "<div id='t' class='b c'></div>",
1232            ":is(#nomatch, .b){color:red} .c{color:blue}",
1233            "t",
1234            "color",
1235            "red",
1236        ),
1237        // :where() の特異度は常に0。同じクラスセレクタ単体より弱いため、ソース順に関わらず
1238        // 通常のクラスセレクタが勝つ。
1239        (
1240            "<div id='t' class='a'></div>",
1241            ".a{color:blue} :where(.a){color:red}",
1242            "t",
1243            "color",
1244            "blue",
1245        ),
1246        (
1247            "<p id='t' class='x'></p>",
1248            "p{color:red !important} .x{color:green}",
1249            "t",
1250            "color",
1251            "red",
1252        ), // !important が特異度に勝つ
1253        (
1254            "<p id='t'></p>",
1255            "p{color:red !important} p{color:blue !important}",
1256            "t",
1257            "color",
1258            "blue",
1259        ), // 両 important→ソース順
1260        (
1261            "<p id='t' class='x'></p>",
1262            "#nope{color:black} .x{color:purple}",
1263            "t",
1264            "color",
1265            "purple",
1266        ),
1267        // text-transform は継承される(div → 子 p)。
1268        (
1269            "<div><p id='t'>x</p></div>",
1270            "div{text-transform:uppercase}",
1271            "t",
1272            "text-transform",
1273            "uppercase",
1274        ),
1275        // -webkit-text-fill-color も継承される(div → 子 p)。
1276        (
1277            "<div><p id='t'>x</p></div>",
1278            "div{-webkit-text-fill-color:red}",
1279            "t",
1280            "-webkit-text-fill-color",
1281            "red",
1282        ),
1283        // list-style-type も継承(ul → li)。
1284        (
1285            "<ul><li id='t'>x</li></ul>",
1286            "ul{list-style-type:none}",
1287            "t",
1288            "list-style-type",
1289            "none",
1290        ),
1291        // flex gap がカスケードで届く。
1292        (
1293            "<div id='t' class='f'></div>",
1294            ".f{display:flex; gap:10px}",
1295            "t",
1296            "gap",
1297            "10px",
1298        ),
1299        // overflow / position が specified_values に届く(flatten_layout が参照する)。
1300        (
1301            "<div id='t' class='o'></div>",
1302            ".o{overflow:hidden}",
1303            "t",
1304            "overflow",
1305            "hidden",
1306        ),
1307        (
1308            "<div id='t' class='lc'></div>",
1309            ".lc{-webkit-line-clamp: 3}",
1310            "t",
1311            "-webkit-line-clamp",
1312            "3",
1313        ),
1314        (
1315            "<div id='t' class='p'></div>",
1316            ".p{position:absolute; top:5px}",
1317            "t",
1318            "position",
1319            "absolute",
1320        ),
1321        // ::before / ::after の content が合成プロパティへ届く。
1322        (
1323            "<div id='t' class='q'></div>",
1324            ".q::before{content:\"★\"}",
1325            "t",
1326            "x-before-content",
1327            "★",
1328        ),
1329        (
1330            "<div id='t' class='q'></div>",
1331            ".q::after{content:'!'}",
1332            "t",
1333            "x-after-content",
1334            "!",
1335        ),
1336        // ::before の他プロパティ(color等)は要素自体には適用されない。
1337        (
1338            "<div id='t' class='q'></div>",
1339            ".q::before{content:\"x\";color:#ff0000}",
1340            "t",
1341            "color",
1342            "",
1343        ),
1344        // content: none は空文字(誤描画防止)。
1345        (
1346            "<div id='t' class='q'></div>",
1347            ".q::before{content:none}",
1348            "t",
1349            "x-before-content",
1350            "",
1351        ),
1352        // content: attr(name) — 対象要素自身の属性値を展開。
1353        (
1354            "<a id='t' href='/foo'></a>",
1355            "a::after{content:attr(href)}",
1356            "t",
1357            "x-after-content",
1358            "/foo",
1359        ),
1360        // content: "\f2c9" — 16進Unicodeエスケープ(FontAwesome等のアイコン
1361        // フォントが private-use-area コードポイントを埋め込むのに使う記法)を
1362        // デコードできること。以前は未対応でバックスラッシュ込みの生文字列
1363        // "\f2c9" がそのまま出力され、アイコンが表示されないバグだった。
1364        (
1365            "<div id='t' class='q'></div>",
1366            ".q::before{content:\"\\f2c9\"}",
1367            "t",
1368            "x-before-content",
1369            "\u{f2c9}",
1370        ),
1371        // 16進エスケープ直後の空白1つは区切りとして消費され、出力には残らない。
1372        (
1373            "<div id='t' class='q'></div>",
1374            ".q::before{content:\"\\f2c9 icon\"}",
1375            "t",
1376            "x-before-content",
1377            "\u{f2c9}icon",
1378        ),
1379        // ::before/::after の display は合成プロパティ x-before-display/x-after-display へ
1380        // 振り替えられ、`display:none` はボックス自体の非生成(描画側で content を空扱い)
1381        // に使われる。ここでは振り替え自体(cascade側)のみを検証する。
1382        (
1383            "<div id='t' class='q'></div>",
1384            ".q::before{content:\"x\";display:none}",
1385            "t",
1386            "x-before-display",
1387            "none",
1388        ),
1389        (
1390            "<div id='t' data-tip='hi'></div>",
1391            "div::after{content:attr(data-tip)}",
1392            "t",
1393            "x-after-content",
1394            "hi",
1395        ),
1396        // attr() + 文字列リテラルの連結。
1397        (
1398            "<a id='t' href='/x'></a>",
1399            "a::after{content:\"(\" attr(href) \")\"}",
1400            "t",
1401            "x-after-content",
1402            "(/x)",
1403        ),
1404        // 属性が無ければ空文字。
1405        (
1406            "<div id='t'></div>",
1407            "div::after{content:attr(data-tip)}",
1408            "t",
1409            "x-after-content",
1410            "",
1411        ),
1412        // `attr(name, fallback)`(CSS Values and Units Level 5、丸ごと未対応
1413        // だった。以前はフォールバック指定自体が属性名の一部として誤解釈され、
1414        // 属性が存在する場合すら空文字になっていた)。
1415        (
1416            "<div id='t'></div>",
1417            "div::after{content:attr(data-tip, \"none\")}",
1418            "t",
1419            "x-after-content",
1420            "none",
1421        ),
1422        // 属性が存在する場合はフォールバックを無視し属性値を使う。
1423        (
1424            "<div id='t' data-tip='hi'></div>",
1425            "div::after{content:attr(data-tip, \"none\")}",
1426            "t",
1427            "x-after-content",
1428            "hi",
1429        ),
1430        // content: counter(name) — counter-reset の初期値 + counter-increment の加算。
1431        (
1432            "<div id='t' class='q'></div>",
1433            ".q{counter-reset:c 4;counter-increment:c}.q::after{content:counter(c)}",
1434            "t",
1435            "x-after-content",
1436            "5",
1437        ),
1438        // @counter-style(カスタムカウンタ書式): system:cyclic(既定)で symbols を循環。
1439        // counter-reset:c 1; counter-increment:c → val=2 → idx=(2-1)%2=1 → 2番目のsymbol。
1440        (
1441            "<div id='t' class='q'></div>",
1442            "@counter-style stars{system:cyclic;symbols:\"★\" \"☆\";suffix:\" \"} \
1443             .q{counter-reset:c 1;counter-increment:c}.q::after{content:counter(c, stars)}",
1444            "t",
1445            "x-after-content",
1446            "☆",
1447        ),
1448        // @counter-style system:numeric — symbols を桁の数字として位取り記数法で整形。
1449        // symbols:"0" "1"(2進数)で value=5 → "101"。
1450        (
1451            "<div id='t' class='q'></div>",
1452            "@counter-style bin{system:numeric;symbols:\"0\" \"1\"} \
1453             .q{counter-reset:c 4;counter-increment:c}.q::after{content:counter(c, bin)}",
1454            "t",
1455            "x-after-content",
1456            "101",
1457        ),
1458        // content: counter(name, style) — upper-roman/lower-roman/upper-alpha/lower-alpha は
1459        // list-style-type と同じ変換関数を再利用して整形される(value=5)。
1460        (
1461            "<div id='t' class='q'></div>",
1462            ".q{counter-reset:c 4;counter-increment:c}.q::after{content:counter(c, upper-roman)}",
1463            "t",
1464            "x-after-content",
1465            "V",
1466        ),
1467        (
1468            "<div id='t' class='q'></div>",
1469            ".q{counter-reset:c 4;counter-increment:c}.q::after{content:counter(c, lower-roman)}",
1470            "t",
1471            "x-after-content",
1472            "v",
1473        ),
1474        (
1475            "<div id='t' class='q'></div>",
1476            ".q{counter-reset:c 4;counter-increment:c}.q::after{content:counter(c, upper-alpha)}",
1477            "t",
1478            "x-after-content",
1479            "E",
1480        ),
1481        (
1482            "<div id='t' class='q'></div>",
1483            ".q{counter-reset:c 4;counter-increment:c}.q::after{content:counter(c, lower-alpha)}",
1484            "t",
1485            "x-after-content",
1486            "e",
1487        ),
1488        // 文書順で兄弟間にカウンタ状態が引き継がれる(リスト番号付けの典型パターン)。
1489        (
1490            "<ol><li class='c' id='a'>x</li><li class='c' id='t'>y</li></ol>",
1491            ".c{counter-increment:item}.c::after{content:counter(item)}",
1492            "t",
1493            "x-after-content",
1494            "2",
1495        ),
1496        // 未定義のカウンタ名は 0 として扱う。
1497        (
1498            "<div id='t' class='q'></div>",
1499            ".q::after{content:counter(missing)}",
1500            "t",
1501            "x-after-content",
1502            "0",
1503        ),
1504        // 入れ子リストの counter スコープ: 内側の <ol> の counter-reset は外側の兄弟へ
1505        // 漏れてはならない(CSS仕様の入れ子番号付けの典型パターン)。以前は counter が
1506        // 単一のフラットマップを兄弟間で素通りさせるだけで、深さに関わらず全カウンタを
1507        // 共有していたため、内側リストの reset/increment が外側リストの後続 <li> の
1508        // 番号を汚染するバグがあった。ここでは外側3番目の <li>(id='t')が、内側リストの
1509        // 影響を受けず正しく3(1,2 に続く3番目)になることを確認する。
1510        (
1511            "<ol><li class='c'></li><li class='c'><ol><li class='c'></li><li class='c'></li></ol></li><li class='c' id='t'></li></ol>",
1512            "ol{counter-reset:item}.c{counter-increment:item}.c::after{content:counter(item)}",
1513            "t",
1514            "x-after-content",
1515            "3",
1516        ),
1517        // `content: counter(list-item)`: `<ul>`/`<ol>` は UA 既定で `counter-reset: list-item`、
1518        // `<li>` は `counter-increment: list-item` を持つ(実 CSS の UA スタイルシート相当)。
1519        // 著者が明示指定しなくても暗黙カウンタとして参照できることを確認する。
1520        (
1521            "<ol><li class='q'></li><li class='q' id='t'></li></ol>",
1522            ".q::after{content:counter(list-item)}",
1523            "t",
1524            "x-after-content",
1525            "2",
1526        ),
1527        // `<ol start="N">`(HTML属性、丸ごと未対応だった): 最初の `<li>` の番号が
1528        // `N` から始まる。
1529        (
1530            "<ol start='5'><li class='q' id='t'></li><li class='q'></li></ol>",
1531            ".q::after{content:counter(list-item)}",
1532            "t",
1533            "x-after-content",
1534            "5",
1535        ),
1536        // `<ol reversed>`(HTML属性、丸ごと未対応だった): `start` 未指定なら
1537        // 直下の `<li>` 総数(ここでは3)から降順に採番する。
1538        (
1539            "<ol reversed><li class='q' id='t'></li><li class='q'></li><li class='q'></li></ol>",
1540            ".q::after{content:counter(list-item)}",
1541            "t",
1542            "x-after-content",
1543            "3",
1544        ),
1545        // `<ol reversed start="10">`: 明示 `start` から降順に採番する。
1546        (
1547            "<ol reversed start='10'><li class='q' id='t'></li><li class='q'></li></ol>",
1548            ".q::after{content:counter(list-item)}",
1549            "t",
1550            "x-after-content",
1551            "10",
1552        ),
1553        // 著者が `counter-reset`/`counter-increment` を明示指定した場合、UA既定の
1554        // `list-item` 用の値を通常のカスケード規則どおり完全に上書きできることを確認する
1555        // (`ol{counter-reset:list-item 10}` が UA既定の `counter-reset:list-item`(=0開始)を上書き)。
1556        (
1557            "<ol><li class='q' id='t'></li></ol>",
1558            "ol{counter-reset:list-item 10}.q::after{content:counter(list-item)}",
1559            "t",
1560            "x-after-content",
1561            "11",
1562        ),
1563        // content: counters(name, "sep") — counter-reset/counter-increment がフラット共有の
1564        // 簡略実装(祖先スコープ積み上げ非対応)であることに合わせ、現在値を単一セグメントとして
1565        // 出す簡略実装。sep 引数自体はパースして消費するが実質未使用(value=5)。
1566        (
1567            "<div id='t' class='q'></div>",
1568            ".q{counter-reset:c 4;counter-increment:c}.q::after{content:counters(c, \".\")}",
1569            "t",
1570            "x-after-content",
1571            "5",
1572        ),
1573        // content: counters(name, "sep", style) — style 引数も counter() と同じ変換を適用。
1574        (
1575            "<div id='t' class='q'></div>",
1576            ".q{counter-reset:c 4;counter-increment:c}.q::after{content:counters(c, \".\", upper-roman)}",
1577            "t",
1578            "x-after-content",
1579            "V",
1580        ),
1581        // open-quote/close-quote: `quotes` プロパティ無指定時は既定の二重引用符を使う。
1582        (
1583            "<q id='t' class='q'></q>",
1584            ".q::before{content:open-quote}",
1585            "t",
1586            "x-before-content",
1587            "\u{201C}",
1588        ),
1589        (
1590            "<q id='t' class='q'></q>",
1591            ".q::after{content:close-quote}",
1592            "t",
1593            "x-after-content",
1594            "\u{201D}",
1595        ),
1596        // no-open-quote/no-close-quote は何も出力しない。
1597        (
1598            "<q id='t' class='q'></q>",
1599            ".q::before{content:no-open-quote}",
1600            "t",
1601            "x-before-content",
1602            "",
1603        ),
1604        // 文字列との連結(例: 属性由来の引用符囲み表示)。
1605        (
1606            "<q id='t' class='q'></q>",
1607            ".q::before{content:open-quote \"cited\" close-quote}",
1608            "t",
1609            "x-before-content",
1610            "\u{201C}cited\u{201D}",
1611        ),
1612        // quotes プロパティ(同一要素への明示指定)でカスタム引用符を指定できる。
1613        (
1614            "<q id='t' class='q'></q>",
1615            ".q{quotes:\"\u{00AB}\" \"\u{00BB}\"}.q::before{content:open-quote}",
1616            "t",
1617            "x-before-content",
1618            "\u{00AB}",
1619        ),
1620        (
1621            "<q id='t' class='q'></q>",
1622            ".q{quotes:\"\u{00AB}\" \"\u{00BB}\"}.q::after{content:close-quote}",
1623            "t",
1624            "x-after-content",
1625            "\u{00BB}",
1626        ),
1627        // quotes:none は open/close-quote とも空文字列。
1628        (
1629            "<q id='t' class='q'></q>",
1630            ".q{quotes:none}.q::before{content:open-quote}",
1631            "t",
1632            "x-before-content",
1633            "",
1634        ),
1635        // 入れ子の <q>: `quotes` に2組指定した場合、外側は1組目、内側(祖先に <q> が1つ)は
1636        // 2組目を使う(CSS3仕様の入れ子レベル規則)。以前は入れ子レベルを一切追跡せず、
1637        // 常に1組目だけを使っていたため、内側の <q> も外側と同じ引用符になっていた。
1638        (
1639            "<q class='q'><q id='t' class='q'></q></q>",
1640            ".q{quotes:\"\u{00AB}\" \"\u{00BB}\" \"\u{2018}\" \"\u{2019}\"}.q::before{content:open-quote}",
1641            "t",
1642            "x-before-content",
1643            "\u{2018}",
1644        ),
1645        // 手持ちのペア数を超える深さでは最後のペアを繰り返す(仕様どおり)。
1646        (
1647            "<q class='q'><q class='q'><q id='t' class='q'></q></q></q>",
1648            ".q{quotes:\"\u{00AB}\" \"\u{00BB}\" \"\u{2018}\" \"\u{2019}\"}.q::before{content:open-quote}",
1649            "t",
1650            "x-before-content",
1651            "\u{2018}",
1652        ),
1653        // ::marker{color:...} が合成プロパティ x-marker-color へ振り替えられる。
1654        (
1655            "<li id='t' class='q'></li>",
1656            ".q::marker{color:#ff0000}",
1657            "t",
1658            "x-marker-color",
1659            "#ff0000",
1660        ),
1661        // ::marker{font-size:...} が合成プロパティ x-marker-font-size へ振り替えられる。
1662        (
1663            "<li id='t' class='q'></li>",
1664            ".q::marker{font-size:99px}",
1665            "t",
1666            "x-marker-font-size",
1667            "99px",
1668        ),
1669        // ::marker{content:...} が合成プロパティ x-marker-content へ振り替えられる
1670        // (カスタムマーカー文字。counter()/attr() 等の content 関数も x-before-content/
1671        // x-after-content と同じ経路で解決される)。
1672        (
1673            "<li id='t' class='q'></li>",
1674            ".q::marker{content:\"\u{2192} \"}",
1675            "t",
1676            "x-marker-content",
1677            "\u{2192} ",
1678        ),
1679        // ::marker の color/font-size/content 以外の宣言は依然として無視される(未対応につき振り替え無し)。
1680        (
1681            "<li id='t' class='q'></li>",
1682            ".q::marker{font-weight:bold}",
1683            "t",
1684            "x-marker-color",
1685            "",
1686        ),
1687        // ::placeholder{color:...} が合成プロパティ x-placeholder-color へ振り替えられる。
1688        (
1689            "<input id='t' class='q'></input>",
1690            ".q::placeholder{color:#00aaff}",
1691            "t",
1692            "x-placeholder-color",
1693            "#00aaff",
1694        ),
1695        // ::placeholder の color 以外の宣言(例 font-size)は無視される(未対応につき振り替え無し)。
1696        (
1697            "<input id='t' class='q'></input>",
1698            ".q::placeholder{font-size:99px}",
1699            "t",
1700            "x-placeholder-color",
1701            "",
1702        ),
1703        // ::first-line{color/font-size/font-weight/font-style} が合成プロパティへ振り替えられる。
1704        (
1705            "<p id='t' class='q'></p>",
1706            ".q::first-line{color:#00ff00}",
1707            "t",
1708            "x-first-line-color",
1709            "#00ff00",
1710        ),
1711        (
1712            "<p id='t' class='q'></p>",
1713            ".q::first-line{font-size:24px}",
1714            "t",
1715            "x-first-line-font-size",
1716            "24px",
1717        ),
1718        // ::first-line の対応外プロパティ(例 background-color)は振り替えられない。
1719        (
1720            "<p id='t' class='q'></p>",
1721            ".q::first-line{background-color:#000000}",
1722            "t",
1723            "x-first-line-background-color",
1724            "",
1725        ),
1726        // ::first-letter{color/font-size} が合成プロパティへ振り替えられる。
1727        (
1728            "<p id='t' class='q'></p>",
1729            ".q::first-letter{color:#0000ff}",
1730            "t",
1731            "x-first-letter-color",
1732            "#0000ff",
1733        ),
1734        (
1735            "<p id='t' class='q'></p>",
1736            ".q::first-letter{font-size:32px}",
1737            "t",
1738            "x-first-letter-font-size",
1739            "32px",
1740        ),
1741        // 論理プロパティ: block 軸は常に縦方向 → margin-block-start は margin-top へ。
1742        (
1743            "<div id='t'></div>",
1744            "#t{margin-block-start:10px;margin-block-end:20px}",
1745            "t",
1746            "margin-top",
1747            "10px",
1748        ),
1749        // ltr(既定): inline-start→left。
1750        (
1751            "<div id='t'></div>",
1752            "#t{padding-inline-start:5px}",
1753            "t",
1754            "padding-left",
1755            "5px",
1756        ),
1757        // `margin-block`/`margin-inline`/`padding-block`/`padding-inline`/`inset-block`/
1758        // `inset-inline`(-start/-end をまとめて指定するショートハンド)が丸ごと
1759        // 未対応だった。1値指定は start/end 両方に適用される。
1760        (
1761            "<div id='t'></div>",
1762            "#t{margin-block:15px}",
1763            "t",
1764            "margin-top",
1765            "15px",
1766        ),
1767        (
1768            "<div id='t'></div>",
1769            "#t{margin-block:15px}",
1770            "t",
1771            "margin-bottom",
1772            "15px",
1773        ),
1774        // 2値指定は1番目=start、2番目=end。
1775        (
1776            "<div id='t'></div>",
1777            "#t{padding-inline:5px 9px}",
1778            "t",
1779            "padding-left",
1780            "5px",
1781        ),
1782        (
1783            "<div id='t'></div>",
1784            "#t{padding-inline:5px 9px}",
1785            "t",
1786            "padding-right",
1787            "9px",
1788        ),
1789        // shorthand は longhand(-start/-end 個別指定)より優先度が低い。
1790        (
1791            "<div id='t'></div>",
1792            "#t{margin-inline:1px;margin-inline-start:2px}",
1793            "t",
1794            "margin-left",
1795            "2px",
1796        ),
1797        // direction:rtl では inline-start→right に反転する。
1798        (
1799            "<div id='t'></div>",
1800            "#t{direction:rtl;margin-inline-start:7px}",
1801            "t",
1802            "margin-right",
1803            "7px",
1804        ),
1805        // 祖先の direction:rtl も継承されてから論理プロパティが解決される。
1806        (
1807            "<div style='direction:rtl'><div id='t'></div></div>",
1808            "#t{padding-inline-end:9px}",
1809            "t",
1810            "padding-left",
1811            "9px",
1812        ),
1813        // 物理プロパティが明示されていれば論理プロパティより優先される。
1814        (
1815            "<div id='t'></div>",
1816            "#t{margin-left:1px;margin-inline-start:2px}",
1817            "t",
1818            "margin-left",
1819            "1px",
1820        ),
1821        // border-inline-start-width/style/color(ショートハンドではなく longhand 単体)も
1822        // margin/padding と同じ方式で論理方向解決される。
1823        (
1824            "<div id='t'></div>",
1825            "#t{border-inline-start-width:3px}",
1826            "t",
1827            "border-left-width",
1828            "3px",
1829        ),
1830        (
1831            "<div id='t'></div>",
1832            "#t{border-inline-start-style:dashed}",
1833            "t",
1834            "border-left-style",
1835            "dashed",
1836        ),
1837        (
1838            "<div id='t'></div>",
1839            "#t{direction:rtl;border-inline-end-color:#ff0000}",
1840            "t",
1841            "border-left-color",
1842            "#ff0000",
1843        ),
1844        (
1845            "<div id='t'></div>",
1846            "#t{border-block-start-width:4px}",
1847            "t",
1848            "border-top-width",
1849            "4px",
1850        ),
1851        // grid-template: <rows> / <columns> ショートハンドの展開。
1852        (
1853            "<div id='t'></div>",
1854            "#t{grid-template:1fr 2fr / 100px 200px}",
1855            "t",
1856            "grid-template-rows",
1857            "1fr 2fr",
1858        ),
1859        (
1860            "<div id='t'></div>",
1861            "#t{grid-template:1fr 2fr / 100px 200px}",
1862            "t",
1863            "grid-template-columns",
1864            "100px 200px",
1865        ),
1866        // text-emphasis: <style> <color> ショートハンドの展開。
1867        (
1868            "<div id='t'></div>",
1869            "#t{text-emphasis:dot red}",
1870            "t",
1871            "text-emphasis-style",
1872            "dot",
1873        ),
1874        (
1875            "<div id='t'></div>",
1876            "#t{text-emphasis:dot red}",
1877            "t",
1878            "text-emphasis-color",
1879            "red",
1880        ),
1881        // filled/open の fill キーワードは読み飛ばされ、style/color だけ展開される。
1882        (
1883            "<div id='t'></div>",
1884            "#t{text-emphasis:filled circle #00ff00}",
1885            "t",
1886            "text-emphasis-style",
1887            "circle",
1888        ),
1889        // grid-template-areas 構文混在時: 文字列リテラルは grid-template-areas へ、
1890        // トラックサイズのみ grid-template-rows へ展開される。
1891        (
1892            "<div id='t'></div>",
1893            "#t{grid-template:\"a\" 1fr / 1fr}",
1894            "t",
1895            "grid-template-areas",
1896            "\"a\" 1fr",
1897        ),
1898        (
1899            "<div id='t'></div>",
1900            "#t{grid-template:\"a\" 1fr / 1fr}",
1901            "t",
1902            "grid-template-rows",
1903            "1fr",
1904        ),
1905        (
1906            "<div id='t'></div>",
1907            "#t{grid-template:\"h h\" 1fr \"m m\" 2fr / 1fr 1fr}",
1908            "t",
1909            "grid-template-areas",
1910            "\"h h\" 1fr \"m m\" 2fr",
1911        ),
1912        (
1913            "<div id='t'></div>",
1914            "#t{grid-template:\"h h\" 1fr \"m m\" 2fr / 1fr 1fr}",
1915            "t",
1916            "grid-template-rows",
1917            "1fr 2fr",
1918        ),
1919        (
1920            "<div id='t'></div>",
1921            "#t{grid-template:\"h h\" 1fr \"m m\" 2fr / 1fr 1fr}",
1922            "t",
1923            "grid-template-columns",
1924            "1fr 1fr",
1925        ),
1926        // longhand が既に指定されていればショートハンドより優先される。
1927        (
1928            "<div id='t'></div>",
1929            "#t{grid-template-rows:5fr;grid-template:1fr / 1fr}",
1930            "t",
1931            "grid-template-rows",
1932            "5fr",
1933        ),
1934        // CSS-wide keywords: color:inherit は文字列 "inherit" を残さず、
1935        // 親の実際の計算値(継承プロパティなので自然な継承伝搬)へ解決される。
1936        (
1937            "<div style='color:red'><p id='t' style='color:inherit'></p></div>",
1938            "",
1939            "t",
1940            "color",
1941            "red",
1942        ),
1943        // カスタムプロパティの間接参照: --a が var(--b) を値に持つとき、
1944        // var(--a) は --b の実際の値まで再帰的に解決される。
1945        (
1946            "<div id='t'></div>",
1947            "#t{--b:red;--a:var(--b);color:var(--a)}",
1948            "t",
1949            "color",
1950            "red",
1951        ),
1952        // 間接参照が2段(--a → --b → --c)でも正しく解決される。
1953        (
1954            "<div id='t'></div>",
1955            "#t{--c:blue;--b:var(--c);--a:var(--b);color:var(--a)}",
1956            "t",
1957            "color",
1958            "blue",
1959        ),
1960        // 間接参照先の変数が未定義の場合、フォールバック値まで再帰的に解決される。
1961        (
1962            "<div id='t'></div>",
1963            "#t{--a:var(--undefined, var(--also-undefined, green));color:var(--a)}",
1964            "t",
1965            "color",
1966            "green",
1967        ),
1968        // `env(safe-area-inset-*)`(CSS Environment Variables)が丸ごと未対応
1969        // だった。ノッチ/セーフエリアという概念自体がこの処理系に無いため、
1970        // フォールバック省略時は常に`0px`へ解決される「正直な既定値ゼロ」。
1971        // 2026-07-18 発見・実装。
1972        (
1973            "<div id='t'></div>",
1974            "#t{margin-top:env(safe-area-inset-top)}",
1975            "t",
1976            "margin-top",
1977            "0px",
1978        ),
1979        // フォールバック指定がある場合はそちらが優先される。
1980        (
1981            "<div id='t'></div>",
1982            "#t{margin-top:env(safe-area-inset-top, 20px)}",
1983            "t",
1984            "margin-top",
1985            "20px",
1986        ),
1987        // font ショートハンド: style + weight + size/line-height + family の完全形。
1988        (
1989            "<div id='t'></div>",
1990            "#t{font:italic bold 16px/1.5 Arial, sans-serif}",
1991            "t",
1992            "font-style",
1993            "italic",
1994        ),
1995        (
1996            "<div id='t'></div>",
1997            "#t{font:italic bold 16px/1.5 Arial, sans-serif}",
1998            "t",
1999            "font-weight",
2000            "bold",
2001        ),
2002        (
2003            "<div id='t'></div>",
2004            "#t{font:italic bold 16px/1.5 Arial, sans-serif}",
2005            "t",
2006            "font-size",
2007            "16px",
2008        ),
2009        (
2010            "<div id='t'></div>",
2011            "#t{font:italic bold 16px/1.5 Arial, sans-serif}",
2012            "t",
2013            "line-height",
2014            "1.5",
2015        ),
2016        (
2017            "<div id='t'></div>",
2018            "#t{font:italic bold 16px/1.5 Arial, sans-serif}",
2019            "t",
2020            "font-family",
2021            "Arial, sans-serif",
2022        ),
2023        // size のみの簡略形(style/weight省略、line-height無し)。数値の裸weightにも対応。
2024        (
2025            "<div id='t'></div>",
2026            "#t{font:700 14px sans-serif}",
2027            "t",
2028            "font-weight",
2029            "700",
2030        ),
2031        (
2032            "<div id='t'></div>",
2033            "#t{font:700 14px sans-serif}",
2034            "t",
2035            "font-size",
2036            "14px",
2037        ),
2038        // font: oblique <angle> — CSS Fonts 4 の `oblique 10deg` 形式。角度単位(deg)を
2039        // size と誤認して font-size が壊れないことを確認する(is_size_token は既知の
2040        // サイズ単位のみ許容し、deg/grad/rad/turn は size と判定しない)。
2041        (
2042            "<div id='t'></div>",
2043            "#t{font:oblique 10deg 12px/1.5 sans-serif}",
2044            "t",
2045            "font-size",
2046            "12px",
2047        ),
2048        (
2049            "<div id='t'></div>",
2050            "#t{font:oblique 10deg 12px/1.5 sans-serif}",
2051            "t",
2052            "line-height",
2053            "1.5",
2054        ),
2055        (
2056            "<div id='t'></div>",
2057            "#t{font:oblique 10deg 12px/1.5 sans-serif}",
2058            "t",
2059            "font-family",
2060            "sans-serif",
2061        ),
2062        (
2063            "<div id='t'></div>",
2064            "#t{font:oblique 10deg 12px/1.5 sans-serif}",
2065            "t",
2066            "font-style",
2067            "oblique",
2068        ),
2069        // longhand が既に指定されていればショートハンドより優先される。
2070        (
2071            "<div id='t'></div>",
2072            "#t{font-size:9px;font:16px Arial}",
2073            "t",
2074            "font-size",
2075            "9px",
2076        ),
2077        // border ショートハンド: groove/ridge/inset/outset/hidden は BorderStyle enum が
2078        // 5種のみのため縮退させるが、トークン自体は無視されず border-top-style へ届く。
2079        (
2080            "<div id='t'></div>",
2081            "#t{border:2px groove #333333}",
2082            "t",
2083            "border-top-style",
2084            "solid",
2085        ),
2086        (
2087            "<div id='t'></div>",
2088            "#t{border:2px ridge #333333}",
2089            "t",
2090            "border-top-width",
2091            "2px",
2092        ),
2093        (
2094            "<div id='t'></div>",
2095            "#t{border:2px hidden #333333}",
2096            "t",
2097            "border-top-style",
2098            "none",
2099        ),
2100        (
2101            "<div id='t'></div>",
2102            "#t{border:2px outset #333333}",
2103            "t",
2104            "border-top-color",
2105            "#333333",
2106        ),
2107        // currentColor: 要素自身の color を border-color 等の値として解決する。
2108        (
2109            "<div id='t' style='color:#ff0000;border-color:currentColor'></div>",
2110            "",
2111            "t",
2112            "border-color",
2113            "#ff0000",
2114        ),
2115        // currentColor は継承された color(親から伝搬)も正しく参照できる。
2116        (
2117            "<div style='color:blue'><p id='t' style='outline-color:currentColor'></p></div>",
2118            "",
2119            "t",
2120            "outline-color",
2121            "blue",
2122        ),
2123        // 表記ゆれ(大文字小文字混在)も解決される。
2124        (
2125            "<div id='t' style='color:green;background-color:CURRENTCOLOR'></div>",
2126            "",
2127            "t",
2128            "background-color",
2129            "green",
2130        ),
2131        // color 自身への currentColor は対象外(自己参照を避けるため置換しない)。
2132        (
2133            "<div id='t' style='color:currentColor'></div>",
2134            "",
2135            "t",
2136            "color",
2137            "currentColor",
2138        ),
2139        // initial は該当プロパティを取り除く(未指定=各所のデフォルトへフォールバック)。
2140        (
2141            "<div id='t' style='width:initial'></div>",
2142            "",
2143            "t",
2144            "width",
2145            "",
2146        ),
2147        // unset も同様に取り除かれる。
2148        (
2149            "<div id='t' style='border-color:unset'></div>",
2150            "",
2151            "t",
2152            "border-color",
2153            "",
2154        ),
2155        // all: unset — 他の個別プロパティも一括で消える。
2156        (
2157            "<div id='t' style='color:red;all:unset'></div>",
2158            "",
2159            "t",
2160            "color",
2161            "",
2162        ),
2163        // all に未知の値(unset/initial/revert 以外)が来た場合は一括リセットしない
2164        // (他のプロパティは無事)。
2165        (
2166            "<div id='t' style='color:red;all:revert-layer'></div>",
2167            "",
2168            "t",
2169            "color",
2170            "red",
2171        ),
2172        // place-items: 1値指定は align-items/justify-items 両方へ展開される。
2173        (
2174            "<div id='t' style='place-items:center'></div>",
2175            "",
2176            "t",
2177            "align-items",
2178            "center",
2179        ),
2180        (
2181            "<div id='t' style='place-items:center'></div>",
2182            "",
2183            "t",
2184            "justify-items",
2185            "center",
2186        ),
2187        // place-content: 2値指定は align-content/justify-content へそれぞれ展開される。
2188        (
2189            "<div id='t' style='place-content:start end'></div>",
2190            "",
2191            "t",
2192            "align-content",
2193            "start",
2194        ),
2195        (
2196            "<div id='t' style='place-content:start end'></div>",
2197            "",
2198            "t",
2199            "justify-content",
2200            "end",
2201        ),
2202        // longhand が明示されていればショートハンドより優先される。
2203        (
2204            "<div id='t' style='align-self:end;place-self:start'></div>",
2205            "",
2206            "t",
2207            "align-self",
2208            "end",
2209        ),
2210    ];
2211
2212    // text-transform 変換関数(純関数)。
2213    let tt_cases: &[(&str, &str, &str)] = &[
2214        ("hello world", "uppercase", "HELLO WORLD"),
2215        ("Hello WORLD", "lowercase", "hello world"),
2216        ("hello world", "capitalize", "Hello World"),
2217        ("hello world", "none", "hello world"),
2218    ];
2219
2220    fn find_prop(node: &StyledNode, id: &str, prop: &str) -> Option<String> {
2221        if let NodeType::Element { id: nid, .. } = &node.node.node_type {
2222            if nid.as_deref() == Some(id) {
2223                return Some(node.specified_values.get(prop).cloned().unwrap_or_default());
2224            }
2225        }
2226        for c in &node.children {
2227            if let Some(r) = find_prop(c, id, prop) {
2228                return Some(r);
2229            }
2230        }
2231        None
2232    }
2233
2234    // list-style-type: upper-alpha/lower-alpha/upper-roman/lower-roman の変換関数(純関数)。
2235    let alpha_roman_cases: &[(&str, u32, bool, &str)] = &[
2236        ("alpha", 1, true, "A"),
2237        ("alpha", 26, true, "Z"),
2238        ("alpha", 27, true, "AA"),
2239        ("alpha", 1, false, "a"),
2240        ("roman", 1994, true, "MCMXCIV"),
2241        ("roman", 4, false, "iv"),
2242        ("roman", 2026, true, "MMXXVI"),
2243    ];
2244
2245    // CSS `filter`: grayscale/brightness/invert/sepia/contrast/saturate の per-pixel 変換(純関数)。
2246    let filter_cases: &[(&str, (u8, u8, u8), (u8, u8, u8))] = &[
2247        ("grayscale(1)", (255, 0, 0), (76, 76, 76)),
2248        ("brightness(0.5)", (200, 200, 200), (100, 100, 100)),
2249        ("invert(1)", (0, 0, 0), (255, 255, 255)),
2250        ("", (10, 20, 30), (10, 20, 30)),
2251        // contrast(2): (200-128)*2+128=272→クランプ255、(50-128)*2+128=-28→クランプ0
2252        ("contrast(2)", (200, 50, 128), (255, 0, 128)),
2253        // saturate(0) はグレースケール化と同じ結果になる(amount=1のgrayscaleと同値)。
2254        ("saturate(0)", (255, 0, 0), (76, 76, 76)),
2255    ];
2256
2257    // filter: blur(Npx) の半径パース(純関数)。
2258    let blur_radius_cases: &[(&str, i32)] = &[
2259        ("blur(5px)", 5),
2260        ("blur(20px)", 8), // 1-8pxにクランプ
2261        ("grayscale(1)", 0),
2262        ("", 0),
2263    ];
2264
2265    // mix-blend-mode / background-blend-mode の合成関数(純関数)。
2266    let blend_cases: &[(&str, (u8, u8, u8), (u8, u8, u8), (u8, u8, u8))] = &[
2267        // multiply: 200*100/255=78
2268        ("multiply", (200, 200, 200), (100, 100, 100), (78, 78, 78)),
2269        // screen: 255-(255-200)*(255-100)/255 = 255-55*155/255 = 255-33 = 222
2270        ("screen", (200, 200, 200), (100, 100, 100), (222, 222, 222)),
2271        ("darken", (200, 50, 128), (100, 150, 128), (100, 50, 128)),
2272        ("lighten", (200, 50, 128), (100, 150, 128), (200, 150, 128)),
2273        ("difference", (200, 50, 128), (100, 150, 128), (100, 100, 0)),
2274        // 未対応/normal は src をそのまま返す。
2275        ("normal", (10, 20, 30), (200, 200, 200), (10, 20, 30)),
2276        // overlay: dst<=127分岐 → 2*200*100/255=156
2277        ("overlay", (200, 200, 200), (100, 100, 100), (156, 156, 156)),
2278        // hard-light: src>127分岐 → 255-2*55*155/255=189
2279        ("hard-light", (200, 200, 200), (100, 100, 100), (189, 189, 189)),
2280        // exclusion: 200+100-2*200*100/255=144
2281        ("exclusion", (200, 200, 200), (100, 100, 100), (144, 144, 144)),
2282        // color-dodge: 255*100/(255-200)=463→クランプ255
2283        ("color-dodge", (200, 200, 200), (100, 100, 100), (255, 255, 255)),
2284        // color-burn: 255-255*(255-100)/200=255-197=58
2285        ("color-burn", (200, 200, 200), (100, 100, 100), (58, 58, 58)),
2286        // 非可分モード: dst=黒/src=白の組み合わせは Lum/Sat が単純化されるため手計算で検証しやすい。
2287        // luminosity: SetLum(黒, Lum(白)=1.0) → 白
2288        ("luminosity", (255, 255, 255), (0, 0, 0), (255, 255, 255)),
2289        // color: SetLum(白, Lum(黒)=0) → 黒
2290        ("color", (255, 255, 255), (0, 0, 0), (0, 0, 0)),
2291        // hue: SetSat(白, Sat(黒)=0) は無彩色化 → SetLum後も黒
2292        ("hue", (255, 255, 255), (0, 0, 0), (0, 0, 0)),
2293        // saturation: SetSat(黒, Sat(白)=0) → 黒のまま
2294        ("saturation", (255, 255, 255), (0, 0, 0), (0, 0, 0)),
2295        // soft-light: cs=0.7843>0.5, cb=0.3922>0.25 → dd=sqrt(cb)。b=cb+(2cs-1)*(dd-cb)≈0.525→133
2296        ("soft-light", (200, 200, 200), (100, 100, 100), (133, 133, 133)),
2297    ];
2298
2299    // background-repeat の解析(純関数)。(入力, want_x, want_y)
2300    let bg_repeat_cases: &[(&str, super::super::web_engine::BgRepeatMode, super::super::web_engine::BgRepeatMode)] = &[
2301        ("no-repeat", super::super::web_engine::BgRepeatMode::NoRepeat, super::super::web_engine::BgRepeatMode::NoRepeat),
2302        ("repeat-x", super::super::web_engine::BgRepeatMode::Repeat, super::super::web_engine::BgRepeatMode::NoRepeat),
2303        ("repeat-y", super::super::web_engine::BgRepeatMode::NoRepeat, super::super::web_engine::BgRepeatMode::Repeat),
2304        ("space", super::super::web_engine::BgRepeatMode::Space, super::super::web_engine::BgRepeatMode::Space),
2305        ("round", super::super::web_engine::BgRepeatMode::Round, super::super::web_engine::BgRepeatMode::Round),
2306        ("repeat space", super::super::web_engine::BgRepeatMode::Repeat, super::super::web_engine::BgRepeatMode::Space),
2307        ("round no-repeat", super::super::web_engine::BgRepeatMode::Round, super::super::web_engine::BgRepeatMode::NoRepeat),
2308    ];
2309
2310    // rgb()/rgba() のパーセント表記コンポーネント(CSS1から有効な構文)の解析。
2311    let rgb_pct_cases: &[(&str, u32)] = &[
2312        ("rgb(100%, 50%, 0%)", 0xFFFF7F00),
2313        ("rgb(0%, 0%, 0%)", 0xFF000000),
2314        ("rgba(100%, 0%, 0%, 0.5)", 0x7FFF0000),
2315        // 整数表記と混在しても壊れない。
2316        ("rgb(255, 50%, 0)", 0xFFFF7F00),
2317        // `#RGBA`(4桁ショートハンド+アルファ、CSS Color Module Level 4)が
2318        // `#RGB`/6桁/8桁は対応済みなのにこれだけ丸ごと未対応で色指定自体が
2319        // 無視されるバグだった。
2320        ("#f00f", 0xFFFF0000),
2321        ("#f008", 0x88FF0000),
2322    ];
2323
2324    // hsl() の hue は 360 を法とする値(負値・360以上でも巻き戻って解釈される)。
2325    let hsl_hue_wrap_cases: &[(&str, u32)] = &[
2326        // hsl(390,...) は hsl(30,...)(オレンジ)と同じになるはず。
2327        ("hsl(390, 100%, 50%)", 0xFFFF7F00),
2328        ("hsl(30, 100%, 50%)", 0xFFFF7F00),
2329        // hsl(-30,...) は hsl(330,...)(マゼンタ寄りのピンク)と同じになるはず。
2330        ("hsl(-30, 100%, 50%)", 0xFFFF007F),
2331        ("hsl(330, 100%, 50%)", 0xFFFF007F),
2332    ];
2333
2334    // rgb()/rgba() のスペース区切り + 任意の `/ alpha`(CSS Color Level 4)。
2335    // 以前は `inner.split(',')` 固定でスペース区切りが常に色なし(None)に
2336    // 落ちるバグだった(hsl()/hwb() には既にこの構文対応があったが rgb()/rgba()
2337    // だけ漏れていた非対称ペア)。
2338    let rgb_space_cases: &[(&str, u32)] = &[
2339        ("rgb(255 0 0)", 0xFFFF0000),
2340        ("rgb(255 0 0 / 50%)", 0x7FFF0000),
2341        ("rgba(0 255 0 / 0.5)", 0x7F00FF00),
2342    ];
2343
2344    // oklab()/oklch() — CSS Color Level 4(丸ごと未実装だった。OKLab⇄線形sRGB
2345    // 変換行列の設計上、L=1・彩度0 は必ず白、L=0 は必ず黒になる不変量で検証する)。
2346    let oklab_oklch_cases: &[(&str, u32)] = &[
2347        ("oklch(1 0 0)", 0xFFFFFFFF),
2348        ("oklch(0 0 0)", 0xFF000000),
2349        ("oklab(1 0 0)", 0xFFFFFFFF),
2350        ("oklab(0 0 0)", 0xFF000000),
2351    ];
2352
2353    // color(COLORSPACE c1 c2 c3 [/ a]) — CSS Color Module Level 4の述語関数。
2354    // 丸ごと未対応だった。`srgb`(0-1範囲、ガンマ符号化済み。`rgb()`の値を
2355    // 255で割ったものと同じ)と`srgb-linear`(線形light、`oklab()`と同じ
2356    // 変換)のみ対応、それ以外の色空間(`display-p3`等)はNoneのまま。
2357    let color_fn_cases: &[(&str, u32)] = &[
2358        ("color(srgb 1 0 0)", 0xFFFF0000),
2359        ("color(srgb 0 1 0)", 0xFF00FF00),
2360        ("color(srgb 1 1 1 / 0.5)", 0x7FFFFFFF),
2361        ("color(srgb-linear 1 1 1)", 0xFFFFFFFF),
2362        ("color(srgb-linear 0 0 0)", 0xFF000000),
2363    ];
2364
2365    // hwb(h w% b%) — CSS Color Level 4。
2366    let hwb_cases: &[(&str, u32)] = &[
2367        ("hwb(0, 0%, 0%)", 0xFFFF0000),   // 純赤
2368        ("hwb(0, 100%, 0%)", 0xFFFFFFFF), // white=100% は色相に関わらず白
2369        ("hwb(0, 0%, 100%)", 0xFF000000), // black=100% は色相に関わらず黒
2370        ("hwb(120, 0%, 0%)", 0xFF00FF00), // 純緑
2371        ("hwb(240, 0%, 0%)", 0xFF0000FF), // 純青
2372        ("hwb(0 0% 0% / 50%)", 0x7FFF0000), // スペース区切り + アルファ
2373    ];
2374
2375    // color-mix(in <space>, <color> [<pct>]?, <color> [<pct>]?) — CSS Color Module Level 5。
2376    // 期待値はチャンネルごとの整数除算(切り捨て、四捨五入ではない)で手計算したもの。
2377    let color_mix_cases: &[(&str, u32)] = &[
2378        ("color-mix(in srgb, red, blue)", 0xFF7F007F), // 明示比率なし = 50/50
2379        ("color-mix(in srgb, red 100%, blue 0%)", 0xFFFF0000), // 100/0 = 片方そのまま
2380        ("color-mix(in srgb, red 0%, blue 100%)", 0xFF0000FF),
2381        ("color-mix(in srgb, red 75%, blue 25%)", 0xFFBF003F), // 3:1 比率
2382        ("color-mix(in srgb, red 40%, blue)", 0xFF660099), // 片方だけ指定 -> 他方は残り(60%)
2383        ("color-mix(in srgb, white, black)", 0xFF7F7F7F), // 白黒混合 = 灰色
2384    ];
2385
2386    // text-decoration ショートハンド(line/style/color 空白区切り任意順)の解析(純関数)。
2387    // (入力, want_underline, want_line_through, want_overline, want_none, want_style, want_color)
2388    let text_decoration_cases: &[(&str, bool, bool, bool, bool, Option<&str>, Option<u32>)] = &[
2389        ("underline", true, false, false, false, None, None),
2390        ("none", false, false, false, true, None, None),
2391        ("underline wavy red", true, false, false, false, Some("wavy"), Some(0xFFFF0000)),
2392        (
2393            "line-through dotted #00ff00",
2394            false,
2395            true,
2396            false,
2397            false,
2398            Some("dotted"),
2399            Some(0xFF00FF00),
2400        ),
2401        ("overline", false, false, true, false, None, None),
2402    ];
2403
2404    let mut passed = 0;
2405    // +3 は :focus/:focus-visible/:active 事前計算の直接検査、+2 は <style>/<script> の
2406    // Text 子ノード検査(下記)、+3 は複数背景レイヤー抽出(extract_all_url_paths)の
2407    // 直接検査の分、+1 は sample_box_blur の直接検査の分、+1 は :target の直接検査の分、
2408    // +2 は :root の直接検査の分、+1 は疑似要素の特異度寄与の直接検査の分、
2409    // +1 は :focus-within の直接検査の分、+3 は light-dark()(light/dark/
2410    // ネストの3件)の直接検査の分(この項が丸ごと抜けており `passed` が `total` を
2411    // 超えて CSS_SELFTEST が個別 FAIL メッセージ無しで丸ごと FAIL する回帰があった)、
2412    // +10 は `eval_calc`(除算/3項連鎖/括弧ネスト対応。2026-07-16)の
2413    // 直接検査10件の分、+10 は `parse_px_like`(vmin/vmax/絶対単位/
2414    // thin・medium・thickキーワード対応。2026-07-16/17)の直接検査10件の
2415    // 分、+7 は`parse_calc_unit`/`eval_length_token`(calc()本体/min()/
2416    // max()/clamp()引数のvh/vw/vmin/vmax/絶対単位対応。2026-07-17)の
2417    // 直接検査7件の分、+5 は `parse_line_height`(%/em単位対応。
2418    // 2026-07-17)の直接検査5件の分、+5 は `resolve_text_indent`
2419    // (%/em単位対応。2026-07-17)の直接検査5件の分、+5 は
2420    // `parse_shadow_len_tok`(box-shadow/text-shadowのrem単位対応。
2421    // 2026-07-17)の直接検査5件の分、末尾の +4 は`resolve_border_radius`
2422    // (rem単位対応。2026-07-17)の直接検査4件の分。
2423    let mut total = cases.len()
2424        + value_cases.len()
2425        + tt_cases.len()
2426        + alpha_roman_cases.len()
2427        + filter_cases.len()
2428        + blur_radius_cases.len()
2429        + blend_cases.len()
2430        + text_decoration_cases.len()
2431        + bg_repeat_cases.len()
2432        + rgb_pct_cases.len()
2433        + rgb_space_cases.len()
2434        + hsl_hue_wrap_cases.len()
2435        + hwb_cases.len()
2436        + oklab_oklch_cases.len()
2437        + color_fn_cases.len()
2438        + color_mix_cases.len()
2439        + 3
2440        + 2
2441        + 3
2442        + 1
2443        + 1
2444        + 2
2445        + 1
2446        + 1
2447        + 3
2448        + 10
2449        + 10
2450        + 7
2451        + 5
2452        + 5
2453        + 5
2454        + 4;
2455    for (input, want_radius) in blur_radius_cases {
2456        let got = super::super::web_engine::draw_helpers::parse_blur_radius(input);
2457        if got == *want_radius {
2458            passed += 1;
2459        } else {
2460            crate::println!(
2461                "CSS_SELFTEST FAIL: parse_blur_radius(`{}`) => {} (want {})",
2462                input, got, want_radius
2463            );
2464        }
2465    }
2466    // sample_box_blur: 3x3画像で中心のみ赤、周囲は黒。半径1で中心をぼかすと
2467    // R = 255/9 = 28(整数除算切り捨て)、G=B=0。
2468    {
2469        let mut rgba = alloc::vec![0u8; 3 * 3 * 4];
2470        let center_idx = 4 * 4;
2471        rgba[center_idx] = 255;
2472        rgba[center_idx + 3] = 255;
2473        let img = super::super::web_engine::DecodedImage { width: 3, height: 3, rgba };
2474        let got = super::super::web_engine::draw_helpers::sample_box_blur(&img, 1, 1, 1);
2475        if got == (28, 0, 0) {
2476            passed += 1;
2477        } else {
2478            crate::println!("CSS_SELFTEST FAIL: sample_box_blur => {:?} (want (28,0,0))", got);
2479        }
2480    }
2481    // `calc()`(丸ごと限定的な実装だった。以前は`+`/`-`/`*`を1回ずつ・2項までしか
2482    // 扱えず`/`(除算)が丸ごと未対応、括弧ネストも不可だった。実務でよく使う
2483    // `calc(100% / 3)`のような除算や`calc(100% - 20px - 10px)`のような3項式、
2484    // `calc((100% - 20px) / 2)`のような括弧ネストが軒並み評価失敗していた。
2485    // 2026-07-16 発見・修正)。
2486    {
2487        let calc_cases: &[(&str, i32, i32)] = &[
2488            // 既存の2項ケース(回帰確認)。
2489            ("calc(100px - 20px)", 0, 80),
2490            ("calc(50% + 10px)", 200, 110),
2491            ("calc(10px * 2)", 0, 20),
2492            // 除算(以前は丸ごと未対応)。
2493            ("calc(100px / 4)", 0, 25),
2494            ("calc(300% / 3)", 100, 100),
2495            // 3項以上の連鎖(以前は2項までしか扱えなかった)。
2496            ("calc(100% - 20px - 10px)", 200, 170),
2497            ("calc(10px + 20px + 30px)", 0, 60),
2498            // 丸括弧ネスト(以前は丸ごと未対応)。
2499            ("calc((100px - 20px) / 2)", 0, 40),
2500            ("calc(50% - (10px + 5px))", 200, 85),
2501            // 単項マイナス(先頭の `-` は演算子ではなく数値の一部)。
2502            ("calc(-10px + 50px)", 0, 40),
2503        ];
2504        for (expr, cw, want) in calc_cases {
2505            let got = super::values::eval_calc(expr, *cw);
2506            if got == Some(*want) {
2507                passed += 1;
2508            } else {
2509                crate::println!(
2510                    "CSS_SELFTEST FAIL: eval_calc(`{}`, {}) => {:?} (want Some({}))",
2511                    expr, cw, got, want
2512                );
2513            }
2514        }
2515    }
2516    // `parse_px_like`(`layout/values.rs`)に`vmin`/`vmax`(ビューポート相対
2517    // 単位。`vh`/`vw`は実装済みなのに同じ仲間だけ抜けていた兄弟ギャップ)と
2518    // 絶対長さ単位`in`/`cm`/`mm`/`pt`/`pc`/`q`(丸ごと未対応)が丸ごと
2519    // 未対応だったバグ(2026-07-16 発見・修正。CSS Values and Units の
2520    // 規定どおり`96px = 1in`を基準に換算)。ビューポート寸法を既知の値へ
2521    // 固定してから検証する。
2522    {
2523        // 他のテストケース(`@media`のビューポート幅判定等)へ影響しない
2524        // よう、変更前の値を退避し検証後に復元する(この検証で一度この
2525        // 復元漏れがあり、後続の`@media (min-width/max-width)`テストが
2526        // 巻き添えでFAILする回帰を自己発見・修正した)。
2527        let orig_w = crate::os_lib::layout::VIEWPORT_HINT_W.load(core::sync::atomic::Ordering::Relaxed);
2528        let orig_h = crate::os_lib::layout::VIEWPORT_HINT_H.load(core::sync::atomic::Ordering::Relaxed);
2529        crate::os_lib::layout::VIEWPORT_HINT_W.store(1000, core::sync::atomic::Ordering::Relaxed);
2530        crate::os_lib::layout::VIEWPORT_HINT_H.store(500, core::sync::atomic::Ordering::Relaxed);
2531        let px_cases: &[(&str, i32)] = &[
2532            // vmin/vmax: min(1000,500)=500, max(1000,500)=1000。
2533            ("50vmin", 250),
2534            ("50vmax", 500),
2535            // 絶対単位(96px基準)。
2536            ("1in", 96),
2537            ("2.54cm", 96),
2538            ("25.4mm", 96),
2539            ("72pt", 96),
2540            ("6pc", 96),
2541            // `border-width`のキーワード値(丸ごと未対応だった。`outline-
2542            // width`側は既に対応済みで`border-width`側にだけ兄弟ギャップが
2543            // 残っていた。2026-07-17 発見・実装)。
2544            ("thin", 1),
2545            ("medium", 3),
2546            ("thick", 5),
2547        ];
2548        for (input, want) in px_cases {
2549            let got = crate::os_lib::layout::parse_px_like(input);
2550            if got == Some(*want) {
2551                passed += 1;
2552            } else {
2553                crate::println!(
2554                    "CSS_SELFTEST FAIL: parse_px_like(`{}`) => {:?} (want Some({}))",
2555                    input, got, want
2556                );
2557            }
2558        }
2559        // `calc()`本体の単位トークン解決(`parse_calc_unit`)と、
2560        // `min()`/`max()`/`clamp()`の引数解決(`eval_length_token`)にも
2561        // 同じ`vh`/`vw`/`vmin`/`vmax`/絶対単位が丸ごと未対応だった
2562        // バグ(`calc(50vw + 10px)`/`min(50vw, 300px)`のようなビュー
2563        // ポート単位を含む式が丸ごと評価失敗、または引数が静かに脱落
2564        // していた。2026-07-17 発見・実装)。同じビューポート寸法
2565        // (1000×500)で検証する。
2566        let calc_unit_cases: &[(&str, i32, i32)] = &[
2567            ("calc(50vw + 10px)", 0, 510),
2568            ("calc(50vh - 10px)", 0, 240),
2569            ("calc(50vmin + 50px)", 0, 300),
2570            ("calc(1in + 4px)", 0, 100),
2571            ("min(50vw, 300px)", 0, 300),
2572            ("max(50vw, 300px)", 0, 500),
2573            ("clamp(10px, 50vw, 100px)", 0, 100),
2574        ];
2575        for (expr, cw, want) in calc_unit_cases {
2576            let got = super::values::eval_css_math(expr, *cw);
2577            if got == Some(*want) {
2578                passed += 1;
2579            } else {
2580                crate::println!(
2581                    "CSS_SELFTEST FAIL: eval_css_math(`{}`, {}) => {:?} (want Some({}))",
2582                    expr, cw, got, want
2583                );
2584            }
2585        }
2586        crate::os_lib::layout::VIEWPORT_HINT_W.store(orig_w, core::sync::atomic::Ordering::Relaxed);
2587        crate::os_lib::layout::VIEWPORT_HINT_H.store(orig_h, core::sync::atomic::Ordering::Relaxed);
2588    }
2589    // `line-height`が`%`/`em`単位を丸ごとサポートしていなかったバグ
2590    // (`web_engine/layout.rs`の`parse_line_height`。2026-07-17 発見・
2591    // 実装。自己テストのためネスト関数からモジュールトップレベルへ移動
2592    // 済み。詳細は同関数のdocコメント参照)。
2593    {
2594        let line_height_cases: &[(&str, u32, i32)] = &[
2595            ("16px", 16, 16),
2596            ("1.5", 16, 24),
2597            ("150%", 16, 24),
2598            ("1.5em", 16, 24),
2599            ("2em", 20, 40),
2600        ];
2601        for (input, font_size, want) in line_height_cases {
2602            let got = super::super::web_engine::parse_line_height(input, *font_size);
2603            if got == *want {
2604                passed += 1;
2605            } else {
2606                crate::println!(
2607                    "CSS_SELFTEST FAIL: parse_line_height(`{}`, {}) => {} (want {})",
2608                    input, font_size, got, want
2609                );
2610            }
2611        }
2612    }
2613    // `text-indent`が`%`/`em`単位を丸ごとサポートしていなかったバグ
2614    // (`web_engine/layout.rs`の`resolve_text_indent`。2026-07-17
2615    // 発見・実装。詳細は同関数のdocコメント参照)。
2616    {
2617        // (input, container_width, font_size, want)
2618        let text_indent_cases: &[(&str, i32, u32, i32)] = &[
2619            ("20px", 400, 16, 20),
2620            ("10", 400, 16, 10),
2621            ("10%", 400, 16, 40),
2622            ("2em", 400, 16, 32),
2623            ("2em", 400, 20, 40),
2624        ];
2625        for (input, cw, font_size, want) in text_indent_cases {
2626            let got = super::super::web_engine::resolve_text_indent(input, *cw, *font_size);
2627            if got == *want {
2628                passed += 1;
2629            } else {
2630                crate::println!(
2631                    "CSS_SELFTEST FAIL: resolve_text_indent(`{}`, {}, {}) => {} (want {})",
2632                    input, cw, font_size, got, want
2633                );
2634            }
2635        }
2636    }
2637    // `box-shadow`/`text-shadow`のオフセット/ぼかし値が`rem`単位を丸ごと
2638    // サポートしていなかったバグ(`web_engine/layout.rs`の
2639    // `parse_shadow_len_tok`。2026-07-17 発見・実装。詳細は同関数の
2640    // docコメント参照)。
2641    {
2642        let shadow_len_cases: &[(&str, Option<i32>)] = &[
2643            ("10px", Some(10)),
2644            ("0.5rem", Some(8)),
2645            ("2rem", Some(32)),
2646            ("red", None),
2647            ("inset", None),
2648        ];
2649        for (input, want) in shadow_len_cases {
2650            let got = super::super::web_engine::parse_shadow_len_tok(input);
2651            if got == *want {
2652                passed += 1;
2653            } else {
2654                crate::println!(
2655                    "CSS_SELFTEST FAIL: parse_shadow_len_tok(`{}`) => {:?} (want {:?})",
2656                    input, got, want
2657                );
2658            }
2659        }
2660    }
2661    // `border-radius`が`rem`単位を丸ごとサポートしていなかったバグ
2662    // (`web_engine/layout.rs`の`resolve_border_radius`。2026-07-17
2663    // 発見・実装。詳細は同関数のdocコメント参照)。
2664    {
2665        // (input, container_w, want)
2666        let border_radius_cases: &[(&str, i32, i32)] = &[
2667            ("10px", 200, 10),
2668            ("50%", 200, 100),
2669            ("1rem", 200, 16),
2670            ("0.5rem", 200, 8),
2671        ];
2672        for (input, cw, want) in border_radius_cases {
2673            let got = super::super::web_engine::resolve_border_radius(input, *cw);
2674            if got == *want {
2675                passed += 1;
2676            } else {
2677                crate::println!(
2678                    "CSS_SELFTEST FAIL: resolve_border_radius(`{}`, {}) => {} (want {})",
2679                    input, cw, got, want
2680                );
2681            }
2682        }
2683    }
2684    for (input, want_u, want_lt, want_ov, want_none, want_style, want_color) in text_decoration_cases {
2685        let (u, lt, ov, is_none, style, color) =
2686            super::super::web_engine::helpers::parse_text_decoration_shorthand(input);
2687        let style_ok = style.as_deref() == *want_style;
2688        let color_ok = color == *want_color;
2689        if u == *want_u
2690            && lt == *want_lt
2691            && ov == *want_ov
2692            && is_none == *want_none
2693            && style_ok
2694            && color_ok
2695        {
2696            passed += 1;
2697        } else {
2698            crate::println!(
2699                "CSS_SELFTEST FAIL: parse_text_decoration_shorthand(`{}`) => ({},{},{},{},{:?},{:?}) (want ({},{},{},{},{:?},{:?}))",
2700                input, u, lt, ov, is_none, style, color, want_u, want_lt, want_ov, want_none, want_style, want_color
2701            );
2702        }
2703    }
2704    for (input, want_x, want_y) in bg_repeat_cases {
2705        let (x, y) = super::super::web_engine::helpers::parse_bg_repeat(Some(input));
2706        if x == *want_x && y == *want_y {
2707            passed += 1;
2708        } else {
2709            crate::println!(
2710                "CSS_SELFTEST FAIL: parse_bg_repeat(`{}`) => ({:?},{:?}) (want ({:?},{:?}))",
2711                input, x, y, want_x, want_y
2712            );
2713        }
2714    }
2715    for (input, want) in rgb_pct_cases {
2716        let got = parse_color(input);
2717        if got == Some(*want) {
2718            passed += 1;
2719        } else {
2720            crate::println!(
2721                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2722                input, got, want
2723            );
2724        }
2725    }
2726    for (input, want) in rgb_space_cases {
2727        let got = parse_color(input);
2728        if got == Some(*want) {
2729            passed += 1;
2730        } else {
2731            crate::println!(
2732                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2733                input, got, want
2734            );
2735        }
2736    }
2737    for (input, want) in hsl_hue_wrap_cases {
2738        let got = parse_color(input);
2739        if got == Some(*want) {
2740            passed += 1;
2741        } else {
2742            crate::println!(
2743                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2744                input, got, want
2745            );
2746        }
2747    }
2748    for (input, want) in hwb_cases {
2749        let got = parse_color(input);
2750        if got == Some(*want) {
2751            passed += 1;
2752        } else {
2753            crate::println!(
2754                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2755                input, got, want
2756            );
2757        }
2758    }
2759    for (input, want) in oklab_oklch_cases {
2760        let got = parse_color(input);
2761        if got == Some(*want) {
2762            passed += 1;
2763        } else {
2764            crate::println!(
2765                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2766                input, got, want
2767            );
2768        }
2769    }
2770    for (input, want) in color_fn_cases {
2771        let got = parse_color(input);
2772        if got == Some(*want) {
2773            passed += 1;
2774        } else {
2775            crate::println!(
2776                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2777                input, got, want
2778            );
2779        }
2780    }
2781    for (input, want) in color_mix_cases {
2782        let got = parse_color(input);
2783        if got == Some(*want) {
2784            passed += 1;
2785        } else {
2786            crate::println!(
2787                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2788                input, got, want
2789            );
2790        }
2791    }
2792    // light-dark(light, dark) — CSS Color Module Level 5。丸ごと未対応だった。
2793    // PREFERS_DARK(既定 light=false)を明示的に切り替えて両分岐を検証する。
2794    {
2795        let saved = PREFERS_DARK.load(AtomicOrd::Relaxed);
2796        PREFERS_DARK.store(false, AtomicOrd::Relaxed);
2797        let got_light = parse_color("light-dark(#ff0000, #0000ff)");
2798        if got_light == Some(0xFFFF0000) {
2799            passed += 1;
2800        } else {
2801            crate::println!(
2802                "CSS_SELFTEST FAIL: light-dark (light mode) => {:?} (want 0xFFFF0000)",
2803                got_light
2804            );
2805        }
2806        PREFERS_DARK.store(true, AtomicOrd::Relaxed);
2807        let got_dark = parse_color("light-dark(#ff0000, #0000ff)");
2808        if got_dark == Some(0xFF0000FF) {
2809            passed += 1;
2810        } else {
2811            crate::println!(
2812                "CSS_SELFTEST FAIL: light-dark (dark mode) => {:?} (want 0xFF0000FF)",
2813                got_dark
2814            );
2815        }
2816        // ネストした light-dark() の再帰解決も確認する。
2817        let got_nested = parse_color("light-dark(#ff0000, light-dark(#00ff00, #0000ff))");
2818        if got_nested == Some(0xFF0000FF) {
2819            passed += 1;
2820        } else {
2821            crate::println!(
2822                "CSS_SELFTEST FAIL: light-dark nested => {:?} (want 0xFF0000FF)",
2823                got_nested
2824            );
2825        }
2826        PREFERS_DARK.store(saved, AtomicOrd::Relaxed);
2827    }
2828    // :target — CURRENT_TARGET_ID を直接操作して判定を検証する。
2829    {
2830        *super::super::layout::CURRENT_TARGET_ID.lock() = String::from("sec2");
2831        let dom = super::super::dom::parse_html(
2832            "<div id='sec1'></div><div id='sec2'></div><div id='t'></div>",
2833        );
2834        let sheet = parse_css("#sec2:target{zz:1}");
2835        let styled = style_tree(&dom, &sheet);
2836        fn find_marked_target(node: &StyledNode, id: &str) -> Option<bool> {
2837            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
2838                if nid.as_deref() == Some(id) {
2839                    return Some(node.specified_values.contains_key("zz"));
2840                }
2841            }
2842            for c in &node.children {
2843                if let Some(r) = find_marked_target(c, id) {
2844                    return Some(r);
2845                }
2846            }
2847            None
2848        }
2849        let matched = find_marked_target(&styled, "sec2").unwrap_or(false);
2850        let not_matched = find_marked_target(&styled, "sec1").unwrap_or(true);
2851        if matched && !not_matched {
2852            passed += 1;
2853        } else {
2854            crate::println!(
2855                "CSS_SELFTEST FAIL: :target => sec2={} sec1={} (want true/false)",
2856                matched, not_matched
2857            );
2858        }
2859        *super::super::layout::CURRENT_TARGET_ID.lock() = String::new();
2860    }
2861    // :focus-within — FOCUS_WITHIN_PTRS を直接操作して判定を検証する(本番コードでは
2862    // parse_and_layout がフォーカス変更のたびに祖先パスを再計算する)。id を持たない
2863    // 要素も含めてノードポインタの同一性で判定できることも確認する。
2864    {
2865        fn collect_path_to(
2866            node: &super::super::dom::Node,
2867            target_id: &str,
2868            path: &mut alloc::vec::Vec<usize>,
2869        ) -> bool {
2870            path.push(node as *const _ as usize);
2871            if let NodeType::Element { id, .. } = &node.node_type {
2872                if id.as_deref() == Some(target_id) {
2873                    return true;
2874                }
2875            }
2876            for c in &node.children {
2877                if collect_path_to(c, target_id, path) {
2878                    return true;
2879                }
2880            }
2881            path.pop();
2882            false
2883        }
2884        let dom = super::super::dom::parse_html(
2885            "<div id='outer'><span><input id='inp'></span></div><div id='other'></div>",
2886        );
2887        let mut path = alloc::vec::Vec::new();
2888        collect_path_to(&dom, "inp", &mut path);
2889        {
2890            let mut set = super::super::layout::FOCUS_WITHIN_PTRS.lock();
2891            set.clear();
2892            set.extend(path);
2893        }
2894        let sheet = parse_css("#outer:focus-within{zz:1}");
2895        let styled = style_tree(&dom, &sheet);
2896        fn find_marked_fw(node: &StyledNode, id: &str) -> Option<bool> {
2897            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
2898                if nid.as_deref() == Some(id) {
2899                    return Some(node.specified_values.contains_key("zz"));
2900                }
2901            }
2902            for c in &node.children {
2903                if let Some(r) = find_marked_fw(c, id) {
2904                    return Some(r);
2905                }
2906            }
2907            None
2908        }
2909        let matched = find_marked_fw(&styled, "outer").unwrap_or(false);
2910        let not_matched = find_marked_fw(&styled, "other").unwrap_or(true);
2911        if matched && !not_matched {
2912            passed += 1;
2913        } else {
2914            crate::println!(
2915                "CSS_SELFTEST FAIL: :focus-within => outer={} other={} (want true/false)",
2916                matched, not_matched
2917            );
2918        }
2919        super::super::layout::FOCUS_WITHIN_PTRS.lock().clear();
2920    }
2921    // fieldset[disabled] 祖先による暗黙の無効化を :disabled/:enabled が反映することの検証
2922    {
2923        let html = r#"<div id="root"><fieldset disabled="true"><input id="i1" /></fieldset><fieldset><input id="i2" /></fieldset></div>"#;
2924        let dom = super::super::dom::parse_html(html);
2925        let sheet = parse_css("input:disabled{zz:1} input:enabled{yy:1}");
2926        let styled = style_tree(&dom, &sheet);
2927        fn find_marked_styles(node: &StyledNode, id: &str) -> Option<(bool, bool)> {
2928            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
2929                if nid.as_deref() == Some(id) {
2930                    return Some((
2931                        node.specified_values.contains_key("zz"),
2932                        node.specified_values.contains_key("yy")
2933                    ));
2934                }
2935            }
2936            for c in &node.children {
2937                if let Some(r) = find_marked_styles(c, id) {
2938                    return Some(r);
2939                }
2940            }
2941            None
2942        }
2943        let i1_styles = find_marked_styles(&styled, "i1").unwrap_or((false, false));
2944        let i2_styles = find_marked_styles(&styled, "i2").unwrap_or((false, false));
2945
2946        // i1 (disabled) => zz (disabled) should match, yy (enabled) should not.
2947        // i2 (enabled)  => zz (disabled) should not, yy (enabled) should match.
2948        let ok = i1_styles.0 && !i1_styles.1 && !i2_styles.0 && i2_styles.1;
2949        total += 1;
2950        if ok {
2951            passed += 1;
2952        } else {
2953            crate::println!(
2954                "CSS_SELFTEST FAIL: fieldset[disabled] CSS state propagation => i1={:?} i2={:?} (want (true,false) and (false,true))",
2955                i1_styles, i2_styles
2956            );
2957        }
2958    }
2959    // background-image の複数レイヤー抽出(純関数)。
2960    {
2961        let got = super::super::web_engine::helpers::extract_all_url_paths("url(a.png), url('b.png')");
2962        if got == alloc::vec!["a.png", "b.png"] {
2963            passed += 1;
2964        } else {
2965            crate::println!("CSS_SELFTEST FAIL: extract_all_url_paths multi => {:?}", got);
2966        }
2967        let got_single = super::super::web_engine::helpers::extract_all_url_paths("url(only.png)");
2968        if got_single == alloc::vec!["only.png"] {
2969            passed += 1;
2970        } else {
2971            crate::println!(
2972                "CSS_SELFTEST FAIL: extract_all_url_paths single => {:?}",
2973                got_single
2974            );
2975        }
2976        let got_none = super::super::web_engine::helpers::extract_all_url_paths("none");
2977        if got_none.is_empty() {
2978            passed += 1;
2979        } else {
2980            crate::println!("CSS_SELFTEST FAIL: extract_all_url_paths none => {:?}", got_none);
2981        }
2982    }
2983    // backdrop-filter / filter tokens test
2984    // `total`の集計漏れ(`fieldset[disabled]`テストと同じ自己追跡`total += 1`
2985    // パターンが無く、かつファイル冒頭の巨大な手動合計コメントにも記載が
2986    // 無かった。ファイル冒頭のコメントが警告している「`passed`が`total`を
2987    // 超えて個別FAILメッセージ無しで丸ごとFAILする」回帰そのもの。
2988    // 2026-07-18 発見・修正)。
2989    {
2990        let radius = super::super::web_engine::draw_helpers::parse_blur_radius("blur(8px) brightness(1.2)");
2991        total += 1;
2992        if radius == 8 {
2993            passed += 1;
2994        } else {
2995            crate::println!("CSS_SELFTEST FAIL: parse_blur_radius => {}", radius);
2996        }
2997    }
2998    // gradient alpha interpolation test
2999    {
3000        let stops = alloc::vec![
3001            super::super::web_engine::GradientStop { position: 0.0, color: 0x00FF0000 },
3002            super::super::web_engine::GradientStop { position: 1.0, color: 0xFF00FF00 },
3003        ];
3004        let mid_color = super::super::web_engine::draw_helpers::sample_gradient_stops(&stops, 0.5);
3005        let mid_alpha = (mid_color >> 24) & 0xFF;
3006        total += 1;
3007        if (120..=135).contains(&mid_alpha) {
3008            passed += 1;
3009        } else {
3010            crate::println!("CSS_SELFTEST FAIL: sample_gradient_stops alpha => 0x{:08X}", mid_color);
3011        }
3012    }
3013    // clip-path inset round test
3014    {
3015        total += 1;
3016        if let Some(shape) = super::super::web_engine::draw_helpers::parse_clip_shape("inset(10px round 20px)", 100, 100) {
3017            let inside_center = super::super::web_engine::draw_helpers::clip_inside(&shape, 50, 50);
3018            let inside_corner = super::super::web_engine::draw_helpers::clip_inside(&shape, 12, 12);
3019            if inside_center && !inside_corner {
3020                passed += 1;
3021            } else {
3022                crate::println!("CSS_SELFTEST FAIL: clip-path inset round => center={}, corner={}", inside_center, inside_corner);
3023            }
3024        } else {
3025            crate::println!("CSS_SELFTEST FAIL: clip-path inset round parse failed");
3026        }
3027    }
3028    // text-stroke test
3029    {
3030        let sheet = parse_css("h1 { -webkit-text-stroke: 2px #ff0000; }");
3031        total += 1;
3032        if !sheet.rules.is_empty() {
3033            passed += 1;
3034        } else {
3035            crate::println!("CSS_SELFTEST FAIL: text-stroke parse failed");
3036        }
3037    }
3038    // `background`ショートハンドの`<position>/<size>`スラッシュ構文
3039    // (`background: url(...) center/cover no-repeat`)がカスケード時点の
3040    // `expand_background_shorthand`で`background-position`/`-size`/`-repeat`
3041    // longhandへ正しく展開されることを検証。以前はこの分解ロジックが
3042    // `web_engine/layout.rs`内の1箇所にしか配線されておらず、それ以外の
3043    // 10箇所以上の重複コードブロックでは`/size`が無視されるバグだった
3044    // (2026-07-21発見・修正。詳細はwalkthrough.md参照)。
3045    {
3046        let dom = super::super::dom::parse_html("<div id='d'></div>");
3047        let sheet = parse_css(
3048            "#d{background:url('x.jpg') center/cover no-repeat}",
3049        );
3050        let styled = style_tree(&dom, &sheet);
3051        fn find_div<'a>(node: &'a StyledNode<'a>) -> Option<&'a StyledNode<'a>> {
3052            if let NodeType::Element { id, .. } = &node.node.node_type {
3053                if id.as_deref() == Some("d") {
3054                    return Some(node);
3055                }
3056            }
3057            for c in &node.children {
3058                if let Some(r) = find_div(c) {
3059                    return Some(r);
3060                }
3061            }
3062            None
3063        }
3064        total += 1;
3065        if let Some(d) = find_div(&styled) {
3066            let size = d.specified_values.get("background-size").map(|s| s.as_str());
3067            let position = d.specified_values.get("background-position").map(|s| s.as_str());
3068            let repeat = d.specified_values.get("background-repeat").map(|s| s.as_str());
3069            if size == Some("cover") && position == Some("center") && repeat == Some("no-repeat") {
3070                passed += 1;
3071            } else {
3072                crate::println!(
3073                    "CSS_SELFTEST FAIL: background shorthand slash expansion => size={:?} position={:?} repeat={:?}",
3074                    size, position, repeat
3075                );
3076            }
3077        } else {
3078            crate::println!("CSS_SELFTEST FAIL: background shorthand slash expansion => #d not found");
3079        }
3080    }
3081    // `@font-face`の解析(従来は完全にスキップされていた)。`font-family`の
3082    // 抽出と、`src`の複数候補(`url() format()`)からTTF/OTF/WOFF2形式を
3083    // 優先してURLを選ぶロジックを検証する。**2026-07-22更新**: WOFF2デコーダ
3084    // (`decompress_woff2`/`decompress_brotli`)が実装されたことに伴い、
3085    // `parse_font_face_def`の`is_preferred`判定に`woff2`も追加された
3086    // (以前はTTF/OTFのみ優先しWOFF2は非優先扱いだったため`b.ttf`を選んで
3087    // いたが、今はWOFF2も同格に優先されるため先頭に現れた`a.woff2`が
3088    // 選ばれるのが正しい挙動)。
3089    {
3090        let sheet = parse_css(
3091            "@font-face{font-family:'Noto Sans JP';src:url(a.woff2) format('woff2'),url(b.ttf) format('truetype')}",
3092        );
3093        total += 1;
3094        if sheet.font_faces.len() == 1
3095            && sheet.font_faces[0].family == "Noto Sans JP"
3096            && sheet.font_faces[0].src_url.as_deref() == Some("a.woff2")
3097        {
3098            passed += 1;
3099        } else {
3100            crate::println!(
3101                "CSS_SELFTEST FAIL: @font-face parse => {:?}",
3102                sheet.font_faces
3103            );
3104        }
3105    }
3106    // `@font-face`で`format('truetype')`ヒントが無い場合は、見つかった最初の
3107    // `url()`をそのまま採用すること(フォーマットヒント自体は仕様上省略可能)。
3108    {
3109        let sheet = parse_css("@font-face{font-family:'X';src:url(only.ttf)}");
3110        total += 1;
3111        if sheet.font_faces.len() == 1 && sheet.font_faces[0].src_url.as_deref() == Some("only.ttf")
3112        {
3113            passed += 1;
3114        } else {
3115            crate::println!(
3116                "CSS_SELFTEST FAIL: @font-face parse (no format hint) => {:?}",
3117                sheet.font_faces
3118            );
3119        }
3120    }
3121    // 実行時カスタムフォント登録(`kernel::vector_font::register_custom_font`)の
3122    // 往復テスト。実際のネットワーク取得は自己テスト環境では行えないため、
3123    // 既にビルドに埋め込み済みのフォントのバイト列を「ダウンロードされた
3124    // バイト列」の代用として使い、シグネチャ判定→登録→検索の一連のパイプライン
3125    // 全体が正しく動作することを検証する。
3126    {
3127        let font_bytes = alloc::vec::Vec::from(
3128            &include_bytes!("../../../fonts/ipa/ipag.ttf")[..],
3129        );
3130        total += 1;
3131        let ok = crate::kernel::vector_font::register_custom_font("Test Custom Font", font_bytes);
3132        if ok && crate::kernel::vector_font::has_custom_font("test custom font") {
3133            passed += 1;
3134        } else {
3135            crate::println!("CSS_SELFTEST FAIL: register_custom_font round-trip => registered={}", ok);
3136        }
3137    }
3138    // `register_custom_font`は成功時に`GLOBAL_VECTOR_FONT`のプライマリを新フォント
3139    // へ切り替える(`VectorFont::set_primary`。ページ全体のテキストへ取得済み
3140    // Webフォントを反映するための単純だが安全な戦略。2026-07-22実装)。
3141    // `GLOBAL_VECTOR_FONT`自体はこの自己テストの実行時点(`main.rs`で
3142    // `init_global_font_chain`より前に`css::selftest()`が呼ばれる)ではまだ
3143    // 未初期化(`None`)のため、グローバルではなく独立した`VectorFont`
3144    // インスタンスを直接構築して`set_primary`の安全性(切替後も旧プライマリの
3145    // グリフカバレッジがフォールバック経由で失われないこと)を検証する。
3146    // このロジックが壊れていれば実運用では全ページのテキスト描画・幅計測に
3147    // 波及する重大な回帰になるため、最も基本的な安全性チェックとして重要。
3148    {
3149        total += 1;
3150        let font_a = include_bytes!("../../../fonts/MPLUS2/MPLUS2-Regular.ttf");
3151        let font_b = include_bytes!("../../../fonts/ipa/ipag.ttf");
3152        match crate::kernel::vector_font::VectorFont::new(font_a) {
3153            Ok(mut vf) => {
3154                let had_glyph_before = vf.get_glyph('a', 16).is_some();
3155                let switched = vf.set_primary(font_b).is_ok();
3156                let has_glyph_after = vf.get_glyph('a', 16).is_some();
3157                if had_glyph_before && switched && has_glyph_after {
3158                    passed += 1;
3159                } else {
3160                    crate::println!(
3161                        "CSS_SELFTEST FAIL: VectorFont::set_primary => before={} switched={} after={}",
3162                        had_glyph_before, switched, has_glyph_after
3163                    );
3164                }
3165            }
3166            Err(_) => {
3167                crate::println!("CSS_SELFTEST FAIL: VectorFont::set_primary => failed to build base font");
3168            }
3169        }
3170    }
3171    // Brotli解凍機能(`decompress_brotli`)が正常に機能すること。
3172    // 空Brotliストリームの正しいバイト値は`0x06`(`[3]`ではない)。Brotli仕様
3173    // (RFC 7932 §9.2)ではビットをLSB-firstで詰めるため、WBITS既定値(1bit=0)・
3174    // ISLAST(1bit=1)・ISLASTEMPTY(1bit=1)の3ビットは、それぞれバイトの
3175    // bit0/bit1/bit2に入る(0*1 + 1*2 + 1*4 = 6)。以前使っていた`[3]`
3176    // (0b00000011)はWBITSの1ビット目が1になっており「デフォルト以外の
3177    // window宣言」を意味してしまい、ISLASTEMPTYが0のため不完全な
3178    // メタブロックヘッダとして扱われ、実装のバグではなくテストデータ自体が
3179    // 不正だったために失敗していたと判明(2026-07-22発見・修正)。
3180    {
3181        total += 1;
3182        if let Some(dec) = crate::kernel::vector_font::decompress_brotli(&[6], 0) {
3183            if dec.is_empty() {
3184                passed += 1;
3185            } else {
3186                crate::println!("CSS_SELFTEST FAIL: decompress_brotli empty stream should be empty");
3187            }
3188        } else {
3189            crate::println!("CSS_SELFTEST FAIL: decompress_brotli empty stream failed");
3190        }
3191    }
3192    // 不正な/切り詰められた WOFF2 ヘッダーは安全に拒否され、クラッシュせず `false` を返すこと。
3193    {
3194        let fake_woff2 = alloc::vec![b'w', b'O', b'F', b'2', 0, 0, 0, 0];
3195        total += 1;
3196        if !crate::kernel::vector_font::register_custom_font("Fake Woff2", fake_woff2) {
3197            passed += 1;
3198        } else {
3199            crate::println!("CSS_SELFTEST FAIL: register_custom_font should reject invalid WOFF2 header");
3200        }
3201    }
3202    // `word-break`/`overflow-wrap`/`word-wrap` が親要素から子ノードへ継承されること。
3203    {
3204        total += 1;
3205        let html = "<html><body><div id='parent' style='word-break:break-all;overflow-wrap:break-word;'><span id='child'>text</span></div></body></html>";
3206        let doc = super::super::dom::parse_html(html);
3207        let stylesheet = parse_css("");
3208        let styled = style_tree(&doc, &stylesheet);
3209        fn find_by_id<'a>(node: &'a StyledNode<'a>, id: &str) -> Option<&'a StyledNode<'a>> {
3210            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
3211                if nid.as_deref() == Some(id) {
3212                    return Some(node);
3213                }
3214            }
3215            for c in &node.children {
3216                if let Some(r) = find_by_id(c, id) {
3217                    return Some(r);
3218                }
3219            }
3220            None
3221        }
3222        if let Some(child) = find_by_id(&styled, "child") {
3223            let wb = child.value("word-break").map(|v| v.trim() == "break-all").unwrap_or(false);
3224            let ow = child.value("overflow-wrap").map(|v| v.trim() == "break-word").unwrap_or(false);
3225            if wb && ow {
3226                passed += 1;
3227            } else {
3228                crate::println!("CSS_SELFTEST FAIL: word-break/overflow-wrap inheritance => wb={} ow={}", wb, ow);
3229            }
3230        } else {
3231            crate::println!("CSS_SELFTEST FAIL: child node not found in style_tree");
3232        }
3233    }
3234    for (mode, src, dst, expect) in blend_cases {
3235        let got = super::super::web_engine::draw_helpers::blend_pixel(mode, *src, *dst);
3236        if got == *expect {
3237            passed += 1;
3238        } else {
3239            crate::println!(
3240                "CSS_SELFTEST FAIL: blend_pixel(`{}`, {:?}, {:?}) => {:?} (want {:?})",
3241                mode,
3242                src,
3243                dst,
3244                got,
3245                expect
3246            );
3247        }
3248    }
3249    for (filter, input, expect) in filter_cases {
3250        let got = super::super::web_engine::draw_helpers::apply_css_filters(filter, input.0, input.1, input.2);
3251        if got == *expect {
3252            passed += 1;
3253        } else {
3254            crate::println!(
3255                "CSS_SELFTEST FAIL: apply_css_filters(`{}`, {:?}) => {:?} (want {:?})",
3256                filter,
3257                input,
3258                got,
3259                expect
3260            );
3261        }
3262    }
3263    for (kind, n, upper, expect) in alpha_roman_cases {
3264        let got = if *kind == "alpha" {
3265            super::super::web_engine::number_to_alpha(*n, *upper)
3266        } else {
3267            super::super::web_engine::number_to_roman(*n, *upper)
3268        };
3269        if got == *expect {
3270            passed += 1;
3271        } else {
3272            crate::println!(
3273                "CSS_SELFTEST FAIL: number_to_{}({}, {}) => `{}` (want `{}`)",
3274                kind,
3275                n,
3276                upper,
3277                got,
3278                expect
3279            );
3280        }
3281    }
3282    for (input, mode, expect) in tt_cases {
3283        if apply_text_transform(input, mode) == *expect {
3284            passed += 1;
3285        } else {
3286            crate::println!(
3287                "CSS_SELFTEST FAIL: text-transform({}, {}) => `{}` (want `{}`)",
3288                input,
3289                mode,
3290                apply_text_transform(input, mode),
3291                expect
3292            );
3293        }
3294    }
3295    for (html, css, id, expect) in cases {
3296        let dom = super::super::dom::parse_html(html);
3297        let sheet = parse_css(css);
3298        let styled = style_tree(&dom, &sheet);
3299        let got = find_marked(&styled, id).unwrap_or(false);
3300        if got == *expect {
3301            passed += 1;
3302        } else {
3303            crate::println!(
3304                "CSS_SELFTEST FAIL: `{}` + `{}` on #{} => {} (want {})",
3305                html,
3306                css,
3307                id,
3308                got,
3309                expect
3310            );
3311        }
3312    }
3313    for (html, css, id, prop, expect) in value_cases {
3314        let dom = super::super::dom::parse_html(html);
3315        let sheet = parse_css(css);
3316        let styled = style_tree(&dom, &sheet);
3317        let got = find_prop(&styled, id, prop).unwrap_or_default();
3318        if got == *expect {
3319            passed += 1;
3320        } else {
3321            crate::println!(
3322                "CSS_SELFTEST FAIL: `{}` + `{}` on #{}.{} => `{}` (want `{}`)",
3323                html,
3324                css,
3325                id,
3326                prop,
3327                got,
3328                expect
3329            );
3330        }
3331    }
3332    // ---- :focus(hover と同じ事前計算方式)----
3333    // focus_values は実際のフォーカス状態に関わらず「:focus 適用時にどう変わるか」を
3334    // 常に事前計算しておく(実際の適用可否は描画側が focused_id と比較して選ぶ)。
3335    {
3336        fn find_node<'a>(node: &'a StyledNode, id: &str) -> Option<&'a StyledNode<'a>> {
3337            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
3338                if nid.as_deref() == Some(id) {
3339                    return Some(node);
3340                }
3341            }
3342            for c in &node.children {
3343                if let Some(r) = find_node(c, id) {
3344                    return Some(r);
3345                }
3346            }
3347            None
3348        }
3349        let dom = super::super::dom::parse_html("<input id='t' type='text'>");
3350        let sheet = parse_css("input:focus{border-color:#ff0000}");
3351        let styled = style_tree(&dom, &sheet);
3352        let focus_border = find_node(&styled, "t")
3353            .and_then(|n| n.focus_values.as_ref())
3354            .and_then(|h| h.get("border-color").cloned())
3355            .unwrap_or_default();
3356        if focus_border == "#ff0000" {
3357            passed += 1;
3358        } else {
3359            crate::println!(
3360                "CSS_SELFTEST FAIL: :focus border-color => `{}` (want `#ff0000`)",
3361                focus_border
3362            );
3363        }
3364        // :focus-visible は :focus と同一視される簡略実装(キーボード/ポインタ操作の区別なし)。
3365        let sheet_fv = parse_css("input:focus-visible{border-color:#00ff00}");
3366        let styled_fv = style_tree(&dom, &sheet_fv);
3367        let focus_visible_border = find_node(&styled_fv, "t")
3368            .and_then(|n| n.focus_values.as_ref())
3369            .and_then(|h| h.get("border-color").cloned())
3370            .unwrap_or_default();
3371        if focus_visible_border == "#00ff00" {
3372            passed += 1;
3373        } else {
3374            crate::println!(
3375                "CSS_SELFTEST FAIL: :focus-visible border-color => `{}` (want `#00ff00`)",
3376                focus_visible_border
3377            );
3378        }
3379        // :active(hover/focus と同じ事前計算方式。実際の適用可否は描画側が
3380        // 「ホバー中 かつ マウス左ボタン押下中」を見て選ぶ)。
3381        let sheet_active = parse_css("input:active{border-color:#0000ff}");
3382        let styled_active = style_tree(&dom, &sheet_active);
3383        let active_border = find_node(&styled_active, "t")
3384            .and_then(|n| n.active_values.as_ref())
3385            .and_then(|h| h.get("border-color").cloned())
3386            .unwrap_or_default();
3387        if active_border == "#0000ff" {
3388            passed += 1;
3389        } else {
3390            crate::println!(
3391                "CSS_SELFTEST FAIL: :active border-color => `{}` (want `#0000ff`)",
3392                active_border
3393            );
3394        }
3395    }
3396
3397    // ---- 疑似要素(::before 等)の特異度寄与 ----
3398    // 以前は simple_specificity() が sel.pseudo_element を一切見ておらず、
3399    // `::before` 単体の特異度が (0,0,0)(無寄与)のままになっていた。
3400    // CSS 仕様では疑似要素はタグ名セレクタと同じ重み(タグ数バケット)で寄与するため、
3401    // `::before` 単体は `div` 単体と同じ (0,0,1) になるべき。
3402    {
3403        let s1 = parse_selector("div::before").map(|s| selector_specificity(&s));
3404        let s2 = parse_selector("div").map(|s| selector_specificity(&s));
3405        if s1 == Some((0, 0, 2)) && s2 == Some((0, 0, 1)) {
3406            passed += 1;
3407        } else {
3408            crate::println!(
3409                "CSS_SELFTEST FAIL: pseudo-element specificity => div::before={:?} (want Some((0,0,2))), div={:?} (want Some((0,0,1)))",
3410                s1, s2
3411            );
3412        }
3413    }
3414
3415    // ---- :root(文書ルート要素 <html> にのみ一致)----
3416    // parse_html() は常に暗黙の "html" 要素をルートとして生成するため、id を付けずに
3417    // ルート自身の specified_values を直接検査する。
3418    {
3419        let dom_root = super::super::dom::parse_html("<div id='child'></div>");
3420        let sheet_root = parse_css(":root{zz:1}");
3421        let styled_root = style_tree(&dom_root, &sheet_root);
3422        let root_marker = styled_root
3423            .specified_values
3424            .get("zz")
3425            .cloned()
3426            .unwrap_or_default();
3427        if root_marker == "1" {
3428            passed += 1;
3429        } else {
3430            crate::println!(
3431                "CSS_SELFTEST FAIL: :root zz => `{}` (want `1`)",
3432                root_marker
3433            );
3434        }
3435        // 子要素(html自身ではない)には :root がマッチしない。
3436        fn find_node2<'a>(node: &'a StyledNode, id: &str) -> Option<&'a StyledNode<'a>> {
3437            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
3438                if nid.as_deref() == Some(id) {
3439                    return Some(node);
3440                }
3441            }
3442            for c in &node.children {
3443                if let Some(r) = find_node2(c, id) {
3444                    return Some(r);
3445                }
3446            }
3447            None
3448        }
3449        let child_matched = find_node2(&styled_root, "child")
3450            .and_then(|n| n.specified_values.get("zz").cloned())
3451            .is_some();
3452        if !child_matched {
3453            passed += 1;
3454        } else {
3455            crate::println!("CSS_SELFTEST FAIL: :root should not match non-root child element");
3456        }
3457    }
3458
3459    // ---- <style>/<script> の中身が Text 子ノードとして保持されるか ----
3460    // 以前は skip_raw_content が中身を完全に読み捨てており、.textContent での参照や
3461    // DOM ツリー経由でのスタイルシート抽出(動的に追加された <style> 等)が
3462    // 一切効かないバグだった(2026-07-03 修正)。
3463    {
3464        fn find_tag_text<'a>(node: &'a super::super::dom::Node, tag: &str) -> Option<&'a str> {
3465            if let NodeType::Element { tag_name, .. } = &node.node_type {
3466                if tag_name == tag {
3467                    for c in &node.children {
3468                        if let NodeType::Text(t) = &c.node_type {
3469                            return Some(t.as_str());
3470                        }
3471                    }
3472                    return None;
3473                }
3474            }
3475            for c in &node.children {
3476                if let Some(r) = find_tag_text(c, tag) {
3477                    return Some(r);
3478                }
3479            }
3480            None
3481        }
3482        let dom = super::super::dom::parse_html(
3483            "<html><style>a{color:red}</style><script>var x=1;</script></html>",
3484        );
3485        let style_text = find_tag_text(&dom, "style").unwrap_or("");
3486        if style_text.contains("color:red") {
3487            passed += 1;
3488        } else {
3489            crate::println!(
3490                "CSS_SELFTEST FAIL: <style> text child => `{}` (want to contain `color:red`)",
3491                style_text
3492            );
3493        }
3494        let script_text = find_tag_text(&dom, "script").unwrap_or("");
3495        if script_text.contains("var x=1") {
3496            passed += 1;
3497        } else {
3498            crate::println!(
3499                "CSS_SELFTEST FAIL: <script> text child => `{}` (want to contain `var x=1`)",
3500                script_text
3501            );
3502        }
3503    }
3504
3505    // ---- CSS Transitions / Animations ----
3506    // (説明, 実測値, 期待値) の closure ベース検査。
3507    let mut anim_total = 0usize;
3508    let mut anim_check = |desc: &str, got: String, want: &str| {
3509        anim_total += 1;
3510        if got == want {
3511            passed += 1;
3512        } else {
3513            crate::println!(
3514                "CSS_SELFTEST FAIL: anim {} => `{}` (want `{}`)",
3515                desc,
3516                got,
3517                want
3518            );
3519        }
3520    };
3521
3522    // --- イージング関数 ---
3523    anim_check(
3524        "linear@0.5",
3525        fmt_num(TimingFunction::Linear.ease(0.5)),
3526        "0.5",
3527    );
3528    anim_check("linear@0", fmt_num(TimingFunction::Linear.ease(0.0)), "0");
3529    anim_check("linear@1", fmt_num(TimingFunction::Linear.ease(1.0)), "1");
3530    // ease は中央でやや進む(>0.5)。
3531    {
3532        let e = TimingFunction::parse("ease").ease(0.5);
3533        anim_check("ease@0.5>0.5", (e > 0.55).to_string(), "true");
3534    }
3535    // ease-in は中央で遅れる(<0.5)。
3536    {
3537        let e = TimingFunction::parse("ease-in").ease(0.5);
3538        anim_check("ease-in@0.5<0.5", (e < 0.45).to_string(), "true");
3539    }
3540    // 端点は厳密に 0/1。
3541    anim_check(
3542        "ease@0",
3543        fmt_num(TimingFunction::parse("ease").ease(0.0)),
3544        "0",
3545    );
3546    anim_check(
3547        "ease@1",
3548        fmt_num(TimingFunction::parse("ease").ease(1.0)),
3549        "1",
3550    );
3551    // steps(4, end): t=0.5 → floor(2)/4 = 0.5。
3552    anim_check(
3553        "steps(4,end)@0.5",
3554        fmt_num(TimingFunction::parse("steps(4, end)").ease(0.5)),
3555        "0.5",
3556    );
3557    // steps(4, end): t=0.7 → floor(2.8)/4 = 0.5。
3558    anim_check(
3559        "steps(4,end)@0.7",
3560        fmt_num(TimingFunction::parse("steps(4, end)").ease(0.7)),
3561        "0.5",
3562    );
3563    // steps(4, start): t=0.5 → (floor(2)+1)/4 = 0.75。
3564    anim_check(
3565        "steps(4,start)@0.5",
3566        fmt_num(TimingFunction::parse("steps(4, start)").ease(0.5)),
3567        "0.75",
3568    );
3569    // cubic-bezier(0,0,1,1) は linear と等価。
3570    anim_check(
3571        "cubic-linear@0.3",
3572        fmt_num(TimingFunction::parse("cubic-bezier(0,0,1,1)").ease(0.3)),
3573        "0.3",
3574    );
3575
3576    // --- 値の補間 ---
3577    anim_check("px@0.5", interpolate_value("0px", "100px", 0.5), "50px");
3578    anim_check("px@0", interpolate_value("10px", "100px", 0.0), "10px");
3579    anim_check("px@1", interpolate_value("10px", "100px", 1.0), "100px");
3580    anim_check("num@0.25", interpolate_value("0", "1", 0.25), "0.25");
3581    anim_check("pct@0.5", interpolate_value("0%", "50%", 0.5), "25%");
3582    anim_check("em@0.5", interpolate_value("1em", "3em", 0.5), "2em");
3583    // 単位無し`0`と`%`/`px`の混在(実サイト www.sugi-lab.net のタイピング演出
3584    // `@keyframes typing { from{width:0} to{width:100%} }`と同じパターン)。
3585    // 以前は単位の厳密一致(`u1 == u2`)のみで判定しており、この組合せは
3586    // 補間不能な離散ジャンプ扱いになるバグだった(2026-07-22発見・修正)。
3587    anim_check("zero-to-pct@0.5", interpolate_value("0", "100%", 0.5), "50%");
3588    anim_check("zero-to-pct@0", interpolate_value("0", "100%", 0.0), "0%");
3589    anim_check("zero-to-pct@1", interpolate_value("0", "100%", 1.0), "100%");
3590    anim_check("pct-to-zero@0.5", interpolate_value("100%", "0", 0.5), "50%");
3591    anim_check("zero-to-px@0.5", interpolate_value("0", "40px", 0.5), "20px");
3592    // 同じ単位無し0の互換性修正は、`translateY(30px)`→`translateY(0)`という
3593    // 関数呼び出し引数内の再帰的な補間(`translateY(...)`の中身を
3594    // `interpolate_value`で再帰処理する経路)にも自動的に効く。実サイトの
3595    // IntersectionObserverによるフェードイン演出(`.research-card`等多数の
3596    // 要素が`transform: translateY(30px)` → `translateY(0)`で登場する、
3597    // このサイト全体で広く使われるパターン)が同じバグの影響を受けていた
3598    // ことの確認(2026-07-22)。
3599    anim_check(
3600        "translateY-fadein@0.5",
3601        interpolate_value("translateY(30px)", "translateY(0)", 0.5),
3602        "translateY(15px)",
3603    );
3604    // 色補間(black→white の中点 = 灰)。
3605    anim_check(
3606        "color@0.5",
3607        interpolate_value("#000000", "#ffffff", 0.5),
3608        "#7f7f7f",
3609    );
3610    anim_check(
3611        "color-name@0",
3612        interpolate_value("red", "blue", 0.0),
3613        "#ff0000",
3614    );
3615    // 複数値(margin: 0 0 → 10px 20px)。
3616    anim_check(
3617        "multi@0.5",
3618        interpolate_value("0px 0px", "10px 20px", 0.5),
3619        "5px 10px",
3620    );
3621    // transform 関数の内側補間。
3622    anim_check(
3623        "translateX@0.5",
3624        interpolate_value("translateX(0px)", "translateX(40px)", 0.5),
3625        "translateX(20px)",
3626    );
3627    // カンマ区切り引数を持つ単一関数(`translate(x, y)`)。以前はカンマ後の空白が
3628    // 余分な空白区切りトークンを作り、各引数が正しく補間されずジャンプしていた。
3629    anim_check(
3630        "translate-xy@0.5",
3631        interpolate_value("translate(10px, 20px)", "translate(30px, 40px)", 0.5),
3632        "translate(20px, 30px)",
3633    );
3634    // matrix() のような6引数関数でも同様に全引数が独立して補間されることを確認する。
3635    anim_check(
3636        "matrix@0.5",
3637        interpolate_value("matrix(0, 0, 0, 0, 0, 0)", "matrix(2, 2, 2, 2, 2, 2)", 0.5),
3638        "matrix(1, 1, 1, 1, 1, 1)",
3639    );
3640    // 空白区切りの複数関数の並び(`translateX(..) rotate(..)`)は引き続き
3641    // トークンごとの独立補間のまま維持されること(単一関数誤認による回帰の防止)。
3642    anim_check(
3643        "translate-rotate-chain@0.5",
3644        interpolate_value(
3645            "translateX(0px) rotate(0deg)",
3646            "translateX(40px) rotate(90deg)",
3647            0.5,
3648        ),
3649        "translateX(20px) rotate(45deg)",
3650    );
3651    // `box-shadow`/`text-shadow` のようなカンマ区切りの複数レイヤー値は、レイヤーごとに
3652    // 独立して補間されること(以前はカンマ後の空白によるトークン化でレイヤー境界を
3653    // 跨いだ位置ベース補間になり、色や単位不一致で discrete フォールバックしていた)。
3654    anim_check(
3655        "multi-layer-shadow@0.5",
3656        interpolate_value("0px 0px, 1px 1px", "10px 10px, 21px 21px", 0.5),
3657        "5px 5px, 11px 11px",
3658    );
3659    // 補間不能(単位不一致)→ discrete。
3660    anim_check(
3661        "discrete@0.3",
3662        interpolate_value("auto", "100px", 0.3),
3663        "auto",
3664    );
3665    anim_check(
3666        "discrete@0.7",
3667        interpolate_value("auto", "100px", 0.7),
3668        "100px",
3669    );
3670
3671    // --- 時間値パース ---
3672    anim_check("time-1s", fmt_num(parse_time_ms("1s")), "1000");
3673    anim_check("time-250ms", fmt_num(parse_time_ms("250ms")), "250");
3674    anim_check("time-0.5s", fmt_num(parse_time_ms("0.5s")), "500");
3675
3676    // --- transition ショートハンドのパース ---
3677    {
3678        let specs = parse_transition("width 1s ease, background-color 0.5s linear 0.2s");
3679        anim_check("trans-count", specs.len().to_string(), "2");
3680        if specs.len() == 2 {
3681            anim_check("trans0-prop", specs[0].property.clone(), "width");
3682            anim_check("trans0-dur", fmt_num(specs[0].duration_ms), "1000");
3683            anim_check("trans1-prop", specs[1].property.clone(), "background-color");
3684            anim_check("trans1-delay", fmt_num(specs[1].delay_ms), "200");
3685            anim_check(
3686                "trans1-timing",
3687                (specs[1].timing == TimingFunction::Linear).to_string(),
3688                "true",
3689            );
3690        }
3691    }
3692
3693    // --- transition ショートハンド: プロパティ名が 's' で終わる場合の誤分類バグ修正確認。
3694    // 旧実装は `ends_with('s')` だけで時間値と判定していたため、`flex-basis`/`border-radius`
3695    // のようなプロパティ名まで時間値と誤認し、本来の duration が消え property が "all" に
3696    // 化けていた。
3697    {
3698        let specs = parse_transition("flex-basis 0.3s ease");
3699        anim_check("trans-s-count-flexbasis", specs.len().to_string(), "1");
3700        if specs.len() == 1 {
3701            anim_check("trans-s-prop-flexbasis", specs[0].property.clone(), "flex-basis");
3702            anim_check("trans-s-dur-flexbasis", fmt_num(specs[0].duration_ms), "300");
3703        }
3704    }
3705    {
3706        let specs = parse_transition("border-radius 0.5s linear");
3707        if specs.len() == 1 {
3708            anim_check("trans-s-prop-borderradius", specs[0].property.clone(), "border-radius");
3709            anim_check("trans-s-dur-borderradius", fmt_num(specs[0].duration_ms), "500");
3710        }
3711    }
3712
3713    // --- animation ショートハンド: アニメーション名が 's' で終わる場合の誤分類バグ修正確認。
3714    {
3715        let specs = parse_animations("appears 1s ease");
3716        anim_check("anim-s-count", specs.len().to_string(), "1");
3717        if specs.len() == 1 {
3718            anim_check("anim-s-name", specs[0].name.clone(), "appears");
3719            anim_check("anim-s-dur", fmt_num(specs[0].duration_ms), "1000");
3720        }
3721    }
3722
3723    // --- animation ショートハンド内の `steps(n, jump-*)` タイミング関数
3724    // (実サイト www.sugi-lab.net のタイピング演出`animation: 2.5s steps(16,end)
3725    // typing`と同じパターン)が、カンマを含む関数呼び出しとして正しく1トークンへ
3726    // 結合され、`animation-name`と誤認識されないことを検証(2026-07-21)。
3727    {
3728        let specs = parse_animations("2.5s steps(16,end) typing");
3729        anim_check("anim-steps-count", specs.len().to_string(), "1");
3730        if specs.len() == 1 {
3731            anim_check("anim-steps-name", specs[0].name.clone(), "typing");
3732            anim_check("anim-steps-dur", fmt_num(specs[0].duration_ms), "2500");
3733            // steps(16,end)@0.5 = floor(0.5*16)/16 = 8/16 = 0.5。
3734            anim_check("anim-steps-timing", fmt_num(specs[0].timing.ease(0.5)), "0.5");
3735        }
3736    }
3737
3738    // --- transition サンプリング ---
3739    {
3740        let spec = TransitionSpec {
3741            property: "width".into(),
3742            duration_ms: 1000.0,
3743            timing: TimingFunction::Linear,
3744            delay_ms: 0.0,
3745        };
3746        let (v, active) = sample_transition(&spec, "0px", "100px", 500.0);
3747        anim_check("samp-trans-mid", v, "50px");
3748        anim_check("samp-trans-active", active.to_string(), "true");
3749        let (v2, active2) = sample_transition(&spec, "0px", "100px", 1200.0);
3750        anim_check("samp-trans-end", v2, "100px");
3751        anim_check("samp-trans-done", active2.to_string(), "false");
3752        // delay 中は from のまま。
3753        let spec_d = TransitionSpec {
3754            property: "width".into(),
3755            duration_ms: 1000.0,
3756            timing: TimingFunction::Linear,
3757            delay_ms: 300.0,
3758        };
3759        let (v3, _) = sample_transition(&spec_d, "0px", "100px", 100.0);
3760        anim_check("samp-trans-delay", v3, "0px");
3761    }
3762
3763    // --- @keyframes パース + animation サンプリング ---
3764    {
3765        let sheet = parse_css(
3766            "@keyframes slide { from { left: 0px; } to { left: 100px; } } \
3767             @keyframes fade { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } }",
3768        );
3769        anim_check("kf-count", sheet.keyframes.len().to_string(), "2");
3770        let slide = sheet.keyframes.iter().find(|k| k.name == "slide");
3771        anim_check("kf-slide-found", slide.is_some().to_string(), "true");
3772        if let Some(slide) = slide {
3773            anim_check("kf-slide-frames", slide.frames.len().to_string(), "2");
3774            let spec = AnimationSpec {
3775                name: "slide".into(),
3776                duration_ms: 1000.0,
3777                timing: TimingFunction::Linear,
3778                delay_ms: 0.0,
3779                iterations: 1.0,
3780                direction: "normal".into(),
3781                fill: "none".into(),
3782                play_state: "running".into(),
3783            };
3784            let m = sample_animation(&spec, slide, 500.0);
3785            anim_check(
3786                "anim-slide-mid",
3787                m.get("left").cloned().unwrap_or_default(),
3788                "50px",
3789            );
3790        }
3791        let fade = sheet.keyframes.iter().find(|k| k.name == "fade");
3792        if let Some(fade) = fade {
3793            anim_check("kf-fade-frames", fade.frames.len().to_string(), "3");
3794            let spec = AnimationSpec {
3795                name: "fade".into(),
3796                duration_ms: 1000.0,
3797                timing: TimingFunction::Linear,
3798                delay_ms: 0.0,
3799                iterations: 1.0,
3800                direction: "normal".into(),
3801                fill: "none".into(),
3802                play_state: "running".into(),
3803            };
3804            // t=250ms → 0%→50% の中点 → opacity 0.5。
3805            let m = sample_animation(&spec, fade, 250.0);
3806            anim_check(
3807                "anim-fade-q1",
3808                m.get("opacity").cloned().unwrap_or_default(),
3809                "0.5",
3810            );
3811            // t=500ms → opacity 1。
3812            let m2 = sample_animation(&spec, fade, 500.0);
3813            anim_check(
3814                "anim-fade-mid",
3815                m2.get("opacity").cloned().unwrap_or_default(),
3816                "1",
3817            );
3818        }
3819    }
3820
3821    // --- animation ショートハンドのパース ---
3822    {
3823        let spec = parse_animation("slide 2s ease-in-out 0.5s infinite alternate both");
3824        anim_check("anim-parse-some", spec.is_some().to_string(), "true");
3825        if let Some(s) = spec {
3826            anim_check("anim-name", s.name.clone(), "slide");
3827            anim_check("anim-dur", fmt_num(s.duration_ms), "2000");
3828            anim_check("anim-delay", fmt_num(s.delay_ms), "500");
3829            anim_check(
3830                "anim-iter-inf",
3831                s.iterations.is_infinite().to_string(),
3832                "true",
3833            );
3834            anim_check("anim-dir", s.direction.clone(), "alternate");
3835            anim_check("anim-fill", s.fill.clone(), "both");
3836        }
3837    }
3838
3839    // --- animation ショートハンドのカンマ区切り複数項目対応 ---
3840    // (box-shadow/text-shadow と同じく、以前は最初の1件のみが解析され残りは無視されていたバグの修正確認)
3841    {
3842        let specs = parse_animations("fade 1s, bounce 2s infinite");
3843        anim_check("anim-multi-count", specs.len().to_string(), "2");
3844        if specs.len() == 2 {
3845            anim_check("anim-multi-name-0", specs[0].name.clone(), "fade");
3846            anim_check("anim-multi-dur-0", fmt_num(specs[0].duration_ms), "1000");
3847            anim_check("anim-multi-name-1", specs[1].name.clone(), "bounce");
3848            anim_check("anim-multi-dur-1", fmt_num(specs[1].duration_ms), "2000");
3849            anim_check(
3850                "anim-multi-iter-1-inf",
3851                specs[1].iterations.is_infinite().to_string(),
3852                "true",
3853            );
3854        }
3855    }
3856
3857    // --- alternate direction(偶数/奇数反復で方向反転) ---
3858    {
3859        let sheet = parse_css("@keyframes mv { from { left: 0px; } to { left: 100px; } }");
3860        let kf = &sheet.keyframes[0];
3861        let spec = AnimationSpec {
3862            name: "mv".into(),
3863            duration_ms: 1000.0,
3864            timing: TimingFunction::Linear,
3865            delay_ms: 0.0,
3866            iterations: 4.0,
3867            direction: "alternate".into(),
3868            fill: "none".into(),
3869            play_state: "running".into(),
3870        };
3871        // 反復0 (0..1s) は順方向: t=500ms → 50px。
3872        let m0 = sample_animation(&spec, kf, 500.0);
3873        anim_check(
3874            "anim-alt-iter0",
3875            m0.get("left").cloned().unwrap_or_default(),
3876            "50px",
3877        );
3878        // 反復1 (1..2s) は逆方向: t=1500ms → local 0.5 reversed → 50px(中点なので同じ)。
3879        // 端で確認: t=1100ms → local 0.1 reversed=0.9 → 90px。
3880        let m1 = sample_animation(&spec, kf, 1100.0);
3881        anim_check(
3882            "anim-alt-iter1",
3883            m1.get("left").cloned().unwrap_or_default(),
3884            "90px",
3885        );
3886    }
3887
3888    // --- 非整数 iteration-count + alternate + fill:forwards の完了後フレーム ---
3889    // 以前は完了時の反復インデックスが常に `floor(iterations) - 1` で計算されており、
3890    // 端数のある最終反復(本来のインデックスは `floor(iterations)`)を1つ前の反復と
3891    // 誤認していたため、alternate の偶奇判定が反転し、最終停止位置が左右逆になる
3892    // バグがあった。
3893    {
3894        let sheet = parse_css("@keyframes mv3b { from { left: 0px; } to { left: 100px; } }");
3895        let kf = &sheet.keyframes[0];
3896        let spec = AnimationSpec {
3897            name: "mv3b".into(),
3898            duration_ms: 1000.0,
3899            timing: TimingFunction::Linear,
3900            delay_ms: 0.0,
3901            iterations: 2.25,
3902            direction: "alternate".into(),
3903            fill: "forwards".into(),
3904            play_state: "running".into(),
3905        };
3906        // t=2250ms(=2.25反復ぶん経過、完了後)。反復0=順方向、反復1=逆方向、
3907        // 反復2(インデックス偶数→順方向)の25%地点で止まるはずなので left=25px。
3908        // 修正前はインデックスを1つ前(奇数=逆方向)と誤認し75pxになっていた。
3909        let m = sample_animation(&spec, kf, 2250.0);
3910        anim_check(
3911            "anim-frac-iter-alternate-forwards",
3912            m.get("left").cloned().unwrap_or_default(),
3913            "25px",
3914        );
3915    }
3916
3917    // --- fill-mode forwards(終了後に最終値で固定) ---
3918    {
3919        let sheet = parse_css("@keyframes mv2 { from { left: 0px; } to { left: 100px; } }");
3920        let kf = &sheet.keyframes[0];
3921        let spec = AnimationSpec {
3922            name: "mv2".into(),
3923            duration_ms: 1000.0,
3924            timing: TimingFunction::Linear,
3925            delay_ms: 0.0,
3926            iterations: 1.0,
3927            direction: "normal".into(),
3928            fill: "forwards".into(),
3929            play_state: "running".into(),
3930        };
3931        // 終了後(2s)も forwards なら 100px。
3932        let m = sample_animation(&spec, kf, 2000.0);
3933        anim_check(
3934            "anim-fill-fwd",
3935            m.get("left").cloned().unwrap_or_default(),
3936            "100px",
3937        );
3938    }
3939
3940    // --- AnimationEngine 統合: トランジションの時間進行 ---
3941    {
3942        let mut eng = AnimationEngine::new();
3943        let spec = TransitionSpec {
3944            property: "left".into(),
3945            duration_ms: 1000.0,
3946            timing: TimingFunction::Linear,
3947            delay_ms: 0.0,
3948        };
3949        // t=0 で 0px→100px のトランジション開始。
3950        eng.start_transition("box", "left", "0px", "100px", spec, 0.0);
3951        anim_check(
3952            "eng-tr-active",
3953            if eng.is_active() {
3954                "y".into()
3955            } else {
3956                "n".into()
3957            },
3958            "y",
3959        );
3960        // t=500ms → 50px、まだ進行中。
3961        let active = eng.tick(500.0);
3962        let v = eng
3963            .computed
3964            .iter()
3965            .find(|(e, p, _)| e == "box" && p == "left")
3966            .map(|(_, _, v)| v.clone())
3967            .unwrap_or_default();
3968        anim_check("eng-tr-mid", v, "50px");
3969        anim_check(
3970            "eng-tr-mid-active",
3971            if active { "y".into() } else { "n".into() },
3972            "y",
3973        );
3974        // t=1000ms → 100px、完了して transitions から除去される。
3975        eng.tick(1000.0);
3976        let v_end = eng
3977            .computed
3978            .iter()
3979            .find(|(e, p, _)| e == "box" && p == "left")
3980            .map(|(_, _, v)| v.clone())
3981            .unwrap_or_default();
3982        anim_check("eng-tr-end", v_end, "100px");
3983        anim_check(
3984            "eng-tr-done",
3985            if eng.is_active() {
3986                "y".into()
3987            } else {
3988                "n".into()
3989            },
3990            "n",
3991        );
3992    }
3993
3994    // --- AnimationEngine 統合: `lifecycle_events`(`animationend`/`transitionend`/
3995    // `animationstart`の自動発火が丸ごと未対応だった。`AnimationEvent`/
3996    // `TransitionEvent`コンストラクタ自体は既に実装済みで手動dispatchは可能
3997    // だったが、実際の開始/完了を検知して自動発火する仕組みが無かった) ---
3998    {
3999        let mut eng = AnimationEngine::new();
4000        let spec = TransitionSpec {
4001            property: "left".into(),
4002            duration_ms: 1000.0,
4003            timing: TimingFunction::Linear,
4004            delay_ms: 0.0,
4005        };
4006        eng.start_transition("box3", "left", "0px", "100px", spec, 0.0);
4007        // t=500ms: まだ進行中なので lifecycle_events は空(トランジションには
4008        // `transitionstart`は今回対象外のため発火しない)。
4009        eng.tick(500.0);
4010        anim_check(
4011            "eng-transitionend-mid-empty",
4012            if eng.lifecycle_events.is_empty() { "y".into() } else { "n".into() },
4013            "y",
4014        );
4015        // t=1000ms: 完了し、この tick で `transitionend` が1件記録される。
4016        eng.tick(1000.0);
4017        let got = eng
4018            .lifecycle_events
4019            .iter()
4020            .find(|(e, ty, _)| e == "box3" && *ty == "transitionend")
4021            .map(|(_, _, name)| name.clone())
4022            .unwrap_or_default();
4023        anim_check("eng-transitionend-name", got, "left");
4024        // t=1500ms: 既に除去済みのトランジションなので再発火しない(毎tick重複しない
4025        // ことの確認)。
4026        eng.tick(1500.0);
4027        anim_check(
4028            "eng-transitionend-no-duplicate",
4029            if eng.lifecycle_events.is_empty() { "y".into() } else { "n".into() },
4030            "y",
4031        );
4032    }
4033    {
4034        let sheet = parse_css("@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }");
4035        let kf = sheet.keyframes[0].clone();
4036        let spec = AnimationSpec {
4037            name: "fadein".into(),
4038            duration_ms: 1000.0,
4039            timing: TimingFunction::Linear,
4040            delay_ms: 100.0,
4041            iterations: 1.0,
4042            direction: "normal".into(),
4043            fill: "none".into(),
4044            play_state: "running".into(),
4045        };
4046        let mut eng = AnimationEngine::new();
4047        eng.start_animation("box4", spec, kf, 0.0);
4048        // t=50ms: delay(100ms)未経過なので animationstart はまだ発火しない。
4049        eng.tick(50.0);
4050        anim_check(
4051            "eng-animationstart-before-delay-empty",
4052            if eng.lifecycle_events.is_empty() { "y".into() } else { "n".into() },
4053            "y",
4054        );
4055        // t=500ms: delay を過ぎ animationstart が1件記録される。まだ再生中なので
4056        // animationend は無い。
4057        eng.tick(500.0);
4058        let got_start = eng
4059            .lifecycle_events
4060            .iter()
4061            .find(|(e, ty, _)| e == "box4" && *ty == "animationstart")
4062            .map(|(_, _, name)| name.clone())
4063            .unwrap_or_default();
4064        anim_check("eng-animationstart-name", got_start, "fadein");
4065        anim_check(
4066            "eng-animationend-mid-empty",
4067            if eng.lifecycle_events.iter().any(|(_, ty, _)| *ty == "animationend") {
4068                "n".into()
4069            } else {
4070                "y".into()
4071            },
4072            "y",
4073        );
4074        // t=1100ms(delay 100ms + duration 1000ms): 完了し animationend が1件
4075        // 記録される。
4076        eng.tick(1100.0);
4077        let got = eng
4078            .lifecycle_events
4079            .iter()
4080            .find(|(e, ty, _)| e == "box4" && *ty == "animationend")
4081            .map(|(_, _, name)| name.clone())
4082            .unwrap_or_default();
4083        anim_check("eng-animationend-name", got, "fadein");
4084    }
4085
4086    // --- AnimationEngine 統合: 進行中トランジションの中断→逆再生(現在値からの再開) ---
4087    // 以前は中断時に呼び出し側から渡された stale な from をそのまま使っており、
4088    // 現在の補間位置を無視して一瞬ジャンプしてから逆再生してしまうバグがあった。
4089    {
4090        let mut eng = AnimationEngine::new();
4091        let spec = TransitionSpec {
4092            property: "opacity".into(),
4093            duration_ms: 1000.0,
4094            timing: TimingFunction::Linear,
4095            delay_ms: 0.0,
4096        };
4097        // t=0 で opacity 1→0(hover開始相当)。
4098        eng.start_transition("box2", "opacity", "1", "0", spec.clone(), 0.0);
4099        // t=500ms(50%地点、opacity=0.5)で中断し、逆方向(0→1、hover解除相当)へ retarget。
4100        // 呼び出し側が渡す from="0"(1つ前の style スナップショット値、stale)は無視され、
4101        // 実際の現在値(0.5)からの再開になるはず。
4102        eng.start_transition("box2", "opacity", "0", "1", spec, 500.0);
4103        eng.tick(500.0);
4104        let v_retarget_start = eng
4105            .computed
4106            .iter()
4107            .find(|(e, p, _)| e == "box2" && p == "opacity")
4108            .map(|(_, _, v)| v.clone())
4109            .unwrap_or_default();
4110        anim_check("eng-tr-retarget-from-current", v_retarget_start, "0.5");
4111        // さらに500ms進む(retarget後の経過500ms、1000ms尺の50% → 0.5→1の中間=0.75)。
4112        eng.tick(1000.0);
4113        let v_retarget_mid = eng
4114            .computed
4115            .iter()
4116            .find(|(e, p, _)| e == "box2" && p == "opacity")
4117            .map(|(_, _, v)| v.clone())
4118            .unwrap_or_default();
4119        anim_check("eng-tr-retarget-progress", v_retarget_mid, "0.75");
4120    }
4121
4122    // --- AnimationEngine 統合: キーフレームアニメの時間進行 ---
4123    {
4124        let sheet = parse_css("@keyframes slide { from { left: 0px; } to { left: 200px; } }");
4125        let kf = sheet.keyframes[0].clone();
4126        let spec = AnimationSpec {
4127            name: "slide".into(),
4128            duration_ms: 1000.0,
4129            timing: TimingFunction::Linear,
4130            delay_ms: 0.0,
4131            iterations: 1.0,
4132            direction: "normal".into(),
4133            fill: "forwards".into(),
4134            play_state: "running".into(),
4135        };
4136        let mut eng = AnimationEngine::new();
4137        eng.start_animation("a", spec, kf, 0.0);
4138        // t=250ms → 50px。
4139        eng.tick(250.0);
4140        let v = eng
4141            .computed
4142            .iter()
4143            .find(|(e, p, _)| e == "a" && p == "left")
4144            .map(|(_, _, v)| v.clone())
4145            .unwrap_or_default();
4146        anim_check("eng-anim-q1", v, "50px");
4147        // 完了後 forwards なので 200px で固定、is_active は false。
4148        eng.tick(2000.0);
4149        let v_end = eng
4150            .computed
4151            .iter()
4152            .find(|(e, p, _)| e == "a" && p == "left")
4153            .map(|(_, _, v)| v.clone())
4154            .unwrap_or_default();
4155        anim_check("eng-anim-fwd-hold", v_end, "200px");
4156        anim_check(
4157            "eng-anim-done",
4158            if eng.is_active() {
4159                "y".into()
4160            } else {
4161                "n".into()
4162            },
4163            "n",
4164        );
4165    }
4166
4167    // --- AnimationEngine 統合: animation-play-state:paused で経過時間が凍結される ---
4168    // 以前は `animation-play-state` が一切パース/適用されず、paused 指定が常に無視され
4169    // 再生を止められないバグがあった。
4170    {
4171        let sheet = parse_css("@keyframes slide2 { from { left: 0px; } to { left: 200px; } }");
4172        let kf = sheet.keyframes[0].clone();
4173        let mut spec = AnimationSpec {
4174            name: "slide2".into(),
4175            duration_ms: 1000.0,
4176            timing: TimingFunction::Linear,
4177            delay_ms: 0.0,
4178            iterations: 1.0,
4179            direction: "normal".into(),
4180            fill: "forwards".into(),
4181            play_state: "running".into(),
4182        };
4183        let mut eng = AnimationEngine::new();
4184        eng.start_animation("b", spec.clone(), kf.clone(), 0.0);
4185        // t=250ms → 50px(一時停止前)。
4186        eng.tick(250.0);
4187        // ここで一時停止に切り替え(既存アニメの play_state だけを更新する経路を使う)。
4188        spec.play_state = "paused".into();
4189        eng.start_animation("b", spec.clone(), kf.clone(), 250.0);
4190        eng.tick(250.0);
4191        let v_paused = eng
4192            .computed
4193            .iter()
4194            .find(|(e, p, _)| e == "b" && p == "left")
4195            .map(|(_, _, v)| v.clone())
4196            .unwrap_or_default();
4197        anim_check("eng-anim-paused-frozen1", v_paused.clone(), "50px");
4198        // 一時停止中は時間が進んでも値が凍結されたままであることを確認(t=1000ms でも50px)。
4199        eng.tick(1000.0);
4200        let v_still_paused = eng
4201            .computed
4202            .iter()
4203            .find(|(e, p, _)| e == "b" && p == "left")
4204            .map(|(_, _, v)| v.clone())
4205            .unwrap_or_default();
4206        anim_check("eng-anim-paused-frozen2", v_still_paused, "50px");
4207        // 再開(play_state を running に戻す): 再開を検知した tick の時刻が
4208        // 「一時停止が明けた瞬間」として扱われるため、再開検知直後の同時刻ではまだ
4209        // 凍結値(50px)のまま。その後さらに250ms進めると凍結値から連続して75pxへ進行する
4210        // (250ms分の進行 = 200px*250/1000=50px、50px+50px=100pxではなく凍結値50px起点で
4211        // 250ms分=50px進むので合計100px)。
4212        spec.play_state = "running".into();
4213        eng.start_animation("b", spec, kf, 1000.0);
4214        eng.tick(1000.0);
4215        let v_resume_instant = eng
4216            .computed
4217            .iter()
4218            .find(|(e, p, _)| e == "b" && p == "left")
4219            .map(|(_, _, v)| v.clone())
4220            .unwrap_or_default();
4221        anim_check("eng-anim-resume-instant", v_resume_instant, "50px");
4222        eng.tick(1250.0);
4223        let v_resumed = eng
4224            .computed
4225            .iter()
4226            .find(|(e, p, _)| e == "b" && p == "left")
4227            .map(|(_, _, v)| v.clone())
4228            .unwrap_or_default();
4229        anim_check("eng-anim-resumed", v_resumed, "100px");
4230    }
4231
4232    // --- AnimationEngine 統合: animation-name 切り替え時に古いアニメが取り消されること ---
4233    // 以前は `start_animation` の「同一 (element, name) なら更新のみ」という判定しか無く、
4234    // 別名へ切り替えても古いアニメが除去されず(特に infinite だと自然完了もしない)、
4235    // 新旧2つが永久に同時に走り続けてしまうバグがあった。
4236    {
4237        let sheet = parse_css(
4238            "@keyframes fadeA { from { opacity: 0; } to { opacity: 1; } } \
4239             @keyframes fadeB { from { opacity: 1; } to { opacity: 0; } }",
4240        );
4241        let empty_kf = |name: &str| Keyframes { name: name.into(), frames: Vec::new() };
4242        let kf_a = sheet
4243            .keyframes
4244            .iter()
4245            .find(|k| k.name == "fadeA")
4246            .cloned()
4247            .unwrap_or_else(|| empty_kf("fadeA"));
4248        let kf_b = sheet
4249            .keyframes
4250            .iter()
4251            .find(|k| k.name == "fadeB")
4252            .cloned()
4253            .unwrap_or_else(|| empty_kf("fadeB"));
4254        let mut eng = AnimationEngine::new();
4255        let spec_a = AnimationSpec {
4256            name: "fadeA".into(),
4257            duration_ms: 1000.0,
4258            timing: TimingFunction::Linear,
4259            delay_ms: 0.0,
4260            iterations: f32::INFINITY,
4261            direction: "normal".into(),
4262            fill: "none".into(),
4263            play_state: "running".into(),
4264        };
4265        eng.start_animation("c", spec_a, kf_a, 0.0);
4266        // "fadeA" から "fadeB" へ切り替え。current_names=["fadeB"] で同期すると
4267        // "fadeA" は取り消され、"fadeB" だけが残るはず。
4268        eng.retain_animation_names("c", &[alloc::string::String::from("fadeB")]);
4269        let spec_b = AnimationSpec {
4270            name: "fadeB".into(),
4271            duration_ms: 1000.0,
4272            timing: TimingFunction::Linear,
4273            delay_ms: 0.0,
4274            iterations: 1.0,
4275            direction: "normal".into(),
4276            fill: "forwards".into(),
4277            play_state: "running".into(),
4278        };
4279        eng.start_animation("c", spec_b, kf_b, 500.0);
4280        eng.tick(500.0);
4281        let props: alloc::vec::Vec<&alloc::string::String> = eng
4282            .computed
4283            .iter()
4284            .filter(|(e, _, _)| e == "c")
4285            .map(|(_, p, _)| p)
4286            .collect();
4287        // "fadeA" が残っていれば opacity への書き込みが重複/矛盾するが、ここでは
4288        // 単に "fadeB"(fadeA除去後の唯一のアニメ)由来の値が1件だけ出ることを確認する。
4289        anim_check("eng-anim-name-switch-count", props.len().to_string(), "1");
4290    }
4291
4292    // --- AnimationEngine 統合: @keyframes の内容が更新されたら追従すること ---
4293    // 以前は同名アニメが既に存在する場合、`start_animation` が渡された新しい `keyframes`
4294    // を破棄し `play_state` しか更新しなかったため、`<style>` 再注入等で同名の @keyframes
4295    // ルールの中身が変わっても古い内容のまま固定されるバグがあった。
4296    {
4297        let sheet_v1 = parse_css("@keyframes mv3 { from { left: 0px; } to { left: 100px; } }");
4298        let kf_v1 = sheet_v1.keyframes[0].clone();
4299        let spec = AnimationSpec {
4300            name: "mv3".into(),
4301            duration_ms: 1000.0,
4302            timing: TimingFunction::Linear,
4303            delay_ms: 0.0,
4304            iterations: f32::INFINITY,
4305            direction: "normal".into(),
4306            fill: "none".into(),
4307            play_state: "running".into(),
4308        };
4309        let mut eng = AnimationEngine::new();
4310        eng.start_animation("d", spec.clone(), kf_v1, 0.0);
4311        eng.tick(500.0);
4312        let v_before = eng
4313            .computed
4314            .iter()
4315            .find(|(e, p, _)| e == "d" && p == "left")
4316            .map(|(_, _, v)| v.clone())
4317            .unwrap_or_default();
4318        anim_check("eng-anim-kf-before-update", v_before, "50px");
4319        // 同名 "mv3" のまま @keyframes の中身だけを差し替えて再度 start_animation を呼ぶ
4320        // (同じ経過時間 t=500ms でも到達点が変わるはず)。
4321        let sheet_v2 = parse_css("@keyframes mv3 { from { left: 0px; } to { left: 400px; } }");
4322        let kf_v2 = sheet_v2.keyframes[0].clone();
4323        eng.start_animation("d", spec, kf_v2, 500.0);
4324        eng.tick(500.0);
4325        let v_after = eng
4326            .computed
4327            .iter()
4328            .find(|(e, p, _)| e == "d" && p == "left")
4329            .map(|(_, _, v)| v.clone())
4330            .unwrap_or_default();
4331        anim_check("eng-anim-kf-after-update", v_after, "200px");
4332    }
4333
4334    // --- AnimationEngine 統合: 再ターゲット(進行中に行先変更)---
4335    {
4336        let mut eng = AnimationEngine::new();
4337        let spec = TransitionSpec {
4338            property: "left".into(),
4339            duration_ms: 1000.0,
4340            timing: TimingFunction::Linear,
4341            delay_ms: 0.0,
4342        };
4343        eng.start_transition("b", "left", "0px", "100px", spec.clone(), 0.0);
4344        eng.tick(500.0); // 50px
4345                         // 進行中に 50px→0px へ再ターゲット(now=500)。
4346        eng.start_transition("b", "left", "50px", "0px", spec, 500.0);
4347        // 同一 (要素,プロパティ) は1本だけ。
4348        eng.tick(1000.0); // 再ターゲットから500ms → 25px
4349        let v = eng
4350            .computed
4351            .iter()
4352            .find(|(e, p, _)| e == "b" && p == "left")
4353            .map(|(_, _, v)| v.clone())
4354            .unwrap_or_default();
4355        anim_check("eng-retarget", v, "25px");
4356    }
4357
4358    let total = total + anim_total;
4359    (passed, total)
4360}