namespace LivingVillage.Desktop.Tests open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop open LivingVillage.Desktop.VillagePresentation module private Harness = let atNpc (id: int) (world: World) : World = let npc = world.Npcs.[id] { world with Avatar = { world.Avatar with Pos = npc.Pos } } [] type DesktopTests () = [] member _.InteractOpensTheSixIntentMenu () = let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0 let next, view = M5Interaction.apply Interact world M5Interaction.initial Assert.AreEqual(world, next) Assert.AreEqual(DialoguePanel, view.Panel) Assert.AreEqual(6, view.Menu.Value.Options.Length) Assert.IsTrue(M5Interaction.titleText view |> fun text -> text.Contains("闲聊")) [] member _.SelectingAnIntentUpdatesWorldAndChronicle () = let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0 let openedWorld, opened = M5Interaction.apply Interact world M5Interaction.initial let next, view = M5Interaction.apply Intent1 openedWorld opened Assert.IsTrue(next.Events |> List.exists (fun event -> match event.Kind with | DialogueEvent _ -> true | _ -> false)) Assert.IsTrue(next.Annals |> List.exists (fun entry -> match entry.Kind with | DialogueAnnal _ -> true | _ -> false)) Assert.IsTrue(view.Chronicle.Contains("dialogue")) Assert.IsTrue(view.Status.Contains("dialogue")) [] member _.NeedsAndObservationPanelsUseKernelViews () = let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0 let afterStep = Sim.step { Input = { MoveX = 0.0f; MoveY = 0.0f } } world let _, needsView = M5Interaction.apply ToggleNeeds afterStep M5Interaction.initial let _, observationView = M5Interaction.apply Observe world M5Interaction.initial Assert.AreEqual(afterStep.Avatar.Mind.Needs, needsView.Needs.Value.Needs) Assert.IsTrue(observationView.Observations |> List.exists (fun observation -> observation.Id = NpcId 0)) [] member _.DialogueAndOverlayViewsRejectWorldCommandsUntilClosed () = let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0 let openedWorld, dialogueView = M5Interaction.apply Interact world M5Interaction.initial let _, afterObserve = M5Interaction.apply Observe openedWorld dialogueView let _, afterNeeds = M5Interaction.apply ToggleNeeds openedWorld dialogueView let _, afterChronicle = M5Interaction.apply ShowChronicle openedWorld dialogueView let _, needsView = M5Interaction.apply ToggleNeeds world M5Interaction.initial let _, interactionAttempt = M5Interaction.apply Interact world needsView let _, closedView = M5Interaction.apply ClosePanel world dialogueView Assert.AreEqual(dialogueView, afterObserve) Assert.AreEqual(dialogueView, afterNeeds) Assert.AreEqual(dialogueView, afterChronicle) Assert.AreEqual(NeedsPanel, interactionAttempt.Panel) Assert.AreEqual(WorldPanel, closedView.Panel) [] member _.HeadlessSmokeProvesTheThreeHopCausalChain () = let result = Program.M5Smoke.run () Assert.IsTrue(result.Passed, result.Failure) Assert.AreEqual(6, result.MenuOptions) Assert.AreEqual(4, result.RumorPathLength) Assert.IsTrue(result.Chronicle.Contains("dialogue")) Assert.IsTrue(result.RumorTrace.Contains("receiver=-1")) Assert.IsTrue(result.RumorTrace.Contains("depth=3")) [] member _.DayNightStateChangesTheRenderProfile () = let day = M6Presentation.profileAtTick (6L * Sim.ticksPerDay / 24L) let night = M6Presentation.profileAtTick (22L * Sim.ticksPerDay / 24L) Assert.AreEqual(Day, day.Mode) Assert.AreEqual(Night, night.Mode) Assert.AreNotEqual(day.Background, night.Background) Assert.AreNotEqual(day.WorldTint, night.WorldTint) [] member _.DesktopBootstrapMovesAvatarOffTheHomeDoorAndSeparatesVillagers () = let world = WorldBootstrap.initialWorld 42UL Sim.npcCount let avatarPixel : PixelPosition = { X = world.Avatar.Pos.X Y = world.Avatar.Pos.Y } let avatarTile = sceneTileFromPixel avatarPixel let home = scene.Homes |> List.head let renderPlan = VillageArt.sampleRenderPlan () let blockedTiles = (renderPlan.Grounds |> List.map (fun (tile, _) -> VillageArt.worldTile renderPlan tile)) @ VillageArt.planFootprint renderPlan |> List.map (fun tile -> tile.X, tile.Y) |> Set.ofList let sceneTileOf (position: Vec2) : TilePosition = sceneTileFromPixel { X = position.X Y = position.Y } let distanceSquared (first: Vec2) (second: Vec2) : float32 = let dx = second.X - first.X let dy = second.Y - first.Y dx * dx + dy * dy let npcTiles = world.Npcs |> Array.map (fun npc -> sceneTileOf npc.Pos) Assert.AreEqual(6, avatarTile.X) Assert.AreEqual(7, avatarTile.Y) Assert.IsFalse(isNearHomeDoor avatarTile home, "The avatar must start beyond the doorway interaction radius.") Assert.IsTrue( npcTiles |> Array.forall (fun tile -> not (Set.contains (tile.X, tile.Y) blockedTiles)), "A villager must not spawn on the house, water, bridge, or garden footprint.") Assert.IsTrue( world.Npcs |> Array.forall (fun npc -> distanceSquared world.Avatar.Pos npc.Pos >= 96.0f * 96.0f), "Villagers must not overlap the avatar spawn.") for first in 0 .. world.Npcs.Length - 2 do for second in first + 1 .. world.Npcs.Length - 1 do Assert.IsTrue( distanceSquared world.Npcs.[first].Pos world.Npcs.[second].Pos >= 96.0f * 96.0f, sprintf "villagers %d and %d are too close" first second) let repeated = WorldBootstrap.initialWorld 42UL Sim.npcCount Assert.AreEqual(world, repeated) [] member _.DaylightUsesAWarmHighContrastWorldTint () = let day = M6Presentation.profileAtTick (6L * Sim.ticksPerDay / 24L) Assert.IsTrue(day.WorldTint.R > day.WorldTint.G) Assert.IsTrue(day.WorldTint.G > day.WorldTint.B) [] member _.DaylightAutoplayBootstrapStartsAtDawnAndPreservesDefaultStart () = let dawnTick = 6L * Sim.ticksPerDay / 24L let daylightWorld = WorldBootstrap.initialWorldForDaylight true 42UL Sim.npcCount let defaultWorld = WorldBootstrap.initialWorldForDaylight false 42UL Sim.npcCount let daylightProfile = M6Presentation.profileAtTick daylightWorld.Tick let defaultProfile = M6Presentation.profileAtTick defaultWorld.Tick Assert.AreEqual(dawnTick, daylightWorld.Tick) Assert.IsTrue(abs (daylightWorld.Time - float dawnTick * Sim.dtSeconds) < 1e-9) Assert.AreEqual(Day, daylightProfile.Mode) Assert.AreEqual(0L, defaultWorld.Tick) Assert.AreEqual(0.0, defaultWorld.Time) Assert.AreEqual(Night, defaultProfile.Mode) // ---- P12 中文每日任务面板 ---- module private TaskPanelHarness = let occupationWithTask (taskState: Occupation.TaskState) : Occupation.State = let occupation = Occupation.stateOf Occupation.Farmer let task = (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Farmer).Value |> Occupation.acceptTask |> fun active -> if taskState = Occupation.Done then { active with State = Occupation.Done } else { active with State = taskState } { occupation with Today = Some task } [] type TaskPanelTests () = [] member _.TaskPanelWithoutOccupationShowsNoOccupationLine () = let world = Sim.initialWorldN 42UL 4 let _, view = M5Interaction.apply ShowTaskPanel world M5Interaction.initial Assert.AreEqual(TaskPanel, view.Panel) Assert.AreEqual("task", M5Interaction.panelName view) let lines = M5Interaction.panelLines world view Assert.IsTrue(lines |> List.contains "任务", "panel heading expected") Assert.IsTrue(lines |> List.contains "无职业") [] member _.TaskPanelShowsChineseOccupationNameAndTheThreeStates () = let world = Sim.initialWorldN 42UL 4 for taskState, expected in [ Occupation.Offered, "待接受"; Occupation.Active, "进行中"; Occupation.Done, "已完成" ] do let withTask = { M5Interaction.initial with Task = Some (TaskPanelHarness.occupationWithTask taskState) } let _, view = M5Interaction.apply ShowTaskPanel world withTask let lines = M5Interaction.panelLines world view Assert.IsTrue(lines |> List.contains "农夫", $"occupation name missing for {taskState}") Assert.IsTrue(lines |> List.exists (fun line -> line.Contains(expected)), $"state {expected} missing in {lines}") [] member _.TaskPanelTitleUsesChineseHeading () = let world = Sim.initialWorldN 42UL 4 let _, view = M5Interaction.apply ShowTaskPanel world M5Interaction.initial Assert.IsTrue(M5Interaction.titleText view |> fun text -> text.Contains("任务")) [] member _.ShowTaskPanelIsRejectedFromDialoguePanelLikeOtherOverlays () = let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0 let openedWorld, dialogueView = M5Interaction.apply Interact world M5Interaction.initial let _, afterTask = M5Interaction.apply ShowTaskPanel openedWorld dialogueView Assert.AreEqual(dialogueView, afterTask) let _, closed = M5Interaction.apply ClosePanel world { dialogueView with Panel = TaskPanel } Assert.AreEqual(WorldPanel, closed.Panel) // ---- P15 开局职业选择接入:bootstrap 职业状态 / 存档 round-trip / 面板联调 ---- module private OccupationFlowHarness = let withTempSave (action: string -> unit) = let id = System.Guid.NewGuid().ToString("N") let path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"living-village-p15-{id}.save") try action path finally if System.IO.File.Exists path then System.IO.File.Delete path [] type OccupationFlowTests () = [] member _.BootstrapStampsOccupationMoneyEnergyAndTodaysOfferedTask () = for kind in Occupation.all do let state = WorldBootstrap.occupationStateFor 42UL kind 0L let task = state.Today.Value Assert.AreEqual(Occupation.Offered, task.State) Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId) Assert.AreEqual( (Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf kind) 0L kind).Value, task) let world = WorldBootstrap.initialWorldWithOccupation true 42UL 4 (Some state) Assert.AreEqual(float32 state.Profile.InitialMoney, world.Avatar.Mind.Needs.Money) Assert.AreEqual(state.Profile.InitialEnergy, world.Avatar.Mind.Needs.Energy) [] member _.ChosenOccupationAndTodaysTaskRoundTripThroughV3Save () = for kind in Occupation.all do let state = WorldBootstrap.occupationStateFor 42UL kind (3L * Sim.ticksPerDay + 120L) let world = WorldBootstrap.initialWorldWithOccupation false 42UL 4 (Some state) OccupationFlowHarness.withTempSave (fun path -> WorldSave.saveToFileWith (Some state) path world let text = System.IO.File.ReadAllText path Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "chosen occupation must save as v3") match WorldSave.loadFromFileWith path with | Ok (restoredWorld, Some restoredState) -> Assert.AreEqual(kind, restoredState.Profile.Kind) Assert.AreEqual(state, restoredState) Assert.AreEqual(text, WorldSave.saveWith (Some restoredState) restoredWorld) | Ok (_, None) -> Assert.Fail($"v3 occupation lost for {kind}") | Error message -> Assert.Fail($"v3 round trip failed for {kind}: {message}")) [] member _.TaskPanelShowsTheChosenOccupationsOfferedTask () = let world = Sim.initialWorldN 42UL 4 for kind in Occupation.all do let state = WorldBootstrap.occupationStateFor 42UL kind 0L let view = { M5Interaction.initial with Task = Some state } let _, taskView = M5Interaction.apply ShowTaskPanel world view let lines = M5Interaction.panelLines world taskView Assert.IsTrue(lines |> List.contains (Occupation.nameOf kind), $"occupation name missing for {kind}") Assert.IsTrue( lines |> List.exists (fun line -> line.Contains(Occupation.taskNameOf state.Today.Value.TemplateId)), $"task name missing for {kind}") Assert.IsTrue(lines |> List.exists (fun line -> line.Contains("待接受")), $"offered state missing for {kind}") [] member _.RefreshOccupationTodayKeepsSameDayAndRegeneratesForTheNewDay () = let state = WorldBootstrap.occupationStateFor 42UL Occupation.Farmer 0L Assert.AreEqual(state, WorldBootstrap.refreshOccupationToday 42UL state (Sim.ticksPerDay - 1L)) let day1 = WorldBootstrap.refreshOccupationToday 42UL state Sim.ticksPerDay Assert.AreEqual( Some (Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf Occupation.Farmer) 1L Occupation.Farmer).Value, day1.Today) [] member _.ChosenOccupationFirstDayTaskReachesDoneThroughItsSignal () = // 既有事件通道:交易/对话模板由 InteractionEvent 经 signalOfInteraction 推进; // 到达瓦片/夜水边/观察由调用方折叠为信号(Sim.step 不感知)。 let eventOf (template: Occupation.TaskTemplateId) : Sim.InteractionEvent option = match template with | Occupation.DeliverGrain -> Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Food, 1, 1.0f) } | Occupation.SellFish -> Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Fish, 1, 1.0f) } | Occupation.Resell -> Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Spice, 1, 1.0f) } | Occupation.BuyGoods -> Some { Tick = 0L; Kind = Sim.TradeEvent(Sim.playerId, NpcId 1, Spice, 1, 1.0f) } | Occupation.HelpWork | Occupation.MarketInquiry | Occupation.ReasonDebate -> Some { Tick = 0L Kind = Sim.DialogueEvent { Tick = 0L Actor = Sim.playerId Target = NpcId 0 Intent = SmallTalk Response = Friendly Valence = 0.3f } } | Occupation.TillSoil | Occupation.NightCatch | Occupation.ObserveNotes -> None let foldedSignal (template: Occupation.TaskTemplateId) : Occupation.TaskSignal = match template with | Occupation.TillSoil -> Occupation.ArrivedAt(52, 38) | Occupation.NightCatch -> Occupation.NightAtWater | Occupation.ObserveNotes -> Occupation.Observed | _ -> failwith "event-backed template" for kind in Occupation.all do let state = WorldBootstrap.occupationStateFor 42UL kind 0L let offered = state.Today.Value Assert.AreEqual(Occupation.Offered, offered.State) Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains offered.TemplateId) let active = { state with Today = Some(Occupation.acceptTask offered) } let completed = match eventOf offered.TemplateId with | Some event -> Occupation.advanceTask event active | None -> { active with Today = active.Today |> Option.map (Occupation.applySignal (foldedSignal offered.TemplateId)) } Assert.AreEqual(Occupation.Done, completed.Today.Value.State)