diff options
Diffstat (limited to 'src/LivingVillage.Desktop.Tests')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs | 203 |
2 files changed, 204 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 85381fc..bda9932 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -30,6 +30,7 @@ <Compile Include="P48MapDigestTests.fs" /> <Compile Include="P51ProfessionSliceTests.fs" /> <Compile Include="P52ProfessionTradeTests.fs" /> + <Compile Include="P53InteractionPromptTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs b/src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs new file mode 100644 index 0000000..c3e9f62 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs @@ -0,0 +1,203 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop +open LivingVillage.Desktop.VillagePresentation + +/// P53:情境提示与输入隔离回归(brief 第 8 条交互硬要求)。 +/// 每个用例都断言**显式目标**(NpcId / HomeId),并把「提示文案」与「实际作用对象」 +/// 收敛到同一选择规则:提示写「E 交谈」时按 E 必须作用于同一 Npc。 +/// 提示层是瞬时叠加层,绝非常驻面板。 +[<TestClass>] +type P53InteractionPromptTests () = + + let atPosition (x: float32) (y: float32) (world: World) : World = + { world with Avatar = { world.Avatar with Pos = ({ X = x; Y = y } : Vec2) } } + + let withNpcsAt (positions: (int * Vec2) list) (world: World) : World = + let npcs = Array.copy world.Npcs + for index in 0 .. npcs.Length - 1 do + npcs.[index] <- { npcs.[index] with Pos = ({ X = 4000.0f; Y = 4000.0f } : Vec2) } + for (index, position) in positions do + npcs.[index] <- { npcs.[index] with Pos = position } + { world with Npcs = npcs } + + let avatar : Vec2 = { X = 1600.0f; Y = 800.0f } + + let promptTarget (world: World) : InteractionAction option = + InteractionResolver.resolve world Outside |> Option.map (fun target -> target.Action) + + let promptText (world: World) : string option = + (M5Interaction.refreshPrompt world M5Interaction.initial).Prompt + + [<TestMethod>] + member _.PromptAppearsOnlyInsideRangeWithExplicitTarget () = + let baseWorld = Sim.initialWorldN 42UL 4 |> withNpcsAt [] + let atBoundary = + baseWorld + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + Sim.chatRangePx; Y = avatar.Y } ] + let beyond = + baseWorld + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + Sim.chatRangePx + 0.5f; Y = avatar.Y } ] + + Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), promptTarget atBoundary) + Assert.AreEqual<string>("E 交谈", (InteractionResolver.resolve atBoundary Outside).Value.Text) + Assert.AreEqual<string option>(Some "E 交谈", promptText atBoundary) + Assert.AreEqual<InteractionAction option>(None, promptTarget beyond) + Assert.AreEqual<string option>(None, promptText beyond) + + [<TestMethod>] + member _.NearestReachableTargetWinsDeterministicallyAndOnlyOneIsShown () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt + [ 0, { X = avatar.X + 72.0f; Y = avatar.Y } + 1, { X = avatar.X + 48.0f; Y = avatar.Y } + 2, { X = avatar.X + 60.0f; Y = avatar.Y } ] + + for _ in 1 .. 5 do + Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 1)), promptTarget world) + let view = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual<string option>(Some "E 交谈", view.Prompt) + // 仅一个高亮:提示目标位置就是被选中的 1 号村民,别处不出现第二个提示。 + Assert.AreEqual<Vec2 option>(Some world.Npcs.[1].Pos, view.PromptTargetPos) + + // 走近远端村民:最近目标切换为 0 号,仍然只选一个。 + let switched = world |> atPosition (avatar.X + 84.0f) avatar.Y + Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), promptTarget switched) + + [<TestMethod>] + member _.EqualDistanceTieBreaksByStableId () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt + [ 0, { X = avatar.X + 40.0f; Y = avatar.Y } + 1, { X = avatar.X; Y = avatar.Y + 40.0f } ] + // 两个候选与玩家的距离平方相同(1600),稳定按 NpcId 升序取 0 号。 + Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), promptTarget world) + + [<TestMethod>] + member _.PromptDisappearsImmediatelyAfterLeavingWithExplicitNone () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ] + let shown = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual<string option>(Some "E 交谈", shown.Prompt) + Assert.IsTrue(shown.PromptTargetPos.IsSome) + + let left = world |> atPosition 3200.0f 2400.0f + Assert.AreEqual<InteractionAction option>(None, promptTarget left) + let cleared = M5Interaction.refreshPrompt left shown + Assert.AreEqual<string option>(None, cleared.Prompt) + Assert.AreEqual<Vec2 option>(None, cleared.PromptTargetPos) + + [<TestMethod>] + member _.WallBlocksBothThePromptAndTheInteraction () = + let wallAvatar : Vec2 = + { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (22 * Sim.tilePixels + Sim.tilePixels / 2) } + let wallNpc : Vec2 = + { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (22 * Sim.tilePixels + Sim.tilePixels / 2) } + let world = + Sim.initialWorldN 42UL 1 + |> atPosition wallAvatar.X wallAvatar.Y + |> withNpcsAt [ 0, wallNpc ] + + Assert.IsFalse(InteractionResolver.hasClearPath wallAvatar wallNpc) + Assert.AreEqual<InteractionAction option>(None, promptTarget world) + Assert.AreEqual<string option>(None, promptText world) + + // 隔墙时按 E 不产生世界副作用,面板停在世界层。 + let nextWorld, nextView = M5Interaction.apply Interact world M5Interaction.initial + Assert.AreEqual<World>(world, nextWorld) + Assert.AreEqual<M5Panel>(WorldPanel, nextView.Panel) + + [<TestMethod>] + member _.OpenLayersIsolateWorldInteractionAndClearThePrompt () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ] + let panels = [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ] + for panel in panels do + let view = + { M5Interaction.initial with + Panel = panel + Menu = (if panel = DialoguePanel then Some { Target = NpcId 0; Options = Sim.dialogueOptions } else None) } + Assert.IsFalse(M5Interaction.worldInputAllowed view, sprintf "%A 不得放行世界输入" panel) + let nextWorld, nextView = M5Interaction.apply Interact world view + Assert.AreEqual<World>(world, nextWorld) + Assert.IsTrue((panel = nextView.Panel), sprintf "%A 开启时 Interact 不得改选目标" panel) + Assert.AreEqual<string option>(None, (M5Interaction.refreshPrompt world view).Prompt) + // 世界层里非对话面板命令不得误开对话;买卖键只在对话层生效。 + let worldLayer = M5Interaction.initial + Assert.IsFalse(M5Interaction.worldInputAllowed { worldLayer with Panel = TaskPanel }) + let _, bought = M5Interaction.apply BuyFromTarget world worldLayer + Assert.AreEqual<string option>(None, bought.Menu |> Option.map (fun m -> m.Target.ToString())) + + [<TestMethod>] + member _.EscapeClosesTheCurrentLayerAndRecomputesTheWorldPrompt () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ] + for panel in [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ] do + let view = + { M5Interaction.initial with + Panel = panel + Menu = (if panel = DialoguePanel then Some { Target = NpcId 0; Options = Sim.dialogueOptions } else None) } + let _, closed = M5Interaction.apply ClosePanel world view + Assert.IsTrue((closed.Panel = WorldPanel), sprintf "Esc 应关闭 %A" panel) + Assert.IsTrue(closed.Menu.IsNone, sprintf "关闭 %A 后应清空菜单" panel) + Assert.AreEqual<string option>(Some "E 交谈", closed.Prompt) + + [<TestMethod>] + member _.PromptTextAlwaysMatchesTheActedTarget () = + // 文案映射唯一来源。 + Assert.AreEqual<string>("E 交谈", InteractionResolver.actionText (TalkTo(NpcId 0))) + Assert.AreEqual<string>("E 进屋", InteractionResolver.actionText (EnterHome(HomeId 1))) + Assert.AreEqual<string>("E 出屋", InteractionResolver.actionText (ExitHome(HomeId 1))) + + // 多候选中提示 1 号村民 → 按 E 必须作用于同一 1 号村民。 + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt + [ 0, { X = avatar.X + 72.0f; Y = avatar.Y } + 1, { X = avatar.X + 40.0f; Y = avatar.Y } ] + let target = InteractionResolver.resolve world Outside + Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 1)), target |> Option.map (fun t -> t.Action)) + Assert.AreEqual<string>(InteractionResolver.actionText target.Value.Action, target.Value.Text) + let view = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual<string option>(Some target.Value.Text, view.Prompt) + Assert.AreEqual<Vec2 option>(Some world.Npcs.[1].Pos, view.PromptTargetPos) + + let _, interacted = M5Interaction.apply Interact world M5Interaction.initial + Assert.AreEqual<M5Panel>(DialoguePanel, interacted.Panel) + Assert.AreEqual<NpcId>(NpcId 1, interacted.Menu.Value.Target) + + [<TestMethod>] + member _.PromptLayerIsTransientNotAPersistentPanel () = + // 初始世界层无提示、无面板;靠近只补一条瞬时提示,不新开面板。 + Assert.AreEqual<M5Panel>(WorldPanel, M5Interaction.initial.Panel) + Assert.AreEqual<string option>(None, M5Interaction.initial.Prompt) + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ] + let shown = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual<M5Panel>(WorldPanel, shown.Panel) + Assert.IsTrue(shown.Menu.IsNone) + // 面板一旦开启,提示立即消失,且刷新多帧都保持无提示(非常驻)。 + let opened = { shown with Panel = TaskPanel } + for _ in 1 .. 5 do + let refreshed = M5Interaction.refreshPrompt world opened + Assert.AreEqual<string option>(None, refreshed.Prompt) |
