use bevy_ecs::prelude::*;
use windows::Win32::Foundation::HWND;
use super::{
ExpectedOrder, InsertSpec, KeepDirectlyAbove, OwnerLink, PairFixDecision, PairObservation,
PairTrigger, ReassertZOrder, SkipReason, ZOrderPairStrategy, decide_pair_fix,
};
fn fake_hwnd(v: usize) -> HWND {
HWND(v as *mut _)
}
fn assert_send_sync<T: Send + Sync + 'static>() {}
#[test]
fn zorder_pair_strategy_default_is_owner_link_without_raise_assist() {
let default = ZOrderPairStrategy::default();
assert_eq!(
default,
ZOrderPairStrategy::OwnerLink {
raise_assist: false
},
"既定は案 A(owner 保証)かつ raise assist 無効であること"
);
assert_ne!(
default,
ZOrderPairStrategy::OwnerLink { raise_assist: true },
"raise assist はゲート G7 FAIL 時のみ有効化される"
);
assert_ne!(
default,
ZOrderPairStrategy::ExplicitMaintenance,
"案 B は実機ゲート FAIL 時のフォールバックであり既定ではない"
);
}
#[test]
fn zorder_pair_strategy_round_trips_as_resource() {
assert_send_sync::<ZOrderPairStrategy>();
let mut world = World::new();
world.insert_resource(ZOrderPairStrategy::default());
assert_eq!(
*world.resource::<ZOrderPairStrategy>(),
ZOrderPairStrategy::OwnerLink {
raise_assist: false
}
);
world.insert_resource(ZOrderPairStrategy::ExplicitMaintenance);
assert_eq!(
*world.resource::<ZOrderPairStrategy>(),
ZOrderPairStrategy::ExplicitMaintenance
);
world.insert_resource(ZOrderPairStrategy::OwnerLink { raise_assist: true });
assert_eq!(
*world.resource::<ZOrderPairStrategy>(),
ZOrderPairStrategy::OwnerLink { raise_assist: true }
);
}
#[test]
fn reassert_zorder_default_is_not_yet_applied_and_applied_stage_round_trips() {
let fresh = ReassertZOrder::default();
assert_eq!(
fresh.pending_verify, None,
"挿入直後は未適用(検証待ちではない)"
);
let expected = ExpectedOrder {
above: fake_hwnd(0x1234),
below: fake_hwnd(0x5678),
};
let applied = ReassertZOrder {
pending_verify: Some(expected),
};
assert_eq!(
applied.pending_verify,
Some(expected),
"適用済み段階は期待隣接をそのまま保持する"
);
assert_ne!(
applied, fresh,
"未適用と適用済み・検証待ちは区別できる(段階が潰れていない)"
);
assert_eq!(expected.above, fake_hwnd(0x1234));
assert_eq!(expected.below, fake_hwnd(0x5678));
assert_ne!(
expected,
ExpectedOrder {
above: fake_hwnd(0x5678),
below: fake_hwnd(0x1234),
},
"上下を入れ替えた期待値は別物として区別される"
);
}
#[test]
fn keep_directly_above_round_trips_on_bare_world() {
assert_send_sync::<KeepDirectlyAbove>();
let mut world = World::new();
let char_window = world.spawn_empty().id();
let balloon_window = world.spawn_empty().id();
world
.entity_mut(balloon_window)
.insert(KeepDirectlyAbove { peer: char_window });
let decl = *world
.entity(balloon_window)
.get::<KeepDirectlyAbove>()
.expect("宣言はバルーン窓側に付く");
assert_eq!(decl.peer, char_window, "peer はキャラ窓 entity を指す");
assert!(
world
.entity(char_window)
.get::<KeepDirectlyAbove>()
.is_none(),
"宣言は片側(バルーン窓)にのみ付く"
);
world
.entity_mut(balloon_window)
.remove::<KeepDirectlyAbove>();
assert!(
world
.entity(balloon_window)
.get::<KeepDirectlyAbove>()
.is_none(),
"除去できる(宣言の撤回が可能)"
);
}
#[test]
fn reassert_zorder_round_trips_on_bare_world() {
assert_send_sync::<ReassertZOrder>();
let mut world = World::new();
let balloon_window = world.spawn_empty().id();
world
.entity_mut(balloon_window)
.insert(ReassertZOrder::default());
assert_eq!(
world
.entity(balloon_window)
.get::<ReassertZOrder>()
.expect("挿入した要求が読める")
.pending_verify,
None
);
let expected = ExpectedOrder {
above: fake_hwnd(0xAAAA),
below: fake_hwnd(0xBBBB),
};
world
.entity_mut(balloon_window)
.get_mut::<ReassertZOrder>()
.expect("要求は生きている")
.pending_verify = Some(expected);
assert_eq!(
world
.entity(balloon_window)
.get::<ReassertZOrder>()
.expect("段階更新後も読める")
.pending_verify,
Some(expected),
"適用済み段階が World 上で保持される"
);
world.entity_mut(balloon_window).remove::<ReassertZOrder>();
assert!(
world
.entity(balloon_window)
.get::<ReassertZOrder>()
.is_none(),
"一回限りの要求ゆえ消費できる"
);
}
#[test]
fn owner_link_round_trips_on_bare_world() {
assert_send_sync::<OwnerLink>();
let mut world = World::new();
let balloon_window = world.spawn_empty().id();
let owner_hwnd = fake_hwnd(0xC0DE);
world
.entity_mut(balloon_window)
.insert(OwnerLink { owner_hwnd });
assert_eq!(
world
.entity(balloon_window)
.get::<OwnerLink>()
.expect("確立記録が読める")
.owner_hwnd,
owner_hwnd,
"切離しに使う owner の HWND がそのまま残る"
);
let unlinked = world.spawn_empty().id();
assert!(
world.entity(unlinked).get::<OwnerLink>().is_none(),
"owner を張っていない窓には記録が無い"
);
world.entity_mut(balloon_window).remove::<OwnerLink>();
assert!(
world.entity(balloon_window).get::<OwnerLink>().is_none(),
"切離し時に記録を除去できる"
);
}
fn balloon() -> HWND {
fake_hwnd(0x00B0)
}
fn character() -> HWND {
fake_hwnd(0x00C0)
}
fn intruder_front() -> HWND {
fake_hwnd(0x00F1)
}
fn intruder_back() -> HWND {
fake_hwnd(0x00F2)
}
const ALL_TRIGGERS: [PairTrigger; 4] = [
PairTrigger::Establish,
PairTrigger::Reassert,
PairTrigger::RaisedAbove,
PairTrigger::RaisedBelow,
];
const ALL_STRATEGIES: [ZOrderPairStrategy; 3] = [
ZOrderPairStrategy::OwnerLink {
raise_assist: false,
},
ZOrderPairStrategy::OwnerLink { raise_assist: true },
ZOrderPairStrategy::ExplicitMaintenance,
];
fn intruded(trigger: PairTrigger, strategy: ZOrderPairStrategy) -> PairObservation {
PairObservation {
trigger,
strategy,
above_alive: true,
below_alive: true,
above_hwnd: Some(balloon()),
below_hwnd: Some(character()),
measured_below_of_above: Some(intruder_back()),
measured_above_of_below: Some(intruder_front()),
measured_above_of_below_is_always_on_top: false,
}
}
fn observation_matrix() -> Vec<(&'static str, PairObservation)> {
let mut matrix = Vec::new();
for trigger in ALL_TRIGGERS {
for strategy in ALL_STRATEGIES {
let base = intruded(trigger, strategy);
matrix.push(("割り込みあり", base.clone()));
matrix.push((
"キャラが最前面(手前に窓が無い)",
PairObservation {
measured_above_of_below: None,
..base.clone()
},
));
matrix.push((
"既に隣接",
PairObservation {
measured_below_of_above: Some(character()),
measured_above_of_below: Some(balloon()),
..base.clone()
},
));
matrix.push((
"キャラの手前の窓が常時最前面(帯の中)",
PairObservation {
measured_above_of_below_is_always_on_top: true,
..base.clone()
},
));
matrix.push((
"実測 2 値の食い違い(キャラの最も近い可視の手前だけがバルーン)",
PairObservation {
measured_above_of_below: Some(balloon()),
..base.clone()
},
));
matrix.push((
"バルーン不在",
PairObservation {
above_alive: false,
..base.clone()
},
));
matrix.push((
"キャラ不在",
PairObservation {
below_alive: false,
..base.clone()
},
));
matrix.push((
"バルーンのハンドル未付与",
PairObservation {
above_hwnd: None,
..base.clone()
},
));
matrix.push((
"キャラのハンドル未付与",
PairObservation {
below_hwnd: None,
..base
},
));
}
}
matrix
}
#[test]
fn decide_pair_fix_places_balloon_directly_above_character_on_establish_and_reassert() {
for trigger in [PairTrigger::Establish, PairTrigger::Reassert] {
for strategy in ALL_STRATEGIES {
assert_eq!(
decide_pair_fix(&intruded(trigger, strategy)),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::After(intruder_front()),
},
"{trigger:?}/{strategy:?}: バルーンはキャラの最も近い可視の手前の窓の背後へ入る"
);
assert_ne!(
decide_pair_fix(&intruded(trigger, strategy)),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::After(character()),
},
"{trigger:?}/{strategy:?}: キャラ自身を挿入位置にはしない"
);
}
}
}
#[test]
fn decide_pair_fix_yields_top_edge_when_character_is_frontmost() {
let obs = PairObservation {
measured_above_of_below: None,
..intruded(PairTrigger::Reassert, ZOrderPairStrategy::default())
};
assert_eq!(
decide_pair_fix(&obs),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::TopEdge,
},
"キャラより手前に窓が無いなら、バルーンは最上位へ置くほか無い"
);
assert_eq!(
decide_pair_fix(&intruded(
PairTrigger::Reassert,
ZOrderPairStrategy::default()
)),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::After(intruder_front()),
},
);
}
#[test]
fn decide_pair_fix_avoids_pointing_at_an_always_on_top_window_as_the_insert_position() {
let base = intruded(PairTrigger::Reassert, ZOrderPairStrategy::default());
assert_eq!(
decide_pair_fix(&PairObservation {
measured_above_of_below_is_always_on_top: true,
..base.clone()
}),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::TopOfNormalBand,
},
"手前の窓が常時最前面なら、その窓を指さず通常帯の最上へ置く(要件 8.1)"
);
assert_eq!(
decide_pair_fix(&base),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::After(intruder_front()),
},
"普通の窓が手前に居るときの挿入位置は変わらない(帯の分岐が既定へ潰れていない)"
);
assert_eq!(
decide_pair_fix(&PairObservation {
measured_above_of_below: None,
measured_above_of_below_is_always_on_top: true,
..base.clone()
}),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::TopEdge,
},
"手前に窓が無い縁は、帯の所属の値によらず `TopEdge` のまま"
);
for trigger in ALL_TRIGGERS {
for strategy in ALL_STRATEGIES {
let obs = PairObservation {
measured_above_of_below_is_always_on_top: true,
..intruded(trigger, strategy)
};
if let PairFixDecision::PlaceAboveOverBelow { insert_after } = decide_pair_fix(&obs) {
assert_ne!(
insert_after,
InsertSpec::After(intruder_front()),
"{trigger:?}/{strategy:?}: 常時最前面の窓を挿入位置にしている"
);
}
}
}
}
#[test]
fn decide_pair_fix_places_character_directly_under_balloon_on_raised_above() {
for strategy in [
ZOrderPairStrategy::OwnerLink { raise_assist: true },
ZOrderPairStrategy::ExplicitMaintenance,
] {
assert_eq!(
decide_pair_fix(&intruded(PairTrigger::RaisedAbove, strategy)),
PairFixDecision::PlaceBelowUnderAbove {
insert_after: balloon(),
},
"{strategy:?}: キャラはバルーンの背後へ入る=挿入位置はバルーン自身"
);
}
}
#[test]
fn decide_pair_fix_handles_raised_below_per_strategy() {
assert_eq!(
decide_pair_fix(&intruded(
PairTrigger::RaisedBelow,
ZOrderPairStrategy::ExplicitMaintenance
)),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::After(intruder_front()),
},
"案 B ではキャラの浮上に追随してバルーンを手前へ戻す"
);
assert_eq!(
decide_pair_fix(&intruded(
PairTrigger::RaisedBelow,
ZOrderPairStrategy::OwnerLink { raise_assist: true }
)),
PairFixDecision::Skip(SkipReason::EchoOrIrrelevant),
"案 A ではキャラの浮上は OS がペアごと持ち上げる=是正は無関係"
);
}
#[test]
fn decide_pair_fix_skips_z_change_triggers_when_raise_assist_is_disabled() {
let disabled = ZOrderPairStrategy::OwnerLink {
raise_assist: false,
};
for trigger in [PairTrigger::RaisedAbove, PairTrigger::RaisedBelow] {
assert_eq!(
decide_pair_fix(&intruded(trigger, disabled)),
PairFixDecision::Skip(SkipReason::StrategyDisabled),
"{trigger:?}: raise assist 無効の案 A では見送る"
);
assert_ne!(
decide_pair_fix(&intruded(
trigger,
ZOrderPairStrategy::OwnerLink { raise_assist: true }
)),
PairFixDecision::Skip(SkipReason::StrategyDisabled),
"{trigger:?}: raise assist 有効時は見送り理由が変わる"
);
}
assert_eq!(
decide_pair_fix(&intruded(PairTrigger::Reassert, disabled)),
PairFixDecision::PlaceAboveOverBelow {
insert_after: InsertSpec::After(intruder_front()),
},
"再断行の要求は案 A でも処理する(2.6 シームは両案共通)"
);
}
#[test]
fn decide_pair_fix_skips_with_peer_missing_when_either_window_is_gone() {
for trigger in ALL_TRIGGERS {
for strategy in ALL_STRATEGIES {
let base = intruded(trigger, strategy);
for (label, obs) in [
(
"バルーン不在",
PairObservation {
above_alive: false,
..base.clone()
},
),
(
"キャラ不在",
PairObservation {
below_alive: false,
..base.clone()
},
),
(
"両方不在",
PairObservation {
above_alive: false,
below_alive: false,
..base.clone()
},
),
] {
assert_eq!(
decide_pair_fix(&obs),
PairFixDecision::Skip(SkipReason::PeerMissing),
"{label}/{trigger:?}/{strategy:?}: 相手が居なければ何もしない"
);
}
}
}
}
#[test]
fn decide_pair_fix_skips_with_handle_missing_before_any_measurement() {
let base = intruded(PairTrigger::Establish, ZOrderPairStrategy::default());
for (label, obs) in [
(
"バルーンのハンドル未付与",
PairObservation {
above_hwnd: None,
..base.clone()
},
),
(
"キャラのハンドル未付与",
PairObservation {
below_hwnd: None,
..base.clone()
},
),
] {
assert_eq!(
decide_pair_fix(&obs),
PairFixDecision::Skip(SkipReason::HandleMissing),
"{label}: 窓は生きているがハンドルがまだ無い(不在とは別の理由)"
);
}
assert_eq!(
decide_pair_fix(&PairObservation {
above_alive: false,
above_hwnd: None,
..base
}),
PairFixDecision::Skip(SkipReason::PeerMissing),
);
}
#[test]
fn decide_pair_fix_always_skips_when_already_adjacent() {
for trigger in ALL_TRIGGERS {
for strategy in ALL_STRATEGIES {
let adjacent = PairObservation {
measured_below_of_above: Some(character()),
measured_above_of_below: Some(balloon()),
..intruded(trigger, strategy)
};
assert_eq!(
decide_pair_fix(&adjacent),
PairFixDecision::Skip(SkipReason::AlreadyAdjacent),
"{trigger:?}/{strategy:?}: バルーンの最も近い可視の背後がキャラなら是正は不要"
);
let inconsistent = PairObservation {
measured_above_of_below: Some(balloon()),
..intruded(trigger, strategy)
};
assert_eq!(
decide_pair_fix(&inconsistent),
PairFixDecision::Skip(SkipReason::AlreadyAdjacent),
"{trigger:?}/{strategy:?}: 実測が食い違っても自己参照の指令は出さない"
);
}
}
}
#[test]
fn decide_pair_fix_covers_every_arm_without_ever_yielding_always_on_top() {
let mut seen_skips: Vec<SkipReason> = Vec::new();
let mut seen_after = false;
let mut seen_top_edge = false;
let mut seen_top_of_normal_band = false;
let mut seen_place_below = false;
for (label, obs) in observation_matrix() {
match decide_pair_fix(&obs) {
PairFixDecision::Skip(reason) => {
if !seen_skips.contains(&reason) {
seen_skips.push(reason);
}
}
PairFixDecision::PlaceAboveOverBelow { insert_after } => match insert_after {
InsertSpec::After(hwnd) => {
seen_after = true;
assert_ne!(
hwnd,
balloon(),
"{label}: 動かす窓自身を挿入位置にはしない(自己参照の指令)"
);
}
InsertSpec::TopEdge => seen_top_edge = true,
InsertSpec::TopOfNormalBand => seen_top_of_normal_band = true,
},
PairFixDecision::PlaceBelowUnderAbove { insert_after } => {
seen_place_below = true;
assert_eq!(
insert_after,
balloon(),
"{label}: キャラの挿入位置はバルーン自身に限る"
);
}
}
}
for reason in [
SkipReason::AlreadyAdjacent,
SkipReason::PeerMissing,
SkipReason::HandleMissing,
SkipReason::EchoOrIrrelevant,
SkipReason::StrategyDisabled,
] {
assert!(
seen_skips.contains(&reason),
"見送り理由 {reason:?} が全列挙で一度も出ていない(分岐が到達不能)"
);
}
assert!(seen_after, "「ある窓の背後へ」が一度も出ていない");
assert!(seen_top_edge, "「最上位へ」(縁)が一度も出ていない");
assert!(
seen_top_of_normal_band,
"「通常帯の最上へ」(挿入位置が常時最前面だった場合)が一度も出ていない"
);
assert!(seen_place_below, "「キャラを背後へ」が一度も出ていない");
}
#[test]
fn decide_pair_fix_moves_only_the_two_declared_pair_windows() {
let pair = [balloon(), character()];
let mut moved_balloon = false;
let mut moved_character = false;
for (label, obs) in observation_matrix() {
let moved = match decide_pair_fix(&obs) {
PairFixDecision::Skip(_) => None,
PairFixDecision::PlaceAboveOverBelow { insert_after: _ } => obs.above_hwnd,
PairFixDecision::PlaceBelowUnderAbove { insert_after: _ } => obs.below_hwnd,
};
let Some(moved) = moved else {
continue;
};
assert!(
pair.contains(&moved),
"{label}: 宣言ペア以外の窓を動かそうとしている({moved:?})"
);
moved_balloon |= moved == balloon();
moved_character |= moved == character();
}
assert!(moved_balloon, "バルーン窓が動く腕が一度も出ていない");
assert!(moved_character, "キャラ窓が動く腕が一度も出ていない");
}