From 07897d9765f99561878c6c7921e39ec3d0728827 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 4 Sep 2026 06:13:31 +0000 Subject: [PATCH] =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=8B=A4=ED=96=89=20=EA=B6=8C=EC=9C=84?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../turn-state-differential-testing.md | 14 ++- docs/integration-tests.md | 40 +++++++-- docs/testing-policy.md | 7 ++ package.json | 4 +- tools/integration-tests/package.json | 4 +- tools/integration-tests/run-test-lane.mjs | 89 +++++++++++++++++++ tools/integration-tests/test-lanes.tsv | 30 +++++++ .../test/battleDifferential.test.ts | 24 +++-- tools/run-conditional-integration.sh | 1 + 9 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 tools/integration-tests/run-test-lane.mjs create mode 100644 tools/integration-tests/test-lanes.tsv diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index a61c712d..ae796f1d 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -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`을 확인해 주세요. diff --git a/docs/integration-tests.md b/docs/integration-tests.md index c10b1e27..04672b53 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -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을 줄이는 최적화로 사용하지 않습니다. diff --git a/docs/testing-policy.md b/docs/testing-policy.md index 824e5c6a..96d5e6f3 100644 --- a/docs/testing-policy.md +++ b/docs/testing-policy.md @@ -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를 사용하지 않습니다. diff --git a/package.json b/package.json index de9a7e98..d71053dd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/integration-tests/package.json b/tools/integration-tests/package.json index c0be8f1a..37326821 100644 --- a/tools/integration-tests/package.json +++ b/tools/integration-tests/package.json @@ -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": { diff --git a/tools/integration-tests/run-test-lane.mjs b/tools/integration-tests/run-test-lane.mjs new file mode 100644 index 00000000..587e59fb --- /dev/null +++ b/tools/integration-tests/run-test-lane.mjs @@ -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 [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); diff --git a/tools/integration-tests/test-lanes.tsv b/tools/integration-tests/test-lanes.tsv new file mode 100644 index 00000000..a9cc9df6 --- /dev/null +++ b/tools/integration-tests/test-lanes.tsv @@ -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 diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index 9f0cf569..919bc50d 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -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( 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 () => { diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index c461fa31..b6bb0a45 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -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