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            ("1.5rem", 400, 20, 24),
2625        ];
2626        for (input, cw, font_size, want) in text_indent_cases {
2627            let got = super::super::web_engine::resolve_text_indent(input, *cw, *font_size);
2628            if got == *want {
2629                passed += 1;
2630            } else {
2631                crate::println!(
2632                    "CSS_SELFTEST FAIL: resolve_text_indent(`{}`, {}, {}) => {} (want {})",
2633                    input, cw, font_size, got, want
2634                );
2635            }
2636        }
2637    }
2638    // `box-shadow`/`text-shadow`のオフセット/ぼかし値が`rem`単位を丸ごと
2639    // サポートしていなかったバグ(`web_engine/layout.rs`の
2640    // `parse_shadow_len_tok`。2026-07-17 発見・実装。詳細は同関数の
2641    // docコメント参照)。
2642    {
2643        let shadow_len_cases: &[(&str, Option<i32>)] = &[
2644            ("10px", Some(10)),
2645            ("0.5rem", Some(8)),
2646            ("2rem", Some(32)),
2647            ("red", None),
2648            ("inset", None),
2649        ];
2650        for (input, want) in shadow_len_cases {
2651            let got = super::super::web_engine::parse_shadow_len_tok(input);
2652            if got == *want {
2653                passed += 1;
2654            } else {
2655                crate::println!(
2656                    "CSS_SELFTEST FAIL: parse_shadow_len_tok(`{}`) => {:?} (want {:?})",
2657                    input, got, want
2658                );
2659            }
2660        }
2661    }
2662    // `border-radius`が`rem`単位を丸ごとサポートしていなかったバグ
2663    // (`web_engine/layout.rs`の`resolve_border_radius`。2026-07-17
2664    // 発見・実装。詳細は同関数のdocコメント参照)。
2665    {
2666        // (input, container_w, want)
2667        let border_radius_cases: &[(&str, i32, i32)] = &[
2668            ("10px", 200, 10),
2669            ("50%", 200, 100),
2670            ("1rem", 200, 16),
2671            ("0.5rem", 200, 8),
2672        ];
2673        for (input, cw, want) in border_radius_cases {
2674            let got = super::super::web_engine::resolve_border_radius(input, *cw);
2675            if got == *want {
2676                passed += 1;
2677            } else {
2678                crate::println!(
2679                    "CSS_SELFTEST FAIL: resolve_border_radius(`{}`, {}) => {} (want {})",
2680                    input, cw, got, want
2681                );
2682            }
2683        }
2684    }
2685    for (input, want_u, want_lt, want_ov, want_none, want_style, want_color) in text_decoration_cases {
2686        let (u, lt, ov, is_none, style, color) =
2687            super::super::web_engine::helpers::parse_text_decoration_shorthand(input);
2688        let style_ok = style.as_deref() == *want_style;
2689        let color_ok = color == *want_color;
2690        if u == *want_u
2691            && lt == *want_lt
2692            && ov == *want_ov
2693            && is_none == *want_none
2694            && style_ok
2695            && color_ok
2696        {
2697            passed += 1;
2698        } else {
2699            crate::println!(
2700                "CSS_SELFTEST FAIL: parse_text_decoration_shorthand(`{}`) => ({},{},{},{},{:?},{:?}) (want ({},{},{},{},{:?},{:?}))",
2701                input, u, lt, ov, is_none, style, color, want_u, want_lt, want_ov, want_none, want_style, want_color
2702            );
2703        }
2704    }
2705    for (input, want_x, want_y) in bg_repeat_cases {
2706        let (x, y) = super::super::web_engine::helpers::parse_bg_repeat(Some(input));
2707        if x == *want_x && y == *want_y {
2708            passed += 1;
2709        } else {
2710            crate::println!(
2711                "CSS_SELFTEST FAIL: parse_bg_repeat(`{}`) => ({:?},{:?}) (want ({:?},{:?}))",
2712                input, x, y, want_x, want_y
2713            );
2714        }
2715    }
2716    for (input, want) in rgb_pct_cases {
2717        let got = parse_color(input);
2718        if got == Some(*want) {
2719            passed += 1;
2720        } else {
2721            crate::println!(
2722                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2723                input, got, want
2724            );
2725        }
2726    }
2727    for (input, want) in rgb_space_cases {
2728        let got = parse_color(input);
2729        if got == Some(*want) {
2730            passed += 1;
2731        } else {
2732            crate::println!(
2733                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2734                input, got, want
2735            );
2736        }
2737    }
2738    for (input, want) in hsl_hue_wrap_cases {
2739        let got = parse_color(input);
2740        if got == Some(*want) {
2741            passed += 1;
2742        } else {
2743            crate::println!(
2744                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2745                input, got, want
2746            );
2747        }
2748    }
2749    for (input, want) in hwb_cases {
2750        let got = parse_color(input);
2751        if got == Some(*want) {
2752            passed += 1;
2753        } else {
2754            crate::println!(
2755                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2756                input, got, want
2757            );
2758        }
2759    }
2760    for (input, want) in oklab_oklch_cases {
2761        let got = parse_color(input);
2762        if got == Some(*want) {
2763            passed += 1;
2764        } else {
2765            crate::println!(
2766                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2767                input, got, want
2768            );
2769        }
2770    }
2771    for (input, want) in color_fn_cases {
2772        let got = parse_color(input);
2773        if got == Some(*want) {
2774            passed += 1;
2775        } else {
2776            crate::println!(
2777                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2778                input, got, want
2779            );
2780        }
2781    }
2782    for (input, want) in color_mix_cases {
2783        let got = parse_color(input);
2784        if got == Some(*want) {
2785            passed += 1;
2786        } else {
2787            crate::println!(
2788                "CSS_SELFTEST FAIL: parse_color(`{}`) => {:?} (want {:#010X})",
2789                input, got, want
2790            );
2791        }
2792    }
2793    // light-dark(light, dark) — CSS Color Module Level 5。丸ごと未対応だった。
2794    // PREFERS_DARK(既定 light=false)を明示的に切り替えて両分岐を検証する。
2795    {
2796        let saved = PREFERS_DARK.load(AtomicOrd::Relaxed);
2797        PREFERS_DARK.store(false, AtomicOrd::Relaxed);
2798        let got_light = parse_color("light-dark(#ff0000, #0000ff)");
2799        if got_light == Some(0xFFFF0000) {
2800            passed += 1;
2801        } else {
2802            crate::println!(
2803                "CSS_SELFTEST FAIL: light-dark (light mode) => {:?} (want 0xFFFF0000)",
2804                got_light
2805            );
2806        }
2807        PREFERS_DARK.store(true, AtomicOrd::Relaxed);
2808        let got_dark = parse_color("light-dark(#ff0000, #0000ff)");
2809        if got_dark == Some(0xFF0000FF) {
2810            passed += 1;
2811        } else {
2812            crate::println!(
2813                "CSS_SELFTEST FAIL: light-dark (dark mode) => {:?} (want 0xFF0000FF)",
2814                got_dark
2815            );
2816        }
2817        // ネストした light-dark() の再帰解決も確認する。
2818        let got_nested = parse_color("light-dark(#ff0000, light-dark(#00ff00, #0000ff))");
2819        if got_nested == Some(0xFF0000FF) {
2820            passed += 1;
2821        } else {
2822            crate::println!(
2823                "CSS_SELFTEST FAIL: light-dark nested => {:?} (want 0xFF0000FF)",
2824                got_nested
2825            );
2826        }
2827        PREFERS_DARK.store(saved, AtomicOrd::Relaxed);
2828    }
2829    // :target — CURRENT_TARGET_ID を直接操作して判定を検証する。
2830    {
2831        *super::super::layout::CURRENT_TARGET_ID.lock() = String::from("sec2");
2832        let dom = super::super::dom::parse_html(
2833            "<div id='sec1'></div><div id='sec2'></div><div id='t'></div>",
2834        );
2835        let sheet = parse_css("#sec2:target{zz:1}");
2836        let styled = style_tree(&dom, &sheet);
2837        fn find_marked_target(node: &StyledNode, id: &str) -> Option<bool> {
2838            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
2839                if nid.as_deref() == Some(id) {
2840                    return Some(node.specified_values.contains_key("zz"));
2841                }
2842            }
2843            for c in &node.children {
2844                if let Some(r) = find_marked_target(c, id) {
2845                    return Some(r);
2846                }
2847            }
2848            None
2849        }
2850        let matched = find_marked_target(&styled, "sec2").unwrap_or(false);
2851        let not_matched = find_marked_target(&styled, "sec1").unwrap_or(true);
2852        if matched && !not_matched {
2853            passed += 1;
2854        } else {
2855            crate::println!(
2856                "CSS_SELFTEST FAIL: :target => sec2={} sec1={} (want true/false)",
2857                matched, not_matched
2858            );
2859        }
2860        *super::super::layout::CURRENT_TARGET_ID.lock() = String::new();
2861    }
2862    // :focus-within — FOCUS_WITHIN_PTRS を直接操作して判定を検証する(本番コードでは
2863    // parse_and_layout がフォーカス変更のたびに祖先パスを再計算する)。id を持たない
2864    // 要素も含めてノードポインタの同一性で判定できることも確認する。
2865    {
2866        fn collect_path_to(
2867            node: &super::super::dom::Node,
2868            target_id: &str,
2869            path: &mut alloc::vec::Vec<usize>,
2870        ) -> bool {
2871            path.push(node as *const _ as usize);
2872            if let NodeType::Element { id, .. } = &node.node_type {
2873                if id.as_deref() == Some(target_id) {
2874                    return true;
2875                }
2876            }
2877            for c in &node.children {
2878                if collect_path_to(c, target_id, path) {
2879                    return true;
2880                }
2881            }
2882            path.pop();
2883            false
2884        }
2885        let dom = super::super::dom::parse_html(
2886            "<div id='outer'><span><input id='inp'></span></div><div id='other'></div>",
2887        );
2888        let mut path = alloc::vec::Vec::new();
2889        collect_path_to(&dom, "inp", &mut path);
2890        {
2891            let mut set = super::super::layout::FOCUS_WITHIN_PTRS.lock();
2892            set.clear();
2893            set.extend(path);
2894        }
2895        let sheet = parse_css("#outer:focus-within{zz:1}");
2896        let styled = style_tree(&dom, &sheet);
2897        fn find_marked_fw(node: &StyledNode, id: &str) -> Option<bool> {
2898            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
2899                if nid.as_deref() == Some(id) {
2900                    return Some(node.specified_values.contains_key("zz"));
2901                }
2902            }
2903            for c in &node.children {
2904                if let Some(r) = find_marked_fw(c, id) {
2905                    return Some(r);
2906                }
2907            }
2908            None
2909        }
2910        let matched = find_marked_fw(&styled, "outer").unwrap_or(false);
2911        let not_matched = find_marked_fw(&styled, "other").unwrap_or(true);
2912        if matched && !not_matched {
2913            passed += 1;
2914        } else {
2915            crate::println!(
2916                "CSS_SELFTEST FAIL: :focus-within => outer={} other={} (want true/false)",
2917                matched, not_matched
2918            );
2919        }
2920        super::super::layout::FOCUS_WITHIN_PTRS.lock().clear();
2921    }
2922    // fieldset[disabled] 祖先による暗黙の無効化を :disabled/:enabled が反映することの検証
2923    {
2924        let html = r#"<div id="root"><fieldset disabled="true"><input id="i1" /></fieldset><fieldset><input id="i2" /></fieldset></div>"#;
2925        let dom = super::super::dom::parse_html(html);
2926        let sheet = parse_css("input:disabled{zz:1} input:enabled{yy:1}");
2927        let styled = style_tree(&dom, &sheet);
2928        fn find_marked_styles(node: &StyledNode, id: &str) -> Option<(bool, bool)> {
2929            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
2930                if nid.as_deref() == Some(id) {
2931                    return Some((
2932                        node.specified_values.contains_key("zz"),
2933                        node.specified_values.contains_key("yy")
2934                    ));
2935                }
2936            }
2937            for c in &node.children {
2938                if let Some(r) = find_marked_styles(c, id) {
2939                    return Some(r);
2940                }
2941            }
2942            None
2943        }
2944        let i1_styles = find_marked_styles(&styled, "i1").unwrap_or((false, false));
2945        let i2_styles = find_marked_styles(&styled, "i2").unwrap_or((false, false));
2946
2947        // i1 (disabled) => zz (disabled) should match, yy (enabled) should not.
2948        // i2 (enabled)  => zz (disabled) should not, yy (enabled) should match.
2949        let ok = i1_styles.0 && !i1_styles.1 && !i2_styles.0 && i2_styles.1;
2950        total += 1;
2951        if ok {
2952            passed += 1;
2953        } else {
2954            crate::println!(
2955                "CSS_SELFTEST FAIL: fieldset[disabled] CSS state propagation => i1={:?} i2={:?} (want (true,false) and (false,true))",
2956                i1_styles, i2_styles
2957            );
2958        }
2959    }
2960    // background-image の複数レイヤー抽出(純関数)。
2961    {
2962        let got = super::super::web_engine::helpers::extract_all_url_paths("url(a.png), url('b.png')");
2963        if got == alloc::vec!["a.png", "b.png"] {
2964            passed += 1;
2965        } else {
2966            crate::println!("CSS_SELFTEST FAIL: extract_all_url_paths multi => {:?}", got);
2967        }
2968        let got_single = super::super::web_engine::helpers::extract_all_url_paths("url(only.png)");
2969        if got_single == alloc::vec!["only.png"] {
2970            passed += 1;
2971        } else {
2972            crate::println!(
2973                "CSS_SELFTEST FAIL: extract_all_url_paths single => {:?}",
2974                got_single
2975            );
2976        }
2977        let got_none = super::super::web_engine::helpers::extract_all_url_paths("none");
2978        if got_none.is_empty() {
2979            passed += 1;
2980        } else {
2981            crate::println!("CSS_SELFTEST FAIL: extract_all_url_paths none => {:?}", got_none);
2982        }
2983    }
2984    // backdrop-filter / filter tokens test
2985    // `total`の集計漏れ(`fieldset[disabled]`テストと同じ自己追跡`total += 1`
2986    // パターンが無く、かつファイル冒頭の巨大な手動合計コメントにも記載が
2987    // 無かった。ファイル冒頭のコメントが警告している「`passed`が`total`を
2988    // 超えて個別FAILメッセージ無しで丸ごとFAILする」回帰そのもの。
2989    // 2026-07-18 発見・修正)。
2990    {
2991        let radius = super::super::web_engine::draw_helpers::parse_blur_radius("blur(8px) brightness(1.2)");
2992        total += 1;
2993        if radius == 8 {
2994            passed += 1;
2995        } else {
2996            crate::println!("CSS_SELFTEST FAIL: parse_blur_radius => {}", radius);
2997        }
2998    }
2999    // gradient alpha interpolation test
3000    {
3001        let stops = alloc::vec![
3002            super::super::web_engine::GradientStop { position: 0.0, color: 0x00FF0000 },
3003            super::super::web_engine::GradientStop { position: 1.0, color: 0xFF00FF00 },
3004        ];
3005        let mid_color = super::super::web_engine::draw_helpers::sample_gradient_stops(&stops, 0.5);
3006        let mid_alpha = (mid_color >> 24) & 0xFF;
3007        total += 1;
3008        if (120..=135).contains(&mid_alpha) {
3009            passed += 1;
3010        } else {
3011            crate::println!("CSS_SELFTEST FAIL: sample_gradient_stops alpha => 0x{:08X}", mid_color);
3012        }
3013    }
3014    // clip-path inset round test
3015    {
3016        total += 1;
3017        if let Some(shape) = super::super::web_engine::draw_helpers::parse_clip_shape("inset(10px round 20px)", 100, 100) {
3018            let inside_center = super::super::web_engine::draw_helpers::clip_inside(&shape, 50, 50);
3019            let inside_corner = super::super::web_engine::draw_helpers::clip_inside(&shape, 12, 12);
3020            if inside_center && !inside_corner {
3021                passed += 1;
3022            } else {
3023                crate::println!("CSS_SELFTEST FAIL: clip-path inset round => center={}, corner={}", inside_center, inside_corner);
3024            }
3025        } else {
3026            crate::println!("CSS_SELFTEST FAIL: clip-path inset round parse failed");
3027        }
3028    }
3029    // clip-path path() test
3030    {
3031        total += 1;
3032        if let Some(shape) = super::super::web_engine::draw_helpers::parse_clip_shape(r#"path("M 0 0 L 100 0 L 100 100 Z")"#, 100, 100) {
3033            let inside = super::super::web_engine::draw_helpers::clip_inside(&shape, 50, 20);
3034            let outside = super::super::web_engine::draw_helpers::clip_inside(&shape, 20, 50);
3035            if inside && !outside {
3036                passed += 1;
3037            } else {
3038                crate::println!("CSS_SELFTEST FAIL: clip-path path() => in={}, out={}", inside, outside);
3039            }
3040        } else {
3041            crate::println!("CSS_SELFTEST FAIL: clip-path path() parse failed");
3042        }
3043    }
3044
3045    // text-stroke test
3046    {
3047        let sheet = parse_css("h1 { -webkit-text-stroke: 2px #ff0000; }");
3048        total += 1;
3049        if !sheet.rules.is_empty() {
3050            passed += 1;
3051        } else {
3052            crate::println!("CSS_SELFTEST FAIL: text-stroke parse failed");
3053        }
3054    }
3055    // `background`ショートハンドの`<position>/<size>`スラッシュ構文
3056    // (`background: url(...) center/cover no-repeat`)がカスケード時点の
3057    // `expand_background_shorthand`で`background-position`/`-size`/`-repeat`
3058    // longhandへ正しく展開されることを検証。以前はこの分解ロジックが
3059    // `web_engine/layout.rs`内の1箇所にしか配線されておらず、それ以外の
3060    // 10箇所以上の重複コードブロックでは`/size`が無視されるバグだった
3061    // (2026-07-21発見・修正。詳細はwalkthrough.md参照)。
3062    {
3063        let dom = super::super::dom::parse_html("<div id='d'></div>");
3064        let sheet = parse_css(
3065            "#d{background:url('x.jpg') center/cover no-repeat}",
3066        );
3067        let styled = style_tree(&dom, &sheet);
3068        fn find_div<'a>(node: &'a StyledNode<'a>) -> Option<&'a StyledNode<'a>> {
3069            if let NodeType::Element { id, .. } = &node.node.node_type {
3070                if id.as_deref() == Some("d") {
3071                    return Some(node);
3072                }
3073            }
3074            for c in &node.children {
3075                if let Some(r) = find_div(c) {
3076                    return Some(r);
3077                }
3078            }
3079            None
3080        }
3081        total += 1;
3082        if let Some(d) = find_div(&styled) {
3083            let size = d.specified_values.get("background-size").map(|s| s.as_str());
3084            let position = d.specified_values.get("background-position").map(|s| s.as_str());
3085            let repeat = d.specified_values.get("background-repeat").map(|s| s.as_str());
3086            if size == Some("cover") && position == Some("center") && repeat == Some("no-repeat") {
3087                passed += 1;
3088            } else {
3089                crate::println!(
3090                    "CSS_SELFTEST FAIL: background shorthand slash expansion => size={:?} position={:?} repeat={:?}",
3091                    size, position, repeat
3092                );
3093            }
3094        } else {
3095            crate::println!("CSS_SELFTEST FAIL: background shorthand slash expansion => #d not found");
3096        }
3097    }
3098    // `@font-face`の解析(従来は完全にスキップされていた)。`font-family`の
3099    // 抽出と、`src`の複数候補(`url() format()`)からTTF/OTF/WOFF2形式を
3100    // 優先してURLを選ぶロジックを検証する。**2026-07-22更新**: WOFF2デコーダ
3101    // (`decompress_woff2`/`decompress_brotli`)が実装されたことに伴い、
3102    // `parse_font_face_def`の`is_preferred`判定に`woff2`も追加された
3103    // (以前はTTF/OTFのみ優先しWOFF2は非優先扱いだったため`b.ttf`を選んで
3104    // いたが、今はWOFF2も同格に優先されるため先頭に現れた`a.woff2`が
3105    // 選ばれるのが正しい挙動)。
3106    {
3107        let sheet = parse_css(
3108            "@font-face{font-family:'Noto Sans JP';src:url(a.woff2) format('woff2'),url(b.ttf) format('truetype')}",
3109        );
3110        total += 1;
3111        if sheet.font_faces.len() == 1
3112            && sheet.font_faces[0].family == "Noto Sans JP"
3113            && sheet.font_faces[0].src_url.as_deref() == Some("a.woff2")
3114        {
3115            passed += 1;
3116        } else {
3117            crate::println!(
3118                "CSS_SELFTEST FAIL: @font-face parse => {:?}",
3119                sheet.font_faces
3120            );
3121        }
3122    }
3123    // `@font-face`で`format('truetype')`ヒントが無い場合は、見つかった最初の
3124    // `url()`をそのまま採用すること(フォーマットヒント自体は仕様上省略可能)。
3125    {
3126        let sheet = parse_css("@font-face{font-family:'X';src:url(only.ttf)}");
3127        total += 1;
3128        if sheet.font_faces.len() == 1 && sheet.font_faces[0].src_url.as_deref() == Some("only.ttf")
3129        {
3130            passed += 1;
3131        } else {
3132            crate::println!(
3133                "CSS_SELFTEST FAIL: @font-face parse (no format hint) => {:?}",
3134                sheet.font_faces
3135            );
3136        }
3137    }
3138    // 実行時カスタムフォント登録(`kernel::vector_font::register_custom_font`)の
3139    // 往復テスト。実際のネットワーク取得は自己テスト環境では行えないため、
3140    // 既にビルドに埋め込み済みのフォントのバイト列を「ダウンロードされた
3141    // バイト列」の代用として使い、シグネチャ判定→登録→検索の一連のパイプライン
3142    // 全体が正しく動作することを検証する。
3143    {
3144        let font_bytes = alloc::vec::Vec::from(
3145            &include_bytes!("../../../fonts/ipa/ipag.ttf")[..],
3146        );
3147        total += 1;
3148        let ok = crate::kernel::vector_font::register_custom_font("Test Custom Font", font_bytes);
3149        if ok && crate::kernel::vector_font::has_custom_font("test custom font") {
3150            passed += 1;
3151        } else {
3152            crate::println!("CSS_SELFTEST FAIL: register_custom_font round-trip => registered={}", ok);
3153        }
3154    }
3155    // `register_custom_font`は成功時に`GLOBAL_VECTOR_FONT`のプライマリを新フォント
3156    // へ切り替える(`VectorFont::set_primary`。ページ全体のテキストへ取得済み
3157    // Webフォントを反映するための単純だが安全な戦略。2026-07-22実装)。
3158    // `GLOBAL_VECTOR_FONT`自体はこの自己テストの実行時点(`main.rs`で
3159    // `init_global_font_chain`より前に`css::selftest()`が呼ばれる)ではまだ
3160    // 未初期化(`None`)のため、グローバルではなく独立した`VectorFont`
3161    // インスタンスを直接構築して`set_primary`の安全性(切替後も旧プライマリの
3162    // グリフカバレッジがフォールバック経由で失われないこと)を検証する。
3163    // このロジックが壊れていれば実運用では全ページのテキスト描画・幅計測に
3164    // 波及する重大な回帰になるため、最も基本的な安全性チェックとして重要。
3165    {
3166        total += 1;
3167        let font_a = include_bytes!("../../../fonts/MPLUS2/MPLUS2-Regular.ttf");
3168        let font_b = include_bytes!("../../../fonts/ipa/ipag.ttf");
3169        match crate::kernel::vector_font::VectorFont::new(font_a) {
3170            Ok(mut vf) => {
3171                let had_glyph_before = vf.get_glyph('a', 16).is_some();
3172                let switched = vf.set_primary(font_b).is_ok();
3173                let has_glyph_after = vf.get_glyph('a', 16).is_some();
3174                if had_glyph_before && switched && has_glyph_after {
3175                    passed += 1;
3176                } else {
3177                    crate::println!(
3178                        "CSS_SELFTEST FAIL: VectorFont::set_primary => before={} switched={} after={}",
3179                        had_glyph_before, switched, has_glyph_after
3180                    );
3181                }
3182            }
3183            Err(_) => {
3184                crate::println!("CSS_SELFTEST FAIL: VectorFont::set_primary => failed to build base font");
3185            }
3186        }
3187    }
3188    // Brotli解凍機能(`decompress_brotli`)が正常に機能すること。
3189    // 空Brotliストリームの正しいバイト値は`0x06`(`[3]`ではない)。Brotli仕様
3190    // (RFC 7932 §9.2)ではビットをLSB-firstで詰めるため、WBITS既定値(1bit=0)・
3191    // ISLAST(1bit=1)・ISLASTEMPTY(1bit=1)の3ビットは、それぞれバイトの
3192    // bit0/bit1/bit2に入る(0*1 + 1*2 + 1*4 = 6)。以前使っていた`[3]`
3193    // (0b00000011)はWBITSの1ビット目が1になっており「デフォルト以外の
3194    // window宣言」を意味してしまい、ISLASTEMPTYが0のため不完全な
3195    // メタブロックヘッダとして扱われ、実装のバグではなくテストデータ自体が
3196    // 不正だったために失敗していたと判明(2026-07-22発見・修正)。
3197    {
3198        total += 1;
3199        if let Some(dec) = crate::kernel::vector_font::decompress_brotli(&[6], 0) {
3200            if dec.is_empty() {
3201                passed += 1;
3202            } else {
3203                crate::println!("CSS_SELFTEST FAIL: decompress_brotli empty stream should be empty");
3204            }
3205        } else {
3206            crate::println!("CSS_SELFTEST FAIL: decompress_brotli empty stream failed");
3207        }
3208    }
3209    // 不正な/切り詰められた WOFF2 ヘッダーは安全に拒否され、クラッシュせず `false` を返すこと。
3210    {
3211        let fake_woff2 = alloc::vec![b'w', b'O', b'F', b'2', 0, 0, 0, 0];
3212        total += 1;
3213        if !crate::kernel::vector_font::register_custom_font("Fake Woff2", fake_woff2) {
3214            passed += 1;
3215        } else {
3216            crate::println!("CSS_SELFTEST FAIL: register_custom_font should reject invalid WOFF2 header");
3217        }
3218    }
3219    // `word-break`/`overflow-wrap`/`word-wrap` が親要素から子ノードへ継承されること。
3220    {
3221        total += 1;
3222        let html = "<html><body><div id='parent' style='word-break:break-all;overflow-wrap:break-word;'><span id='child'>text</span></div></body></html>";
3223        let doc = super::super::dom::parse_html(html);
3224        let stylesheet = parse_css("");
3225        let styled = style_tree(&doc, &stylesheet);
3226        fn find_by_id<'a>(node: &'a StyledNode<'a>, id: &str) -> Option<&'a StyledNode<'a>> {
3227            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
3228                if nid.as_deref() == Some(id) {
3229                    return Some(node);
3230                }
3231            }
3232            for c in &node.children {
3233                if let Some(r) = find_by_id(c, id) {
3234                    return Some(r);
3235                }
3236            }
3237            None
3238        }
3239        if let Some(child) = find_by_id(&styled, "child") {
3240            let wb = child.value("word-break").map(|v| v.trim() == "break-all").unwrap_or(false);
3241            let ow = child.value("overflow-wrap").map(|v| v.trim() == "break-word").unwrap_or(false);
3242            if wb && ow {
3243                passed += 1;
3244            } else {
3245                crate::println!("CSS_SELFTEST FAIL: word-break/overflow-wrap inheritance => wb={} ow={}", wb, ow);
3246            }
3247        } else {
3248            crate::println!("CSS_SELFTEST FAIL: child node not found in style_tree");
3249        }
3250    }
3251    for (mode, src, dst, expect) in blend_cases {
3252        let got = super::super::web_engine::draw_helpers::blend_pixel(mode, *src, *dst);
3253        if got == *expect {
3254            passed += 1;
3255        } else {
3256            crate::println!(
3257                "CSS_SELFTEST FAIL: blend_pixel(`{}`, {:?}, {:?}) => {:?} (want {:?})",
3258                mode,
3259                src,
3260                dst,
3261                got,
3262                expect
3263            );
3264        }
3265    }
3266    for (filter, input, expect) in filter_cases {
3267        let got = super::super::web_engine::draw_helpers::apply_css_filters(filter, input.0, input.1, input.2);
3268        if got == *expect {
3269            passed += 1;
3270        } else {
3271            crate::println!(
3272                "CSS_SELFTEST FAIL: apply_css_filters(`{}`, {:?}) => {:?} (want {:?})",
3273                filter,
3274                input,
3275                got,
3276                expect
3277            );
3278        }
3279    }
3280    for (kind, n, upper, expect) in alpha_roman_cases {
3281        let got = if *kind == "alpha" {
3282            super::super::web_engine::number_to_alpha(*n, *upper)
3283        } else {
3284            super::super::web_engine::number_to_roman(*n, *upper)
3285        };
3286        if got == *expect {
3287            passed += 1;
3288        } else {
3289            crate::println!(
3290                "CSS_SELFTEST FAIL: number_to_{}({}, {}) => `{}` (want `{}`)",
3291                kind,
3292                n,
3293                upper,
3294                got,
3295                expect
3296            );
3297        }
3298    }
3299    for (input, mode, expect) in tt_cases {
3300        if apply_text_transform(input, mode) == *expect {
3301            passed += 1;
3302        } else {
3303            crate::println!(
3304                "CSS_SELFTEST FAIL: text-transform({}, {}) => `{}` (want `{}`)",
3305                input,
3306                mode,
3307                apply_text_transform(input, mode),
3308                expect
3309            );
3310        }
3311    }
3312    for (html, css, id, expect) in cases {
3313        let dom = super::super::dom::parse_html(html);
3314        let sheet = parse_css(css);
3315        let styled = style_tree(&dom, &sheet);
3316        let got = find_marked(&styled, id).unwrap_or(false);
3317        if got == *expect {
3318            passed += 1;
3319        } else {
3320            crate::println!(
3321                "CSS_SELFTEST FAIL: `{}` + `{}` on #{} => {} (want {})",
3322                html,
3323                css,
3324                id,
3325                got,
3326                expect
3327            );
3328        }
3329    }
3330    for (html, css, id, prop, expect) in value_cases {
3331        let dom = super::super::dom::parse_html(html);
3332        let sheet = parse_css(css);
3333        let styled = style_tree(&dom, &sheet);
3334        let got = find_prop(&styled, id, prop).unwrap_or_default();
3335        if got == *expect {
3336            passed += 1;
3337        } else {
3338            crate::println!(
3339                "CSS_SELFTEST FAIL: `{}` + `{}` on #{}.{} => `{}` (want `{}`)",
3340                html,
3341                css,
3342                id,
3343                prop,
3344                got,
3345                expect
3346            );
3347        }
3348    }
3349    // ---- :focus(hover と同じ事前計算方式)----
3350    // focus_values は実際のフォーカス状態に関わらず「:focus 適用時にどう変わるか」を
3351    // 常に事前計算しておく(実際の適用可否は描画側が focused_id と比較して選ぶ)。
3352    {
3353        fn find_node<'a>(node: &'a StyledNode, id: &str) -> Option<&'a StyledNode<'a>> {
3354            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
3355                if nid.as_deref() == Some(id) {
3356                    return Some(node);
3357                }
3358            }
3359            for c in &node.children {
3360                if let Some(r) = find_node(c, id) {
3361                    return Some(r);
3362                }
3363            }
3364            None
3365        }
3366        let dom = super::super::dom::parse_html("<input id='t' type='text'>");
3367        let sheet = parse_css("input:focus{border-color:#ff0000}");
3368        let styled = style_tree(&dom, &sheet);
3369        let focus_border = find_node(&styled, "t")
3370            .and_then(|n| n.focus_values.as_ref())
3371            .and_then(|h| h.get("border-color").cloned())
3372            .unwrap_or_default();
3373        if focus_border == "#ff0000" {
3374            passed += 1;
3375        } else {
3376            crate::println!(
3377                "CSS_SELFTEST FAIL: :focus border-color => `{}` (want `#ff0000`)",
3378                focus_border
3379            );
3380        }
3381        // :focus-visible は :focus と同一視される簡略実装(キーボード/ポインタ操作の区別なし)。
3382        let sheet_fv = parse_css("input:focus-visible{border-color:#00ff00}");
3383        let styled_fv = style_tree(&dom, &sheet_fv);
3384        let focus_visible_border = find_node(&styled_fv, "t")
3385            .and_then(|n| n.focus_values.as_ref())
3386            .and_then(|h| h.get("border-color").cloned())
3387            .unwrap_or_default();
3388        if focus_visible_border == "#00ff00" {
3389            passed += 1;
3390        } else {
3391            crate::println!(
3392                "CSS_SELFTEST FAIL: :focus-visible border-color => `{}` (want `#00ff00`)",
3393                focus_visible_border
3394            );
3395        }
3396        // :active(hover/focus と同じ事前計算方式。実際の適用可否は描画側が
3397        // 「ホバー中 かつ マウス左ボタン押下中」を見て選ぶ)。
3398        let sheet_active = parse_css("input:active{border-color:#0000ff}");
3399        let styled_active = style_tree(&dom, &sheet_active);
3400        let active_border = find_node(&styled_active, "t")
3401            .and_then(|n| n.active_values.as_ref())
3402            .and_then(|h| h.get("border-color").cloned())
3403            .unwrap_or_default();
3404        if active_border == "#0000ff" {
3405            passed += 1;
3406        } else {
3407            crate::println!(
3408                "CSS_SELFTEST FAIL: :active border-color => `{}` (want `#0000ff`)",
3409                active_border
3410            );
3411        }
3412    }
3413
3414    // ---- 疑似要素(::before 等)の特異度寄与 ----
3415    // 以前は simple_specificity() が sel.pseudo_element を一切見ておらず、
3416    // `::before` 単体の特異度が (0,0,0)(無寄与)のままになっていた。
3417    // CSS 仕様では疑似要素はタグ名セレクタと同じ重み(タグ数バケット)で寄与するため、
3418    // `::before` 単体は `div` 単体と同じ (0,0,1) になるべき。
3419    {
3420        let s1 = parse_selector("div::before").map(|s| selector_specificity(&s));
3421        let s2 = parse_selector("div").map(|s| selector_specificity(&s));
3422        if s1 == Some((0, 0, 2)) && s2 == Some((0, 0, 1)) {
3423            passed += 1;
3424        } else {
3425            crate::println!(
3426                "CSS_SELFTEST FAIL: pseudo-element specificity => div::before={:?} (want Some((0,0,2))), div={:?} (want Some((0,0,1)))",
3427                s1, s2
3428            );
3429        }
3430    }
3431
3432    // ---- :root(文書ルート要素 <html> にのみ一致)----
3433    // parse_html() は常に暗黙の "html" 要素をルートとして生成するため、id を付けずに
3434    // ルート自身の specified_values を直接検査する。
3435    {
3436        let dom_root = super::super::dom::parse_html("<div id='child'></div>");
3437        let sheet_root = parse_css(":root{zz:1}");
3438        let styled_root = style_tree(&dom_root, &sheet_root);
3439        let root_marker = styled_root
3440            .specified_values
3441            .get("zz")
3442            .cloned()
3443            .unwrap_or_default();
3444        if root_marker == "1" {
3445            passed += 1;
3446        } else {
3447            crate::println!(
3448                "CSS_SELFTEST FAIL: :root zz => `{}` (want `1`)",
3449                root_marker
3450            );
3451        }
3452        // 子要素(html自身ではない)には :root がマッチしない。
3453        fn find_node2<'a>(node: &'a StyledNode, id: &str) -> Option<&'a StyledNode<'a>> {
3454            if let NodeType::Element { id: nid, .. } = &node.node.node_type {
3455                if nid.as_deref() == Some(id) {
3456                    return Some(node);
3457                }
3458            }
3459            for c in &node.children {
3460                if let Some(r) = find_node2(c, id) {
3461                    return Some(r);
3462                }
3463            }
3464            None
3465        }
3466        let child_matched = find_node2(&styled_root, "child")
3467            .and_then(|n| n.specified_values.get("zz").cloned())
3468            .is_some();
3469        if !child_matched {
3470            passed += 1;
3471        } else {
3472            crate::println!("CSS_SELFTEST FAIL: :root should not match non-root child element");
3473        }
3474    }
3475
3476    // ---- <style>/<script> の中身が Text 子ノードとして保持されるか ----
3477    // 以前は skip_raw_content が中身を完全に読み捨てており、.textContent での参照や
3478    // DOM ツリー経由でのスタイルシート抽出(動的に追加された <style> 等)が
3479    // 一切効かないバグだった(2026-07-03 修正)。
3480    {
3481        fn find_tag_text<'a>(node: &'a super::super::dom::Node, tag: &str) -> Option<&'a str> {
3482            if let NodeType::Element { tag_name, .. } = &node.node_type {
3483                if tag_name == tag {
3484                    for c in &node.children {
3485                        if let NodeType::Text(t) = &c.node_type {
3486                            return Some(t.as_str());
3487                        }
3488                    }
3489                    return None;
3490                }
3491            }
3492            for c in &node.children {
3493                if let Some(r) = find_tag_text(c, tag) {
3494                    return Some(r);
3495                }
3496            }
3497            None
3498        }
3499        total += 1;
3500        let dom = super::super::dom::parse_html(
3501            "<html><style>a{color:red}</style><script>var x=1;</script></html>",
3502        );
3503        let style_text = find_tag_text(&dom, "style").unwrap_or("");
3504        if style_text.contains("color:red") {
3505            passed += 1;
3506        } else {
3507            crate::println!(
3508                "CSS_SELFTEST FAIL: <style> text child => `{}` (want to contain `color:red`)",
3509                style_text
3510            );
3511        }
3512        let script_text = find_tag_text(&dom, "script").unwrap_or("");
3513        if script_text.contains("var x=1") {
3514            passed += 1;
3515        } else {
3516            crate::println!(
3517                "CSS_SELFTEST FAIL: <script> text child => `{}` (want to contain `var x=1`)",
3518                script_text
3519            );
3520        }
3521    }
3522
3523    // ---- CSS Transitions / Animations ----
3524    // (説明, 実測値, 期待値) の closure ベース検査。
3525    let mut anim_total = 0usize;
3526    let mut anim_check = |desc: &str, got: String, want: &str| {
3527        anim_total += 1;
3528        if got == want {
3529            passed += 1;
3530        } else {
3531            crate::println!(
3532                "CSS_SELFTEST FAIL: anim {} => `{}` (want `{}`)",
3533                desc,
3534                got,
3535                want
3536            );
3537        }
3538    };
3539
3540    // --- イージング関数 ---
3541    anim_check(
3542        "linear@0.5",
3543        fmt_num(TimingFunction::Linear.ease(0.5)),
3544        "0.5",
3545    );
3546    anim_check("linear@0", fmt_num(TimingFunction::Linear.ease(0.0)), "0");
3547    anim_check("linear@1", fmt_num(TimingFunction::Linear.ease(1.0)), "1");
3548    // ease は中央でやや進む(>0.5)。
3549    {
3550        let e = TimingFunction::parse("ease").ease(0.5);
3551        anim_check("ease@0.5>0.5", (e > 0.55).to_string(), "true");
3552    }
3553    // ease-in は中央で遅れる(<0.5)。
3554    {
3555        let e = TimingFunction::parse("ease-in").ease(0.5);
3556        anim_check("ease-in@0.5<0.5", (e < 0.45).to_string(), "true");
3557    }
3558    // 端点は厳密に 0/1。
3559    anim_check(
3560        "ease@0",
3561        fmt_num(TimingFunction::parse("ease").ease(0.0)),
3562        "0",
3563    );
3564    anim_check(
3565        "ease@1",
3566        fmt_num(TimingFunction::parse("ease").ease(1.0)),
3567        "1",
3568    );
3569    // steps(4, end): t=0.5 → floor(2)/4 = 0.5。
3570    anim_check(
3571        "steps(4,end)@0.5",
3572        fmt_num(TimingFunction::parse("steps(4, end)").ease(0.5)),
3573        "0.5",
3574    );
3575    // steps(4, end): t=0.7 → floor(2.8)/4 = 0.5。
3576    anim_check(
3577        "steps(4,end)@0.7",
3578        fmt_num(TimingFunction::parse("steps(4, end)").ease(0.7)),
3579        "0.5",
3580    );
3581    // steps(4, start): t=0.5 → (floor(2)+1)/4 = 0.75。
3582    anim_check(
3583        "steps(4,start)@0.5",
3584        fmt_num(TimingFunction::parse("steps(4, start)").ease(0.5)),
3585        "0.75",
3586    );
3587    // cubic-bezier(0,0,1,1) は linear と等価。
3588    anim_check(
3589        "cubic-linear@0.3",
3590        fmt_num(TimingFunction::parse("cubic-bezier(0,0,1,1)").ease(0.3)),
3591        "0.3",
3592    );
3593
3594    // --- 値の補間 ---
3595    anim_check("px@0.5", interpolate_value("0px", "100px", 0.5), "50px");
3596    anim_check("px@0", interpolate_value("10px", "100px", 0.0), "10px");
3597    anim_check("px@1", interpolate_value("10px", "100px", 1.0), "100px");
3598    anim_check("num@0.25", interpolate_value("0", "1", 0.25), "0.25");
3599    anim_check("pct@0.5", interpolate_value("0%", "50%", 0.5), "25%");
3600    anim_check("em@0.5", interpolate_value("1em", "3em", 0.5), "2em");
3601    // 単位無し`0`と`%`/`px`の混在(実サイト www.sugi-lab.net のタイピング演出
3602    // `@keyframes typing { from{width:0} to{width:100%} }`と同じパターン)。
3603    // 以前は単位の厳密一致(`u1 == u2`)のみで判定しており、この組合せは
3604    // 補間不能な離散ジャンプ扱いになるバグだった(2026-07-22発見・修正)。
3605    anim_check("zero-to-pct@0.5", interpolate_value("0", "100%", 0.5), "50%");
3606    anim_check("zero-to-pct@0", interpolate_value("0", "100%", 0.0), "0%");
3607    anim_check("zero-to-pct@1", interpolate_value("0", "100%", 1.0), "100%");
3608    anim_check("pct-to-zero@0.5", interpolate_value("100%", "0", 0.5), "50%");
3609    anim_check("zero-to-px@0.5", interpolate_value("0", "40px", 0.5), "20px");
3610    // 同じ単位無し0の互換性修正は、`translateY(30px)`→`translateY(0)`という
3611    // 関数呼び出し引数内の再帰的な補間(`translateY(...)`の中身を
3612    // `interpolate_value`で再帰処理する経路)にも自動的に効く。実サイトの
3613    // IntersectionObserverによるフェードイン演出(`.research-card`等多数の
3614    // 要素が`transform: translateY(30px)` → `translateY(0)`で登場する、
3615    // このサイト全体で広く使われるパターン)が同じバグの影響を受けていた
3616    // ことの確認(2026-07-22)。
3617    anim_check(
3618        "translateY-fadein@0.5",
3619        interpolate_value("translateY(30px)", "translateY(0)", 0.5),
3620        "translateY(15px)",
3621    );
3622    // 色補間(black→white の中点 = 灰)。
3623    anim_check(
3624        "color@0.5",
3625        interpolate_value("#000000", "#ffffff", 0.5),
3626        "#7f7f7f",
3627    );
3628    anim_check(
3629        "color-name@0",
3630        interpolate_value("red", "blue", 0.0),
3631        "#ff0000",
3632    );
3633    // 複数値(margin: 0 0 → 10px 20px)。
3634    anim_check(
3635        "multi@0.5",
3636        interpolate_value("0px 0px", "10px 20px", 0.5),
3637        "5px 10px",
3638    );
3639    // transform 関数の内側補間。
3640    anim_check(
3641        "translateX@0.5",
3642        interpolate_value("translateX(0px)", "translateX(40px)", 0.5),
3643        "translateX(20px)",
3644    );
3645    // カンマ区切り引数を持つ単一関数(`translate(x, y)`)。以前はカンマ後の空白が
3646    // 余分な空白区切りトークンを作り、各引数が正しく補間されずジャンプしていた。
3647    anim_check(
3648        "translate-xy@0.5",
3649        interpolate_value("translate(10px, 20px)", "translate(30px, 40px)", 0.5),
3650        "translate(20px, 30px)",
3651    );
3652    // matrix() のような6引数関数でも同様に全引数が独立して補間されることを確認する。
3653    anim_check(
3654        "matrix@0.5",
3655        interpolate_value("matrix(0, 0, 0, 0, 0, 0)", "matrix(2, 2, 2, 2, 2, 2)", 0.5),
3656        "matrix(1, 1, 1, 1, 1, 1)",
3657    );
3658    // 空白区切りの複数関数の並び(`translateX(..) rotate(..)`)は引き続き
3659    // トークンごとの独立補間のまま維持されること(単一関数誤認による回帰の防止)。
3660    anim_check(
3661        "translate-rotate-chain@0.5",
3662        interpolate_value(
3663            "translateX(0px) rotate(0deg)",
3664            "translateX(40px) rotate(90deg)",
3665            0.5,
3666        ),
3667        "translateX(20px) rotate(45deg)",
3668    );
3669    // `box-shadow`/`text-shadow` のようなカンマ区切りの複数レイヤー値は、レイヤーごとに
3670    // 独立して補間されること(以前はカンマ後の空白によるトークン化でレイヤー境界を
3671    // 跨いだ位置ベース補間になり、色や単位不一致で discrete フォールバックしていた)。
3672    anim_check(
3673        "multi-layer-shadow@0.5",
3674        interpolate_value("0px 0px, 1px 1px", "10px 10px, 21px 21px", 0.5),
3675        "5px 5px, 11px 11px",
3676    );
3677    // 補間不能(単位不一致)→ discrete。
3678    anim_check(
3679        "discrete@0.3",
3680        interpolate_value("auto", "100px", 0.3),
3681        "auto",
3682    );
3683    anim_check(
3684        "discrete@0.7",
3685        interpolate_value("auto", "100px", 0.7),
3686        "100px",
3687    );
3688
3689    // --- 時間値パース ---
3690    anim_check("time-1s", fmt_num(parse_time_ms("1s")), "1000");
3691    anim_check("time-250ms", fmt_num(parse_time_ms("250ms")), "250");
3692    anim_check("time-0.5s", fmt_num(parse_time_ms("0.5s")), "500");
3693
3694    // --- transition ショートハンドのパース ---
3695    {
3696        let specs = parse_transition("width 1s ease, background-color 0.5s linear 0.2s");
3697        anim_check("trans-count", specs.len().to_string(), "2");
3698        if specs.len() == 2 {
3699            anim_check("trans0-prop", specs[0].property.clone(), "width");
3700            anim_check("trans0-dur", fmt_num(specs[0].duration_ms), "1000");
3701            anim_check("trans1-prop", specs[1].property.clone(), "background-color");
3702            anim_check("trans1-delay", fmt_num(specs[1].delay_ms), "200");
3703            anim_check(
3704                "trans1-timing",
3705                (specs[1].timing == TimingFunction::Linear).to_string(),
3706                "true",
3707            );
3708        }
3709    }
3710
3711    // --- transition ショートハンド: プロパティ名が 's' で終わる場合の誤分類バグ修正確認。
3712    // 旧実装は `ends_with('s')` だけで時間値と判定していたため、`flex-basis`/`border-radius`
3713    // のようなプロパティ名まで時間値と誤認し、本来の duration が消え property が "all" に
3714    // 化けていた。
3715    {
3716        let specs = parse_transition("flex-basis 0.3s ease");
3717        anim_check("trans-s-count-flexbasis", specs.len().to_string(), "1");
3718        if specs.len() == 1 {
3719            anim_check("trans-s-prop-flexbasis", specs[0].property.clone(), "flex-basis");
3720            anim_check("trans-s-dur-flexbasis", fmt_num(specs[0].duration_ms), "300");
3721        }
3722    }
3723    {
3724        let specs = parse_transition("border-radius 0.5s linear");
3725        if specs.len() == 1 {
3726            anim_check("trans-s-prop-borderradius", specs[0].property.clone(), "border-radius");
3727            anim_check("trans-s-dur-borderradius", fmt_num(specs[0].duration_ms), "500");
3728        }
3729    }
3730
3731    // --- animation ショートハンド: アニメーション名が 's' で終わる場合の誤分類バグ修正確認。
3732    {
3733        let specs = parse_animations("appears 1s ease");
3734        anim_check("anim-s-count", specs.len().to_string(), "1");
3735        if specs.len() == 1 {
3736            anim_check("anim-s-name", specs[0].name.clone(), "appears");
3737            anim_check("anim-s-dur", fmt_num(specs[0].duration_ms), "1000");
3738        }
3739    }
3740
3741    // --- animation ショートハンド内の `steps(n, jump-*)` タイミング関数
3742    // (実サイト www.sugi-lab.net のタイピング演出`animation: 2.5s steps(16,end)
3743    // typing`と同じパターン)が、カンマを含む関数呼び出しとして正しく1トークンへ
3744    // 結合され、`animation-name`と誤認識されないことを検証(2026-07-21)。
3745    {
3746        let specs = parse_animations("2.5s steps(16,end) typing");
3747        anim_check("anim-steps-count", specs.len().to_string(), "1");
3748        if specs.len() == 1 {
3749            anim_check("anim-steps-name", specs[0].name.clone(), "typing");
3750            anim_check("anim-steps-dur", fmt_num(specs[0].duration_ms), "2500");
3751            // steps(16,end)@0.5 = floor(0.5*16)/16 = 8/16 = 0.5。
3752            anim_check("anim-steps-timing", fmt_num(specs[0].timing.ease(0.5)), "0.5");
3753        }
3754    }
3755
3756    // --- transition サンプリング ---
3757    {
3758        let spec = TransitionSpec {
3759            property: "width".into(),
3760            duration_ms: 1000.0,
3761            timing: TimingFunction::Linear,
3762            delay_ms: 0.0,
3763        };
3764        let (v, active) = sample_transition(&spec, "0px", "100px", 500.0);
3765        anim_check("samp-trans-mid", v, "50px");
3766        anim_check("samp-trans-active", active.to_string(), "true");
3767        let (v2, active2) = sample_transition(&spec, "0px", "100px", 1200.0);
3768        anim_check("samp-trans-end", v2, "100px");
3769        anim_check("samp-trans-done", active2.to_string(), "false");
3770        // delay 中は from のまま。
3771        let spec_d = TransitionSpec {
3772            property: "width".into(),
3773            duration_ms: 1000.0,
3774            timing: TimingFunction::Linear,
3775            delay_ms: 300.0,
3776        };
3777        let (v3, _) = sample_transition(&spec_d, "0px", "100px", 100.0);
3778        anim_check("samp-trans-delay", v3, "0px");
3779    }
3780
3781    // --- @keyframes パース + animation サンプリング ---
3782    {
3783        let sheet = parse_css(
3784            "@keyframes slide { from { left: 0px; } to { left: 100px; } } \
3785             @keyframes fade { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } }",
3786        );
3787        anim_check("kf-count", sheet.keyframes.len().to_string(), "2");
3788        let slide = sheet.keyframes.iter().find(|k| k.name == "slide");
3789        anim_check("kf-slide-found", slide.is_some().to_string(), "true");
3790        if let Some(slide) = slide {
3791            anim_check("kf-slide-frames", slide.frames.len().to_string(), "2");
3792            let spec = AnimationSpec {
3793                name: "slide".into(),
3794                duration_ms: 1000.0,
3795                timing: TimingFunction::Linear,
3796                delay_ms: 0.0,
3797                iterations: 1.0,
3798                direction: "normal".into(),
3799                fill: "none".into(),
3800                play_state: "running".into(),
3801            };
3802            let m = sample_animation(&spec, slide, 500.0);
3803            anim_check(
3804                "anim-slide-mid",
3805                m.get("left").cloned().unwrap_or_default(),
3806                "50px",
3807            );
3808        }
3809        let fade = sheet.keyframes.iter().find(|k| k.name == "fade");
3810        if let Some(fade) = fade {
3811            anim_check("kf-fade-frames", fade.frames.len().to_string(), "3");
3812            let spec = AnimationSpec {
3813                name: "fade".into(),
3814                duration_ms: 1000.0,
3815                timing: TimingFunction::Linear,
3816                delay_ms: 0.0,
3817                iterations: 1.0,
3818                direction: "normal".into(),
3819                fill: "none".into(),
3820                play_state: "running".into(),
3821            };
3822            // t=250ms → 0%→50% の中点 → opacity 0.5。
3823            let m = sample_animation(&spec, fade, 250.0);
3824            anim_check(
3825                "anim-fade-q1",
3826                m.get("opacity").cloned().unwrap_or_default(),
3827                "0.5",
3828            );
3829            // t=500ms → opacity 1。
3830            let m2 = sample_animation(&spec, fade, 500.0);
3831            anim_check(
3832                "anim-fade-mid",
3833                m2.get("opacity").cloned().unwrap_or_default(),
3834                "1",
3835            );
3836        }
3837    }
3838
3839    // --- animation ショートハンドのパース ---
3840    {
3841        let spec = parse_animation("slide 2s ease-in-out 0.5s infinite alternate both");
3842        anim_check("anim-parse-some", spec.is_some().to_string(), "true");
3843        if let Some(s) = spec {
3844            anim_check("anim-name", s.name.clone(), "slide");
3845            anim_check("anim-dur", fmt_num(s.duration_ms), "2000");
3846            anim_check("anim-delay", fmt_num(s.delay_ms), "500");
3847            anim_check(
3848                "anim-iter-inf",
3849                s.iterations.is_infinite().to_string(),
3850                "true",
3851            );
3852            anim_check("anim-dir", s.direction.clone(), "alternate");
3853            anim_check("anim-fill", s.fill.clone(), "both");
3854        }
3855    }
3856
3857    // --- animation ショートハンドのカンマ区切り複数項目対応 ---
3858    // (box-shadow/text-shadow と同じく、以前は最初の1件のみが解析され残りは無視されていたバグの修正確認)
3859    {
3860        let specs = parse_animations("fade 1s, bounce 2s infinite");
3861        anim_check("anim-multi-count", specs.len().to_string(), "2");
3862        if specs.len() == 2 {
3863            anim_check("anim-multi-name-0", specs[0].name.clone(), "fade");
3864            anim_check("anim-multi-dur-0", fmt_num(specs[0].duration_ms), "1000");
3865            anim_check("anim-multi-name-1", specs[1].name.clone(), "bounce");
3866            anim_check("anim-multi-dur-1", fmt_num(specs[1].duration_ms), "2000");
3867            anim_check(
3868                "anim-multi-iter-1-inf",
3869                specs[1].iterations.is_infinite().to_string(),
3870                "true",
3871            );
3872        }
3873    }
3874
3875    // --- alternate direction(偶数/奇数反復で方向反転) ---
3876    {
3877        let sheet = parse_css("@keyframes mv { from { left: 0px; } to { left: 100px; } }");
3878        let kf = &sheet.keyframes[0];
3879        let spec = AnimationSpec {
3880            name: "mv".into(),
3881            duration_ms: 1000.0,
3882            timing: TimingFunction::Linear,
3883            delay_ms: 0.0,
3884            iterations: 4.0,
3885            direction: "alternate".into(),
3886            fill: "none".into(),
3887            play_state: "running".into(),
3888        };
3889        // 反復0 (0..1s) は順方向: t=500ms → 50px。
3890        let m0 = sample_animation(&spec, kf, 500.0);
3891        anim_check(
3892            "anim-alt-iter0",
3893            m0.get("left").cloned().unwrap_or_default(),
3894            "50px",
3895        );
3896        // 反復1 (1..2s) は逆方向: t=1500ms → local 0.5 reversed → 50px(中点なので同じ)。
3897        // 端で確認: t=1100ms → local 0.1 reversed=0.9 → 90px。
3898        let m1 = sample_animation(&spec, kf, 1100.0);
3899        anim_check(
3900            "anim-alt-iter1",
3901            m1.get("left").cloned().unwrap_or_default(),
3902            "90px",
3903        );
3904    }
3905
3906    // --- 非整数 iteration-count + alternate + fill:forwards の完了後フレーム ---
3907    // 以前は完了時の反復インデックスが常に `floor(iterations) - 1` で計算されており、
3908    // 端数のある最終反復(本来のインデックスは `floor(iterations)`)を1つ前の反復と
3909    // 誤認していたため、alternate の偶奇判定が反転し、最終停止位置が左右逆になる
3910    // バグがあった。
3911    {
3912        let sheet = parse_css("@keyframes mv3b { from { left: 0px; } to { left: 100px; } }");
3913        let kf = &sheet.keyframes[0];
3914        let spec = AnimationSpec {
3915            name: "mv3b".into(),
3916            duration_ms: 1000.0,
3917            timing: TimingFunction::Linear,
3918            delay_ms: 0.0,
3919            iterations: 2.25,
3920            direction: "alternate".into(),
3921            fill: "forwards".into(),
3922            play_state: "running".into(),
3923        };
3924        // t=2250ms(=2.25反復ぶん経過、完了後)。反復0=順方向、反復1=逆方向、
3925        // 反復2(インデックス偶数→順方向)の25%地点で止まるはずなので left=25px。
3926        // 修正前はインデックスを1つ前(奇数=逆方向)と誤認し75pxになっていた。
3927        let m = sample_animation(&spec, kf, 2250.0);
3928        anim_check(
3929            "anim-frac-iter-alternate-forwards",
3930            m.get("left").cloned().unwrap_or_default(),
3931            "25px",
3932        );
3933    }
3934
3935    // --- fill-mode forwards(終了後に最終値で固定) ---
3936    {
3937        let sheet = parse_css("@keyframes mv2 { from { left: 0px; } to { left: 100px; } }");
3938        let kf = &sheet.keyframes[0];
3939        let spec = AnimationSpec {
3940            name: "mv2".into(),
3941            duration_ms: 1000.0,
3942            timing: TimingFunction::Linear,
3943            delay_ms: 0.0,
3944            iterations: 1.0,
3945            direction: "normal".into(),
3946            fill: "forwards".into(),
3947            play_state: "running".into(),
3948        };
3949        // 終了後(2s)も forwards なら 100px。
3950        let m = sample_animation(&spec, kf, 2000.0);
3951        anim_check(
3952            "anim-fill-fwd",
3953            m.get("left").cloned().unwrap_or_default(),
3954            "100px",
3955        );
3956    }
3957
3958    // --- AnimationEngine 統合: トランジションの時間進行 ---
3959    {
3960        let mut eng = AnimationEngine::new();
3961        let spec = TransitionSpec {
3962            property: "left".into(),
3963            duration_ms: 1000.0,
3964            timing: TimingFunction::Linear,
3965            delay_ms: 0.0,
3966        };
3967        // t=0 で 0px→100px のトランジション開始。
3968        eng.start_transition("box", "left", "0px", "100px", spec, 0.0);
3969        anim_check(
3970            "eng-tr-active",
3971            if eng.is_active() {
3972                "y".into()
3973            } else {
3974                "n".into()
3975            },
3976            "y",
3977        );
3978        // t=500ms → 50px、まだ進行中。
3979        let active = eng.tick(500.0);
3980        let v = eng
3981            .computed
3982            .iter()
3983            .find(|(e, p, _)| e == "box" && p == "left")
3984            .map(|(_, _, v)| v.clone())
3985            .unwrap_or_default();
3986        anim_check("eng-tr-mid", v, "50px");
3987        anim_check(
3988            "eng-tr-mid-active",
3989            if active { "y".into() } else { "n".into() },
3990            "y",
3991        );
3992        // t=1000ms → 100px、完了して transitions から除去される。
3993        eng.tick(1000.0);
3994        let v_end = eng
3995            .computed
3996            .iter()
3997            .find(|(e, p, _)| e == "box" && p == "left")
3998            .map(|(_, _, v)| v.clone())
3999            .unwrap_or_default();
4000        anim_check("eng-tr-end", v_end, "100px");
4001        anim_check(
4002            "eng-tr-done",
4003            if eng.is_active() {
4004                "y".into()
4005            } else {
4006                "n".into()
4007            },
4008            "n",
4009        );
4010    }
4011
4012    // --- AnimationEngine 統合: `lifecycle_events`(`animationend`/`transitionend`/
4013    // `animationstart`の自動発火が丸ごと未対応だった。`AnimationEvent`/
4014    // `TransitionEvent`コンストラクタ自体は既に実装済みで手動dispatchは可能
4015    // だったが、実際の開始/完了を検知して自動発火する仕組みが無かった) ---
4016    {
4017        let mut eng = AnimationEngine::new();
4018        let spec = TransitionSpec {
4019            property: "left".into(),
4020            duration_ms: 1000.0,
4021            timing: TimingFunction::Linear,
4022            delay_ms: 0.0,
4023        };
4024        eng.start_transition("box3", "left", "0px", "100px", spec, 0.0);
4025        // t=500ms: まだ進行中なので lifecycle_events は空(トランジションには
4026        // `transitionstart`は今回対象外のため発火しない)。
4027        eng.tick(500.0);
4028        anim_check(
4029            "eng-transitionend-mid-empty",
4030            if eng.lifecycle_events.is_empty() { "y".into() } else { "n".into() },
4031            "y",
4032        );
4033        // t=1000ms: 完了し、この tick で `transitionend` が1件記録される。
4034        eng.tick(1000.0);
4035        let got = eng
4036            .lifecycle_events
4037            .iter()
4038            .find(|(e, ty, _)| e == "box3" && *ty == "transitionend")
4039            .map(|(_, _, name)| name.clone())
4040            .unwrap_or_default();
4041        anim_check("eng-transitionend-name", got, "left");
4042        // t=1500ms: 既に除去済みのトランジションなので再発火しない(毎tick重複しない
4043        // ことの確認)。
4044        eng.tick(1500.0);
4045        anim_check(
4046            "eng-transitionend-no-duplicate",
4047            if eng.lifecycle_events.is_empty() { "y".into() } else { "n".into() },
4048            "y",
4049        );
4050    }
4051    {
4052        let sheet = parse_css("@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }");
4053        let kf = sheet.keyframes[0].clone();
4054        let spec = AnimationSpec {
4055            name: "fadein".into(),
4056            duration_ms: 1000.0,
4057            timing: TimingFunction::Linear,
4058            delay_ms: 100.0,
4059            iterations: 1.0,
4060            direction: "normal".into(),
4061            fill: "none".into(),
4062            play_state: "running".into(),
4063        };
4064        let mut eng = AnimationEngine::new();
4065        eng.start_animation("box4", spec, kf, 0.0);
4066        // t=50ms: delay(100ms)未経過なので animationstart はまだ発火しない。
4067        eng.tick(50.0);
4068        anim_check(
4069            "eng-animationstart-before-delay-empty",
4070            if eng.lifecycle_events.is_empty() { "y".into() } else { "n".into() },
4071            "y",
4072        );
4073        // t=500ms: delay を過ぎ animationstart が1件記録される。まだ再生中なので
4074        // animationend は無い。
4075        eng.tick(500.0);
4076        let got_start = eng
4077            .lifecycle_events
4078            .iter()
4079            .find(|(e, ty, _)| e == "box4" && *ty == "animationstart")
4080            .map(|(_, _, name)| name.clone())
4081            .unwrap_or_default();
4082        anim_check("eng-animationstart-name", got_start, "fadein");
4083        anim_check(
4084            "eng-animationend-mid-empty",
4085            if eng.lifecycle_events.iter().any(|(_, ty, _)| *ty == "animationend") {
4086                "n".into()
4087            } else {
4088                "y".into()
4089            },
4090            "y",
4091        );
4092        // t=1100ms(delay 100ms + duration 1000ms): 完了し animationend が1件
4093        // 記録される。
4094        eng.tick(1100.0);
4095        let got = eng
4096            .lifecycle_events
4097            .iter()
4098            .find(|(e, ty, _)| e == "box4" && *ty == "animationend")
4099            .map(|(_, _, name)| name.clone())
4100            .unwrap_or_default();
4101        anim_check("eng-animationend-name", got, "fadein");
4102    }
4103
4104    // --- AnimationEngine 統合: 進行中トランジションの中断→逆再生(現在値からの再開) ---
4105    // 以前は中断時に呼び出し側から渡された stale な from をそのまま使っており、
4106    // 現在の補間位置を無視して一瞬ジャンプしてから逆再生してしまうバグがあった。
4107    {
4108        let mut eng = AnimationEngine::new();
4109        let spec = TransitionSpec {
4110            property: "opacity".into(),
4111            duration_ms: 1000.0,
4112            timing: TimingFunction::Linear,
4113            delay_ms: 0.0,
4114        };
4115        // t=0 で opacity 1→0(hover開始相当)。
4116        eng.start_transition("box2", "opacity", "1", "0", spec.clone(), 0.0);
4117        // t=500ms(50%地点、opacity=0.5)で中断し、逆方向(0→1、hover解除相当)へ retarget。
4118        // 呼び出し側が渡す from="0"(1つ前の style スナップショット値、stale)は無視され、
4119        // 実際の現在値(0.5)からの再開になるはず。
4120        eng.start_transition("box2", "opacity", "0", "1", spec, 500.0);
4121        eng.tick(500.0);
4122        let v_retarget_start = eng
4123            .computed
4124            .iter()
4125            .find(|(e, p, _)| e == "box2" && p == "opacity")
4126            .map(|(_, _, v)| v.clone())
4127            .unwrap_or_default();
4128        anim_check("eng-tr-retarget-from-current", v_retarget_start, "0.5");
4129        // さらに500ms進む(retarget後の経過500ms、1000ms尺の50% → 0.5→1の中間=0.75)。
4130        eng.tick(1000.0);
4131        let v_retarget_mid = eng
4132            .computed
4133            .iter()
4134            .find(|(e, p, _)| e == "box2" && p == "opacity")
4135            .map(|(_, _, v)| v.clone())
4136            .unwrap_or_default();
4137        anim_check("eng-tr-retarget-progress", v_retarget_mid, "0.75");
4138    }
4139
4140    // --- AnimationEngine 統合: キーフレームアニメの時間進行 ---
4141    {
4142        let sheet = parse_css("@keyframes slide { from { left: 0px; } to { left: 200px; } }");
4143        let kf = sheet.keyframes[0].clone();
4144        let spec = AnimationSpec {
4145            name: "slide".into(),
4146            duration_ms: 1000.0,
4147            timing: TimingFunction::Linear,
4148            delay_ms: 0.0,
4149            iterations: 1.0,
4150            direction: "normal".into(),
4151            fill: "forwards".into(),
4152            play_state: "running".into(),
4153        };
4154        let mut eng = AnimationEngine::new();
4155        eng.start_animation("a", spec, kf, 0.0);
4156        // t=250ms → 50px。
4157        eng.tick(250.0);
4158        let v = eng
4159            .computed
4160            .iter()
4161            .find(|(e, p, _)| e == "a" && p == "left")
4162            .map(|(_, _, v)| v.clone())
4163            .unwrap_or_default();
4164        anim_check("eng-anim-q1", v, "50px");
4165        // 完了後 forwards なので 200px で固定、is_active は false。
4166        eng.tick(2000.0);
4167        let v_end = eng
4168            .computed
4169            .iter()
4170            .find(|(e, p, _)| e == "a" && p == "left")
4171            .map(|(_, _, v)| v.clone())
4172            .unwrap_or_default();
4173        anim_check("eng-anim-fwd-hold", v_end, "200px");
4174        anim_check(
4175            "eng-anim-done",
4176            if eng.is_active() {
4177                "y".into()
4178            } else {
4179                "n".into()
4180            },
4181            "n",
4182        );
4183    }
4184
4185    // --- AnimationEngine 統合: animation-play-state:paused で経過時間が凍結される ---
4186    // 以前は `animation-play-state` が一切パース/適用されず、paused 指定が常に無視され
4187    // 再生を止められないバグがあった。
4188    {
4189        let sheet = parse_css("@keyframes slide2 { from { left: 0px; } to { left: 200px; } }");
4190        let kf = sheet.keyframes[0].clone();
4191        let mut spec = AnimationSpec {
4192            name: "slide2".into(),
4193            duration_ms: 1000.0,
4194            timing: TimingFunction::Linear,
4195            delay_ms: 0.0,
4196            iterations: 1.0,
4197            direction: "normal".into(),
4198            fill: "forwards".into(),
4199            play_state: "running".into(),
4200        };
4201        let mut eng = AnimationEngine::new();
4202        eng.start_animation("b", spec.clone(), kf.clone(), 0.0);
4203        // t=250ms → 50px(一時停止前)。
4204        eng.tick(250.0);
4205        // ここで一時停止に切り替え(既存アニメの play_state だけを更新する経路を使う)。
4206        spec.play_state = "paused".into();
4207        eng.start_animation("b", spec.clone(), kf.clone(), 250.0);
4208        eng.tick(250.0);
4209        let v_paused = eng
4210            .computed
4211            .iter()
4212            .find(|(e, p, _)| e == "b" && p == "left")
4213            .map(|(_, _, v)| v.clone())
4214            .unwrap_or_default();
4215        anim_check("eng-anim-paused-frozen1", v_paused.clone(), "50px");
4216        // 一時停止中は時間が進んでも値が凍結されたままであることを確認(t=1000ms でも50px)。
4217        eng.tick(1000.0);
4218        let v_still_paused = eng
4219            .computed
4220            .iter()
4221            .find(|(e, p, _)| e == "b" && p == "left")
4222            .map(|(_, _, v)| v.clone())
4223            .unwrap_or_default();
4224        anim_check("eng-anim-paused-frozen2", v_still_paused, "50px");
4225        // 再開(play_state を running に戻す): 再開を検知した tick の時刻が
4226        // 「一時停止が明けた瞬間」として扱われるため、再開検知直後の同時刻ではまだ
4227        // 凍結値(50px)のまま。その後さらに250ms進めると凍結値から連続して75pxへ進行する
4228        // (250ms分の進行 = 200px*250/1000=50px、50px+50px=100pxではなく凍結値50px起点で
4229        // 250ms分=50px進むので合計100px)。
4230        spec.play_state = "running".into();
4231        eng.start_animation("b", spec, kf, 1000.0);
4232        eng.tick(1000.0);
4233        let v_resume_instant = eng
4234            .computed
4235            .iter()
4236            .find(|(e, p, _)| e == "b" && p == "left")
4237            .map(|(_, _, v)| v.clone())
4238            .unwrap_or_default();
4239        anim_check("eng-anim-resume-instant", v_resume_instant, "50px");
4240        eng.tick(1250.0);
4241        let v_resumed = eng
4242            .computed
4243            .iter()
4244            .find(|(e, p, _)| e == "b" && p == "left")
4245            .map(|(_, _, v)| v.clone())
4246            .unwrap_or_default();
4247        anim_check("eng-anim-resumed", v_resumed, "100px");
4248    }
4249
4250    // --- AnimationEngine 統合: animation-name 切り替え時に古いアニメが取り消されること ---
4251    // 以前は `start_animation` の「同一 (element, name) なら更新のみ」という判定しか無く、
4252    // 別名へ切り替えても古いアニメが除去されず(特に infinite だと自然完了もしない)、
4253    // 新旧2つが永久に同時に走り続けてしまうバグがあった。
4254    {
4255        let sheet = parse_css(
4256            "@keyframes fadeA { from { opacity: 0; } to { opacity: 1; } } \
4257             @keyframes fadeB { from { opacity: 1; } to { opacity: 0; } }",
4258        );
4259        let empty_kf = |name: &str| Keyframes { name: name.into(), frames: Vec::new() };
4260        let kf_a = sheet
4261            .keyframes
4262            .iter()
4263            .find(|k| k.name == "fadeA")
4264            .cloned()
4265            .unwrap_or_else(|| empty_kf("fadeA"));
4266        let kf_b = sheet
4267            .keyframes
4268            .iter()
4269            .find(|k| k.name == "fadeB")
4270            .cloned()
4271            .unwrap_or_else(|| empty_kf("fadeB"));
4272        let mut eng = AnimationEngine::new();
4273        let spec_a = AnimationSpec {
4274            name: "fadeA".into(),
4275            duration_ms: 1000.0,
4276            timing: TimingFunction::Linear,
4277            delay_ms: 0.0,
4278            iterations: f32::INFINITY,
4279            direction: "normal".into(),
4280            fill: "none".into(),
4281            play_state: "running".into(),
4282        };
4283        eng.start_animation("c", spec_a, kf_a, 0.0);
4284        // "fadeA" から "fadeB" へ切り替え。current_names=["fadeB"] で同期すると
4285        // "fadeA" は取り消され、"fadeB" だけが残るはず。
4286        eng.retain_animation_names("c", &[alloc::string::String::from("fadeB")]);
4287        let spec_b = AnimationSpec {
4288            name: "fadeB".into(),
4289            duration_ms: 1000.0,
4290            timing: TimingFunction::Linear,
4291            delay_ms: 0.0,
4292            iterations: 1.0,
4293            direction: "normal".into(),
4294            fill: "forwards".into(),
4295            play_state: "running".into(),
4296        };
4297        eng.start_animation("c", spec_b, kf_b, 500.0);
4298        eng.tick(500.0);
4299        let props: alloc::vec::Vec<&alloc::string::String> = eng
4300            .computed
4301            .iter()
4302            .filter(|(e, _, _)| e == "c")
4303            .map(|(_, p, _)| p)
4304            .collect();
4305        // "fadeA" が残っていれば opacity への書き込みが重複/矛盾するが、ここでは
4306        // 単に "fadeB"(fadeA除去後の唯一のアニメ)由来の値が1件だけ出ることを確認する。
4307        anim_check("eng-anim-name-switch-count", props.len().to_string(), "1");
4308    }
4309
4310    // --- AnimationEngine 統合: @keyframes の内容が更新されたら追従すること ---
4311    // 以前は同名アニメが既に存在する場合、`start_animation` が渡された新しい `keyframes`
4312    // を破棄し `play_state` しか更新しなかったため、`<style>` 再注入等で同名の @keyframes
4313    // ルールの中身が変わっても古い内容のまま固定されるバグがあった。
4314    {
4315        let sheet_v1 = parse_css("@keyframes mv3 { from { left: 0px; } to { left: 100px; } }");
4316        let kf_v1 = sheet_v1.keyframes[0].clone();
4317        let spec = AnimationSpec {
4318            name: "mv3".into(),
4319            duration_ms: 1000.0,
4320            timing: TimingFunction::Linear,
4321            delay_ms: 0.0,
4322            iterations: f32::INFINITY,
4323            direction: "normal".into(),
4324            fill: "none".into(),
4325            play_state: "running".into(),
4326        };
4327        let mut eng = AnimationEngine::new();
4328        eng.start_animation("d", spec.clone(), kf_v1, 0.0);
4329        eng.tick(500.0);
4330        let v_before = eng
4331            .computed
4332            .iter()
4333            .find(|(e, p, _)| e == "d" && p == "left")
4334            .map(|(_, _, v)| v.clone())
4335            .unwrap_or_default();
4336        anim_check("eng-anim-kf-before-update", v_before, "50px");
4337        // 同名 "mv3" のまま @keyframes の中身だけを差し替えて再度 start_animation を呼ぶ
4338        // (同じ経過時間 t=500ms でも到達点が変わるはず)。
4339        let sheet_v2 = parse_css("@keyframes mv3 { from { left: 0px; } to { left: 400px; } }");
4340        let kf_v2 = sheet_v2.keyframes[0].clone();
4341        eng.start_animation("d", spec, kf_v2, 500.0);
4342        eng.tick(500.0);
4343        let v_after = eng
4344            .computed
4345            .iter()
4346            .find(|(e, p, _)| e == "d" && p == "left")
4347            .map(|(_, _, v)| v.clone())
4348            .unwrap_or_default();
4349        anim_check("eng-anim-kf-after-update", v_after, "200px");
4350    }
4351
4352    // --- AnimationEngine 統合: 再ターゲット(進行中に行先変更)---
4353    {
4354        let mut eng = AnimationEngine::new();
4355        let spec = TransitionSpec {
4356            property: "left".into(),
4357            duration_ms: 1000.0,
4358            timing: TimingFunction::Linear,
4359            delay_ms: 0.0,
4360        };
4361        eng.start_transition("b", "left", "0px", "100px", spec.clone(), 0.0);
4362        eng.tick(500.0); // 50px
4363                         // 進行中に 50px→0px へ再ターゲット(now=500)。
4364        eng.start_transition("b", "left", "50px", "0px", spec, 500.0);
4365        // 同一 (要素,プロパティ) は1本だけ。
4366        eng.tick(1000.0); // 再ターゲットから500ms → 25px
4367        let v = eng
4368            .computed
4369            .iter()
4370            .find(|(e, p, _)| e == "b" && p == "left")
4371            .map(|(_, _, v)| v.clone())
4372            .unwrap_or_default();
4373        anim_check("eng-retarget", v, "25px");
4374    }
4375
4376    let total = total + anim_total;
4377    (passed, total)
4378}