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, IsWindow, WINDOW_EX_STYLE, WINDOW_STYLE,
WS_POPUP,
};
use windows::core::{PCWSTR, w};
use super::apply_zorder_pair_maintenance;
use crate::api::{clear_window_owner, set_window_owner};
use crate::ecs::test_support::capture_under_filter;
use crate::ecs::window::{
KeepDirectlyAbove, OwnerLink, ReassertZOrder, WindowHandle, ZOrderPairStrategy,
establish_owner_links,
};
const SIGNOFF_DIRECTIVES: &str = "info,wintf::ecs::window::zorder_pair=debug";
const DETACHED_MARK: &str = "owner を切り離しました";
const DETACH_FAILED_MARK: &str = "owner の切離しに失敗しました";
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 is_alive(hwnd: HWND) -> bool {
unsafe { IsWindow(Some(hwnd)) }.as_bool()
}
fn hwnd_hex(hwnd: HWND) -> String {
format!("0x{:X}", hwnd.0 as usize)
}
fn spawn_window(world: &mut World, hwnd: HWND) -> Entity {
world
.spawn(WindowHandle {
hwnd,
instance: HINSTANCE::default(),
})
.id()
}
fn pair_schedule() -> Schedule {
let mut schedule = Schedule::default();
schedule.set_executor(SingleThreadedExecutor::new());
schedule.add_systems((establish_owner_links, apply_zorder_pair_maintenance).chain());
schedule
}
fn establish_pair(
world: &mut World,
schedule: &mut Schedule,
character_hwnd: HWND,
balloon_hwnd: HWND,
) -> (Entity, Entity) {
let character = spawn_window(world, character_hwnd);
let balloon = spawn_window(world, balloon_hwnd);
world
.entity_mut(balloon)
.insert(KeepDirectlyAbove { peer: character });
schedule.run(world);
assert_eq!(
owner_of(balloon_hwnd),
Some(character_hwnd),
"前提: 確立系がバルーン窓の owner をキャラ窓にしているはず"
);
assert!(
world.entity(balloon).contains::<OwnerLink>(),
"前提: 張れた事実の記録が付いているはず"
);
world.entity_mut(balloon).remove::<ReassertZOrder>();
(balloon, character)
}
fn lines_with<'a>(out: &'a str, mark: &str) -> Vec<&'a str> {
out.lines().filter(|l| l.contains(mark)).collect()
}
fn assert_line_count(out: &str, mark: &str, expected: usize, what: &str) {
let found = lines_with(out, mark);
assert_eq!(
found.len(),
expected,
"{what}: `{mark}` の行は {expected} 本のはずが {} 本だった\n---捕捉全文---\n{out}",
found.len()
);
}
#[test]
fn owner_cascade_destroys_the_owned_window_unless_it_is_detached() {
let owner = create_test_window(w!("wintf-zorder-detach-cascade-owner"));
let owned = create_test_window(w!("wintf-zorder-detach-cascade-owned"));
set_window_owner(owned, owner).expect("set_window_owner must succeed");
assert!(is_alive(owned), "前提: 破棄前は両方とも実在する");
destroy_test_window(owner);
assert!(
!is_alive(owned),
"owner 窓の破棄は被 owner 窓を巻き込む(これが要件 5.9 の出所)"
);
let owner = create_test_window(w!("wintf-zorder-detach-cascade-owner-2"));
let owned = create_test_window(w!("wintf-zorder-detach-cascade-owned-2"));
set_window_owner(owned, owner).expect("set_window_owner must succeed");
clear_window_owner(owned).expect("clear_window_owner must succeed");
destroy_test_window(owner);
assert!(
is_alive(owned),
"切り離してあれば owner 窓の破棄に巻き込まれない"
);
destroy_test_window(owned);
}
#[test]
fn character_first_despawn_detaches_the_owner_and_spares_the_balloon() {
let character_hwnd = create_test_window(w!("wintf-zorder-detach-char-first-character"));
let balloon_hwnd = create_test_window(w!("wintf-zorder-detach-char-first-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let mut schedule = pair_schedule();
let (balloon, character) =
establish_pair(&mut world, &mut schedule, character_hwnd, balloon_hwnd);
world.entity_mut(character).despawn();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
});
assert_line_count(&out, DETACHED_MARK, 1, "切離しの記録は 1 本だけ");
assert_line_count(&out, DETACH_FAILED_MARK, 0, "切離しは成功しているはず");
let detached = lines_with(&out, DETACHED_MARK)[0];
assert!(
detached.contains(&format!("entity={balloon:?}")),
"記録は owner を外した窓(バルーン窓)を指すはず: {detached}"
);
assert!(
detached.contains(&format!("peer={character:?}")),
"記録は消えた相手(キャラ窓)を指すはず: {detached}"
);
assert!(
detached.contains(&format!("owned_hwnd={}", hwnd_hex(balloon_hwnd))),
"記録には owner を外した窓のハンドルが載るはず: {detached}"
);
assert!(
detached.contains(&format!("owner_hwnd={}", hwnd_hex(character_hwnd))),
"記録には外した owner のハンドルが載るはず: {detached}"
);
assert!(
detached.contains("peer_state=despawned"),
"記録には相手が実体ごと消えたことが載るはず: {detached}"
);
assert_eq!(
owner_of(balloon_hwnd),
None,
"OS 側でも owner が外れているはず"
);
assert!(
!world.entity(balloon).contains::<OwnerLink>(),
"張れた事実の記録は切離しとともに消えるはず(毎巡の再実行を残さない)"
);
destroy_test_window(character_hwnd);
assert!(!is_alive(character_hwnd), "キャラ窓は破棄されたはず");
assert!(
is_alive(balloon_hwnd),
"切り離してあるのでバルーン窓は巻き込まれないはず(残る窓は消えない)"
);
destroy_test_window(balloon_hwnd);
assert!(!is_alive(balloon_hwnd));
}
#[test]
fn peer_losing_its_handle_also_detaches_the_owner() {
let character_hwnd = create_test_window(w!("wintf-zorder-detach-handle-removed-character"));
let balloon_hwnd = create_test_window(w!("wintf-zorder-detach-handle-removed-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let mut schedule = pair_schedule();
let (balloon, character) =
establish_pair(&mut world, &mut schedule, character_hwnd, balloon_hwnd);
world.entity_mut(character).remove::<WindowHandle>();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
});
assert_line_count(&out, DETACHED_MARK, 1, "切離しの記録は 1 本だけ");
let detached = lines_with(&out, DETACHED_MARK)[0];
assert!(
detached.contains(&format!("entity={balloon:?}")),
"記録は owner を外した窓を指すはず: {detached}"
);
assert!(
detached.contains("peer_state=handle-removed"),
"記録には相手のハンドルが外れた形として載るはず(実体の消滅とは区別する): {detached}"
);
assert_eq!(
owner_of(balloon_hwnd),
None,
"OS 側でも owner が外れているはず"
);
destroy_test_window(character_hwnd);
assert!(is_alive(balloon_hwnd), "残る窓は巻き込まれない");
destroy_test_window(balloon_hwnd);
}
#[test]
fn balloon_first_destruction_leaves_the_character_and_processing_continues() {
let character_hwnd = create_test_window(w!("wintf-zorder-detach-balloon-first-character"));
let balloon_hwnd = create_test_window(w!("wintf-zorder-detach-balloon-first-balloon"));
let other_character_hwnd = create_test_window(w!("wintf-zorder-detach-balloon-first-other-c"));
let other_balloon_hwnd = create_test_window(w!("wintf-zorder-detach-balloon-first-other-b"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let mut schedule = pair_schedule();
let (balloon, _character) =
establish_pair(&mut world, &mut schedule, character_hwnd, balloon_hwnd);
let (other_balloon, other_character) = establish_pair(
&mut world,
&mut schedule,
other_character_hwnd,
other_balloon_hwnd,
);
world.entity_mut(balloon).despawn();
destroy_test_window(balloon_hwnd);
world.entity_mut(other_character).despawn();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
});
assert_line_count(
&out,
DETACHED_MARK,
1,
"切離しが走るのは相手を失った側だけ(バルーン先行では対象が居ない)",
);
assert!(
lines_with(&out, DETACHED_MARK)[0].contains(&format!("entity={other_balloon:?}")),
"その 1 本は対照のスコープを指すはず"
);
assert_line_count(&out, DETACH_FAILED_MARK, 0, "失敗は起きていない");
assert!(!is_alive(balloon_hwnd), "バルーン窓は破棄されたはず");
assert!(
is_alive(character_hwnd),
"被 owner 窓の破棄は owner 窓を巻き込まない(残る窓は消えない)"
);
destroy_test_window(character_hwnd);
assert!(!is_alive(character_hwnd));
destroy_test_window(other_character_hwnd);
assert!(
is_alive(other_balloon_hwnd),
"対照のスコープも切離し済みゆえ巻き込まれないはず"
);
destroy_test_window(other_balloon_hwnd);
}
#[test]
fn detachment_touches_only_the_pair_whose_peer_disappeared() {
let a_character_hwnd = create_test_window(w!("wintf-zorder-detach-scope-a-character"));
let a_balloon_hwnd = create_test_window(w!("wintf-zorder-detach-scope-a-balloon"));
let b_character_hwnd = create_test_window(w!("wintf-zorder-detach-scope-b-character"));
let b_balloon_hwnd = create_test_window(w!("wintf-zorder-detach-scope-b-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let mut schedule = pair_schedule();
let (a_balloon, a_character) =
establish_pair(&mut world, &mut schedule, a_character_hwnd, a_balloon_hwnd);
let (b_balloon, b_character) =
establish_pair(&mut world, &mut schedule, b_character_hwnd, b_balloon_hwnd);
assert_eq!(owner_of(a_balloon_hwnd), Some(a_character_hwnd));
assert_eq!(owner_of(b_balloon_hwnd), Some(b_character_hwnd));
assert_eq!(
owner_of(a_character_hwnd),
None,
"キャラ窓は誰の被 owner でもない"
);
assert_eq!(
owner_of(b_character_hwnd),
None,
"キャラ窓は誰の被 owner でもない"
);
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
world.entity_mut(a_character).despawn();
schedule.run(&mut world);
schedule.run(&mut world);
assert_eq!(
owner_of(a_balloon_hwnd),
None,
"スコープ A のバルーン窓は切り離されたはず"
);
assert_eq!(
owner_of(b_balloon_hwnd),
Some(b_character_hwnd),
"スコープ B の owner には一切触れていないはず"
);
destroy_test_window(a_character_hwnd);
assert!(is_alive(a_balloon_hwnd), "同スコープの残る窓は消えない");
assert!(is_alive(b_character_hwnd), "他スコープの窓は消えない");
assert!(is_alive(b_balloon_hwnd), "他スコープの窓は消えない");
destroy_test_window(a_balloon_hwnd);
world.entity_mut(b_character).despawn();
schedule.run(&mut world);
});
assert_line_count(
&out,
DETACHED_MARK,
2,
"切離しはスコープごとに 1 度ずつ(繰り返しも取りこぼしも無い)",
);
let detached = lines_with(&out, DETACHED_MARK);
assert!(
detached[0].contains(&format!("entity={a_balloon:?}")),
"1 本目はスコープ A: {}",
detached[0]
);
assert!(
detached[1].contains(&format!("entity={b_balloon:?}")),
"2 本目はスコープ B: {}",
detached[1]
);
assert_line_count(&out, DETACH_FAILED_MARK, 0, "失敗は起きていない");
assert_eq!(
owner_of(b_balloon_hwnd),
None,
"スコープ B も自分の番で切り離される"
);
destroy_test_window(b_character_hwnd);
assert!(
is_alive(b_balloon_hwnd),
"スコープ B のバルーン窓も巻き込まれない"
);
destroy_test_window(b_balloon_hwnd);
}
#[test]
fn failed_detachment_is_recorded_once_and_processing_continues() {
let character_hwnd = create_test_window(w!("wintf-zorder-detach-failure-character"));
let balloon_hwnd = create_test_window(w!("wintf-zorder-detach-failure-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let mut schedule = pair_schedule();
let (_balloon, character) =
establish_pair(&mut world, &mut schedule, character_hwnd, balloon_hwnd);
let broken_character = world.spawn_empty().id();
let broken_balloon = spawn_window(&mut world, HWND::default());
world.entity_mut(broken_balloon).insert((
KeepDirectlyAbove {
peer: broken_character,
},
OwnerLink {
owner_hwnd: HWND::default(),
},
));
world.entity_mut(character).despawn();
world.entity_mut(broken_character).despawn();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
schedule.run(&mut world);
});
assert_line_count(
&out,
DETACH_FAILED_MARK,
1,
"失敗は 1 度だけ記録される(毎巡繰り返さない)",
);
assert_line_count(
&out,
DETACHED_MARK,
1,
"対照のペアは同じ捕捉窓で確かに切り離される",
);
let failed = lines_with(&out, DETACH_FAILED_MARK)[0];
assert!(
failed.contains(&format!("entity={broken_balloon:?}")),
"失敗の記録は当該の窓を指すはず: {failed}"
);
assert!(
failed.contains("error="),
"失敗の記録には理由が載るはず: {failed}"
);
assert!(
!world.entity(broken_balloon).contains::<OwnerLink>(),
"失敗しても記録は外す(同じ失敗を毎巡繰り返さない)"
);
destroy_test_window(character_hwnd);
assert!(
is_alive(balloon_hwnd),
"対照のペアは切り離されているので巻き込まれない(処理は続いている)"
);
destroy_test_window(balloon_hwnd);
}
#[test]
fn owner_link_without_a_handle_is_skipped_with_a_reason() {
let character_hwnd = create_test_window(w!("wintf-zorder-detach-handleless-character"));
let balloon_hwnd = create_test_window(w!("wintf-zorder-detach-handleless-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let mut schedule = pair_schedule();
let (_balloon, character) =
establish_pair(&mut world, &mut schedule, character_hwnd, balloon_hwnd);
let handleless_character = world.spawn_empty().id();
let handleless_balloon = world
.spawn((
KeepDirectlyAbove {
peer: handleless_character,
},
OwnerLink {
owner_hwnd: HWND::default(),
},
))
.id();
world.entity_mut(character).despawn();
world.entity_mut(handleless_character).despawn();
let out = capture_under_filter(SIGNOFF_DIRECTIVES, || {
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=HandleMissing"),
"見送りの理由は「ハンドルが無い」であるはず: {skip}"
);
assert!(
skip.contains(&format!("entity={handleless_balloon:?}")),
"見送りの記録は当該の窓を指すはず: {skip}"
);
assert_line_count(&out, DETACHED_MARK, 1, "対照のペアは確かに切り離される");
assert_line_count(&out, DETACH_FAILED_MARK, 0, "Win32 は呼んでいない");
assert!(
!world.entity(handleless_balloon).contains::<OwnerLink>(),
"外す先の無い記録は残さない(毎巡の再評価を作らない)"
);
destroy_test_window(character_hwnd);
destroy_test_window(balloon_hwnd);
}