Files
cmr-mini/tools/runtime-smoke-test.ts

379 lines
12 KiB
TypeScript

declare const console: {
log: (...args: unknown[]) => void
}
import { buildGameDefinitionFromCourse } from '../miniprogram/game/content/courseToGameDefinition'
import { getGameModeDefaults } from '../miniprogram/game/core/gameModeDefaults'
import { GameRuntime } from '../miniprogram/game/core/gameRuntime'
import { ScoreORule } from '../miniprogram/game/rules/scoreORule'
import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState'
import { adaptBackendLaunchResultToEnvelope } from '../miniprogram/utils/backendLaunchAdapter'
import { type GameDefinition } from '../miniprogram/game/core/gameDefinition'
import { type BackendLaunchResult } from '../miniprogram/utils/backendApi'
import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse'
type StorageMap = Record<string, unknown>
function assert(condition: boolean, message: string): void {
if (!condition) {
throw new Error(message)
}
}
function createWxStorage(storage: StorageMap): void {
;(globalThis as { wx?: unknown }).wx = {
getStorageSync(key: string): unknown {
return storage[key]
},
setStorageSync(key: string, value: unknown): void {
storage[key] = value
},
}
}
function buildCourse(): OrienteeringCourseData {
return {
title: 'Smoke Test Course',
layers: {
starts: [
{ label: 'Start', point: { lon: 120.0, lat: 30.0 }, headingDeg: 90 },
],
controls: [
{ label: '1', sequence: 1, point: { lon: 120.0001, lat: 30.0 } },
{ label: '2', sequence: 2, point: { lon: 120.0002, lat: 30.0 } },
],
finishes: [
{ label: 'Finish', point: { lon: 120.0003, lat: 30.0 } },
],
legs: [],
},
}
}
function getControl(definition: GameDefinition, id: string) {
return definition.controls.find((control) => control.id === id) || null
}
function testControlInheritance(): void {
const definition = buildGameDefinitionFromCourse(
buildCourse(),
5,
'score-o',
undefined,
undefined,
undefined,
undefined,
'enter-confirm',
5,
false,
false,
undefined,
undefined,
{ 'control-2': 80 },
{
title: '默认说明',
body: '所有点默认正文',
},
{
'control-2': {
body: '2号点单点覆盖正文',
},
},
30,
)
const control1 = getControl(definition, 'control-1')
const control2 = getControl(definition, 'control-2')
assert(!!control1 && !!control2, '应生成普通检查点')
assert(control1!.score === 30, 'controlDefaults 默认分值应继承到普通点')
assert(control2!.score === 80, '单点 score override 应覆盖默认分值')
assert(!!control1!.displayContent && control1!.displayContent.title === '默认说明', '默认内容标题应继承到普通点')
assert(!!control2!.displayContent && control2!.displayContent.body === '2号点单点覆盖正文', '单点内容 override 应覆盖默认正文')
}
function testScoreOFreePunchAndFinishGate(): void {
const definition = buildGameDefinitionFromCourse(
buildCourse(),
5,
'score-o',
2 * 60 * 60 * 1000,
10 * 60 * 1000,
1,
false,
'enter-confirm',
5,
false,
)
const rule = new ScoreORule()
let state = rule.initialize(definition)
let result = rule.reduce(definition, state, { type: 'session_started', at: 1 })
state = result.nextState
result = rule.reduce(definition, state, {
type: 'gps_updated',
at: 2,
lon: 120.0,
lat: 30.0,
accuracyMeters: null,
})
state = result.nextState
result = rule.reduce(definition, state, {
type: 'punch_requested',
at: 3,
lon: 120.0,
lat: 30.0,
})
state = result.nextState
assert(state.completedControlIds.includes('start-1'), '积分赛应能完成开始点')
result = rule.reduce(definition, state, {
type: 'gps_updated',
at: 4,
lon: 120.0001,
lat: 30.0,
accuracyMeters: null,
})
state = result.nextState
assert(result.presentation.hud.punchButtonEnabled, '自由打点时进入普通点范围应可直接打点')
result = rule.reduce(definition, state, {
type: 'punch_requested',
at: 5,
lon: 120.0001,
lat: 30.0,
})
state = result.nextState
assert(state.completedControlIds.includes('control-1'), '积分赛默认无需先选中也应可打普通点')
const preFinishState = rule.initialize(definition)
let preFinishResult = rule.reduce(definition, preFinishState, { type: 'session_started', at: 10 })
let runningState = preFinishResult.nextState
preFinishResult = rule.reduce(definition, runningState, {
type: 'gps_updated',
at: 11,
lon: 120.0,
lat: 30.0,
accuracyMeters: null,
})
runningState = preFinishResult.nextState
preFinishResult = rule.reduce(definition, runningState, {
type: 'punch_requested',
at: 12,
lon: 120.0,
lat: 30.0,
})
runningState = preFinishResult.nextState
preFinishResult = rule.reduce(definition, runningState, {
type: 'punch_requested',
at: 13,
lon: 120.0003,
lat: 30.0,
})
assert(preFinishResult.effects.some((effect) => effect.type === 'punch_feedback'), '未完成最低点数前打终点应被拦截')
result = rule.reduce(definition, state, {
type: 'gps_updated',
at: 6,
lon: 120.0003,
lat: 30.0,
accuracyMeters: null,
})
state = result.nextState
assert(result.presentation.hud.punchButtonText === '结束打卡', '终点进入范围后按钮文案应切为结束打卡')
result = rule.reduce(definition, state, {
type: 'punch_requested',
at: 7,
lon: 120.0003,
lat: 30.0,
})
state = result.nextState
assert(state.status === 'finished' && state.endReason === 'completed', '达到终点解锁条件后应可正常结束')
}
function testSettingsLockLifecycle(): void {
const storage: StorageMap = {
cmr_user_settings_v1: {
gpsMarkerStyle: 'dot',
trackDisplayMode: 'full',
},
}
createWxStorage(storage)
const runtimeLocked = resolveSystemSettingsState(
{
values: {
gpsMarkerStyle: 'beacon',
},
locks: {
lockGpsMarkerStyle: true,
},
},
'cmr_user_settings_v1',
true,
)
assert(runtimeLocked.values.gpsMarkerStyle === 'beacon', '本局锁定时应以配置值为准')
assert(runtimeLocked.locks.lockGpsMarkerStyle, '本局内锁态应生效')
const runtimeReleased = resolveSystemSettingsState(
{
values: {
gpsMarkerStyle: 'beacon',
},
locks: {
lockGpsMarkerStyle: true,
},
},
'cmr_user_settings_v1',
false,
)
assert(runtimeReleased.values.gpsMarkerStyle === 'dot', '脱离本局后应回落到玩家持久化设置')
assert(!runtimeReleased.locks.lockGpsMarkerStyle, '脱离本局后锁态应自动解除')
}
function testTimeoutEndReason(): void {
const definition = buildGameDefinitionFromCourse(buildCourse(), 5, 'classic-sequential')
const rule = new ScoreORule()
const state = rule.initialize(definition)
const result = rule.reduce(definition, state, { type: 'session_timed_out', at: 99 })
assert(result.nextState.status === 'failed', '超时应进入 failed 状态')
assert(result.nextState.endReason === 'timed_out', '超时结束原因应为 timed_out')
}
function testClassicSequentialSkipConfirmDefault(): void {
const defaults = getGameModeDefaults('classic-sequential')
assert(defaults.skipEnabled, '顺序打点默认应开启跳点')
assert(defaults.skipRequiresConfirm, '顺序打点默认跳点应弹出确认')
}
function testRuntimeRestoreDefinition(): void {
const definition = buildGameDefinitionFromCourse(
buildCourse(),
5,
'score-o',
2 * 60 * 60 * 1000,
10 * 60 * 1000,
1,
false,
'enter-confirm',
5,
false,
)
const runtime = new GameRuntime()
runtime.loadDefinition(definition)
runtime.startSession(1)
runtime.dispatch({
type: 'gps_updated',
at: 2,
lon: 120.0,
lat: 30.0,
accuracyMeters: null,
})
runtime.dispatch({
type: 'punch_requested',
at: 3,
lon: 120.0,
lat: 30.0,
})
runtime.dispatch({
type: 'gps_updated',
at: 4,
lon: 120.0001,
lat: 30.0,
accuracyMeters: null,
})
runtime.dispatch({
type: 'punch_requested',
at: 5,
lon: 120.0001,
lat: 30.0,
})
const savedState = runtime.state
assert(!!savedState, '恢复测试前应存在对局状态')
const restoredRuntime = new GameRuntime()
const restoreResult = restoredRuntime.restoreDefinition(definition, savedState!)
assert(restoredRuntime.state !== null, '恢复后应保留对局状态')
assert(restoredRuntime.state!.completedControlIds.includes('control-1'), '恢复后应保留已完成检查点')
assert(restoredRuntime.state!.status === 'running', '恢复后对局应继续保持 running')
assert(restoreResult.presentation.hud.punchButtonText === runtime.presentation.hud.punchButtonText, '恢复后 HUD 关键按钮文案应可重建')
}
function testLaunchRuntimeAdapter(): void {
const launchResult: BackendLaunchResult = {
event: {
id: 'evt_demo_variant_manual_001',
displayName: 'Manual Variant Demo',
},
launch: {
source: 'event',
config: {
configUrl: 'https://example.com/runtime.json',
configLabel: 'runtime demo',
releaseId: 'rel_runtime_001',
routeCode: 'route-variant-b',
},
business: {
source: 'direct-event',
eventId: 'evt_demo_variant_manual_001',
sessionId: 'sess_001',
sessionToken: 'token_001',
sessionTokenExpiresAt: '2026-04-03T16:00:00+08:00',
routeCode: 'route-variant-b',
},
variant: {
id: 'variant_b',
name: 'B 线',
routeCode: 'route-variant-b',
assignmentMode: 'manual',
},
runtime: {
runtimeBindingId: 'rtb_001',
placeId: 'place_campus',
placeName: '示范校园',
mapId: 'map_main',
mapName: '主图',
tileReleaseId: 'tile_rel_001',
courseSetId: 'course_set_001',
courseVariantId: 'variant_b',
routeCode: 'route-variant-b',
},
presentation: {
presentationId: 'pres_001',
templateKey: 'campus-v1',
version: 'v3',
},
contentBundle: {
bundleId: 'bundle_001',
bundleType: 'quiz-pack',
version: 'v7',
},
},
}
const envelope = adaptBackendLaunchResultToEnvelope(launchResult)
assert(!!envelope.runtime, 'launch.runtime 应映射到 GameLaunchEnvelope.runtime')
assert(envelope.runtime!.runtimeBindingId === 'rtb_001', 'runtimeBindingId 应正确适配')
assert(envelope.runtime!.placeName === '示范校园', 'placeName 应正确适配')
assert(envelope.runtime!.mapName === '主图', 'mapName 应正确适配')
assert(envelope.runtime!.courseVariantId === 'variant_b', 'courseVariantId 应正确适配')
assert(envelope.runtime!.routeCode === 'route-variant-b', 'runtime routeCode 应优先保留后端透出值')
assert(!!envelope.variant && envelope.variant.variantName === 'B 线', 'variant 摘要应继续保持兼容')
assert(!!envelope.presentation && envelope.presentation.presentationId === 'pres_001', 'launch.presentation 应映射到 GameLaunchEnvelope.presentation')
assert(!!envelope.contentBundle && envelope.contentBundle.bundleId === 'bundle_001', 'launch.contentBundle 应映射到 GameLaunchEnvelope.contentBundle')
}
function run(): void {
createWxStorage({})
testControlInheritance()
testScoreOFreePunchAndFinishGate()
testSettingsLockLifecycle()
testTimeoutEndReason()
testClassicSequentialSkipConfirmDefault()
testRuntimeRestoreDefinition()
testLaunchRuntimeAdapter()
console.log('runtime smoke tests passed')
}
run()