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')) })