From 8d901c0dabe26dbef6cbea11d48809461c1ea50c Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 09:18:03 +0800 Subject: test(desktop): P37 职业系统首切片验收回归 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 P37ProfessionTests(8 例):渔夫首项钓鱼相关、同职业同 seed 任务序列 逐项确定、不同职业序列不同、开局钱/体力差异、开局物品包、v1/v2 旧档读入 无职业、选择页键盘导航+Enter 确认、职业中文文案有 CJK 字形可渲染。 --- .../LivingVillage.Desktop.Tests.fsproj | 1 + .../P37ProfessionTests.fs | 124 +++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/LivingVillage.Desktop.Tests/P37ProfessionTests.fs diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 20d9e61..2413f29 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -17,6 +17,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/P37ProfessionTests.fs b/src/LivingVillage.Desktop.Tests/P37ProfessionTests.fs new file mode 100644 index 0000000..e5307d3 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P37ProfessionTests.fs @@ -0,0 +1,124 @@ +namespace LivingVillage.Desktop.Tests + +open System +open System.IO +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop +open LivingVillage.Desktop.ChineseText + +/// P37 职业系统首切片验收回归(docs/design/profession-system.md §4): +/// 渔夫首项钓鱼相关、职业任务生成确定性、开局属性差异、旧档兼容、中文 UI 选择。 +[] +type P37ProfessionTests () = + + let withTempSave (action: string -> unit) = + let id = Guid.NewGuid().ToString("N") + let path = Path.Combine(Path.GetTempPath(), $"living-village-p37-{id}.save") + try + action path + finally + if File.Exists path then File.Delete path + + let fisherPool : Occupation.TaskTemplateId list = + [ Occupation.NightCatch; Occupation.SellFish; Occupation.MarketInquiry ] + + let sequence (seed: uint64) (kind: Occupation.Kind) (days: int64 list) : Occupation.TaskTemplateId list = + days + |> List.map (fun day -> + (Occupation.dailyTaskOf seed (Occupation.occupationSeedOf kind) day kind).Value.TemplateId) + + [] + member _.FisherFirstTaskIsFishingRelated () = + let state = WorldBootstrap.occupationStateFor 42UL Occupation.Fisher 0L + match state.Today with + | Some task -> + Assert.IsTrue(fisherPool |> List.contains task.TemplateId, sprintf "渔夫当日任务越池:%A" task.TemplateId) + Assert.IsTrue( + [ "夜捕"; "卖渔"; "询价" ] |> List.contains (Occupation.taskNameOf task.TemplateId), + "渔夫首项必须是钓鱼相关任务") + | None -> Assert.Fail("渔夫开局必须生成当日任务") + + [] + member _.SameProfessionAndSeedProduceIdenticalTaskSequences () = + let days = [ 0L .. 29L ] + for kind in Occupation.all do + let once = sequence 42UL kind days + let twice = sequence 42UL kind days + Assert.AreEqual(once, twice) + for day in days do + let task = (Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf kind) day kind).Value + Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId, sprintf "day %d 越池" day) + + [] + member _.DifferentProfessionsProduceDifferentTaskSequences () = + let days = [ 0L .. 29L ] + let fisher = sequence 42UL Occupation.Fisher days + for kind in [ Occupation.Farmer; Occupation.Peddler; Occupation.Scholar ] do + Assert.IsTrue(fisher <> sequence 42UL kind days, sprintf "渔夫与 %A 的任务序列不应相同" kind) + + [] + member _.OpeningMoneyAndEnergyDifferPerProfession () = + let needsOf (kind: Occupation.Kind) = + let world = WorldBootstrap.initialWorldWithOccupation false 42UL 4 (Some(Occupation.stateOf kind)) + world.Avatar.Mind.Needs + Assert.AreEqual(60.0f, (needsOf Occupation.Farmer).Money) + Assert.AreEqual(45.0f, (needsOf Occupation.Fisher).Money) + Assert.AreEqual(90.0f, (needsOf Occupation.Peddler).Money) + Assert.AreEqual(70.0f, (needsOf Occupation.Scholar).Money) + Assert.AreEqual(95.0f, (needsOf Occupation.Fisher).Energy) + for kind in [ Occupation.Farmer; Occupation.Peddler; Occupation.Scholar ] do + Assert.AreEqual(100.0f, (needsOf kind).Energy) + + [] + member _.OpeningInventoryBundleIsDefinedPerProfession () = + Assert.AreEqual<(Sim.ItemKind * int) list>([ Sim.Food, 12 ], (Occupation.profileOf Occupation.Farmer).InitialInventory) + Assert.AreEqual<(Sim.ItemKind * int) list>( + [ Sim.Food, 8; Sim.Fish, 3 ], + (Occupation.profileOf Occupation.Fisher).InitialInventory) + Assert.AreEqual<(Sim.ItemKind * int) list>( + [ Sim.Food, 6; Sim.Spice, 4 ], + (Occupation.profileOf Occupation.Peddler).InitialInventory) + Assert.AreEqual<(Sim.ItemKind * int) list>( + [ Sim.Food, 10; Sim.Scroll, 2 ], + (Occupation.profileOf Occupation.Scholar).InitialInventory) + + [] + member _.LegacyAndNoProfessionSavesLoadAsNoOccupation () = + let world = Sim.initialWorldN 42UL 4 + withTempSave (fun path -> + File.WriteAllText(path, WorldSave.save world) + let v2Text = File.ReadAllText path + Assert.IsTrue(v2Text.StartsWith("LV_WORLD_SAVE_V2"), "无职业存档应保持 v2") + match WorldSave.loadFromFileWith path with + | Ok (_, occupation) -> Assert.AreEqual(None, occupation) + | Error message -> Assert.Fail(sprintf "v2 读取失败:%s" message) + + let firstPipe = v2Text.IndexOf('|') + let secondPipe = v2Text.IndexOf('|', firstPipe + 1) + let thirdPipe = v2Text.IndexOf('|', secondPipe + 1) + File.WriteAllText(path, "LV_WORLD_SAVE_V1" + v2Text.Substring(thirdPipe)) + match WorldSave.loadFromFileWith path with + | Ok (_, occupation) -> Assert.AreEqual(None, occupation) + | Error message -> Assert.Fail(sprintf "v1 读取失败:%s" message)) + + [] + member _.OccupationSelectPageNavigatesAndEmitsChosenKind () = + // 默认选中「暂不选择」(index 4);两次 Down 到「渔夫」(index 1),Enter 确认。 + let page = MenuState.openOccupationSelect (MenuState.create SimulationControl.initial false) + Assert.AreEqual(OccupationSelect, page.Page) + Assert.AreEqual(None, MenuState.selectedOccupation page) + let fisher = MenuState.move Down (MenuState.move Down page) + Assert.AreEqual(Some Occupation.Fisher, MenuState.selectedOccupation fisher) + let _, command = MenuState.update Confirm fisher + Assert.AreEqual(StartNewGameWith(Some Occupation.Fisher), command) + + [] + member _.OccupationUiLabelsAreAtlasBacked () = + let labels = + [ "选择职业"; "暂不选择"; Occupation.nameOf Occupation.Farmer; Occupation.nameOf Occupation.Fisher + Occupation.nameOf Occupation.Peddler; Occupation.nameOf Occupation.Scholar ] + for label in labels do + Assert.IsTrue(requiredUiLabels |> List.contains label, sprintf "缺少必备用字登记:%s" label) + Assert.IsTrue(PixelText.isRenderable label, sprintf "文案不可渲染(缺字):%s" label) -- cgit v1.2.3