use bevy_ecs::prelude::*;
use bevy_ecs::schedule::{Schedule, SingleThreadedExecutor};
use windows::Win32::Foundation::{HINSTANCE, HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE, GetWindowLongPtrW, GetWindowRect,
IsWindow, SET_WINDOW_POS_FLAGS, SW_SHOWNA, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
SWP_NOZORDER, SetWindowPos, ShowWindow, WINDOW_EX_STYLE, WINDOW_STYLE, WS_CHILD,
WS_EX_TOOLWINDOW, WS_POPUP, WS_VISIBLE,
};
use windows::core::{PCWSTR, w};
use super::apply_zorder_pair_maintenance;
use crate::api::{get_window_above, get_window_below, is_window_visible};
use crate::ecs::test_support::capture_under_filter;
use crate::ecs::types::{Point, SizeI};
use crate::ecs::window::{
KeepDirectlyAbove, OwnerLink, ReassertZOrder, SetWindowPosCommand, WindowHandle, WindowPos,
ZOrderPairStrategy, establish_owner_links,
};
const CAPTURE_DIRECTIVES: &str =
"info,wintf::ecs::window::zorder_pair=debug,wintf::ecs::window::command=debug";
const FIX_TAG: &str = "[zorder-pair] fix";
const SKIP_TAG: &str = "[zorder-pair] skip";
const VERIFY_FAILED_TAG: &str = "[zorder-pair] verify-failed";
const SET_WINDOW_POS_TAG: &str = "[guarded_set_window_pos] Calling SetWindowPos";
const DETACHED_MARK: &str = "owner を切り離しました";
fn create_window(
title: PCWSTR,
ex_style: WINDOW_EX_STYLE,
style: WINDOW_STYLE,
parent: Option<HWND>,
) -> HWND {
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
unsafe {
let hinstance = GetModuleHandleW(None).expect("GetModuleHandleW");
CreateWindowExW(
ex_style,
w!("Static"),
title,
style,
0,
0,
0,
0,
parent,
None,
Some(hinstance.into()),
None,
)
.expect("CreateWindowExW should create a test window")
}
}
fn create_test_parent(title: PCWSTR) -> HWND {
let parent = create_window(
title,
WINDOW_EX_STYLE(WS_EX_TOOLWINDOW.0),
WINDOW_STYLE(WS_POPUP.0),
None,
);
unsafe {
let _ = ShowWindow(parent, SW_SHOWNA);
}
parent
}
fn create_test_child(parent: HWND, title: PCWSTR) -> HWND {
create_window(
title,
WINDOW_EX_STYLE(0),
WINDOW_STYLE(WS_CHILD.0 | WS_VISIBLE.0),
Some(parent),
)
}
fn create_test_toplevel(title: PCWSTR) -> HWND {
create_window(title, WINDOW_EX_STYLE(0), WINDOW_STYLE(WS_POPUP.0), None)
}
fn insert_after(hwnd: HWND, after: HWND) {
unsafe {
SetWindowPos(
hwnd,
Some(after),
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
)
.expect("SetWindowPos should reorder self-owned test windows");
}
}
fn destroy_test_window(hwnd: HWND) {
unsafe {
let _ = DestroyWindow(hwnd);
}
}
fn is_alive(hwnd: HWND) -> bool {
unsafe { IsWindow(Some(hwnd)) }.as_bool()
}
fn spawn_window(world: &mut World, hwnd: HWND) -> Entity {
world
.spawn(WindowHandle {
hwnd,
instance: HINSTANCE::default(),
})
.id()
}
fn declare_pair(world: &mut World, balloon: Entity, character: Entity) {
world
.entity_mut(balloon)
.insert(KeepDirectlyAbove { peer: character });
}
fn request_reassert(world: &mut World, balloon: Entity) {
world.entity_mut(balloon).insert(ReassertZOrder::default());
}
fn maintain_schedule() -> Schedule {
let mut schedule = Schedule::default();
schedule.set_executor(SingleThreadedExecutor::new());
schedule.add_systems(apply_zorder_pair_maintenance);
schedule
}
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 issue_control_command(hwnd: HWND) {
SetWindowPosCommand::enqueue(SetWindowPosCommand::new(
hwnd,
0,
0,
0,
0,
SET_WINDOW_POS_FLAGS(SWP_NOMOVE.0 | SWP_NOSIZE.0 | SWP_NOACTIVATE.0 | SWP_NOZORDER.0),
None,
));
SetWindowPosCommand::flush();
}
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()
);
}
#[derive(Debug, PartialEq)]
struct DisplayState {
rect: (i32, i32, i32, i32),
style: isize,
ex_style: isize,
visible: bool,
above: Option<Option<HWND>>,
below: Option<Option<HWND>>,
}
impl DisplayState {
fn measure_with_zorder(hwnd: HWND) -> Self {
let mut state = Self::measure_without_zorder(hwnd);
state.above = Some(get_window_above(hwnd).expect("GetWindow は成功するはず"));
state.below = Some(get_window_below(hwnd).expect("GetWindow は成功するはず"));
state
}
fn measure_without_zorder(hwnd: HWND) -> Self {
let mut rect = RECT::default();
unsafe {
GetWindowRect(hwnd, &mut rect).expect("GetWindowRect は成功するはず");
}
let (style, ex_style) = unsafe {
(
GetWindowLongPtrW(hwnd, GWL_STYLE),
GetWindowLongPtrW(hwnd, GWL_EXSTYLE),
)
};
Self {
rect: (rect.left, rect.top, rect.right, rect.bottom),
style,
ex_style,
visible: is_window_visible(hwnd),
above: None,
below: None,
}
}
}
fn marked_window_pos() -> WindowPos {
WindowPos {
position: Some(Point { x: 137, y: 241 }),
size: Some(SizeI {
width: 320,
height: 240,
}),
..WindowPos::new()
}
}
#[test]
fn peer_loss_leaves_the_surviving_windows_display_state_untouched() {
let parent = create_test_parent(w!("wintf-zorder-survivor-skip"));
let character_hwnd = create_test_child(parent, w!("character"));
let intruder_hwnd = create_test_child(parent, w!("intruder"));
let balloon_hwnd = create_test_child(parent, w!("balloon"));
insert_after(intruder_hwnd, balloon_hwnd);
insert_after(character_hwnd, intruder_hwnd);
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let character = spawn_window(&mut world, character_hwnd);
let balloon = spawn_window(&mut world, balloon_hwnd);
world.entity_mut(balloon).insert(marked_window_pos());
declare_pair(&mut world, balloon, character);
request_reassert(&mut world, balloon);
world.entity_mut(character).despawn();
let before = DisplayState::measure_with_zorder(balloon_hwnd);
assert_eq!(
before.below,
Some(Some(intruder_hwnd)),
"前提: バルーンの 1 つ背後は割り込み窓(=キャラの隣に居ない)"
);
let mut schedule = maintain_schedule();
let out = capture_under_filter(CAPTURE_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
schedule.run(&mut world);
SetWindowPosCommand::flush();
issue_control_command(balloon_hwnd);
});
assert_line_count(&out, SKIP_TAG, 1, "見送りは理由つきで 1 本だけ");
assert!(
lines_with(&out, SKIP_TAG)[0].contains("reason=PeerMissing"),
"見送りの理由は「相手が居ない」であるはず: {out}"
);
assert_line_count(&out, FIX_TAG, 0, "是正していないので是正の記録は出ない");
assert_line_count(&out, VERIFY_FAILED_TAG, 0, "正常系なので error にしない");
assert_line_count(
&out,
SET_WINDOW_POS_TAG,
1,
"残る窓へは書き込まず、対照の 1 本だけが出るはず",
);
assert_eq!(
DisplayState::measure_with_zorder(balloon_hwnd),
before,
"残る窓の表示状態(矩形・スタイル・可視性・重なりの隣)が変わっている(要件 1.5)"
);
assert_eq!(
world.get::<WindowPos>(balloon).copied(),
Some(marked_window_pos()),
"残る窓の WindowPos が書き換えられている(要件 1.5・design Integration Tests 4)"
);
assert!(
!world.entity(balloon).contains::<ReassertZOrder>(),
"一回限りの要求は理由つきの見送りとともに消える"
);
destroy_test_window(parent);
}
#[test]
fn detaching_a_lost_peer_leaves_the_surviving_windows_display_state_untouched() {
let character_hwnd = create_test_toplevel(w!("wintf-zorder-survivor-detach-char"));
let balloon_hwnd = create_test_toplevel(w!("wintf-zorder-survivor-detach-balloon"));
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
let character = spawn_window(&mut world, character_hwnd);
let balloon = spawn_window(&mut world, balloon_hwnd);
world.entity_mut(balloon).insert(marked_window_pos());
declare_pair(&mut world, balloon, character);
let mut schedule = pair_schedule();
schedule.run(&mut world);
assert!(
world.entity(balloon).contains::<OwnerLink>(),
"前提: 確立系が owner を張っているはず"
);
world.entity_mut(balloon).remove::<ReassertZOrder>();
SetWindowPosCommand::flush();
let before = DisplayState::measure_without_zorder(balloon_hwnd);
world.entity_mut(character).despawn();
let out = capture_under_filter(CAPTURE_DIRECTIVES, || {
schedule.run(&mut world);
schedule.run(&mut world);
SetWindowPosCommand::flush();
issue_control_command(balloon_hwnd);
});
assert_line_count(
&out,
DETACHED_MARK,
1,
"切離しは確かに走る(この主張が無いと表示状態の不変は空虚になる)",
);
assert_line_count(
&out,
SET_WINDOW_POS_TAG,
1,
"切離しは窓へ書き込まず、対照の 1 本だけが出るはず",
);
assert_eq!(
DisplayState::measure_without_zorder(balloon_hwnd),
before,
"残る窓の表示状態(矩形・スタイル・可視性)が変わっている(要件 1.5)"
);
assert_eq!(
world.get::<WindowPos>(balloon).copied(),
Some(marked_window_pos()),
"残る窓の WindowPos が書き換えられている(要件 1.5)"
);
assert!(
is_alive(balloon_hwnd),
"残る窓は破棄に巻き込まれない(要件 5.7)"
);
assert!(
!world.entity(balloon).contains::<OwnerLink>(),
"切離しの後に張った事実の記録は残さない(要件 5.9)"
);
destroy_test_window(balloon_hwnd);
destroy_test_window(character_hwnd);
}