summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Desktop.Tests')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs203
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)