数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
beginNewJourney,
|
||||
migrateGuestLives,
|
||||
mergeLifeArchive,
|
||||
readCurrentJourney,
|
||||
readLifeArchive,
|
||||
removeLifeArchive,
|
||||
sealCurrentJourney,
|
||||
writeCurrentJourney,
|
||||
} from '../src/lib/lifeJourney.ts';
|
||||
import { chapterDone, currentChapter, entryTo, type ShishiState } from '../src/lib/shishi.ts';
|
||||
import { hasShiSource, SHI_ERAS, SHI_TOTAL } from '../src/data/shici.ts';
|
||||
import xianqinPoems from '../src/data/shici/xianqin.ts';
|
||||
import hanPoems from '../src/data/shici/han.ts';
|
||||
import weijinPoems from '../src/data/shici/weijin.ts';
|
||||
import tangPoems from '../src/data/shici/tang.ts';
|
||||
import songPoems from '../src/data/shici/song.ts';
|
||||
import yuanPoems from '../src/data/shici/yuan.ts';
|
||||
import mingqingPoems from '../src/data/shici/mingqing.ts';
|
||||
|
||||
class MemoryStorage {
|
||||
private values = new Map<string, string>();
|
||||
getItem(key: string) { return this.values.get(key) ?? null; }
|
||||
setItem(key: string, value: string) { this.values.set(key, String(value)); }
|
||||
removeItem(key: string) { this.values.delete(key); }
|
||||
clear() { this.values.clear(); }
|
||||
}
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: storage, configurable: true });
|
||||
|
||||
const pick = (optionId = 'a') => ({ year: 557, title: '陈霸先称帝', optionId, role: 'fengkou', tier: 5 });
|
||||
|
||||
test.beforeEach(() => storage.clear());
|
||||
|
||||
test('legacy journey gets a stable deterministic id', () => {
|
||||
storage.setItem('huixiang.journey.anon', JSON.stringify({ code: 'chen', picks: [pick()] }));
|
||||
const first = readCurrentJourney(null);
|
||||
const second = readCurrentJourney(null);
|
||||
assert.ok(first?.id.startsWith('life_legacy_'));
|
||||
assert.equal(first?.id, second?.id);
|
||||
});
|
||||
|
||||
test('sealing is idempotent and stores a readable archive snapshot', () => {
|
||||
const journey = writeCurrentJourney(null, { code: 'chen', picks: [pick()] }, 1000);
|
||||
const sealed = sealCurrentJourney(null, { title: '江左定鼎', dynastyName: '陈' }, 2000);
|
||||
const sealedAgain = sealCurrentJourney(null, { title: '江左定鼎' }, 3000);
|
||||
|
||||
assert.equal(sealed?.id, journey.id);
|
||||
assert.equal(sealedAgain?.id, journey.id);
|
||||
assert.equal(readLifeArchive(null).length, 1);
|
||||
assert.equal(readLifeArchive(null)[0].journey.sealedAt, 2000);
|
||||
});
|
||||
|
||||
test('changing a sealed life starts a new id without erasing its archive', () => {
|
||||
const first = writeCurrentJourney(null, { code: 'chen', picks: [pick('a')] }, 1000);
|
||||
sealCurrentJourney(null, { title: '第一生' }, 2000);
|
||||
const second = writeCurrentJourney(null, { code: 'chen', picks: [pick('b')] }, 3000);
|
||||
|
||||
assert.notEqual(second.id, first.id);
|
||||
assert.equal(second.sealedAt, undefined);
|
||||
assert.equal(readLifeArchive(null)[0].id, first.id);
|
||||
});
|
||||
|
||||
test('starting another life clears current progress but preserves the archive', () => {
|
||||
writeCurrentJourney(null, { code: 'chen', picks: [pick()] }, 1000);
|
||||
sealCurrentJourney(null, { title: '旧传' }, 2000);
|
||||
beginNewJourney(null);
|
||||
|
||||
assert.equal(readCurrentJourney(null), null);
|
||||
assert.equal(readLifeArchive(null).length, 1);
|
||||
});
|
||||
|
||||
test('guest lives migrate to a twin without overwriting an existing twin journey', () => {
|
||||
writeCurrentJourney(null, { code: 'chen', picks: [pick()] }, 1000);
|
||||
sealCurrentJourney(null, { title: '游客的一生' }, 2000);
|
||||
writeCurrentJourney('twin-1', { code: 'tang', picks: [{ year: 618, title: '唐兴', optionId: 'b' }] }, 3000);
|
||||
migrateGuestLives('twin-1');
|
||||
|
||||
assert.equal(readCurrentJourney('twin-1')?.code, 'tang');
|
||||
assert.equal(readLifeArchive('twin-1')[0].title, '游客的一生');
|
||||
});
|
||||
|
||||
test('cloud merge is idempotent, cloud wins conflicts, and tombstones remove local copies', () => {
|
||||
writeCurrentJourney('twin-1', { code: 'chen', picks: [pick()] }, 1000);
|
||||
const local = sealCurrentJourney('twin-1', { title: '本机旧题' }, 2000)!;
|
||||
const cloud = { ...local, title: '云端封卷', cloud: { syncedAt: 3000, hash: 'abc' } };
|
||||
|
||||
mergeLifeArchive('twin-1', [cloud]);
|
||||
mergeLifeArchive('twin-1', [cloud]);
|
||||
assert.equal(readLifeArchive('twin-1').length, 1);
|
||||
assert.equal(readLifeArchive('twin-1')[0].title, '云端封卷');
|
||||
assert.equal(readLifeArchive('twin-1')[0].cloud?.hash, 'abc');
|
||||
|
||||
mergeLifeArchive('twin-1', [], [local.id]);
|
||||
assert.equal(readLifeArchive('twin-1').length, 0);
|
||||
});
|
||||
|
||||
test('removing one archive preserves the rest', () => {
|
||||
writeCurrentJourney('twin-1', { code: 'chen', picks: [pick('a')] }, 1000);
|
||||
const first = sealCurrentJourney('twin-1', { title: '第一生' }, 2000)!;
|
||||
writeCurrentJourney('twin-1', { code: 'chen', picks: [pick('b')] }, 3000);
|
||||
const second = sealCurrentJourney('twin-1', { title: '第二生' }, 4000)!;
|
||||
removeLifeArchive('twin-1', second.id);
|
||||
assert.deepEqual(readLifeArchive('twin-1').map((item) => item.id), [first.id]);
|
||||
});
|
||||
|
||||
function state(overrides: Partial<ShishiState> = {}): ShishiState {
|
||||
return {
|
||||
yuanqiSeen: false,
|
||||
twin: null,
|
||||
lineages: [],
|
||||
subTalentCount: 0,
|
||||
activeTwinId: null,
|
||||
journeyPicks: 0,
|
||||
journeyCode: null,
|
||||
journeyId: null,
|
||||
journeyYear: null,
|
||||
journeyTier: null,
|
||||
journeySealed: false,
|
||||
guestReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('guest chapter progress advances from first unfinished chapter and seals at 6/6', () => {
|
||||
assert.equal(currentChapter(state()), 1);
|
||||
assert.equal(currentChapter(state({ yuanqiSeen: true })), 2);
|
||||
assert.equal(currentChapter(state({ yuanqiSeen: true, guestReady: true })), 3);
|
||||
|
||||
const played = state({ yuanqiSeen: true, guestReady: true, journeyPicks: 1, journeyCode: 'chen', journeyYear: 557, journeyTier: 5 });
|
||||
assert.equal(currentChapter(played), 6);
|
||||
assert.equal(chapterDone(6, played), false);
|
||||
assert.equal(entryTo(4, played), '/ruju/chen/557?tier=5&dyn=chen');
|
||||
|
||||
const completed = { ...played, journeySealed: true };
|
||||
assert.equal(currentChapter(completed), 6);
|
||||
assert.equal(chapterDone(6, completed), true);
|
||||
});
|
||||
|
||||
test('诗词前端只加载有出处的条目,分朝与总数不失真', async () => {
|
||||
const byEra = { xianqin: xianqinPoems, han: hanPoems, weijin: weijinPoems, tang: tangPoems, song: songPoems, yuan: yuanPoems, mingqing: mingqingPoems };
|
||||
const eras = SHI_ERAS.map((era) => {
|
||||
const poems = byEra[era.slug];
|
||||
assert.equal(poems.length, era.count);
|
||||
assert.ok(poems.every(hasShiSource));
|
||||
return poems;
|
||||
});
|
||||
|
||||
assert.equal(eras.flat().length, SHI_TOTAL);
|
||||
assert.equal(SHI_TOTAL, 646);
|
||||
});
|
||||
Reference in New Issue
Block a user