数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s

This commit is contained in:
2026-10-10 16:24:43 +08:00
commit 0593ff7e62
1893 changed files with 361723 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveApiBase } from '../src/lib/apiBase.ts';
test('Docker same-origin sentinel uses the current Nginx origin', () => {
assert.equal(resolveApiBase('same-origin', '127.0.0.1'), '');
});
test('an explicit API URL wins over hostname defaults', () => {
assert.equal(resolveApiBase('https://api.example.test', 'digitalme.invoratec.cn'), 'https://api.example.test');
});
test('unset API URL keeps production and local development defaults', () => {
assert.equal(resolveApiBase(undefined, 'digitalme.invoratec.cn'), 'https://digitalme-api.invoratec.cn');
assert.equal(resolveApiBase(undefined, 'localhost'), 'http://localhost:3701');
});
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { careerTrajectoryStories } from '../src/data/careerTrajectories.ts';
import {
applyCareerDynastyStoryline,
careerDynastyStoryline,
curatedCareerDynastyStorylineCount,
curatedCareerIds,
curatedDynastyCodes,
} from '../src/data/careerDynastyStorylines.ts';
import { careerHistoryIdForProfession, normalizeCareerHistoryId } from '../src/lib/careerHistory.ts';
test('all 79 modern professions keep their own life trajectory', () => {
assert.equal(careerTrajectoryStories.length, 79);
assert.equal(careerHistoryIdForProfession('game_blogger'), 'game_blogger');
assert.equal(careerHistoryIdForProfession('developer'), 'developer');
assert.equal(careerHistoryIdForProfession('photographer'), 'photographer');
assert.equal(careerHistoryIdForProfession('therapist'), 'therapist');
assert.equal(careerHistoryIdForProfession('beauty_blogger'), 'beauty_blogger');
assert.equal(careerHistoryIdForProfession('esports'), 'esports');
assert.equal(careerHistoryIdForProfession('makeup_artist'), 'makeup_artist');
assert.equal(careerHistoryIdForProfession('lawyer'), 'lawyer');
});
test('shared legacy links still resolve to the new profession ids', () => {
assert.equal(normalizeCareerHistoryId('game-blogger'), 'game_blogger');
assert.equal(normalizeCareerHistoryId('programmer'), 'developer');
assert.equal(normalizeCareerHistoryId('psychologist'), 'therapist');
assert.equal(normalizeCareerHistoryId('makeup-blogger'), 'beauty_blogger');
assert.equal(careerHistoryIdForProfession('not-a-profession'), undefined);
assert.equal(careerHistoryIdForProfession(undefined), undefined);
});
test('first curated batch contains 40 complete career-by-dynasty storylines', () => {
assert.equal(curatedCareerDynastyStorylineCount, 40);
for (const careerId of curatedCareerIds) {
for (const dynastyCode of curatedDynastyCodes) {
const storyline = careerDynastyStoryline(careerId, dynastyCode);
assert.ok(storyline, `${careerId}/${dynastyCode} should exist`);
assert.equal(storyline.acts.length, 4);
assert.ok(storyline.acts.every((act) => act.length >= 12));
assert.ok(storyline.choice.includes('?'));
assert.ok(storyline.result.length >= 12);
}
}
});
test('one profession receives genuinely different roles and actions across dynasties', () => {
const developer = careerTrajectoryStories.find((story) => story.id === 'developer');
assert.ok(developer);
const liao = applyCareerDynastyStoryline(developer, 'liao', '辽');
const tang = applyCareerDynastyStoryline(developer, 'tang', '唐');
const ming = applyCareerDynastyStoryline(developer, 'ming', '明');
assert.equal(liao.ancientRole, '北枢密院军簿计吏');
assert.equal(tang.ancientRole, '军中计吏');
assert.equal(ming.ancientRole, '户部黄册勾稽吏');
assert.notDeepEqual(liao.acts, tang.acts);
assert.notEqual(liao.choice, ming.choice);
});
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
INK_MASK_FRAME_COUNT,
INK_MASK_FRAME_MS,
inkCoverageRadius,
inkEdgeRadius,
inkMaskFrameIndex,
} from '../src/lib/inkTransition.ts';
test('ink coverage reaches every viewport corner from the clicked origin', () => {
const viewport = { width: 1440, height: 900, originX: 1060, originY: 140 };
const radius = inkCoverageRadius(viewport);
const farthestCorner = Math.max(
Math.hypot(viewport.originX, viewport.originY),
Math.hypot(viewport.width - viewport.originX, viewport.originY),
Math.hypot(viewport.originX, viewport.height - viewport.originY),
Math.hypot(viewport.width - viewport.originX, viewport.height - viewport.originY),
);
assert.ok(radius > farthestCorner);
});
test('ink edge is organic but remains close enough to guarantee coverage', () => {
const base = 1000;
const samples = Array.from({ length: 72 }, (_, index) => inkEdgeRadius(base, index / 72 * Math.PI * 2, 1.8));
assert.ok(new Set(samples.map((value) => Math.round(value))).size > 20);
assert.ok(Math.min(...samples) > base * 0.9);
assert.ok(Math.max(...samples) < base * 1.1);
});
test('ink mask sequence matches the shipped APNG and clamps to its final frame', () => {
assert.equal(INK_MASK_FRAME_COUNT, 124);
assert.equal(inkMaskFrameIndex(0), 0);
assert.equal(inkMaskFrameIndex(INK_MASK_FRAME_MS * 10), 10);
assert.equal(inkMaskFrameIndex(60_000), 123);
assert.equal(inkMaskFrameIndex(-100), 0);
});
+155
View File
@@ -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);
});