From 5f0d19ddbb1a49eee9ed839a08f52742c52f9bf9 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 09:01:48 +0800 Subject: test(desktop): P36 情境提示契约回归 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 P36PromptTests(10 例):距离边界、就近目标切换、离开立即消失、 隔墙不触发、对话层输入隔离、提示对象=按键对象一致、Esc 关闭当前层、 暂停帮助 Esc 回上层、保存成功/失败中文 toast、布局多尺寸不遮 HUD。 --- .../LivingVillage.Desktop.Tests.fsproj | 1 + src/LivingVillage.Desktop.Tests/P36PromptTests.fs | 235 +++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/LivingVillage.Desktop.Tests/P36PromptTests.fs (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index d32a21f..20d9e61 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -16,6 +16,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/P36PromptTests.fs b/src/LivingVillage.Desktop.Tests/P36PromptTests.fs new file mode 100644 index 0000000..bc398d3 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P36PromptTests.fs @@ -0,0 +1,235 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop +open LivingVillage.Desktop.VillagePresentation + +/// P36:情境提示系统契约回归。覆盖距离边界、目标切换、离开消失、隔墙不触发、 +/// 对话层输入隔离、提示对象与按键对象一致、布局不遮挡、Esc 关闭当前层、保存反馈。 +[] +type P36PromptTests () = + + 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 actionText (action: InteractionAction) : string = + match action with + | TalkTo _ -> "E 交谈" + | EnterHome _ -> "E 进屋" + | ExitHome _ -> "E 出屋" + + let doorCenter : Vec2 = + { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) } + + [] + member _.PromptAppearsOnlyInsideInteractionRangeAtBoundary () = + let baseWorld = Sim.initialWorldN 42UL 4 |> withNpcsAt [] + let avatar : Vec2 = { X = 1600.0f; Y = 800.0f } + 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( + Some(TalkTo(NpcId 0)), + InteractionResolver.resolve atBoundary Outside |> Option.map (fun target -> target.Action)) + Assert.AreEqual( + None, + InteractionResolver.resolve beyond Outside |> Option.map (fun target -> target.Action)) + + [] + member _.PromptSwitchesToTheNearestReachableTargetDeterministically () = + let baseWorld = Sim.initialWorldN 42UL 4 + let avatar : Vec2 = { X = 1600.0f; Y = 800.0f } + let competing = + baseWorld + |> atPosition avatar.X avatar.Y + |> withNpcsAt + [ 0, { X = avatar.X + 72.0f; Y = avatar.Y } + 1, { X = avatar.X + 48.0f; Y = avatar.Y } ] + + Assert.AreEqual( + Some(TalkTo(NpcId 1)), + InteractionResolver.resolve competing Outside |> Option.map (fun target -> target.Action)) + + // 走近后最近目标切换为 0 号,仍然一帧只选一个。 + let switched = competing |> atPosition (avatar.X + 64.0f) avatar.Y + Assert.AreEqual( + Some(TalkTo(NpcId 0)), + InteractionResolver.resolve switched Outside |> Option.map (fun target -> target.Action)) + + [] + member _.PromptDisappearsImmediatelyAfterLeavingRange () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition 1600.0f 800.0f + |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ] + let view = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual(Some "E 交谈", view.Prompt) + Assert.IsTrue(view.PromptTargetPos.IsSome) + + let left = world |> atPosition 2400.0f 1600.0f + let cleared = M5Interaction.refreshPrompt left view + Assert.AreEqual(None, cleared.Prompt) + Assert.AreEqual(None, cleared.PromptTargetPos) + + [] + member _.PromptNeverTriggersThroughAWall () = + // 民居西墙世界瓦片 (31,22);玩家在墙东侧紧邻,村民在墙后,水平距离在交谈范围内, + // 但视线被墙体挡住 -> 不得触发。 + 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.IsTrue(InteractionResolver.hasClearPath wallAvatar wallNpc |> not) + Assert.AreEqual( + None, + InteractionResolver.resolve world Outside |> Option.map (fun target -> target.Action)) + Assert.AreEqual(None, (M5Interaction.refreshPrompt world M5Interaction.initial).Prompt) + + [] + member _.OpenDialogueLayerIsolatesWorldInteractionCommands () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition 1600.0f 800.0f + |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ] + let dialogueView = + { M5Interaction.initial with + Panel = DialoguePanel + Menu = Some { Target = NpcId 0; Options = Sim.dialogueOptions } } + + Assert.IsFalse(M5Interaction.worldInputAllowed dialogueView) + + // E(Interact)在对白层打开时不得穿透重开/改选目标。 + let interactWorld, interactView = M5Interaction.apply Interact world dialogueView + Assert.AreEqual(world, interactWorld) + Assert.AreEqual(DialoguePanel, interactView.Panel) + Assert.IsTrue(interactView.Menu.IsSome) + + // Q(Observe)同样被隔离。 + let observeWorld, observeView = M5Interaction.apply Observe world dialogueView + Assert.AreEqual(world, observeWorld) + Assert.AreEqual(DialoguePanel, observeView.Panel) + Assert.AreEqual([], observeView.Observations) + + [] + member _.PromptObjectAlwaysMatchesTheInteractTarget () = + // 村民:提示文本与 resolve 的动作一致,按 E 后菜单目标是同一村民。 + let npcWorld = + Sim.initialWorldN 42UL 4 + |> atPosition 1600.0f 800.0f + |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ] + let npcTarget = InteractionResolver.resolve npcWorld Outside + Assert.IsTrue(npcTarget.IsSome) + Assert.AreEqual(actionText npcTarget.Value.Action, npcTarget.Value.Text) + let npcView = M5Interaction.refreshPrompt npcWorld M5Interaction.initial + Assert.AreEqual(Some npcTarget.Value.Text, npcView.Prompt) + let _, npcInteracted = M5Interaction.apply Interact npcWorld M5Interaction.initial + Assert.AreEqual(DialoguePanel, npcInteracted.Panel) + Assert.AreEqual(NpcId 0, npcInteracted.Menu.Value.Target) + + // 民居门:提示 "E 进屋",按 E 后进入同一民居。 + let doorWorld = + Sim.initialWorldN 42UL 4 + |> withNpcsAt [] + |> atPosition doorCenter.X doorCenter.Y + let doorTarget = InteractionResolver.resolve doorWorld Outside + Assert.IsTrue(doorTarget.IsSome) + Assert.AreEqual("E 进屋", doorTarget.Value.Text) + Assert.AreEqual(actionText doorTarget.Value.Action, doorTarget.Value.Text) + let _, doorInteracted = M5Interaction.apply Interact doorWorld M5Interaction.initial + Assert.AreEqual(Inside(HomeId 1), doorInteracted.HomeMode) + + [] + member _.EscapeClosesTheCurrentLayerBackToWorld () = + let world = Sim.initialWorldN 42UL 0 + for panel in [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ] do + let view = { M5Interaction.initial with Panel = panel } + let _, closed = M5Interaction.apply ClosePanel world view + Assert.IsTrue(closed.Panel = WorldPanel, sprintf "Esc 应关闭面板 %A" panel) + Assert.IsTrue(closed.Menu.IsNone, sprintf "关闭 %A 后应清空菜单" panel) + + [] + member _.PauseHelpAndControlsPageReturnToThePreviousLayerOnEscape () = + let control = SimulationControl.initial + let playing = MenuState.create control true |> MenuState.enterGame + let helpState, paused = MenuState.enterPauseHelp control playing + Assert.AreEqual(Controls, helpState.Page) + Assert.IsTrue(paused.Paused) + Assert.AreEqual(Playing, helpState.ReturnPage) + + let returnedState, restored = MenuState.exitPauseHelp control helpState + Assert.AreEqual(Playing, returnedState.Page) + Assert.IsFalse(restored.Paused) + + let backState, _ = MenuState.update Back helpState + Assert.AreEqual(Playing, backState.Page) + + [] + member _.SaveFeedbackUsesATransientChineseToast () = + let success = + { M5Interaction.initial with + Status = "save succeeded" + StatusTick = Some 100L } + Assert.AreEqual("保存成功", M5Interaction.statusText success) + Assert.AreEqual(Some "保存成功", M5Interaction.transientStatusLine 180L success) + Assert.AreEqual( + None, + M5Interaction.transientStatusLine (100L + M5Interaction.statusDisplayDurationTicks) success) + + let failed = + { M5Interaction.initial with + Status = "save failed" + StatusTick = Some 100L } + Assert.AreEqual("保存失败", M5Interaction.statusText failed) + Assert.AreEqual(Some "保存失败", M5Interaction.transientStatusLine 120L failed) + + [] + member _.PromptLayoutFitsSmallAndLargeWindowsWithoutCoveringHud () = + for (width, height) in [ (800, 600); (1024, 576); (1280, 720); (1920, 1080) ] do + let anchors : (int * int) list = + [ (16, 16) + (width / 2, height / 2) + (width - 40, FloatingPrompt.hudTopBarHeight + 4) + (24, height - FloatingPrompt.hudBottomBandHeight - 4) + (width - 24, height - 24) ] + for (anchorX, anchorY) in anchors do + for text in [ "E 交谈"; "E 进屋"; "E 出屋" ] do + let rect = + FloatingPrompt.layout width height (float32 anchorX) (float32 anchorY) 0.0f 0.0f 2 text + Assert.IsTrue(rect.Width > 0, sprintf "提示宽度被截为 0 (%dx%d)" width height) + Assert.IsTrue( + rect.X >= 0 && rect.X + rect.Width <= width, + sprintf "水平越界 %dx%d anchor=(%d,%d) %A" width height anchorX anchorY rect) + Assert.IsTrue( + rect.Y >= 0 && rect.Y + rect.Height <= height, + sprintf "垂直越界 %dx%d anchor=(%d,%d) %A" width height anchorX anchorY rect) + Assert.IsTrue( + rect.Y >= FloatingPrompt.hudTopBarHeight, + sprintf "遮挡顶部状态条 %dx%d" width height) + Assert.IsTrue( + rect.Y + rect.Height <= height - FloatingPrompt.hudBottomBandHeight + 8, + sprintf "遮挡底部 HUD 带 %dx%d" width height) -- cgit v1.2.3