runtime-smoke-test.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. declare const console: {
  2. log: (...args: unknown[]) => void
  3. }
  4. import { buildGameDefinitionFromCourse } from '../miniprogram/game/content/courseToGameDefinition'
  5. import { getGameModeDefaults } from '../miniprogram/game/core/gameModeDefaults'
  6. import { GameRuntime } from '../miniprogram/game/core/gameRuntime'
  7. import { ScoreORule } from '../miniprogram/game/rules/scoreORule'
  8. import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState'
  9. import { type GameDefinition } from '../miniprogram/game/core/gameDefinition'
  10. import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse'
  11. type StorageMap = Record<string, unknown>
  12. function assert(condition: boolean, message: string): void {
  13. if (!condition) {
  14. throw new Error(message)
  15. }
  16. }
  17. function createWxStorage(storage: StorageMap): void {
  18. ;(globalThis as { wx?: unknown }).wx = {
  19. getStorageSync(key: string): unknown {
  20. return storage[key]
  21. },
  22. setStorageSync(key: string, value: unknown): void {
  23. storage[key] = value
  24. },
  25. }
  26. }
  27. function buildCourse(): OrienteeringCourseData {
  28. return {
  29. title: 'Smoke Test Course',
  30. layers: {
  31. starts: [
  32. { label: 'Start', point: { lon: 120.0, lat: 30.0 }, headingDeg: 90 },
  33. ],
  34. controls: [
  35. { label: '1', sequence: 1, point: { lon: 120.0001, lat: 30.0 } },
  36. { label: '2', sequence: 2, point: { lon: 120.0002, lat: 30.0 } },
  37. ],
  38. finishes: [
  39. { label: 'Finish', point: { lon: 120.0003, lat: 30.0 } },
  40. ],
  41. legs: [],
  42. },
  43. }
  44. }
  45. function getControl(definition: GameDefinition, id: string) {
  46. return definition.controls.find((control) => control.id === id) || null
  47. }
  48. function testControlInheritance(): void {
  49. const definition = buildGameDefinitionFromCourse(
  50. buildCourse(),
  51. 5,
  52. 'score-o',
  53. undefined,
  54. undefined,
  55. undefined,
  56. undefined,
  57. 'enter-confirm',
  58. 5,
  59. false,
  60. false,
  61. undefined,
  62. undefined,
  63. { 'control-2': 80 },
  64. {
  65. title: '默认说明',
  66. body: '所有点默认正文',
  67. },
  68. {
  69. 'control-2': {
  70. body: '2号点单点覆盖正文',
  71. },
  72. },
  73. 30,
  74. )
  75. const control1 = getControl(definition, 'control-1')
  76. const control2 = getControl(definition, 'control-2')
  77. assert(!!control1 && !!control2, '应生成普通检查点')
  78. assert(control1!.score === 30, 'controlDefaults 默认分值应继承到普通点')
  79. assert(control2!.score === 80, '单点 score override 应覆盖默认分值')
  80. assert(!!control1!.displayContent && control1!.displayContent.title === '默认说明', '默认内容标题应继承到普通点')
  81. assert(!!control2!.displayContent && control2!.displayContent.body === '2号点单点覆盖正文', '单点内容 override 应覆盖默认正文')
  82. }
  83. function testScoreOFreePunchAndFinishGate(): void {
  84. const definition = buildGameDefinitionFromCourse(
  85. buildCourse(),
  86. 5,
  87. 'score-o',
  88. 2 * 60 * 60 * 1000,
  89. 10 * 60 * 1000,
  90. 1,
  91. false,
  92. 'enter-confirm',
  93. 5,
  94. false,
  95. )
  96. const rule = new ScoreORule()
  97. let state = rule.initialize(definition)
  98. let result = rule.reduce(definition, state, { type: 'session_started', at: 1 })
  99. state = result.nextState
  100. result = rule.reduce(definition, state, {
  101. type: 'gps_updated',
  102. at: 2,
  103. lon: 120.0,
  104. lat: 30.0,
  105. accuracyMeters: null,
  106. })
  107. state = result.nextState
  108. result = rule.reduce(definition, state, {
  109. type: 'punch_requested',
  110. at: 3,
  111. lon: 120.0,
  112. lat: 30.0,
  113. })
  114. state = result.nextState
  115. assert(state.completedControlIds.includes('start-1'), '积分赛应能完成开始点')
  116. result = rule.reduce(definition, state, {
  117. type: 'gps_updated',
  118. at: 4,
  119. lon: 120.0001,
  120. lat: 30.0,
  121. accuracyMeters: null,
  122. })
  123. state = result.nextState
  124. assert(result.presentation.hud.punchButtonEnabled, '自由打点时进入普通点范围应可直接打点')
  125. result = rule.reduce(definition, state, {
  126. type: 'punch_requested',
  127. at: 5,
  128. lon: 120.0001,
  129. lat: 30.0,
  130. })
  131. state = result.nextState
  132. assert(state.completedControlIds.includes('control-1'), '积分赛默认无需先选中也应可打普通点')
  133. const preFinishState = rule.initialize(definition)
  134. let preFinishResult = rule.reduce(definition, preFinishState, { type: 'session_started', at: 10 })
  135. let runningState = preFinishResult.nextState
  136. preFinishResult = rule.reduce(definition, runningState, {
  137. type: 'gps_updated',
  138. at: 11,
  139. lon: 120.0,
  140. lat: 30.0,
  141. accuracyMeters: null,
  142. })
  143. runningState = preFinishResult.nextState
  144. preFinishResult = rule.reduce(definition, runningState, {
  145. type: 'punch_requested',
  146. at: 12,
  147. lon: 120.0,
  148. lat: 30.0,
  149. })
  150. runningState = preFinishResult.nextState
  151. preFinishResult = rule.reduce(definition, runningState, {
  152. type: 'punch_requested',
  153. at: 13,
  154. lon: 120.0003,
  155. lat: 30.0,
  156. })
  157. assert(preFinishResult.effects.some((effect) => effect.type === 'punch_feedback'), '未完成最低点数前打终点应被拦截')
  158. result = rule.reduce(definition, state, {
  159. type: 'gps_updated',
  160. at: 6,
  161. lon: 120.0003,
  162. lat: 30.0,
  163. accuracyMeters: null,
  164. })
  165. state = result.nextState
  166. assert(result.presentation.hud.punchButtonText === '结束打卡', '终点进入范围后按钮文案应切为结束打卡')
  167. result = rule.reduce(definition, state, {
  168. type: 'punch_requested',
  169. at: 7,
  170. lon: 120.0003,
  171. lat: 30.0,
  172. })
  173. state = result.nextState
  174. assert(state.status === 'finished' && state.endReason === 'completed', '达到终点解锁条件后应可正常结束')
  175. }
  176. function testSettingsLockLifecycle(): void {
  177. const storage: StorageMap = {
  178. cmr_user_settings_v1: {
  179. gpsMarkerStyle: 'dot',
  180. trackDisplayMode: 'full',
  181. },
  182. }
  183. createWxStorage(storage)
  184. const runtimeLocked = resolveSystemSettingsState(
  185. {
  186. values: {
  187. gpsMarkerStyle: 'beacon',
  188. },
  189. locks: {
  190. lockGpsMarkerStyle: true,
  191. },
  192. },
  193. 'cmr_user_settings_v1',
  194. true,
  195. )
  196. assert(runtimeLocked.values.gpsMarkerStyle === 'beacon', '本局锁定时应以配置值为准')
  197. assert(runtimeLocked.locks.lockGpsMarkerStyle, '本局内锁态应生效')
  198. const runtimeReleased = resolveSystemSettingsState(
  199. {
  200. values: {
  201. gpsMarkerStyle: 'beacon',
  202. },
  203. locks: {
  204. lockGpsMarkerStyle: true,
  205. },
  206. },
  207. 'cmr_user_settings_v1',
  208. false,
  209. )
  210. assert(runtimeReleased.values.gpsMarkerStyle === 'dot', '脱离本局后应回落到玩家持久化设置')
  211. assert(!runtimeReleased.locks.lockGpsMarkerStyle, '脱离本局后锁态应自动解除')
  212. }
  213. function testTimeoutEndReason(): void {
  214. const definition = buildGameDefinitionFromCourse(buildCourse(), 5, 'classic-sequential')
  215. const rule = new ScoreORule()
  216. const state = rule.initialize(definition)
  217. const result = rule.reduce(definition, state, { type: 'session_timed_out', at: 99 })
  218. assert(result.nextState.status === 'failed', '超时应进入 failed 状态')
  219. assert(result.nextState.endReason === 'timed_out', '超时结束原因应为 timed_out')
  220. }
  221. function testClassicSequentialSkipConfirmDefault(): void {
  222. const defaults = getGameModeDefaults('classic-sequential')
  223. assert(defaults.skipEnabled, '顺序打点默认应开启跳点')
  224. assert(defaults.skipRequiresConfirm, '顺序打点默认跳点应弹出确认')
  225. }
  226. function testRuntimeRestoreDefinition(): void {
  227. const definition = buildGameDefinitionFromCourse(
  228. buildCourse(),
  229. 5,
  230. 'score-o',
  231. 2 * 60 * 60 * 1000,
  232. 10 * 60 * 1000,
  233. 1,
  234. false,
  235. 'enter-confirm',
  236. 5,
  237. false,
  238. )
  239. const runtime = new GameRuntime()
  240. runtime.loadDefinition(definition)
  241. runtime.startSession(1)
  242. runtime.dispatch({
  243. type: 'gps_updated',
  244. at: 2,
  245. lon: 120.0,
  246. lat: 30.0,
  247. accuracyMeters: null,
  248. })
  249. runtime.dispatch({
  250. type: 'punch_requested',
  251. at: 3,
  252. lon: 120.0,
  253. lat: 30.0,
  254. })
  255. runtime.dispatch({
  256. type: 'gps_updated',
  257. at: 4,
  258. lon: 120.0001,
  259. lat: 30.0,
  260. accuracyMeters: null,
  261. })
  262. runtime.dispatch({
  263. type: 'punch_requested',
  264. at: 5,
  265. lon: 120.0001,
  266. lat: 30.0,
  267. })
  268. const savedState = runtime.state
  269. assert(!!savedState, '恢复测试前应存在对局状态')
  270. const restoredRuntime = new GameRuntime()
  271. const restoreResult = restoredRuntime.restoreDefinition(definition, savedState!)
  272. assert(restoredRuntime.state !== null, '恢复后应保留对局状态')
  273. assert(restoredRuntime.state!.completedControlIds.includes('control-1'), '恢复后应保留已完成检查点')
  274. assert(restoredRuntime.state!.status === 'running', '恢复后对局应继续保持 running')
  275. assert(restoreResult.presentation.hud.punchButtonText === runtime.presentation.hud.punchButtonText, '恢复后 HUD 关键按钮文案应可重建')
  276. }
  277. function run(): void {
  278. createWxStorage({})
  279. testControlInheritance()
  280. testScoreOFreePunchAndFinishGate()
  281. testSettingsLockLifecycle()
  282. testTimeoutEndReason()
  283. testClassicSequentialSkipConfirmDefault()
  284. testRuntimeRestoreDefinition()
  285. console.log('runtime smoke tests passed')
  286. }
  287. run()