use bevy_ecs::prelude::*;
use windows::Win32::Foundation::HWND;
use windows::core::HRESULT;
use super::{
ChainSegment, ChainSkipReason, DetachReason, UNKNOWN, absent_line, applied_line,
chain_record_tags, link_failed_line, linked_line, log_group_applied, log_group_rejected,
preserved_group_tags, rejected_line, settled_line, skipped_line, unlink_failed_line,
unlinked_line,
};
use crate::ecs::test_support::capture_under_filter;
const SIGNOFF_DIRECTIVES: &str = "info,wintf::ecs::window::zorder_chain=debug";
const DEFAULT_DIRECTIVES: &str = "info";
const LOG_TARGET: &str = "wintf::ecs::window::zorder_chain_diag";
fn fake_hwnd(v: usize) -> HWND {
HWND(v as *mut _)
}
fn two_entities() -> (Entity, Entity) {
let mut world = World::new();
(world.spawn_empty().id(), world.spawn_empty().id())
}
fn fake_error() -> windows::core::Error {
windows::core::Error::from(HRESULT(0x8007_0005u32 as i32))
}
fn field<'a>(line: &'a str, key: &str) -> &'a str {
let needle = format!(" {key}=");
assert_eq!(
line.matches(needle.as_str()).count(),
1,
"`{key}=` が 1 行にちょうど 1 回ではない: {line}"
);
line.split(needle.as_str())
.nth(1)
.expect("直前で 1 回あることを確かめた")
.split_whitespace()
.next()
.unwrap_or_default()
}
fn only_line_with<'a>(out: &'a str, tag: &str) -> &'a str {
let found: Vec<&str> = out.lines().filter(|l| l.contains(tag)).collect();
assert_eq!(found.len(), 1, "`{tag}` の行がちょうど 1 本ではない: {out}");
found[0]
}
fn code_only(source: &str) -> String {
source
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_seven_chain_tags_share_one_prefix_and_are_all_distinct() {
let tags = chain_record_tags();
assert_eq!(tags.len(), 7, "タグの本数が 7 種でない: {tags:?}");
for tag in tags {
assert!(
tag.starts_with("[zorder-chain] "),
"鎖系の冠を持たないタグがある: {tag}"
);
}
for (i, tag) in tags.iter().enumerate() {
for other in &tags[i + 1..] {
assert_ne!(tag, other, "タグ語が重なっている: {tag}");
}
}
assert_eq!(
tags,
[
"[zorder-chain] linked",
"[zorder-chain] unlinked",
"[zorder-chain] settled",
"[zorder-chain] absent",
"[zorder-chain] skipped",
"[zorder-chain] link-failed",
"[zorder-chain] unlink-failed",
]
);
}
#[test]
fn the_two_preserved_group_tags_kept_their_exact_spelling_through_the_move() {
assert_eq!(
preserved_group_tags(),
["[zorder-group] applied", "[zorder-group] rejected"]
);
}
#[test]
fn no_extra_tag_hides_outside_the_two_rosters() {
let code = code_only(include_str!("zorder_chain_diag.rs"));
let chain_literals = code.matches("\"[zorder-chain] ").count();
assert_eq!(
chain_literals,
chain_record_tags().len(),
"モジュール本体の鎖タグ文字列が名簿と数で合わない"
);
let group_literals = code.matches("\"[zorder-group] ").count();
assert_eq!(
group_literals,
preserved_group_tags().len(),
"モジュール本体の保全タグ文字列が名簿と数で合わない"
);
assert_eq!(chain_literals, 7, "鎖タグの走査そのものが空振りしている");
assert_eq!(group_literals, 2, "保全タグの走査そのものが空振りしている");
for tag in chain_record_tags()
.iter()
.chain(preserved_group_tags().iter())
{
assert!(
code.contains(&format!("\"{tag}\"")),
"名簿の `{tag}` がコード本文のリテラルとして見つからない"
);
}
}
fn production_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let entries = std::fs::read_dir(dir)
.unwrap_or_else(|e| panic!("木を歩けない(検査が空振りする): {} — {e}", dir.display()));
for entry in entries {
let path = entry.expect("ディレクトリ項目が読めない").path();
if path.is_dir() {
production_rs_files(&path, out);
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.ends_with(".rs")
|| name.ends_with("_tests.rs")
|| name.ends_with("test_support.rs")
{
continue;
}
out.push(path);
}
}
fn production_files_containing(needle: &str) -> Vec<String> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
production_rs_files(&root, &mut files);
assert!(
files.len() >= 20,
"走査が {} 件しか歩いていない(木の歩き方が壊れている)",
files.len()
);
let mut found: Vec<String> = files
.iter()
.filter(|path| {
let src = std::fs::read_to_string(path).expect("本番ファイルが読めない");
code_only(&src).contains(needle)
})
.map(|path| {
path.strip_prefix(&root)
.expect("走査の根の下に無い")
.to_string_lossy()
.replace('\\', "/")
})
.collect();
found.sort();
found
}
#[test]
fn the_preserved_tags_have_exactly_one_home_in_this_crate() {
for tag in preserved_group_tags() {
let homes = production_files_containing(&format!("\"{tag}\""));
assert_eq!(
homes,
vec!["ecs/window/zorder_chain_diag.rs".to_string()],
"`{tag}` の住処が 1 つではない(grep 対象が分裂する)"
);
}
assert_eq!(
production_files_containing("\"[zorder-chain] linked\""),
vec!["ecs/window/zorder_chain_diag.rs".to_string()],
"走査そのものが壊れている(既知の鎖タグを見つけられない)"
);
for retired in [
"[zorder-group] fix",
"[zorder-group] skip",
"[zorder-group] verify-failed",
] {
assert!(
production_files_containing(&format!("\"{retired}\"")).is_empty(),
"退役した語彙 `{retired}` が本番コードに残っている"
);
}
}
#[test]
fn the_linked_line_carries_segment_both_ends_and_the_position_in_the_chain() {
let (owned, owner) = two_entities();
let line = linked_line(
Some(ChainSegment::Group(0)),
owned,
owner,
Some(fake_hwnd(0xA0)),
Some(fake_hwnd(0xB0)),
1,
4,
);
assert_eq!(
line,
format!(
"[zorder-chain] linked segment=g0 owned={owned:?} owner={owner:?} \
owned_hwnd=0xA0 owner_hwnd=0xB0 pos=1/4"
)
);
}
#[test]
fn a_tail_segment_renders_as_the_tail_word_not_a_group_number() {
let (owned, owner) = two_entities();
let line = linked_line(
Some(ChainSegment::Tail),
owned,
owner,
Some(fake_hwnd(0x10)),
None,
3,
3,
);
assert_eq!(field(&line, "segment"), "tail", "{line}");
assert_eq!(field(&line, "owner_hwnd"), UNKNOWN, "{line}");
assert_eq!(field(&line, "pos"), "3/3", "{line}");
}
#[test]
fn a_segmentless_call_still_carries_the_field_filled_with_the_sentinel() {
let (owned, owner) = two_entities();
let linked = linked_line(None, owned, owner, Some(fake_hwnd(0xA0)), None, 1, 2);
assert_eq!(field(&linked, "segment"), UNKNOWN, "{linked}");
assert!(
linked.contains(&format!("segment={UNKNOWN} owned={owned:?}")),
"番兵の欄と被所有側の間に別の欄が割り込んでいる: {linked}"
);
let failed = link_failed_line(None, Some(fake_hwnd(0xA0)), None, &fake_error());
assert_eq!(field(&failed, "segment"), UNKNOWN, "{failed}");
assert!(
failed.contains(&format!("segment={UNKNOWN} owned_hwnd=0xA0")),
"番兵の欄と被所有側のハンドルの間に別の欄が割り込んでいる: {failed}"
);
}
#[test]
fn the_segment_field_sits_immediately_before_the_owned_field() {
let (owned, owner) = two_entities();
let line = linked_line(
Some(ChainSegment::Group(2)),
owned,
owner,
Some(fake_hwnd(0xA0)),
Some(fake_hwnd(0xB0)),
2,
4,
);
assert!(
line.contains(&format!("segment=g2 owned={owned:?}")),
"区間と被所有側の間に別の欄が割り込んでいる: {line}"
);
assert!(
line.contains("owned_hwnd=0xA0 owner_hwnd=0xB0 pos=2/4"),
"ハンドル 2 欄と位置の並びが崩れている: {line}"
);
}
#[test]
fn the_entity_fields_never_contain_whitespace() {
let (owned, owner) = two_entities();
let rendered = format!("{owned:?}{owner:?}");
assert!(
!rendered.contains(char::is_whitespace),
"Entity の Debug 表現に空白が混じっている: {rendered}"
);
}
#[test]
fn the_unlinked_line_carries_the_reason_beside_the_handles() {
let (owned, _) = two_entities();
let line = unlinked_line(
Some(ChainSegment::Group(1)),
owned,
Some(fake_hwnd(0xC0)),
Some(fake_hwnd(0xD0)),
DetachReason::Rechain,
);
assert_eq!(
line,
format!(
"[zorder-chain] unlinked segment=g1 owned={owned:?} \
owned_hwnd=0xC0 owner_hwnd=0xD0 reason=Rechain"
)
);
}
#[test]
fn an_unknown_segment_renders_the_sentinel_instead_of_dropping_the_field() {
let (owned, _) = two_entities();
let line = unlinked_line(None, owned, None, None, DetachReason::Teardown);
assert_eq!(field(&line, "segment"), UNKNOWN, "{line}");
assert_eq!(field(&line, "owned_hwnd"), UNKNOWN, "{line}");
assert_eq!(field(&line, "owner_hwnd"), UNKNOWN, "{line}");
assert_eq!(field(&line, "reason"), "Teardown", "{line}");
}
#[test]
fn the_four_detach_reasons_render_as_four_distinct_words() {
let (owned, _) = two_entities();
let mut seen: Vec<String> = Vec::new();
for reason in [
DetachReason::Teardown,
DetachReason::Rechain,
DetachReason::Departing,
DetachReason::Diverged,
] {
let line = unlinked_line(None, owned, None, None, reason);
let word = field(&line, "reason").to_string();
assert!(!word.is_empty(), "理由語が読めない: {line}");
assert!(!seen.contains(&word), "理由語 `{word}` が他と重なっている");
seen.push(word);
}
assert_eq!(seen, ["Teardown", "Rechain", "Departing", "Diverged"]);
}
#[test]
fn the_settled_line_carries_the_declaration_and_the_measurement_together() {
let line = settled_line(
Some(fake_hwnd(0xA0)),
Some(fake_hwnd(0xB0)),
&[fake_hwnd(0xA0), fake_hwnd(0xB0), fake_hwnd(0xC0)],
&[fake_hwnd(0xA0), fake_hwnd(0xB0), fake_hwnd(0xC0)],
Some(true),
);
assert_eq!(
line,
"[zorder-chain] settled nudged_hwnd=0xA0 insert_after=0xB0 \
declared=0xA0,0xB0,0xC0 measured=0xA0,0xB0,0xC0 nudge_ok=true"
);
}
#[test]
fn the_four_signoff_fields_of_the_settled_line_stay_adjacent_in_this_order() {
let line = settled_line(
Some(fake_hwnd(0x1)),
Some(fake_hwnd(0x2)),
&[fake_hwnd(0x1)],
&[fake_hwnd(0x2)],
Some(false),
);
assert!(
line.contains("nudged_hwnd=0x1 insert_after=0x2 declared=0x1 measured=0x2"),
"サインオフが読む 4 欄の隣接が崩れている: {line}"
);
assert!(
line.ends_with(" nudge_ok=false"),
"後押しの成否が 4 欄の後ろに載っていない: {line}"
);
}
#[test]
fn an_unnudged_pass_renders_the_sentinel_in_every_settled_field() {
let line = settled_line(None, None, &[], &[], None);
assert_eq!(
line,
"[zorder-chain] settled nudged_hwnd=- insert_after=- declared=- measured=- nudge_ok=-"
);
}
#[test]
fn the_absent_line_names_the_group_and_the_declared_element() {
assert_eq!(
absent_line(3, "b0"),
"[zorder-chain] absent group_id=3 element=b0"
);
}
#[test]
fn an_empty_element_name_renders_the_sentinel() {
let line = absent_line(0, "");
assert_eq!(line, "[zorder-chain] absent group_id=0 element=-");
}
#[test]
fn the_skipped_line_always_carries_its_reason() {
assert_eq!(
skipped_line(ChainSkipReason::NoChange),
"[zorder-chain] skipped reason=NoChange"
);
}
#[test]
fn the_three_skip_reasons_render_as_three_distinct_words() {
let words: Vec<String> = [
ChainSkipReason::TooFewPresent,
ChainSkipReason::NoChange,
ChainSkipReason::HandleMissing,
]
.into_iter()
.map(|reason| field(&skipped_line(reason), "reason").to_string())
.collect();
assert_eq!(words, ["TooFewPresent", "NoChange", "HandleMissing"]);
}
#[test]
fn the_link_failed_line_carries_the_segment_and_both_handles() {
let line = link_failed_line(
Some(ChainSegment::Group(1)),
Some(fake_hwnd(0xA0)),
Some(fake_hwnd(0xB0)),
&fake_error(),
);
assert_eq!(
line,
"[zorder-chain] link-failed segment=g1 owned_hwnd=0xA0 owner_hwnd=0xB0 \
error=HRESULT(0x80070005)"
);
}
#[test]
fn the_unlink_failed_line_carries_the_owned_handle_and_the_error() {
let line = unlink_failed_line(Some(fake_hwnd(0xC0)), &fake_error());
assert_eq!(
line,
"[zorder-chain] unlink-failed owned_hwnd=0xC0 error=HRESULT(0x80070005)"
);
}
#[test]
fn the_applied_line_prefixes_the_ledger_text_with_the_tag_and_nothing_else() {
assert_eq!(
applied_line("group_id=1 members=2"),
"[zorder-group] applied group_id=1 members=2"
);
}
#[test]
fn the_boot_time_applied_line_keeps_group_id_between_action_and_source() {
let line = applied_line("action=set group_id=7 source=Descript members=b0,s0 normalized=-");
assert_eq!(
line,
"[zorder-group] applied action=set group_id=7 source=Descript \
members=b0,s0 normalized=-"
);
assert!(
!line.contains("action=set source=Descript"),
"欄の割り込みを連結文字列へ丸めた形が成立してしまっている: {line}"
);
}
#[test]
fn the_rejected_line_carries_the_reason_and_the_received_tokens() {
assert_eq!(
rejected_line("CrossGroupRedesignation", "1,0"),
"[zorder-group] rejected reason=CrossGroupRedesignation tokens=1,0"
);
}
#[test]
fn an_empty_reason_or_token_list_renders_the_sentinel() {
let line = rejected_line("", "");
assert_eq!(field(&line, "reason"), UNKNOWN, "{line}");
assert_eq!(field(&line, "tokens"), UNKNOWN, "{line}");
assert_eq!(line, "[zorder-group] rejected reason=- tokens=-");
}
#[test]
fn whitespace_in_caller_supplied_text_is_folded_so_the_cut_still_works() {
let line = rejected_line("Unparsable Token", "b1, s1 , x");
assert_eq!(field(&line, "reason"), "Unparsable_Token", "{line}");
assert_eq!(field(&line, "tokens"), "b1,_s1_,_x", "{line}");
}
#[test]
fn a_rejection_is_recorded_at_warn_level_and_survives_the_default_level() {
let signoff = capture_under_filter(SIGNOFF_DIRECTIVES, || {
log_group_applied("group_id=1 members=2");
log_group_rejected("ModeMixed", "b1,0");
});
let line = only_line_with(&signoff, "[zorder-group] rejected");
assert!(line.contains("WARN"), "拒否が warn 水準でない: {line}");
assert!(
line.contains(LOG_TARGET),
"grep 対象の出力先が module path 既定でない: {line}"
);
assert_eq!(field(line, "reason"), "ModeMixed", "{line}");
assert_eq!(field(line, "tokens"), "b1,0", "{line}");
assert!(
signoff.contains("[zorder-group] applied"),
"サインオフの指定が前方一致で本モジュールを点灯させていない: {signoff}"
);
let default = capture_under_filter(DEFAULT_DIRECTIVES, || {
log_group_applied("group_id=1 members=2");
log_group_rejected("ModeMixed", "b1,0");
});
assert!(
default.contains("[zorder-group] rejected"),
"既定運転で拒否が読めない(黙って捨てられている): {default}"
);
assert!(
!default.contains("[zorder-group] applied"),
"診断専用の受理が既定水準へ漏れている(水準の区別を見ていない): {default}"
);
}
#[test]
fn the_six_pair_tags_survive_verbatim_and_are_not_impersonated_here() {
let pair_src = include_str!("zorder_pair_diag.rs");
let here = code_only(include_str!("zorder_chain_diag.rs"));
for tag in [
"[zorder-pair] owner-established",
"[zorder-pair] fix",
"[zorder-pair] skip",
"[zorder-pair] verify-failed",
"[zorder-pair] owner-establish-failed",
"[zorder-pair] sink-observed",
] {
assert!(
pair_src.contains(&format!("\"{tag}\"")),
"既存ペア機構のタグ `{tag}` が逐語で見つからない(要件 9.5)"
);
}
assert!(
!here.contains("[zorder-pair]"),
"鎖系の記録がペア機構の語彙を名乗っている"
);
}