wintf 0.0.3

Windows Tategaki Framework - Rust UI library with Japanese vertical text support
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
//! 鎖の適用系——**偽ハンドルで 1 巡を丸ごと踏む**決定論的テスト
//! (要件 6.3/6.4/7.1/7.2/8.2/8.3/9.1/9.2/9.3/11.1/14.1/14.2/14.5)。
//!
//! 実機も実ディスプレイも要らない。`HWND` は Win32 へ 1 度も渡さない偽の値であり、
//! 実行環境への 5 つの窓口(所有者の読み取り・撤去・付与・後押し・前面走査)は
//! [`double`](super::double) の台本つき替え玉が受け止める。よって
//! 「**何を・どの順で**実行環境へ頼んだか」をそのまま主張できる。
//!
//! # 檻は単一スレッドの実行器を明示する
//!
//! 記録の捕捉ハーネス `capture_under_filter` はスレッドローカルの差し替えであり、
//! 替え玉の台本も同じくスレッドローカルである。Bevy の**既定の実行器は多スレッド**なので、
//! そのまま使うと system が別スレッドで走り、記録が 1 行も拾えないうえ台本にも当たらず、
//! 検査が空虚に緑になる(既知の盲点=`zorder_pair_establish_tests.rs:142-152`)。
//! よって [`chain_schedule`] は `SingleThreadedExecutor` を明示する。
//! 「出ないこと」を主張する窓には、同じ窓に**確かに出る記録**を併置して本数で固定する。
//!
//! # 「無い」の主張には必ず対照を置く
//!
//! 促しの呼び出しの不在(要件 14.2)はソースの字面で固定するが、走査が壊れていれば
//! 「無い」は恒真になる。よって⑴同じ針が**実際に促している既存経路**では当たること、
//! ⑵**呼ぶ側の実装へ差し替えた本文**では当たること、の 2 つを併置する。

use std::collections::{HashMap, HashSet};

use bevy_ecs::prelude::*;
use bevy_ecs::schedule::{Schedule, SingleThreadedExecutor};
use windows::Win32::Foundation::{HINSTANCE, HWND};

use super::apply_zorder_chain;
use super::double::{self, Call, Script, with_script};
use crate::ecs::test_support::capture_under_filter;
use crate::ecs::window::{
    ChainPlan, ChainSegment, CrossEdge, CrossOwnerLink, WindowHandle, ZOrderChainPlan,
};

/// 実機サインオフが用いる `RUST_LOG` の鎖側の指定そのもの。
const SIGNOFF_DIRECTIVES: &str = "info,wintf::ecs::window::zorder_chain=debug";

const LINKED: &str = "[zorder-chain] linked";
const UNLINKED: &str = "[zorder-chain] unlinked";
const SETTLED: &str = "[zorder-chain] settled";
const SKIPPED: &str = "[zorder-chain] skipped";
const LINK_FAILED: &str = "[zorder-chain] link-failed";

// ===========================================================================
// 道具立て
// ===========================================================================

/// Win32 へは渡さない偽ハンドル(値としてのみ扱う)。
fn fake_hwnd(v: usize) -> HWND {
    HWND(v as *mut _)
}

/// 適用系だけを載せた 1 巡分の schedule(**単一スレッドの実行器を明示**)。
fn chain_schedule() -> Schedule {
    let mut schedule = Schedule::default();
    schedule.set_executor(SingleThreadedExecutor::new());
    schedule.add_systems(apply_zorder_chain);
    schedule
}

/// 窓ハンドル付きの entity を作る。
fn spawn_window(world: &mut World, hwnd: HWND) -> Entity {
    world
        .spawn(WindowHandle {
            hwnd,
            instance: HINSTANCE::default(),
        })
        .id()
}

/// 望む繋ぎ 1 本(区間つき)。
fn edge(owned: Entity, owner: Entity, segment: ChainSegment) -> CrossEdge {
    CrossEdge {
        owned,
        owner,
        segment,
    }
}

/// 帳簿(本 spec が張った繋ぎ)を被所有側へ据える。区間は既定でグループ 0。
fn record_link(
    world: &mut World,
    owned: Entity,
    owner: Entity,
    owned_hwnd: HWND,
    owner_hwnd: HWND,
) {
    record_link_in(
        world,
        owned,
        owner,
        owned_hwnd,
        owner_hwnd,
        ChainSegment::Group(0),
    );
}

/// 区間を明示して帳簿を据える。
fn record_link_in(
    world: &mut World,
    owned: Entity,
    owner: Entity,
    owned_hwnd: HWND,
    owner_hwnd: HWND,
    segment: ChainSegment,
) {
    world.entity_mut(owned).insert(CrossOwnerLink {
        owner,
        owned_hwnd,
        owner_hwnd,
        segment,
    });
}

/// 望む鎖を公開する(`dirty` を立てる=内容が変わった巡)。
fn publish(world: &mut World, members: Vec<Entity>, cross_edges: Vec<CrossEdge>) {
    world.insert_resource(ZOrderChainPlan {
        chain: Some(ChainPlan {
            members,
            cross_edges,
            absent: Vec::new(),
        }),
        dirty: true,
    });
}

/// 台本つきで 1 巡回し、捕捉した記録と実行環境の呼び出しの記録を返す。
fn run_once(world: &mut World, script: Script) -> (String, Script) {
    let mut schedule = chain_schedule();
    let (out, used) = with_script(script, || {
        capture_under_filter(SIGNOFF_DIRECTIVES, || {
            schedule.run(world);
        })
    });
    (out, used)
}

/// 指定タグの行をすべて取り出す。
fn lines_with<'a>(out: &'a str, tag: &str) -> Vec<&'a str> {
    out.lines().filter(|l| l.contains(tag)).collect()
}

/// 指定タグの行がちょうど `expected` 本であることを固定する。
fn assert_line_count(out: &str, tag: &str, expected: usize, what: &str) {
    let found = lines_with(out, tag);
    assert_eq!(
        found.len(),
        expected,
        "{what}: `{tag}` は {expected} 本のはずが {} 本\n---捕捉全文---\n{out}",
        found.len()
    );
}

/// 1 行から `key=value` を切り出す。
fn field<'a>(line: &'a str, key: &str) -> &'a str {
    let needle = format!("{key}=");
    let start = line
        .find(&needle)
        .unwrap_or_else(|| panic!("`{key}=` が行に無い: {line}"))
        + needle.len();
    line[start..].split_whitespace().next().unwrap_or("")
}

/// 説明文(`//` で始まる行)を落とし、コードだけの本文を返す。
fn code_only(source: &str) -> String {
    source
        .lines()
        .filter(|line| !line.trim_start().starts_with("//"))
        .collect::<Vec<_>>()
        .join("\n")
}

/// 実行環境の窓口を 1 度でも呼んだか。
fn touched_runtime(calls: &[Call]) -> bool {
    !calls.is_empty()
}

// ===========================================================================
// 変化が無ければ 1 命令も出さない(要件 6.4/14.2)
// ===========================================================================

/// 印が立っていない巡は、実行環境を 1 度も呼ばず、記録も 1 行も出さない。
///
/// 捕捉が死んでいれば「出ない」は恒真なので、**同じ捕捉窓で確かに出る記録**を併置し、
/// 鎖の記録だけがゼロであることを本数で固定する。
#[test]
fn an_unchanged_plan_issues_nothing_at_all() {
    let mut world = World::new();
    let front = spawn_window(&mut world, fake_hwnd(0x10));
    let back = spawn_window(&mut world, fake_hwnd(0x20));
    record_link(&mut world, front, back, fake_hwnd(0x10), fake_hwnd(0x20));
    world.insert_resource(ZOrderChainPlan {
        chain: Some(ChainPlan {
            members: vec![front, back],
            cross_edges: vec![edge(front, back, ChainSegment::Group(0))],
            absent: Vec::new(),
        }),
        dirty: false,
    });

    let mut schedule = chain_schedule();
    let (out, used) = with_script(Script::default(), || {
        capture_under_filter(SIGNOFF_DIRECTIVES, || {
            // 対照: 同じ捕捉窓に確かに現れる 1 行(捕捉そのものが生きている証拠)。
            tracing::debug!("{}", "[zorder-chain] canary from the same capture window");
            schedule.run(&mut world);
        })
    });

    assert!(
        out.contains("canary from the same capture window"),
        "捕捉窓が死んでいる(対照の 1 行すら拾えていない): {out}"
    );
    assert!(
        !touched_runtime(&used.calls),
        "印が立っていない巡で実行環境を呼んでいる: {:?}",
        used.calls
    );
    for tag in [LINKED, UNLINKED, SETTLED, SKIPPED, LINK_FAILED] {
        assert_line_count(&out, tag, 0, "印が立っていない巡");
    }
}

/// 印は立っているが出す操作が無い巡は、理由つきの見送りを残して実行環境を呼ばない。
///
/// 黙って諦めない(要件 8.3)——「同じ内容の再公開」と「記録が出ていない」を、
/// 事後に区別できる形にしておく。
#[test]
fn a_republished_but_identical_plan_records_a_reason_and_touches_nothing() {
    let mut world = World::new();
    let front = spawn_window(&mut world, fake_hwnd(0x10));
    let back = spawn_window(&mut world, fake_hwnd(0x20));
    record_link(&mut world, front, back, fake_hwnd(0x10), fake_hwnd(0x20));
    publish(
        &mut world,
        vec![front, back],
        vec![edge(front, back, ChainSegment::Group(0))],
    );

    let (out, used) = run_once(&mut world, Script::default());

    assert!(
        !touched_runtime(&used.calls),
        "出す操作が無いのに実行環境を呼んでいる: {:?}",
        used.calls
    );
    assert_line_count(&out, SKIPPED, 1, "同じ内容の再公開");
    assert_eq!(
        field(lines_with(&out, SKIPPED)[0], "reason"),
        "NoChange",
        "見送りの理由が読めない"
    );
    assert_line_count(&out, SETTLED, 0, "操作が走っていない巡に後押しは出ない");
}

// ===========================================================================
// 撤去が先・付与が後(§12.4 の実測手順)
// ===========================================================================

/// 張り替えの巡では、**すべての撤去がすべての付与より前に**実行環境へ届く。
///
/// 混ぜると、外す前に張った窓が一時的に 2 つの所有者を主張する形になり、途中状態で
/// 鎖が分岐する。ここでは替え玉が積んだ呼び出しの列そのものを見て順序を固定する。
#[test]
fn every_detach_reaches_the_runtime_before_every_attach() {
    let mut world = World::new();
    let a = spawn_window(&mut world, fake_hwnd(0x10));
    let b = spawn_window(&mut world, fake_hwnd(0x20));
    let c = spawn_window(&mut world, fake_hwnd(0x30));
    // 現況: a は b に所有されている。望む鎖: a → c → b(a の相手が入れ替わる)。
    record_link(&mut world, a, b, fake_hwnd(0x10), fake_hwnd(0x20));
    publish(
        &mut world,
        vec![a, c, b],
        vec![
            edge(a, c, ChainSegment::Group(0)),
            edge(c, b, ChainSegment::Group(0)),
        ],
    );

    let mut script = Script::default();
    script.owner_of.insert(0x10, 0x20);

    let (out, used) = run_once(&mut world, script);

    let last_detach = used
        .calls
        .iter()
        .rposition(|c| matches!(c, Call::ClearOwner(_)))
        .expect("撤去が 1 度も走っていない");
    let first_attach = used
        .calls
        .iter()
        .position(|c| matches!(c, Call::SetOwner(_, _)))
        .expect("付与が 1 度も走っていない");
    assert!(
        last_detach < first_attach,
        "付与のあとに撤去が並んでいる(途中状態が壊れる手順): {:?}",
        used.calls
    );
    // 撤去の前に必ず現況を読んでいる(照合を省いていない)。
    let read = used
        .calls
        .iter()
        .position(|c| matches!(c, Call::ReadOwner(0x10)))
        .expect("外す前に現況を読んでいない");
    assert!(read < last_detach, "読む前に外している: {:?}", used.calls);

    assert_line_count(&out, UNLINKED, 1, "張り替え");
    assert_line_count(&out, LINKED, 2, "張り替え");
}

// ===========================================================================
// 帳簿と現況が食い違えば実行環境を呼ばない(§12.6・要件 8.3)
// ===========================================================================

/// 現況が帳簿と違う繋ぎは、**実行環境を呼ばず**に帳簿だけを落とし、理由を記録する。
///
/// 照合を省くと、既存のペア機構が張り替えた繋ぎを誤って外し、バルーンがキャラ窓の
/// 直上という不変条件(要件 6.3)を壊す。
#[test]
fn a_diverged_ledger_entry_is_dropped_without_calling_the_runtime() {
    let mut world = World::new();
    let a = spawn_window(&mut world, fake_hwnd(0x10));
    let b = spawn_window(&mut world, fake_hwnd(0x20));
    record_link(&mut world, a, b, fake_hwnd(0x10), fake_hwnd(0x20));
    // 解除(望む繋ぎはゼロ)。帳簿の 1 件は撤去の対象になる。
    world.insert_resource(ZOrderChainPlan {
        chain: None,
        dirty: true,
    });

    let mut script = Script::default();
    // 現況の所有者は帳簿の控え(0x20)と違う——他機構が張り替えた姿。
    script.owner_of.insert(0x10, 0x99);

    let (out, used) = run_once(&mut world, script);

    assert!(
        used.calls.contains(&Call::ReadOwner(0x10)),
        "外す前に現況を読んでいない: {:?}",
        used.calls
    );
    assert!(
        !used.calls.iter().any(|c| matches!(c, Call::ClearOwner(_))),
        "食い違っているのに実行環境へ撤去を頼んでいる: {:?}",
        used.calls
    );
    assert_line_count(&out, UNLINKED, 1, "食い違い");
    assert_eq!(
        field(lines_with(&out, UNLINKED)[0], "reason"),
        "Diverged",
        "食い違いの理由が読めない"
    );
    // 帳簿は落ちる(同じ判断を毎巡繰り返さない)。
    world.flush();
    assert!(
        world.entity(a).get::<CrossOwnerLink>().is_none(),
        "食い違った帳簿が残っている"
    );
    // 実行環境を 1 度も書いていないので後押しも出ない。
    assert_line_count(&out, SETTLED, 0, "書いていない巡");
}

// ===========================================================================
// 失敗した 1 本だけを飛ばす(要件 8.2)
// ===========================================================================

/// 付与に失敗した繋ぎは**その 1 本だけ**飛ばし、残りは張る。同じ巡で再試行しない。
#[test]
fn a_failed_attach_skips_only_that_edge_and_the_rest_are_linked() {
    let mut world = World::new();
    let a = spawn_window(&mut world, fake_hwnd(0x10));
    let b = spawn_window(&mut world, fake_hwnd(0x20));
    let c = spawn_window(&mut world, fake_hwnd(0x30));
    publish(
        &mut world,
        vec![a, b, c],
        vec![
            edge(a, b, ChainSegment::Group(0)),
            edge(b, c, ChainSegment::Group(0)),
        ],
    );

    let mut script = Script::default();
    script.set_fails = HashSet::from([0x10usize]);

    let (out, used) = run_once(&mut world, script);

    let attempts: Vec<&Call> = used
        .calls
        .iter()
        .filter(|c| matches!(c, Call::SetOwner(_, _)))
        .collect();
    assert_eq!(
        attempts.len(),
        2,
        "同じ巡で再試行している、ないし残りを張っていない: {:?}",
        used.calls
    );
    assert_line_count(&out, LINK_FAILED, 1, "1 本だけ失敗");
    assert_line_count(&out, LINKED, 1, "残りは張る");
    assert_eq!(
        field(lines_with(&out, LINKED)[0], "owned_hwnd"),
        "0x20",
        "失敗した側を成功として記録している"
    );

    // 失敗した繋ぎは帳簿に載らない(載せると次巡の照合が嘘の一致を出す)。
    world.flush();
    assert!(
        world.entity(a).get::<CrossOwnerLink>().is_none(),
        "張れなかった繋ぎが帳簿に載っている"
    );
    assert!(
        world.entity(b).get::<CrossOwnerLink>().is_some(),
        "張れた繋ぎが帳簿に載っていない"
    );
}

/// 窓ハンドルがまだ取れていない繋ぎは、理由つきの見送りとして 1 本だけ飛ばす。
#[test]
fn an_edge_without_handles_is_skipped_with_a_reason_and_the_rest_are_linked() {
    let mut world = World::new();
    let a = spawn_window(&mut world, fake_hwnd(0x10));
    let b = spawn_window(&mut world, fake_hwnd(0x20));
    // ハンドル未取得の窓(実体はあるが OS のハンドルがまだ無い)。
    let pending = world.spawn_empty().id();
    publish(
        &mut world,
        vec![a, b, pending],
        vec![
            edge(a, b, ChainSegment::Group(0)),
            edge(b, pending, ChainSegment::Group(0)),
        ],
    );

    let (out, used) = run_once(&mut world, Script::default());

    assert_line_count(&out, SKIPPED, 1, "ハンドル未取得");
    assert_eq!(
        field(lines_with(&out, SKIPPED)[0], "reason"),
        "HandleMissing",
        "見送りの理由が読めない"
    );
    assert_line_count(&out, LINKED, 1, "残りは張る");
    assert_eq!(
        used.calls
            .iter()
            .filter(|c| matches!(c, Call::SetOwner(_, _)))
            .count(),
        1,
        "ハンドルの無い窓へ書き込もうとしている: {:?}",
        used.calls
    );
}

// ===========================================================================
// 破棄に先立って外す(要件 7.2)
// ===========================================================================

/// 所有側の窓が去った繋ぎは、望む鎖の変化を待たずに外れる。
///
/// 所有関係を張ったまま所有側を壊すと OS の破棄カスケードが被所有側を巻き込む。
/// 外す契機は「望む鎖が変わったこと」ではなく「窓が去ること」なので、印を待たない。
#[test]
fn a_link_whose_owner_is_leaving_is_detached_before_the_window_is_destroyed() {
    let mut world = World::new();
    let owned = spawn_window(&mut world, fake_hwnd(0x10));
    let owner = spawn_window(&mut world, fake_hwnd(0x20));
    record_link(&mut world, owned, owner, fake_hwnd(0x10), fake_hwnd(0x20));
    world.insert_resource(ZOrderChainPlan {
        chain: Some(ChainPlan {
            members: vec![owned, owner],
            cross_edges: vec![edge(owned, owner, ChainSegment::Group(0))],
            absent: Vec::new(),
        }),
        // 印は立っていない——それでも去る窓の切離しは走る。
        dirty: false,
    });
    // 所有側の窓が去る(実体ごと消える)。
    world.despawn(owner);

    let mut script = Script::default();
    script.owner_of.insert(0x10, 0x20);

    let (out, used) = run_once(&mut world, script);

    assert!(
        used.calls.contains(&Call::ClearOwner(0x10)),
        "去る窓の繋ぎを外していない: {:?}",
        used.calls
    );
    assert_line_count(&out, UNLINKED, 1, "去る窓");
    assert_eq!(
        field(lines_with(&out, UNLINKED)[0], "reason"),
        "Departing",
        "去る窓の理由が読めない"
    );
    world.flush();
    assert!(
        world.entity(owned).get::<CrossOwnerLink>().is_none(),
        "去った相手の帳簿が残っている"
    );
}

/// 相手が健在なら切離しは走らない(生きている窓の関係には触れない)。
#[test]
fn a_link_whose_owner_is_alive_is_left_untouched() {
    let mut world = World::new();
    let owned = spawn_window(&mut world, fake_hwnd(0x10));
    let owner = spawn_window(&mut world, fake_hwnd(0x20));
    record_link(&mut world, owned, owner, fake_hwnd(0x10), fake_hwnd(0x20));
    world.insert_resource(ZOrderChainPlan {
        chain: None,
        dirty: false,
    });

    let (_out, used) = run_once(&mut world, Script::default());

    assert!(
        !touched_runtime(&used.calls),
        "健在な相手の繋ぎに触っている: {:?}",
        used.calls
    );
}

// ===========================================================================
// 区間の帰属(要件 9.1「どのグループの」)
// ===========================================================================

/// **同じ 1 巡**の中で、グループの繋ぎと後方配置の繋ぎが別々の `segment=` で出る。
///
/// 要件 9.1 は「どのグループの窓を、どの窓のすぐ手前に位置づけたか」を求める。区間は
/// 連結された `members` からは復元できない(グループの境目が列に残らない)ので、
/// 望む鎖が [`CrossEdge::segment`] として運んでくる。ここでは**その値が記録の字面へ
/// 素通しで届いていること**を、区間の異なる 2 種類の繋ぎを 1 巡に混ぜて固定する。
/// 帰属を落とす(全部を番兵や 1 種類へ潰す)変異は、この本数と字面の両方で赤くなる。
#[test]
fn one_pass_records_a_group_edge_and_a_tail_edge_under_different_segments() {
    let mut world = World::new();
    let a = spawn_window(&mut world, fake_hwnd(0x10));
    let b = spawn_window(&mut world, fake_hwnd(0x20));
    let c = spawn_window(&mut world, fake_hwnd(0x30));
    let d = spawn_window(&mut world, fake_hwnd(0x40));
    // 手前 2 枚がグループ 0、奥 2 枚が後方配置。継ぎ目の繋ぎは手前側の区間を名乗る。
    publish(
        &mut world,
        vec![a, b, c, d],
        vec![
            edge(a, b, ChainSegment::Group(0)),
            edge(b, c, ChainSegment::Group(0)),
            edge(c, d, ChainSegment::Tail),
        ],
    );

    let (out, _used) = run_once(&mut world, Script::default());

    assert_line_count(&out, LINKED, 3, "区間の混在した 1 巡");
    let by_owned = |hwnd: &str| -> &str {
        let found: Vec<&str> = lines_with(&out, LINKED)
            .into_iter()
            .filter(|l| field(l, "owned_hwnd") == hwnd)
            .collect();
        assert_eq!(
            found.len(),
            1,
            "owned_hwnd={hwnd} の繋いだ行が 1 本でない: {out}"
        );
        found[0]
    };

    assert_eq!(field(by_owned("0x10"), "segment"), "g0", "{out}");
    assert_eq!(field(by_owned("0x20"), "segment"), "g0", "{out}");
    assert_eq!(field(by_owned("0x30"), "segment"), "tail", "{out}");

    // 番兵へ潰れていないこと(区間を落とす変異は必ずここに現れる)。
    assert!(
        !lines_with(&out, LINKED)
            .iter()
            .any(|l| field(l, "segment") == "-"),
        "区間が番兵へ潰れている: {out}"
    );
    // 2 種類が実際に共存している(1 種類へ潰す変異も赤になる)。
    let mut kinds: Vec<&str> = lines_with(&out, LINKED)
        .iter()
        .map(|l| field(l, "segment"))
        .collect();
    kinds.sort_unstable();
    kinds.dedup();
    assert_eq!(
        kinds,
        vec!["g0", "tail"],
        "区間が 1 種類へ潰れている: {out}"
    );

    // 欄の並びは字面のまま(間に別の欄が割り込まない=初版 6.3 の罠)。
    assert!(
        by_owned("0x10").contains(&format!("segment=g0 owned={a:?}")),
        "{out}"
    );
    assert!(
        by_owned("0x30").contains(&format!("segment=tail owned={c:?}")),
        "{out}"
    );
}

/// 外した行は、**張った時点で帳簿が控えた区間**を名乗る。
///
/// 撤去が起きる局面(解除・窓の退去)では望む鎖から区間を引けない。控えが無ければ
/// ここは必ず番兵になり、「どのグループの繋ぎが解けたか」が記録から読めなくなる。
#[test]
fn the_unlinked_line_names_the_segment_the_ledger_recorded_when_it_was_linked() {
    let mut world = World::new();
    let a = spawn_window(&mut world, fake_hwnd(0x10));
    let b = spawn_window(&mut world, fake_hwnd(0x20));
    let c = spawn_window(&mut world, fake_hwnd(0x30));
    let d = spawn_window(&mut world, fake_hwnd(0x40));
    record_link_in(
        &mut world,
        a,
        b,
        fake_hwnd(0x10),
        fake_hwnd(0x20),
        ChainSegment::Group(2),
    );
    record_link_in(
        &mut world,
        c,
        d,
        fake_hwnd(0x30),
        fake_hwnd(0x40),
        ChainSegment::Tail,
    );
    // 全解除(望む繋ぎはゼロ)。
    world.insert_resource(ZOrderChainPlan {
        chain: None,
        dirty: true,
    });

    let mut script = Script::default();
    script.owner_of.insert(0x10, 0x20);
    script.owner_of.insert(0x30, 0x40);

    let (out, _used) = run_once(&mut world, script);

    assert_line_count(&out, UNLINKED, 2, "全解除");
    let segments: Vec<(&str, &str)> = lines_with(&out, UNLINKED)
        .into_iter()
        .map(|l| (field(l, "owned_hwnd"), field(l, "segment")))
        .collect();
    assert!(
        segments.contains(&("0x10", "g2")) && segments.contains(&("0x30", "tail")),
        "撤去の区間が帳簿の控えを名乗っていない: {segments:?}
{out}"
    );
}

/// 端点が同じまま区間だけが変わった繋ぎは、実行環境を呼ばずに帳簿の控えだけが変わる。
///
/// 同じ隣り合わせが、後方配置からグループへ(あるいはその逆へ)付け替わることは実際に
/// 起こる。所有関係は変わらないので指令は出さない——それでも控えを放置すると、次に
/// 外したときの記録が古い区間を名乗る。
#[test]
fn a_reattributed_edge_updates_the_ledger_without_calling_the_runtime() {
    let mut world = World::new();
    let a = spawn_window(&mut world, fake_hwnd(0x10));
    let b = spawn_window(&mut world, fake_hwnd(0x20));
    record_link_in(
        &mut world,
        a,
        b,
        fake_hwnd(0x10),
        fake_hwnd(0x20),
        ChainSegment::Tail,
    );
    publish(
        &mut world,
        vec![a, b],
        vec![edge(a, b, ChainSegment::Group(1))],
    );

    let (out, used) = run_once(&mut world, Script::default());

    assert!(
        !touched_runtime(&used.calls),
        "帰属が変わっただけで実行環境を呼んでいる: {:?}",
        used.calls
    );
    assert_line_count(&out, SKIPPED, 1, "帰属だけの変化");
    assert_eq!(field(lines_with(&out, SKIPPED)[0], "reason"), "NoChange");

    world.flush();
    assert_eq!(
        world
            .entity(a)
            .get::<CrossOwnerLink>()
            .expect("帳簿は残る")
            .segment,
        ChainSegment::Group(1),
        "帳簿の区間が古いまま残っている"
    );
}

// ===========================================================================
// 促しの呼び出しを持たない(要件 14.2・14.5)
// ===========================================================================

/// 適用系のソースには、処理の実行を促す呼び出しも遅延キューへの積込も現れない。
///
/// 「無い」の主張は走査が壊れていても緑になるので、⑴同じ針が実際に促している既存経路で
/// 当たること、⑵**呼ぶ側の実装へ差し替えた本文**では当たること、の 2 つを併置する。
#[test]
fn the_apply_system_never_asks_the_runtime_to_keep_ticking() {
    let here = code_only(include_str!("zorder_chain_apply.rs"));

    assert!(
        !here.contains("tick_wake"),
        "適用の側から起床を促している(要件 14.2 が禁じた形)"
    );
    assert!(
        !here.contains("SetWindowPosCommand::enqueue"),
        "後押しが遅延キューへ積まれている(同じ巡で実測できない・要件 9.2)"
    );

    // 対照⑴: 同じ針が、実際に促す/積む既存経路では必ず当たる(走査の空振り検出)。
    assert!(
        code_only(include_str!("command.rs")).contains("tick_wake"),
        "走査そのものが壊れている(起床を促す既存経路を見つけられない)"
    );
    assert!(
        code_only(include_str!("zorder_pair_maintain.rs")).contains("SetWindowPosCommand::enqueue"),
        "走査そのものが壊れている(積む既存経路を見つけられない)"
    );

    // 対照⑵: 呼ぶ側の実装へ差し替えると、同じ走査が確かに赤へ倒れる。
    let swapped = here.replace(
        "unsafe {\n        guarded_set_window_pos(",
        "SetWindowPosCommand::enqueue(cmd.clone());\n    unsafe {\n        guarded_set_window_pos(",
    );
    assert_ne!(swapped, here, "差し替えの当て先が本文から消えている");
    assert!(
        swapped.contains("SetWindowPosCommand::enqueue"),
        "呼ぶ側の実装へ差し替えても走査が当たらない(この検査は空虚に緑である)"
    );
}

// ===========================================================================
// 替え玉そのものの較正(台本が本当に効いているか)
// ===========================================================================

/// 替え玉は据えたときだけ効き、外れれば本番の窓口へ戻る。
///
/// 台本が据わっていない走行で呼び出しが積まれるなら、上の各テストの「呼んでいない」は
/// 何も意味しない。ここで札の生死そのものを固定する。
#[test]
fn the_runtime_double_is_only_in_effect_while_its_script_is_installed() {
    let (seen, used) = with_script(Script::default(), || {
        double::read_owner(fake_hwnd(0x10)).is_some()
    });
    assert!(seen, "台本を据えても替え玉が効いていない");
    assert_eq!(
        used.calls,
        vec![Call::ReadOwner(0x10)],
        "呼び出しが記録されていない"
    );

    assert!(
        double::read_owner(fake_hwnd(0x10)).is_none(),
        "台本を外しても替え玉が居座っている(本番の窓口へ戻っていない)"
    );
}

/// 台本の失敗指定が、それぞれの窓口で確かに失敗として現れる。
#[test]
fn the_scripted_failures_actually_fail() {
    let script = Script {
        read_fails: HashSet::from([0x10usize]),
        clear_fails: HashSet::from([0x20usize]),
        set_fails: HashSet::from([0x30usize]),
        owner_of: HashMap::new(),
        ..Script::default()
    };
    let (results, _used) = with_script(script, || {
        (
            double::read_owner(fake_hwnd(0x10))
                .expect("据わっている")
                .is_err(),
            double::clear_owner(fake_hwnd(0x20))
                .expect("据わっている")
                .is_err(),
            double::set_owner(fake_hwnd(0x30), fake_hwnd(0x40))
                .expect("据わっている")
                .is_err(),
            double::set_owner(fake_hwnd(0x50), fake_hwnd(0x60))
                .expect("据わっている")
                .is_ok(),
        )
    });
    assert_eq!(
        results,
        (true, true, true, true),
        "台本の失敗指定が窓口へ届いていない"
    );
}

/// 後押しと印の持ち越しの檻(足場を共有するので子モジュールとして分ける)。
///
/// 分けてあるのは 1 ファイル 1,000 行未満という本 spec の共通制約のためである
/// (`file_length_guard_test.rs` の例外表は触らない)。
#[path = "zorder_chain_apply_nudge_tests.rs"]
mod zorder_chain_apply_nudge_tests;