Files
2026-09-09 14:47:29 +08:00

402 lines
18 KiB
JavaScript

const test = require('node:test')
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const { createPlatformBridge } = require('../native-adapter/tang-detective/utils/platformCore')
const { emptyProgress, projectProgress } = require('../native-adapter/tang-detective/utils/progressContract')
const { sha256Hex } = require('../native-adapter/tang-detective/utils/identityHash')
const clone = value => JSON.parse(JSON.stringify(value))
function fixture(t, initialToken = 'test-account-A') {
const storage = new Map([['token', initialToken]])
const calls = []
const servers = new Map()
const state = { offline: false, expired: false, failWrite: false, loseAck: false, beforeAck: null, saveError: '' }
function server(token) {
if (!servers.has(token)) servers.set(token, { user_id: token.endsWith('B') ? 2 : 1,
schema_version: 1, content_version: 'season-01', revision: 0, story_generation: 0,
progress: emptyProgress() })
return servers.get(token)
}
const platform = {
getStorageSync: key => clone(storage.get(key) || ''),
setStorageSync(key, value) { if (state.failWrite) throw new Error('quota'); storage.set(key, clone(value)) },
request(options) {
calls.push(clone({ url: options.url, method: options.method, header: options.header, data: options.data || null }))
queueMicrotask(() => {
if (state.offline) return options.fail({})
if (state.expired) return options.success({ statusCode: 200, data: { code: -1 } })
let remote = server(options.header.token)
const reply = (code, data) => options.success({ statusCode: 200, data: { code, data: clone(data) } })
if (options.url.endsWith('/catalog')) return reply(1, { schema_version: 1, content_version: 'season-01' })
if (options.url.endsWith('/progress')) return reply(1, remote)
assert.equal(options.method, 'POST')
if (state.saveError) return reply(0, { error_code: state.saveError })
const body = options.data
assert.deepEqual(Object.keys(body).sort(), ['schema_version', 'content_version', 'base_revision', 'story_generation', 'request_id', 'operation', 'progress'].sort())
assert.deepEqual(body.progress, projectProgress(body.progress))
if (remote.lastRequest === body.request_id) return reply(1, remote)
if (remote.revision !== body.base_revision || remote.story_generation !== body.story_generation) return reply(0, { error_code: 'PROGRESS_CONFLICT' })
remote.progress = clone(body.progress)
remote.revision++
if (body.operation === 'reset_story') remote.story_generation++
remote.lastRequest = body.request_id
if (state.beforeAck) { const callback = state.beforeAck; state.beforeAck = null; callback() }
if (state.loseAck) { state.loseAck = false; return options.fail({}) }
reply(1, remote)
})
},
}
const bridge = createPlatformBridge(platform, { apiBaseUrl: 'https://example.invalid/' })
t.after(() => bridge.dispose())
return { bridge, platform, storage, calls, state, server }
}
function advance(bridge, page = 'S01-C01-P02') {
return { ...bridge.getProgress(), lastChapter: 1,
comicReaderByChapter: { 'S01-C01': { currentPageId: page, completedEventIds: [], chapterFinished: false } },
completedHotspots: { 'S01-C01': [] }, lastPageId: page }
}
test('SHA-256 uses the original portable implementation', () => {
for (const value of ['', 'abc', '测试-token']) assert.equal(sha256Hex(value), crypto.createHash('sha256').update(value).digest('hex'))
})
test('wire projection excludes story text, health answers, credentials and invalid IDs', () => {
const projected = projectProgress({ ...emptyProgress(), healthAnswer: 'secret', token: 'secret',
memoryCardSnapshots: { secret: 'story text' }, lastChapter: 100,
collectedMemoryCards: ['S01-C01-MC01', 'illegal'],
comicReaderByChapter: { 'S01-C01': { currentPageId: 'S01-C01-P08', completedEventIds: ['S01-H02'], chapterFinished: true } } })
assert.equal(projected.lastChapter, 1)
assert.equal(projected.comicReaderByChapter['S01-C01'].currentPageId, 'S01-C01-P03')
assert.equal(projected.comicReaderByChapter['S01-C01'].chapterFinished, false)
assert.deepEqual(projected.collectedMemoryCards, ['S01-C01-MC01'])
assert.ok(!JSON.stringify(projected).includes('secret'))
})
test('guest can read locally without any API calls, and guest state is not uploaded after login', async t => {
const f = fixture(t, '')
await f.bridge.open()
assert.equal(f.bridge.saveProgress(advance(f.bridge)), true)
await f.bridge.flush()
assert.equal(f.calls.length, 0)
f.storage.set('token', 'test-account-A')
await f.bridge.open()
assert.equal(f.bridge.getProgress().lastPageId, '')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
})
test('authenticated save uses existing token header, JSON, whitelist and own revision', async t => {
const f = fixture(t)
await f.bridge.open()
f.bridge.saveProgress({ ...advance(f.bridge), memoryCardSnapshots: { a: 'stay local' }, answer: 'stay local' })
await f.bridge.flush()
const post = f.calls.find(c => c.method === 'POST')
assert.equal(post.header.token, 'test-account-A')
assert.equal(post.header['content-type'], 'application/json')
assert.equal(post.data.base_revision, 0)
assert.equal(post.data.progress.lastPageId, 'S01-C01-P02')
assert.ok(!JSON.stringify(post.data).includes('stay local'))
assert.equal(f.bridge.getStatus(), 'synced')
assert.equal(f.bridge.getProgress().answer, 'stay local')
for (const [key, value] of f.storage) if (key !== 'token') assert.ok(!JSON.stringify([key, value]).includes('test-account-A'))
})
test('offline edits survive and retry when API becomes available', async t => {
const f = fixture(t)
f.state.offline = true
await f.bridge.open()
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
f.state.offline = false
await f.bridge.open(true)
assert.equal(f.bridge.getStatus(), 'synced')
assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02')
})
test('login expiration never silently claims synchronization', async t => {
const f = fixture(t)
f.state.expired = true
await f.bridge.open()
assert.equal(f.bridge.getStatus(), 'auth-expired')
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
})
test('account changes isolate progress and reject delayed writes from the previous page', async t => {
const f = fixture(t)
await f.bridge.open()
const old = advance(f.bridge)
f.bridge.saveProgress(old)
await f.bridge.flush()
f.storage.set('token', 'test-account-B')
await f.bridge.open()
assert.equal(f.bridge.getProgress().lastPageId, '')
assert.equal(f.bridge.saveProgress(old), false)
assert.equal(f.server('test-account-B').revision, 0)
})
test('late HTTP response after account switch does not hydrate the new account', async t => {
const f = fixture(t)
const first = f.bridge.open()
f.storage.set('token', 'test-account-B')
const second = f.bridge.open()
await Promise.all([first, second])
assert.equal(f.bridge.getProgress().lastPageId, '')
assert.equal(f.bridge.getStatus(), 'synced')
assert.ok(f.bridge.getScope().endsWith(sha256Hex('test-account-B')))
})
test('conflict keeps both versions; explicit cloud choice takes a recoverable backup', async t => {
const f = fixture(t)
await f.bridge.open()
f.server('test-account-A').revision = 4
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'conflict')
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
assert.equal(await f.bridge.resolveConflict('cloud', f.bridge.getConflictContext()), true)
assert.equal(f.bridge.getProgress().lastPageId, '')
assert.ok([...f.storage.keys()].some(key => key.endsWith(':conflict-backup')))
})
test('explicit local conflict resolution uses the new server revision', async t => {
const f = fixture(t)
await f.bridge.open()
f.server('test-account-A').revision = 2
f.server('test-account-A').story_generation = 1
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(await f.bridge.resolveConflict('local', f.bridge.getConflictContext()), true)
const last = f.calls.filter(c => c.method === 'POST').at(-1)
assert.equal(last.data.base_revision, 2)
assert.equal(last.data.story_generation, 1)
})
test('lost response retries the same request ID and does not double increment', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.loseAck = true
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'offline')
await f.bridge.open(true)
const posts = f.calls.filter(c => c.method === 'POST')
assert.equal(posts.length, 2)
assert.equal(posts[0].data.request_id, posts[1].data.request_id)
assert.equal(f.server('test-account-A').revision, 1)
assert.equal(f.bridge.getStatus(), 'synced')
})
test('new edits made while saving are queued without being overwritten by an old response', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.beforeAck = () => f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03'))
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P03')
await f.bridge.flush()
assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P03')
})
test('reset uses an empty story and preserves valid unsynced card IDs', async t => {
const f = fixture(t)
await f.bridge.open()
f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress(), collectedMemoryCards: ['S01-C01-MC01'] }, true)
await f.bridge.flush()
const post = f.calls.find(c => c.method === 'POST')
assert.equal(post.data.operation, 'reset_story')
assert.deepEqual(post.data.progress.collectedMemoryCards, ['S01-C01-MC01'])
assert.deepEqual(post.data.progress.comicReaderByChapter, {})
assert.equal(f.server('test-account-A').story_generation, 1)
})
test('reset requested during an in-flight replace is not dropped', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.beforeAck = () => f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress() }, true)
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
await f.bridge.flush()
assert.equal(f.calls.filter(c => c.method === 'POST').at(-1).data.operation, 'reset_story')
})
test('storage errors stop cloud writes and are reported truthfully', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.failWrite = true
assert.equal(f.bridge.saveProgress(advance(f.bridge)), false)
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'storage-error')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
})
test('settings/audio remain local and scoped', async t => {
const f = fixture(t)
await f.bridge.open()
f.bridge.writeLocal('settings', { fontScale: 'xlarge' })
f.bridge.writeLocal('audio', { page: 200 })
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
f.storage.set('token', 'test-account-B')
await f.bridge.open()
assert.deepEqual(f.bridge.readLocal('audio', {}), {})
})
test('a renewed token recovers the authenticated user local unsynced queue', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.offline = true
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
f.storage.set('token', 'renewed-test-account-A')
f.state.offline = false
await f.bridge.open()
assert.equal(f.bridge.getStatus(), 'synced')
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
assert.equal(f.server('renewed-test-account-A').revision, 1)
})
test('cloud hydration does not claim local persistence when storage is full', async t => {
const f = fixture(t)
f.state.failWrite = true
await f.bridge.open()
assert.equal(f.bridge.getStatus(), 'storage-error')
})
test('permanent contract failure is not automatically resubmitted by page hide or new edits', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.saveError = 'INVALID_REQUEST'
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'sync-error')
f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03'))
await f.bridge.flush()
await f.bridge.open()
assert.equal(f.calls.filter(c => c.method === 'POST').length, 1)
})
test('review regression: offline reset then new reading sends reset followed by the new progress', async t => {
const f = fixture(t)
f.state.offline = true
await f.bridge.open()
f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress() }, true)
f.bridge.saveProgress(advance(f.bridge))
f.state.offline = false
await f.bridge.open(true)
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
await f.bridge.flush()
const posts = f.calls.filter(c => c.method === 'POST')
assert.deepEqual(posts.map(c => c.data.operation), ['reset_story', 'replace'])
assert.equal(posts[1].data.story_generation, 1)
assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02')
assert.equal(f.bridge.getStatus(), 'synced')
})
test('review regression: queue persistence failure releases in-flight lock and can recover', async t => {
const f = fixture(t)
await f.bridge.open()
assert.equal(f.bridge.saveProgress(advance(f.bridge)), true)
f.state.failWrite = true
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'storage-error')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
f.state.failWrite = false
await f.bridge.open(true)
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'synced')
assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02')
})
test('review regression: conflict confirmation is bound to account and exact local/conflict revision', async t => {
const f = fixture(t)
await f.bridge.open()
f.server('test-account-A').revision = 2
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
const oldContext = f.bridge.getConflictContext()
f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03'))
assert.equal(await f.bridge.resolveConflict('cloud', oldContext), false)
assert.equal(await f.bridge.resolveConflict('cloud'), false)
f.storage.set('token', 'test-account-B')
await f.bridge.open()
f.server('test-account-B').revision = 2
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'conflict')
assert.equal(await f.bridge.resolveConflict('cloud', oldContext), false)
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
})
test('review regression: reset helper rejects missing or stale confirmation scope', async t => {
const f = fixture(t)
await f.bridge.open()
const oldScope = f.bridge.getScope()
const sandbox = { module: { exports: {} }, require: name => name === './platformBridge' ? f.bridge : { emptyProgress } }
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../native-adapter/tang-detective/utils/storage.js'), 'utf8'), sandbox)
const reset = sandbox.module.exports.resetStoryProgress
f.storage.set('token', 'test-account-B')
await f.bridge.open()
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(reset(), false)
assert.equal(reset(oldScope), false)
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
assert.equal(f.server('test-account-B').story_generation, 0)
assert.equal(reset(f.bridge.getScope()), true)
await f.bridge.flush()
assert.equal(f.server('test-account-B').story_generation, 1)
})
function pageFixture() {
let finishBoot
let scope = 'account-A'
let page
const events = []
const boot = new Promise(resolve => { finishBoot = resolve })
const bridge = { getScope: () => scope, open: () => boot, flush: () => events.push('flush') }
const sandbox = { module: { exports: {} }, require: () => bridge,
Page: options => { page = { ...options, data: { ...options.data }, setData(data) { Object.assign(this.data, data) } } },
wx: { reLaunch: () => events.push('reLaunch'), showToast: () => events.push('error') } }
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../native-adapter/tang-detective/utils/tangPage.js'), 'utf8'), sandbox)
sandbox.module.exports({ data: { value: 1 }, onLoad() { events.push('load') }, onShow() { events.push('show') },
onHide() { events.push('hide') }, onUnload() { events.push('unload') }, click() { events.push('click') } })
return { page, events, finishBoot, changeAccount: () => { scope = 'account-B' } }
}
const settlePage = () => new Promise(resolve => setImmediate(resolve))
test('native lifecycle waits for account hydration and blocks pre-boot interaction', async () => {
const f = pageFixture()
f.page.onLoad({})
f.page.onShow()
f.page.click()
assert.deepEqual(f.events, [])
assert.equal(f.page.data.tangBootPending, true)
f.finishBoot()
await settlePage()
assert.deepEqual(f.events, ['load', 'show'])
assert.equal(f.page.data.tangBootPending, false)
f.page.click()
assert.equal(f.events.at(-1), 'click')
})
test('native page hidden during boot initializes only on return, then cleans up', async () => {
const f = pageFixture()
f.page.onLoad({})
f.page.onShow()
f.page.onHide()
f.finishBoot()
await settlePage()
assert.deepEqual(f.events, ['flush'])
f.page.onShow()
await settlePage()
assert.deepEqual(f.events, ['flush', 'load', 'show'])
f.page.onUnload()
f.page.click()
assert.deepEqual(f.events.slice(-2), ['unload', 'flush'])
})
test('native page unloaded before HTTP completion cannot start late playback/initialization', async () => {
const f = pageFixture()
f.page.onLoad({})
f.page.onShow()
f.page.onUnload()
f.finishBoot()
await settlePage()
assert.deepEqual(f.events, ['flush'])
})
test('native event after host account changes returns home before old game mutation', async () => {
const f = pageFixture()
f.page.onLoad({})
f.page.onShow()
f.finishBoot()
await settlePage()
f.changeAccount()
f.page.click()
assert.equal(f.events.at(-1), 'reLaunch')
assert.ok(!f.events.includes('click'))
})