use bevy_ecs::prelude::*;
use bevy_ecs::schedule::{Schedule, SingleThreadedExecutor};
use windows::Win32::Foundation::{HINSTANCE, HWND};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DestroyWindow, GW_OWNER, GetWindow, WINDOW_EX_STYLE, WINDOW_STYLE, WS_POPUP,
};
use windows::core::{PCWSTR, w};
use super::{OwnerEstablishFailed, establish_owner_links, measure_window_above};
use crate::ecs::test_support::capture_under_filter;
use crate::ecs::window::ZOrderPairStrategy;
use crate::ecs::window::{KeepDirectlyAbove, OwnerLink, ReassertZOrder, WindowHandle};
const SIGNOFF_DIRECTIVES: &str = "info,wintf::ecs::window::zorder_pair=debug";
const OWNER_ESTABLISHED_TAG: &str = "[zorder-pair] owner-established";
const OWNER_ESTABLISH_FAILED_TAG: &str = "[zorder-pair] owner-establish-failed";
const SKIP_TAG: &str = "[zorder-pair] skip";
fn create_test_window(title: PCWSTR) -> HWND {
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
unsafe {
let hinstance = GetModuleHandleW(None).expect("GetModuleHandleW");
CreateWindowExW(
WINDOW_EX_STYLE(0),
w!("Static"),
title,
WINDOW_STYLE(WS_POPUP.0),
0,
0,
0,
0,
None,
None,
Some(hinstance.into()),
None,
)
.expect("CreateWindowExW should create a hidden test window")
}
}
fn destroy_test_window(hwnd: HWND) {
unsafe {
let _ = DestroyWindow(hwnd);
}
}
fn owner_of(hwnd: HWND) -> Option<HWND> {
unsafe { GetWindow(hwnd, GW_OWNER) }.ok()
}
fn fake_hwnd(v: usize) -> HWND {
HWND(v as *mut _)
}
fn spawn_window(world: &mut World, hwnd: HWND) -> Entity {
world
.spawn(WindowHandle {
hwnd,
instance: HINSTANCE::default(),
})
.id()
}
fn spawn_handleless_window(world: &mut World) -> Entity {
world.spawn_empty().id()
}
fn declare_pair(world: &mut World, balloon: Entity, character: Entity) {
world
.entity_mut(balloon)
.insert(KeepDirectlyAbove { peer: character });
}
fn retrigger_handle(world: &mut World, entity: Entity) {
let handle = *world
.entity(entity)
.get::<WindowHandle>()
.expect("再付与の対象はハンドルを持っているはず");
world.entity_mut(entity).remove::<WindowHandle>();
world.entity_mut(entity).insert(handle);
}
fn establish_schedule() -> Schedule {
let mut schedule = Schedule::default();
schedule.set_executor(SingleThreadedExecutor::new());
schedule.add_systems(establish_owner_links);
schedule
}
fn lines_with<'a>(out: &'a str, tag: &str) -> Vec<&'a str> {
out.lines().filter(|l| l.contains(tag)).collect()
}
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()
);
}
#[test]
fn establishes_owner_once_and_never_again() {
let owner_hwnd = create_test_window(w!("wintf-zorder-establish-once-char"));
let owned_hwnd = create_test_window(w!("wintf-zorder-establish-once-balloon"));
assert_eq!(
owner_of(owned_hwnd),
None,
"生成直後のバルーン窓に owner は無いはず(ここが最初から有りなら以降の検査は無意味)"
);
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let character = spawn_window(&mut world, owner_hwnd);
let balloon = spawn_window(&mut world, owned_hwnd);
declare_pair(&mut world, balloon, character);
let mut schedule = establish_schedule();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
retrigger_handle(&mut world, character);
schedule.run(&mut world);
});
assert_line_count(&out, OWNER_ESTABLISHED_TAG, 1, "確立は 1 回だけ");
assert_line_count(&out, OWNER_ESTABLISH_FAILED_TAG, 0, "失敗はしていない");
assert_eq!(
owner_of(owned_hwnd),
Some(owner_hwnd),
"OS 側でバルーン窓の owner がキャラ窓になっているはず"
);
assert_eq!(
world.entity(balloon).get::<OwnerLink>().copied(),
Some(OwnerLink { owner_hwnd }),
"確立の記録はバルーン窓側へ付き、owner のハンドルを持つ"
);
assert!(
world.entity(balloon).contains::<ReassertZOrder>(),
"初期隣接を確定させる再断行の要求が入っているはず"
);
assert!(
world.entity(character).get::<OwnerLink>().is_none(),
"確立の記録はキャラ窓側には付かない(切離しの対象はバルーン窓)"
);
destroy_test_window(owned_hwnd);
destroy_test_window(owner_hwnd);
}
#[test]
fn establishes_on_the_cycle_the_second_handle_arrives() {
let owner_hwnd = create_test_window(w!("wintf-zorder-establish-late-char"));
let owned_hwnd = create_test_window(w!("wintf-zorder-establish-late-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let character = spawn_handleless_window(&mut world);
let balloon = spawn_window(&mut world, owned_hwnd);
declare_pair(&mut world, balloon, character);
let mut schedule = establish_schedule();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
assert!(
world.entity(balloon).get::<OwnerLink>().is_none(),
"片方のハンドルが無い巡では確立しない"
);
world.entity_mut(character).insert(WindowHandle {
hwnd: owner_hwnd,
instance: HINSTANCE::default(),
});
schedule.run(&mut world);
schedule.run(&mut world);
});
assert_line_count(&out, OWNER_ESTABLISHED_TAG, 1, "揃った巡で 1 回だけ張る");
assert_line_count(
&out,
SKIP_TAG,
1,
"揃っていない巡は理由つきで 1 回だけ見送られる",
);
let skip = lines_with(&out, SKIP_TAG)[0];
assert!(
skip.contains("reason=HandleMissing"),
"見送りの理由は「まだハンドルが付いていない」であるはず: {skip}"
);
assert_eq!(owner_of(owned_hwnd), Some(owner_hwnd));
destroy_test_window(owned_hwnd);
destroy_test_window(owner_hwnd);
}
#[test]
fn skips_handle_missing_pair_while_the_complete_pair_establishes() {
let owner_hwnd = create_test_window(w!("wintf-zorder-establish-mixed-char"));
let owned_hwnd = create_test_window(w!("wintf-zorder-establish-mixed-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let character = spawn_window(&mut world, owner_hwnd);
let balloon = spawn_window(&mut world, owned_hwnd);
declare_pair(&mut world, balloon, character);
let half_character = spawn_handleless_window(&mut world);
let half_balloon = spawn_window(&mut world, fake_hwnd(0x00B1));
declare_pair(&mut world, half_balloon, half_character);
let mut schedule = establish_schedule();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
schedule.run(&mut world);
});
assert_line_count(&out, OWNER_ESTABLISHED_TAG, 1, "確立するのは揃ったペアだけ");
assert_line_count(
&out,
SKIP_TAG,
1,
"見送りはハンドルが付いた巡に 1 回だけ(毎巡は記録しない)",
);
let skip = lines_with(&out, SKIP_TAG)[0];
assert!(
skip.contains(&format!("entity={half_balloon:?}")),
"見送りの記録は欠けたペアのバルーン窓を指すはず: {skip}"
);
assert!(skip.contains("reason=HandleMissing"), "{skip}");
assert!(
world.entity(half_balloon).get::<OwnerLink>().is_none(),
"欠けたペアには確立の記録が付かない"
);
assert!(
!world.entity(half_balloon).contains::<ReassertZOrder>(),
"欠けたペアには再断行の要求も入らない"
);
assert!(world.entity(balloon).get::<OwnerLink>().is_some());
destroy_test_window(owned_hwnd);
destroy_test_window(owner_hwnd);
}
#[test]
fn skips_with_peer_missing_when_the_declared_peer_is_gone() {
let owner_hwnd = create_test_window(w!("wintf-zorder-establish-gone-char"));
let owned_hwnd = create_test_window(w!("wintf-zorder-establish-gone-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let character = spawn_window(&mut world, owner_hwnd);
let balloon = spawn_window(&mut world, owned_hwnd);
declare_pair(&mut world, balloon, character);
let gone_character = spawn_handleless_window(&mut world);
let orphan_balloon = spawn_window(&mut world, fake_hwnd(0x00B2));
declare_pair(&mut world, orphan_balloon, gone_character);
world.entity_mut(gone_character).despawn();
let mut schedule = establish_schedule();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
schedule.run(&mut world);
});
assert_line_count(&out, OWNER_ESTABLISHED_TAG, 1, "確立するのは生きたペアだけ");
assert_line_count(
&out,
SKIP_TAG,
1,
"見送りはハンドルが付いた巡に 1 回だけ(毎巡は記録しない)",
);
let skip = lines_with(&out, SKIP_TAG)[0];
assert!(skip.contains("reason=PeerMissing"), "{skip}");
assert!(
world.entity(orphan_balloon).get::<OwnerLink>().is_none(),
"相手が居ないペアには何も書き込まない(要件 1.5)"
);
assert!(
!world.entity(orphan_balloon).contains::<ReassertZOrder>(),
"相手が居ないペアには再断行の要求も入らない(要件 1.5)"
);
destroy_test_window(owned_hwnd);
destroy_test_window(owner_hwnd);
}
#[test]
fn skips_with_strategy_disabled_under_explicit_maintenance() {
let owner_hwnd = create_test_window(w!("wintf-zorder-establish-planb-char"));
let owned_hwnd = create_test_window(w!("wintf-zorder-establish-planb-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::ExplicitMaintenance);
let character = spawn_window(&mut world, owner_hwnd);
let balloon = spawn_window(&mut world, owned_hwnd);
declare_pair(&mut world, balloon, character);
let mut schedule = establish_schedule();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
schedule.run(&mut world);
});
assert_line_count(
&out,
SKIP_TAG,
1,
"見送りはハンドルが付いた巡に 1 回だけ(毎巡は記録しない)",
);
let skip = lines_with(&out, SKIP_TAG)[0];
assert!(skip.contains("reason=StrategyDisabled"), "{skip}");
assert_line_count(&out, OWNER_ESTABLISHED_TAG, 0, "案 B では owner を張らない");
assert_eq!(
owner_of(owned_hwnd),
None,
"案 B では OS 側にも owner が付かない"
);
assert!(world.entity(balloon).get::<OwnerLink>().is_none());
assert!(!world.entity(balloon).contains::<ReassertZOrder>());
destroy_test_window(owned_hwnd);
destroy_test_window(owner_hwnd);
}
#[test]
fn records_failure_once_and_does_not_retry_on_the_same_handles() {
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let character = spawn_window(&mut world, HWND::default());
let balloon = spawn_window(&mut world, HWND::default());
declare_pair(&mut world, balloon, character);
let mut schedule = establish_schedule();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
retrigger_handle(&mut world, character);
schedule.run(&mut world);
});
assert_line_count(
&out,
OWNER_ESTABLISH_FAILED_TAG,
1,
"失敗の記録は 1 本だけ(同じハンドルでは再試行しない)",
);
assert_line_count(&out, OWNER_ESTABLISHED_TAG, 0, "失敗したので確立はしない");
assert!(
world.entity(balloon).get::<OwnerLink>().is_none(),
"失敗したペアに確立の記録は付かない"
);
assert!(
!world.entity(balloon).contains::<ReassertZOrder>(),
"失敗したペアに再断行の要求は入らない"
);
assert!(
world.entity(balloon).contains::<OwnerEstablishFailed>(),
"再試行を抑える印が付いているはず"
);
let out_again = capture_under_filter(SIGNOFF_DIRECTIVES, || {
world.entity_mut(balloon).remove::<OwnerEstablishFailed>();
retrigger_handle(&mut world, character);
schedule.run(&mut world);
});
assert_line_count(
&out_again,
OWNER_ESTABLISH_FAILED_TAG,
1,
"印を外せば同じトリガで再び試み、再び失敗が記録される",
);
}
#[test]
fn measure_window_above_maps_failure_to_absence_and_records_it() {
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
assert_eq!(measure_window_above(HWND::default()), None);
});
assert!(
!out.trim().is_empty(),
"走査の失敗は記録に残るはず(黙って None へ潰さない)"
);
}