402 lines
18 KiB
JavaScript
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'))
|
|
})
|