summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 09:18:03 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 09:18:03 +0800
commit8d901c0dabe26dbef6cbea11d48809461c1ea50c (patch)
tree2f103cdd310f89b63c392b3e33cbf1e8256e746d
parent44533c3121fc2bda07f4efff6f8a4d9278d9b176 (diff)
downloadliving-village-8d901c0dabe26dbef6cbea11d48809461c1ea50c.tar.gz
test(desktop): P37 职业系统首切片验收回归
新增 P37ProfessionTests(8 例):渔夫首项钓鱼相关、同职业同 seed 任务序列 逐项确定、不同职业序列不同、开局钱/体力差异、开局物品包、v1/v2 旧档读入 无职业、选择页键盘导航+Enter 确认、职业中文文案有 CJK 字形可渲染。
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P37ProfessionTests.fs124
2 files changed, 125 insertions, 0 deletions
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 @@
<Compile Include="M3BatchOutputTests.fs" />
<Compile Include="AssetPackTests.fs" />
<Compile Include="P36PromptTests.fs" />
+ <Compile Include="P37ProfessionTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
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 选择。
+[<TestClass>]
+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)
+
+ [<TestMethod>]
+ 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("渔夫开局必须生成当日任务")
+
+ [<TestMethod>]
+ 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<Occupation.TaskTemplateId list>(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)
+
+ [<TestMethod>]
+ 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)
+
+ [<TestMethod>]
+ member _.OpeningMoneyAndEnergyDifferPerProfession () =
+ let needsOf (kind: Occupation.Kind) =
+ let world = WorldBootstrap.initialWorldWithOccupation false 42UL 4 (Some(Occupation.stateOf kind))
+ world.Avatar.Mind.Needs
+ Assert.AreEqual<float32>(60.0f, (needsOf Occupation.Farmer).Money)
+ Assert.AreEqual<float32>(45.0f, (needsOf Occupation.Fisher).Money)
+ Assert.AreEqual<float32>(90.0f, (needsOf Occupation.Peddler).Money)
+ Assert.AreEqual<float32>(70.0f, (needsOf Occupation.Scholar).Money)
+ Assert.AreEqual<float32>(95.0f, (needsOf Occupation.Fisher).Energy)
+ for kind in [ Occupation.Farmer; Occupation.Peddler; Occupation.Scholar ] do
+ Assert.AreEqual<float32>(100.0f, (needsOf kind).Energy)
+
+ [<TestMethod>]
+ 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)
+
+ [<TestMethod>]
+ 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<Occupation.State option>(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<Occupation.State option>(None, occupation)
+ | Error message -> Assert.Fail(sprintf "v1 读取失败:%s" message))
+
+ [<TestMethod>]
+ member _.OccupationSelectPageNavigatesAndEmitsChosenKind () =
+ // 默认选中「暂不选择」(index 4);两次 Down 到「渔夫」(index 1),Enter 确认。
+ let page = MenuState.openOccupationSelect (MenuState.create SimulationControl.initial false)
+ Assert.AreEqual<MenuPage>(OccupationSelect, page.Page)
+ Assert.AreEqual<Occupation.Kind option>(None, MenuState.selectedOccupation page)
+ let fisher = MenuState.move Down (MenuState.move Down page)
+ Assert.AreEqual<Occupation.Kind option>(Some Occupation.Fisher, MenuState.selectedOccupation fisher)
+ let _, command = MenuState.update Confirm fisher
+ Assert.AreEqual<MenuCommand>(StartNewGameWith(Some Occupation.Fisher), command)
+
+ [<TestMethod>]
+ 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)