통합 테스트를 실행 권위별로 분리한다
This commit is contained in:
@@ -73,7 +73,7 @@ router 결과를 별도로 비교합니다.
|
|||||||
`turnCommandFullLifecycle.integration.test.ts`, 실제 PostgreSQL flush/reload는
|
`turnCommandFullLifecycle.integration.test.ts`, 실제 PostgreSQL flush/reload는
|
||||||
`turnCommandFullLifecyclePersistence.integration.test.ts`가 대표 fixture로 검증합니다.
|
`turnCommandFullLifecyclePersistence.integration.test.ts`가 대표 fixture로 검증합니다.
|
||||||
|
|
||||||
기본 integration:
|
기본 Core canonical/projection 계약:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm test:integration
|
pnpm test:integration
|
||||||
@@ -85,11 +85,21 @@ PostgreSQL·Redis 조건부 경계:
|
|||||||
pnpm test:integration:conditional
|
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 뒤에 전달합니다.
|
개별 suite와 test name은 package script 뒤에 전달합니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm --filter @sammo-ts/game-engine test inputEventAtomicity.test.ts
|
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`을 확인해 주세요.
|
실제 script 인자는 해당 package의 `package.json`을 확인해 주세요.
|
||||||
|
|||||||
@@ -2,18 +2,39 @@
|
|||||||
|
|
||||||
## 실행 명령
|
## 실행 명령
|
||||||
|
|
||||||
외부 서비스 없이 실행 가능한 integration:
|
빠른 기본 검증은 Core의 canonical snapshot, comparator, log/message projection과
|
||||||
|
logical clock 계약만 실행합니다. Ref runtime이나 DB가 없어도 되며 skip을 성공으로
|
||||||
|
세지 않습니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm test:integration
|
pnpm test:integration
|
||||||
```
|
```
|
||||||
|
|
||||||
전용 PostgreSQL·Redis를 사용하는 조건부 suite:
|
실제 경계는 목적별 lane으로 분리합니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# 고유 PostgreSQL schema와 Redis namespace
|
||||||
pnpm test:integration:conditional
|
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는 환경 변수 존재 여부만으로 안전성을 보장하지 않습니다.
|
조건부 runner는 환경 변수 존재 여부만으로 안전성을 보장하지 않습니다.
|
||||||
대상 host, port, database와 Redis prefix가 해당 worktree 전용인지 확인해
|
대상 host, port, database와 Redis prefix가 해당 worktree 전용인지 확인해
|
||||||
주세요. Runner는 실행 ID가 포함된 game schema를 생성하고 성공, 테스트 실패,
|
주세요. Runner는 실행 ID가 포함된 game schema를 생성하고 성공, 테스트 실패,
|
||||||
@@ -73,8 +94,9 @@ runtime role을 삭제하고 PID와 명령행 및 daemon 종료를 확인한 뒤
|
|||||||
- 관리자 시간 가속·연기의 durable action, checkpoint, Redis 부분 재시도와
|
- 관리자 시간 가속·연기의 durable action, checkpoint, Redis 부분 재시도와
|
||||||
경매 timer race
|
경매 timer race
|
||||||
|
|
||||||
실제 포함 suite는 `tools/run-conditional-integration.sh`, 각 package의
|
실제 포함 suite는 `tools/integration-tests/test-lanes.tsv`,
|
||||||
`package.json`, `*.integration.test.ts`를 기준으로 확인합니다. DB 조건부
|
`tools/run-conditional-integration.sh`, 각 package의 `package.json`,
|
||||||
|
`*.integration.test.ts`를 기준으로 확인합니다. DB 조건부
|
||||||
환경 변수는 `tools/conditional-integration-registry.tsv`에서 명시적으로
|
환경 변수는 `tools/conditional-integration-registry.tsv`에서 명시적으로
|
||||||
관리합니다. 새 `*_DATABASE_URL` gate가 registry에 없거나 registry 항목이
|
관리합니다. 새 `*_DATABASE_URL` gate가 registry에 없거나 registry 항목이
|
||||||
더 이상 테스트에 존재하지 않으면 runner가 테스트 실행 전에 실패합니다.
|
더 이상 테스트에 존재하지 않으면 runner가 테스트 실행 전에 실패합니다.
|
||||||
@@ -135,8 +157,8 @@ PostgreSQL·Redis 경계를 증명하지 않습니다. Full suite 실패는 변
|
|||||||
Ref 호환성 판정은 [차등 검증](architecture/turn-state-differential-testing.md),
|
Ref 호환성 판정은 [차등 검증](architecture/turn-state-differential-testing.md),
|
||||||
UI는 [프론트엔드 호환 검증](frontend-legacy-parity.md)을 함께 사용합니다.
|
UI는 [프론트엔드 호환 검증](frontend-legacy-parity.md)을 함께 사용합니다.
|
||||||
|
|
||||||
전투 아이템 전체 차등 검증은 공격자·방어자 각각 145개 fixture를 모두 Core와
|
Ref 감사 lane의 전투 아이템 전체 차등 검증은 공격자·방어자 각각 145개 fixture를
|
||||||
Ref에서 실행하고 event, RNG, outcome, 전체 log bucket을 항목별로 비교합니다.
|
모두 Core와 Ref에서 실행하고 event, RNG, outcome, 전체 log bucket을 항목별로
|
||||||
Ref harness는 fixture마다 새 PHP process를 띄우지 않고 `--jsonl` 입력을 한
|
비교합니다. Ref harness는 fixture마다 새 PHP process를 띄우지 않고 `--jsonl`
|
||||||
process에서 일괄 처리합니다. `ITEM_PARITY_FILTER`는 특정 항목을 진단할 때만
|
batch를 한 process에서 처리합니다. `ITEM_PARITY_FILTER`는 특정 항목 진단에만
|
||||||
사용하며, 전체 검증의 fixture 수나 assertion을 줄이는 최적화로 사용하지 않습니다.
|
사용하며 전체 감사의 fixture 수나 assertion을 줄이는 최적화로 사용하지 않습니다.
|
||||||
|
|||||||
@@ -143,8 +143,15 @@ Fastify transport, Prisma transaction, lease와 worker 경계를 검증합니다
|
|||||||
```sh
|
```sh
|
||||||
pnpm test:integration
|
pnpm test:integration
|
||||||
pnpm test:integration:conditional
|
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가
|
조건부 suite는 worktree별 PostgreSQL·Redis instance를 준비합니다. Test가
|
||||||
schema truncate와 Redis 정리를 수행하므로 공유 개발 instance를 사용하지
|
schema truncate와 Redis 정리를 수행하므로 공유 개발 instance를 사용하지
|
||||||
않습니다.
|
않습니다.
|
||||||
|
|||||||
+3
-1
@@ -16,8 +16,10 @@
|
|||||||
"test:bootstrap": "pnpm install --offline --frozen-lockfile && pnpm test:prepare",
|
"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: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": "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: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": "turbo build --ui=stream --output-logs=errors-only",
|
||||||
"build-verbose": "turbo build --ui=stream --output-logs=full",
|
"build-verbose": "turbo build --ui=stream --output-logs=full",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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"
|
"typecheck": "pnpm -w tsc7 -b tools/integration-tests/tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
@@ -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) {
|
for (const entry of cases) {
|
||||||
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
@@ -1441,13 +1448,16 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
try {
|
executions.push({ entry, base, coreEvents, coreRng, coreOutcome });
|
||||||
assertTraceParity(
|
}
|
||||||
coreEvents,
|
|
||||||
runReferenceTrace(workspaceRoot!, JSON.stringify(base)),
|
const references = runReferenceTraceBatch(
|
||||||
coreRng,
|
workspaceRoot!,
|
||||||
coreOutcome
|
executions.map(({ base }) => JSON.stringify(base))
|
||||||
);
|
);
|
||||||
|
executions.forEach(({ entry, coreEvents, coreRng, coreOutcome }, index) => {
|
||||||
|
try {
|
||||||
|
assertTraceParity(coreEvents, references[index]!, coreRng, coreOutcome);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(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 () => {
|
it('loads every scenario item with the scenario slot', async () => {
|
||||||
|
|||||||
@@ -571,6 +571,7 @@ run_redis_only_tests() {
|
|||||||
export PATH
|
export PATH
|
||||||
cd "$workspace_root"
|
cd "$workspace_root"
|
||||||
node "$script_dir/check-conditional-integration-files.mjs"
|
node "$script_dir/check-conditional-integration-files.mjs"
|
||||||
|
node "$workspace_root/tools/integration-tests/run-test-lane.mjs" check
|
||||||
validate_marker_registry
|
validate_marker_registry
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|||||||
Reference in New Issue
Block a user