1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
|
namespace LivingVillage.Desktop.Tests
open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop
/// P44 对话框/文字弹层回归:中文断行、分页边界、输入隔离、Esc 关闭、提示隐藏、原创头像。
[<TestClass>]
type P44DialogTests () =
let world = Sim.initialWorldN 42UL 4
let assertEqualMsg (expected: 'a) (actual: 'a) (message: string) =
Assert.IsTrue((expected = actual), sprintf "%s (expected %A, actual %A)" message expected actual)
// ---- 中文正确断行 ----
[<TestMethod>]
member _.WrapBreaksChinesePerCharacterNotPerByte () =
// 每个 CJK 字宽 = 8 * textScale = 16px;宽 64px 恰好容纳 4 字。
assertEqualMsg [ "互动村民"; "求助" ] (DialogOverlay.wrapText 64 "互动村民求助") "按字断行"
// 单字过宽时也必须能断,不得死循环或丢字。
assertEqualMsg [ "互"; "动"; "村" ] (DialogOverlay.wrapText 10 "互动村") "超窄宽度逐字断行"
[<TestMethod>]
member _.WrapKeepsLatinWordsWholeAndHonoursNewlines () =
// "hello" = 5*6*2 = 60px;宽 70px 只能放一个词。
assertEqualMsg [ "hello"; "world" ] (DialogOverlay.wrapText 70 "hello world") "拉丁词不劈开"
assertEqualMsg [ "ab"; ""; "cd" ] (DialogOverlay.wrapText 200 "ab\n\ncd") "显式换行保留空行"
assertEqualMsg [ "" ] (DialogOverlay.wrapText 200 "") "空串给一行空"
for line in DialogOverlay.wrapText 64 "互动村民闲聊求助交易" do
Assert.IsTrue(
FloatingPrompt.measureWidth line DialogOverlay.textScale <= 64,
sprintf "换行后每行须不超宽:%s" line)
// ---- 分页 ----
[<TestMethod>]
member _.PagesChunkBodyByLinesPerPage () =
let texts = [ "a"; "b"; "c"; "d"; "e" ]
let model = DialogOverlay.create "标题" texts 1280 false
assertEqualMsg 2 (DialogOverlay.pageCount model) "5 行按每页 4 行切成 2 页"
let pages = model.Pages
assertEqualMsg 4 pages.[0].Length "首页 4 行"
assertEqualMsg 1 pages.[1].Length "次页 1 行"
[<TestMethod>]
member _.NextPageStopsAtLastPageWithoutCrash () =
let model = DialogOverlay.create "标题" [ "a"; "b"; "c"; "d"; "e" ] 1280 false
// 翻到最后一页。
let mutable current = model
let mutable guard = 0
while (DialogOverlay.nextPage current).IsSome && guard < 100 do
current <- (DialogOverlay.nextPage current).Value
guard <- guard + 1
let last = DialogOverlay.view current
assertEqualMsg last.PageCount last.PageNumber "停在最后一页"
Assert.IsFalse(last.HasNext, "最后一页没有下一页")
// 再翻不崩、保持最后一页。
assertEqualMsg None (DialogOverlay.nextPage current) "最后一页再翻返回 None"
assertEqualMsg current { current with Page = current.Page } "最后一页再翻不改状态"
assertEqualMsg None (DialogOverlay.prevPage { model with Page = 0 }) "首页再往回返回 None"
// ---- 输入隔离 / 关闭 / 提示隐藏 ----
[<TestMethod>]
member _.OverlayBlocksWorldInteractionAndHidesPrompt () =
Assert.IsTrue(DialogOverlay.blocksWorldInteraction true, "弹层开启须禁止世界交互穿透")
Assert.IsFalse(DialogOverlay.blocksWorldInteraction false, "弹层关闭不阻断")
Assert.IsFalse(DialogOverlay.promptVisible true, "弹层开启时世界交互提示须隐藏")
Assert.IsTrue(DialogOverlay.promptVisible false, "弹层关闭后提示恢复")
// 对照 P43 hotbar 的输入隔离:菜单/面板打开时世界输入不允许。
Assert.IsFalse(M5Interaction.worldInputAllowed { M5Interaction.initial with Panel = DialoguePanel })
[<TestMethod>]
member _.EscCloseDropsOverlayAndRestoresWorldPrompt () =
let model = DialogOverlay.create "标题" [ "a" ] 1280 true
let opened = Some model
Assert.IsTrue(DialogOverlay.blocksWorldInteraction opened.IsSome, "开启时阻断")
let closed = DialogOverlay.close opened
Assert.IsTrue(closed.IsNone, "关闭后弹层消失")
Assert.IsFalse(DialogOverlay.blocksWorldInteraction closed.IsSome, "关闭后不阻断")
Assert.IsTrue(DialogOverlay.promptVisible closed.IsSome, "关闭后世界提示恢复")
// Esc 关弹层 = 真实 ClosePanel:DialoguePanel 回到 WorldPanel,提示随后可刷新出来。
let view = { M5Interaction.initial with Panel = DialoguePanel }
let _, closedView = M5Interaction.apply M5Command.ClosePanel world view
assertEqualMsg M5Panel.WorldPanel closedView.Panel "Esc/ClosePanel 回到世界面板"
let refreshed = M5Interaction.refreshPrompt world closedView
Assert.IsTrue(M5Interaction.worldInputAllowed refreshed, "关闭后世界输入恢复")
[<TestMethod>]
member _.WorldPromptIsHiddenWhileDialogPanelOpen () =
let view = { M5Interaction.initial with Panel = DialoguePanel; Prompt = Some "E 交谈" }
let refreshed = M5Interaction.refreshPrompt world view
Assert.IsTrue(refreshed.Prompt.IsNone, "对话弹层开启时 worldview 提示须为 None")
Assert.IsTrue(refreshed.PromptTargetPos.IsNone, "对话弹层开启时提示锚点也清空")
// ---- 完整背景框与原创头像 ----
[<TestMethod>]
member _.DialogFrameFitsViewportAndHoldsAvatar () =
let model = DialogOverlay.create "互动:村民 1" [ "1 闲聊"; "2 求助"; "3 交易"; "4 开玩笑"; "ESC 关闭" ] 1280 true
let layout = DialogOverlay.rect 1280 720 model
Assert.IsTrue(layout.X >= 0 && layout.Y >= 0, "弹层不得越出视口")
Assert.IsTrue(layout.X + layout.Width <= 1280 && layout.Y + layout.Height <= 720, "弹层不得越出视口")
Assert.IsTrue(layout.Width >= DialogOverlay.minFrameWidth, "背景框有足够宽度")
let avatar = DialogOverlay.avatarRect layout
Assert.IsTrue(avatar.X >= layout.X && avatar.Y >= layout.Y, "头像须在框内")
Assert.IsTrue(
avatar.X + avatar.Width <= layout.X + layout.Width && avatar.Y + avatar.Height <= layout.Y + layout.Height,
"头像须在框内")
assertEqualMsg DialogOverlay.avatarWidth avatar.Width "头像宽 32"
assertEqualMsg DialogOverlay.avatarHeight avatar.Height "头像高 64"
[<TestMethod>]
member _.OriginalAvatarPatternIsWellFormedDotMatrix () =
assertEqualMsg DialogOverlay.avatarRows DialogOverlay.avatarPattern.Length "头像行数"
for row in DialogOverlay.avatarPattern do
assertEqualMsg DialogOverlay.avatarColumns row.Length "头像行宽"
Assert.IsTrue(DialogOverlay.avatarPattern |> List.exists (fun r -> r.Contains "1"), "头像须有实心像素")
assertEqualMsg DialogOverlay.avatarWidth (DialogOverlay.avatarColumns * DialogOverlay.avatarScale) "头像宽=列×scale"
assertEqualMsg DialogOverlay.avatarHeight (DialogOverlay.avatarRows * DialogOverlay.avatarScale) "头像高=行×scale"
|