통합 테스트를 실행 권위별로 분리한다

This commit is contained in:
2026-09-04 06:13:31 +00:00
parent 01fdb51b6e
commit 07897d9765
9 changed files with 193 additions and 20 deletions
@@ -73,7 +73,7 @@ router 결과를 별도로 비교합니다.
`turnCommandFullLifecycle.integration.test.ts`, 실제 PostgreSQL flush/reload는
`turnCommandFullLifecyclePersistence.integration.test.ts`가 대표 fixture로 검증합니다.
기본 integration:
기본 Core canonical/projection 계약:
```sh
pnpm test:integration
@@ -85,11 +85,21 @@ PostgreSQL·Redis 조건부 경계:
pnpm test:integration:conditional
```
live Ref 명령·전투 차등 감사:
```sh
pnpm test:integration:reference
```
Ref runtime은 기본 Core acceptance의 필수 의존성이 아닙니다. 계승 계약 변경과
정기 감사에서만 live 비교를 실행하고, 독립한 Core 영역은 versioned Core fixture와
불변조건을 권위값으로 사용합니다.
개별 suite와 test name은 package script 뒤에 전달합니다.
```sh
pnpm --filter @sammo-ts/game-engine test inputEventAtomicity.test.ts
pnpm --filter @sammo-ts/integration-tests test:integration
pnpm --filter @sammo-ts/integration-tests test:integration:reference -- -t 'test name'
```
실제 script 인자는 해당 package의 `package.json`을 확인해 주세요.
+31 -9
View File
@@ -2,18 +2,39 @@
## 실행 명령
외부 서비스 없이 실행 가능한 integration:
빠른 기본 검증은 Core의 canonical snapshot, comparator, log/message projection
logical clock 계약만 실행합니다. Ref runtime이나 DB가 없어도 되며 skip을 성공으로
세지 않습니다.
```sh
pnpm test:integration
```
전용 PostgreSQL·Redis를 사용하는 조건부 suite:
실제 경계는 목적별 lane으로 분리합니다.
```sh
# 고유 PostgreSQL schema와 Redis namespace
pnpm test:integration:conditional
# 필요할 때 실행하는 Ref 호환성 감사
pnpm test:integration:reference
# public/che schema와 Redis를 초기화하는 전용 stack 전용 lifecycle
INTEGRATION_LIFECYCLE_DISPOSABLE=1 pnpm test:integration:lifecycle
```
`tools/integration-tests/test-lanes.tsv`는 모든 `test/**/*.test.ts``core`,
`conditional`, `reference`, `lifecycle` 중 정확히 하나에 배정합니다. 기본 runner는
실행 전에 실제 파일 집합과 registry를 대조하므로 새 파일의 미등록이나 중복 등록을
허용하지 않습니다. Ref 차등과 lifecycle을 기본 명령에서 분리한 것은 test 삭제나
silent skip이 아니라 실행 비용·외부 상태·판정 권위를 분리한 것입니다.
Ref는 매 변경의 제품 실행 의존성이 아닙니다. 계승 계약을 새로 이관하거나 전투·RNG·
명령 의미가 바뀌는 변경, 정기 호환성 감사에서는 `test:integration:reference`
실행합니다. 일반 Core 변경은 Core 자체의 versioned fixture, canonical projection과
불변조건을 기본 acceptance로 사용합니다. 확인된 의도적 제품 차이는 Core 기대값을
갱신하고 문서화하며, Ref 결과에 자동으로 되맞추지 않습니다.
조건부 runner는 환경 변수 존재 여부만으로 안전성을 보장하지 않습니다.
대상 host, port, database와 Redis prefix가 해당 worktree 전용인지 확인해
주세요. Runner는 실행 ID가 포함된 game schema를 생성하고 성공, 테스트 실패,
@@ -73,8 +94,9 @@ runtime role을 삭제하고 PID와 명령행 및 daemon 종료를 확인한 뒤
- 관리자 시간 가속·연기의 durable action, checkpoint, Redis 부분 재시도와
경매 timer race
실제 포함 suite는 `tools/run-conditional-integration.sh`, 각 package의
`package.json`, `*.integration.test.ts`를 기준으로 확인합니다. DB 조건부
실제 포함 suite는 `tools/integration-tests/test-lanes.tsv`,
`tools/run-conditional-integration.sh`, 각 package의 `package.json`,
`*.integration.test.ts`를 기준으로 확인합니다. DB 조건부
환경 변수는 `tools/conditional-integration-registry.tsv`에서 명시적으로
관리합니다. 새 `*_DATABASE_URL` gate가 registry에 없거나 registry 항목이
더 이상 테스트에 존재하지 않으면 runner가 테스트 실행 전에 실패합니다.
@@ -135,8 +157,8 @@ PostgreSQL·Redis 경계를 증명하지 않습니다. Full suite 실패는 변
Ref 호환성 판정은 [차등 검증](architecture/turn-state-differential-testing.md),
UI는 [프론트엔드 호환 검증](frontend-legacy-parity.md)을 함께 사용합니다.
전투 아이템 전체 차등 검증은 공격자·방어자 각각 145개 fixture를 모두 Core와
Ref에서 실행하고 event, RNG, outcome, 전체 log bucket을 항목별로 비교합니다.
Ref harness는 fixture마다 새 PHP process를 띄우지 않고 `--jsonl` 입력을 한
process에서 일괄 처리합니다. `ITEM_PARITY_FILTER`는 특정 항목 진단할 때
사용하며, 전체 검증의 fixture 수나 assertion을 줄이는 최적화로 사용하지 않습니다.
Ref 감사 lane의 전투 아이템 전체 차등 검증은 공격자·방어자 각각 145개 fixture를
모두 Core와 Ref에서 실행하고 event, RNG, outcome, 전체 log bucket을 항목별로
비교합니다. Ref harness는 fixture마다 새 PHP process를 띄우지 않고 `--jsonl`
batch를 한 process에서 처리합니다. `ITEM_PARITY_FILTER`는 특정 항목 진단
사용하며 전체 감사의 fixture 수나 assertion을 줄이는 최적화로 사용하지 않습니다.
+7
View File
@@ -143,8 +143,15 @@ Fastify transport, Prisma transaction, lease와 worker 경계를 검증합니다
```sh
pnpm test:integration
pnpm test:integration:conditional
pnpm test:integration:reference
INTEGRATION_LIFECYCLE_DISPOSABLE=1 pnpm test:integration:lifecycle
```
기본 integration은 Core 계약의 빠른 feedback lane입니다. 실제 PostgreSQL/Redis,
live Ref 호환성 감사, schema·PM2 전체 lifecycle은 각각 별도 명령으로 실행하며
기본 명령의 skip으로 포함된 것처럼 보고하지 않습니다. 모든 integration test file의
lane 소유권은 `tools/integration-tests/test-lanes.tsv`에서 exact-set으로 관리합니다.
조건부 suite는 worktree별 PostgreSQL·Redis instance를 준비합니다. Test가
schema truncate와 Redis 정리를 수행하므로 공유 개발 instance를 사용하지
않습니다.
+3 -1
View File
@@ -16,8 +16,10 @@
"test:bootstrap": "pnpm install --offline --frozen-lockfile && pnpm test:prepare",
"test:prepare": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && pnpm --filter @sammo-ts/gateway-api build",
"test:integration": "pnpm --filter @sammo-ts/integration-tests test:integration",
"test:integration:reference": "pnpm --filter @sammo-ts/integration-tests test:integration:reference",
"test:integration:lifecycle": "pnpm --filter @sammo-ts/integration-tests test:integration:lifecycle",
"test:integration:conditional": "./tools/run-conditional-integration.sh",
"test:conditional-integration-registry": "node --test tools/check-conditional-integration-files.test.mjs",
"test:conditional-integration-registry": "node --test tools/check-conditional-integration-files.test.mjs && node tools/integration-tests/run-test-lane.mjs check",
"build": "turbo build --ui=stream --output-logs=errors-only",
"build-verbose": "turbo build --ui=stream --output-logs=full",
"typecheck": "turbo typecheck",
+3 -1
View File
@@ -4,7 +4,9 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"test:integration": "vitest run --config vitest.config.ts",
"test:integration": "node run-test-lane.mjs core",
"test:integration:reference": "node run-test-lane.mjs reference",
"test:integration:lifecycle": "node run-test-lane.mjs lifecycle",
"typecheck": "pnpm -w tsc7 -b tools/integration-tests/tsconfig.json"
},
"dependencies": {
+89
View File
@@ -0,0 +1,89 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
const registryPath = path.join(packageRoot, 'test-lanes.tsv');
const testRoot = path.join(packageRoot, 'test');
const supportedLanes = new Set(['core', 'reference', 'lifecycle', 'conditional']);
const supportedCommands = new Set(['core', 'reference', 'lifecycle', 'check']);
const usage = () => {
process.stderr.write('usage: node run-test-lane.mjs <core|reference|lifecycle> [vitest arguments...]\n');
process.exit(64);
};
const [lane, ...rawVitestArguments] = process.argv.slice(2);
const vitestArguments = rawVitestArguments[0] === '--' ? rawVitestArguments.slice(1) : rawVitestArguments;
if (!lane || !supportedCommands.has(lane)) {
usage();
}
const rows = fs
.readFileSync(registryPath, 'utf8')
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line !== '' && !line.startsWith('#'))
.map((line, index) => {
const fields = line.split('\t');
if (fields.length !== 2) {
throw new Error(`test-lanes.tsv row ${index + 1} must have exactly two tab-separated fields`);
}
const [file, registeredLane] = fields;
if (!file || !registeredLane || !supportedLanes.has(registeredLane)) {
throw new Error(`test-lanes.tsv row ${index + 1} is invalid`);
}
return { file, lane: registeredLane };
});
const duplicateFiles = rows.map(({ file }) => file).filter((file, index, files) => files.indexOf(file) !== index);
if (duplicateFiles.length > 0) {
throw new Error(`duplicate integration test lane entries: ${[...new Set(duplicateFiles)].join(', ')}`);
}
const discoveredFiles = fs
.readdirSync(testRoot, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.test.ts'))
.map((entry) => `test/${entry.name}`)
.sort();
const registeredFiles = rows.map(({ file }) => file).sort();
if (JSON.stringify(discoveredFiles) !== JSON.stringify(registeredFiles)) {
const registered = new Set(registeredFiles);
const discovered = new Set(discoveredFiles);
const missing = discoveredFiles.filter((file) => !registered.has(file));
const stale = registeredFiles.filter((file) => !discovered.has(file));
throw new Error(
`integration test lane registry mismatch; missing=[${missing.join(', ')}] stale=[${stale.join(', ')}]`
);
}
if (lane === 'check') {
process.stdout.write(`integration test lane registry is valid (${registeredFiles.length} files)\n`);
process.exit(0);
}
if (lane === 'lifecycle' && process.env.INTEGRATION_LIFECYCLE_DISPOSABLE !== '1') {
throw new Error(
'lifecycle tests truncate public/che schemas and Redis; set INTEGRATION_LIFECYCLE_DISPOSABLE=1 only for a dedicated stack'
);
}
const files = rows.filter((row) => row.lane === lane).map(({ file }) => file);
const environment = {
...process.env,
...(lane === 'reference' ? { TURN_DIFFERENTIAL_REFERENCE: '1' } : {}),
};
const result = spawnSync(
'pnpm',
['exec', 'vitest', 'run', '--config', 'vitest.config.ts', ...files, ...vitestArguments],
{
cwd: packageRoot,
env: environment,
stdio: 'inherit',
}
);
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
+30
View File
@@ -0,0 +1,30 @@
# Test file Lane
test/auctionFlow.test.ts lifecycle
test/battleDifferential.test.ts reference
test/coreCommandTraceClock.test.ts core
test/initialization.test.ts lifecycle
test/instantDiplomacyCoreReference.integration.test.ts reference
test/instantDiplomacyReference.integration.test.ts reference
test/liveSortiePersistence.integration.test.ts conditional
test/monthlyDisasterCoreReference.integration.test.ts reference
test/monthlyNationBettingApiLifecycle.integration.test.ts conditional
test/npcPossessionSelectionReference.integration.test.ts conditional
test/orchestrator.e2e.test.ts lifecycle
test/reservedTurnDaemonApiRace.integration.test.ts conditional
test/scenario29NationBettingLifecycle.integration.test.ts conditional
test/tournamentLifecycle.test.ts lifecycle
test/troopStaticEvent.integration.test.ts conditional
test/turnCommandCoreReference.integration.test.ts reference
test/turnCommandFullLifecycle.integration.test.ts reference
test/turnCommandFullLifecyclePersistence.integration.test.ts conditional
test/turnCommandGeneralMatrix.integration.test.ts reference
test/turnCommandNationMatrix.integration.test.ts reference
test/turnCommandReference.integration.test.ts reference
test/turnCommandRiskDurabilityMatrix.integration.test.ts conditional
test/turnLogProjection.test.ts core
test/turnMessageProjection.test.ts core
test/turnSnapshotCanonicalCoverage.test.ts core
test/turnSnapshotComparator.test.ts core
test/turnSnapshotCoreDatabase.integration.test.ts conditional
test/turnSnapshotReference.integration.test.ts reference
test/turnTraceFiles.integration.test.ts conditional
1 # Test file Lane
2 test/auctionFlow.test.ts lifecycle
3 test/battleDifferential.test.ts reference
4 test/coreCommandTraceClock.test.ts core
5 test/initialization.test.ts lifecycle
6 test/instantDiplomacyCoreReference.integration.test.ts reference
7 test/instantDiplomacyReference.integration.test.ts reference
8 test/liveSortiePersistence.integration.test.ts conditional
9 test/monthlyDisasterCoreReference.integration.test.ts reference
10 test/monthlyNationBettingApiLifecycle.integration.test.ts conditional
11 test/npcPossessionSelectionReference.integration.test.ts conditional
12 test/orchestrator.e2e.test.ts lifecycle
13 test/reservedTurnDaemonApiRace.integration.test.ts conditional
14 test/scenario29NationBettingLifecycle.integration.test.ts conditional
15 test/tournamentLifecycle.test.ts lifecycle
16 test/troopStaticEvent.integration.test.ts conditional
17 test/turnCommandCoreReference.integration.test.ts reference
18 test/turnCommandFullLifecycle.integration.test.ts reference
19 test/turnCommandFullLifecyclePersistence.integration.test.ts conditional
20 test/turnCommandGeneralMatrix.integration.test.ts reference
21 test/turnCommandNationMatrix.integration.test.ts reference
22 test/turnCommandReference.integration.test.ts reference
23 test/turnCommandRiskDurabilityMatrix.integration.test.ts conditional
24 test/turnLogProjection.test.ts core
25 test/turnMessageProjection.test.ts core
26 test/turnSnapshotCanonicalCoverage.test.ts core
27 test/turnSnapshotComparator.test.ts core
28 test/turnSnapshotCoreDatabase.integration.test.ts conditional
29 test/turnSnapshotReference.integration.test.ts reference
30 test/turnTraceFiles.integration.test.ts conditional
@@ -1402,6 +1402,13 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
},
];
const executions: Array<{
entry: (typeof cases)[number];
base: BattleSimRequestPayload & { startYear: number };
coreEvents: WarBattleTraceEvent[];
coreRng: TracingRng | null;
coreOutcome: WarBattleOutcome | null;
}> = [];
for (const entry of cases) {
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
@@ -1441,13 +1448,16 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
},
}
);
executions.push({ entry, base, coreEvents, coreRng, coreOutcome });
}
const references = runReferenceTraceBatch(
workspaceRoot!,
executions.map(({ base }) => JSON.stringify(base))
);
executions.forEach(({ entry, coreEvents, coreRng, coreOutcome }, index) => {
try {
assertTraceParity(
coreEvents,
runReferenceTrace(workspaceRoot!, JSON.stringify(base)),
coreRng,
coreOutcome
);
assertTraceParity(coreEvents, references[index]!, coreRng, coreOutcome);
} catch (error) {
throw new Error(
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`,
@@ -1456,7 +1466,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
}
);
}
}
});
});
it('loads every scenario item with the scenario slot', async () => {
+1
View File
@@ -571,6 +571,7 @@ run_redis_only_tests() {
export PATH
cd "$workspace_root"
node "$script_dir/check-conditional-integration-files.mjs"
node "$workspace_root/tools/integration-tests/run-test-lane.mjs" check
validate_marker_registry
pnpm install --frozen-lockfile