mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
test: различать исходы мутационного гейта (#550)
Issue: #550 User-Visible: no
This commit is contained in:
@@ -89,6 +89,25 @@ test('красный гард без мутанта — не сбежавший
|
||||
assert.equal(report.failed, true);
|
||||
});
|
||||
|
||||
test('#550: setup/invalid/interruption reported as unverified, never escaped or caught', () => {
|
||||
const report = mutationGateReport({ ...meta, guards, logs: [
|
||||
{ shard: 1, text: [
|
||||
'FAIL alpha-mutant: ошибка подготовки до заявленного теста',
|
||||
'FAIL beta-mutant: неприменимый мутант',
|
||||
'FAIL gamma-mutant: прерывание инфраструктуры',
|
||||
'FAIL чистая подготовка: npx tsc -p tsconfig.test.json',
|
||||
].join('\n') },
|
||||
] });
|
||||
assert.deepEqual(report.escaped, []);
|
||||
assert.deepEqual(report.redGuards, []);
|
||||
assert.equal(report.unverifiable.length, 4);
|
||||
assert.equal(report.unparsed.length, 0);
|
||||
assert.match(report.body, /Свидетели без доказательства \(4\)/);
|
||||
assert.match(report.body, /не записывается в ledger/);
|
||||
assert.ok(!report.body.includes('--id=alpha-mutant'));
|
||||
assert.equal(report.failed, true);
|
||||
});
|
||||
|
||||
test('id вне реестра не выдумывается, строка сохраняется как есть (#472 AC3)', () => {
|
||||
const report = mutationGateReport({ ...meta, guards, logs: [
|
||||
{ shard: 1, text: 'FAIL ghost-mutant: тест остался зелёным на сломанном коде\n' },
|
||||
|
||||
@@ -48,10 +48,17 @@ test('every guard command points at a file that exists', () => {
|
||||
|
||||
test('every mutant explains itself', () => {
|
||||
const ids = new Set();
|
||||
const guardProofs = new Map();
|
||||
for (const mutant of MUTANTS) {
|
||||
assert.ok(mutant.because && mutant.because.length > 40,
|
||||
`${mutant.id}: без объяснения мутант превратится в карго-культ`);
|
||||
assert.ok(!ids.has(mutant.id), `дубль id: ${mutant.id}`);
|
||||
assert.ok(mutant.oracle == null || ['assertion', 'compile'].includes(mutant.oracle),
|
||||
`${mutant.id}: неизвестный oracle ${mutant.oracle}`);
|
||||
const proof = mutant.oracle || 'assertion';
|
||||
assert.ok(!guardProofs.has(mutant.guard) || guardProofs.get(mutant.guard) === proof,
|
||||
`${mutant.id}: одинаковый guard объявлен с разными oracle`);
|
||||
guardProofs.set(mutant.guard, proof);
|
||||
ids.add(mutant.id);
|
||||
}
|
||||
assert.ok(MUTANTS.length >= 6, 'стартовый набор — шесть мутантов по дырам из #85');
|
||||
@@ -533,10 +540,16 @@ test('#481 AC2: по журналу пропускается только сов
|
||||
const a = { id: 'a', guard: 'g', patches: [] };
|
||||
const b = { id: 'b', guard: 'g', patches: [] };
|
||||
const c = { id: 'c', guard: 'g', patches: [] };
|
||||
const ledger = { schema: LEDGER_SCHEMA, caught: { a: 'fp-a', b: 'fp-old' } };
|
||||
const split = splitByLedger([a, b, c], ledger, (m) => `fp-${m.id}`);
|
||||
const d = { id: 'd', guard: 'g', patches: [] };
|
||||
const ledger = { schema: LEDGER_SCHEMA, caught: {
|
||||
a: { fingerprint: 'fp-a', proof: 'assertion' },
|
||||
b: { fingerprint: 'fp-old', proof: 'assertion' },
|
||||
d: { fingerprint: 'fp-d', proof: 'compile' },
|
||||
} };
|
||||
const split = splitByLedger([a, b, c, d], ledger, (m) => `fp-${m.id}`);
|
||||
assert.deepEqual(split.skipped.map((m) => m.id), ['a']);
|
||||
assert.deepEqual(split.run.map((entry) => `${entry.mutant.id}:${entry.fingerprint}`), ['b:fp-b', 'c:fp-c']);
|
||||
assert.deepEqual(split.run.map((entry) => `${entry.mutant.id}:${entry.fingerprint}`),
|
||||
['b:fp-b', 'c:fp-c', 'd:fp-d'], 'proof другого типа не переиспользуется');
|
||||
});
|
||||
|
||||
test('#481 AC3: журнал пишется сразу при поимке и переживает битый/чужой файл', () => {
|
||||
@@ -546,14 +559,25 @@ test('#481 AC3: журнал пишется сразу при поимке и п
|
||||
assert.deepEqual(readLedger(file), { schema: LEDGER_SCHEMA, caught: {} }, 'нет файла — пустой журнал');
|
||||
const ledger = readLedger(file);
|
||||
recordCaught(file, ledger, { id: 'a' }, 'fp-a');
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), { schema: LEDGER_SCHEMA, caught: { a: 'fp-a' } },
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), { schema: LEDGER_SCHEMA, caught: {
|
||||
a: { fingerprint: 'fp-a', proof: 'assertion' },
|
||||
} },
|
||||
'запись появляется в файле немедленно, не в конце прогона');
|
||||
recordCaught(file, ledger, { id: 'b' }, 'fp-b');
|
||||
assert.deepEqual(readLedger(file).caught, { a: 'fp-a', b: 'fp-b' });
|
||||
recordCaught(file, ledger, { id: 'b' }, 'fp-b', 'compile');
|
||||
assert.deepEqual(readLedger(file).caught, {
|
||||
a: { fingerprint: 'fp-a', proof: 'assertion' },
|
||||
b: { fingerprint: 'fp-b', proof: 'compile' },
|
||||
});
|
||||
writeFileSync(file, '{ not json');
|
||||
assert.deepEqual(readLedger(file).caught, {}, 'битый журнал — пустой, не отказ');
|
||||
writeFileSync(file, JSON.stringify({ schema: 99, caught: { a: 'x' } }));
|
||||
assert.deepEqual(readLedger(file).caught, {}, 'чужая схема — пустой');
|
||||
writeFileSync(file, JSON.stringify({ schema: LEDGER_SCHEMA, caught: {
|
||||
setup: { fingerprint: 'fp-setup', proof: 'setup' },
|
||||
legacy: 'fp-from-schema-1',
|
||||
} }));
|
||||
assert.deepEqual(readLedger(file).caught, {}, 'setup и старые строки не становятся доказательством');
|
||||
assert.throws(() => recordCaught(file, ledger, { id: 'bad' }, 'fp-bad', 'setup'), /недоказанный outcome/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -570,6 +594,8 @@ test('#481: отпечатки реестра детерминированы и
|
||||
const first = witnessFingerprint(MUTANTS[0]);
|
||||
assert.equal(witnessFingerprint(MUTANTS[0]), first);
|
||||
assert.notEqual(witnessFingerprint(MUTANTS[1]), first);
|
||||
assert.notEqual(witnessFingerprint({ ...MUTANTS[0], oracle: 'compile' }), first,
|
||||
'смена типа доказательства инвалидирует ledger');
|
||||
assert.match(first, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
MUTATION_OUTCOME, guardPhases, isProofOutcome, runGuardPhases,
|
||||
runMutationLifecycle, splitAndChain,
|
||||
} from '../scripts/mutation-guard-outcome.mjs';
|
||||
|
||||
const result = (status, output = '') => ({ status, stdout: output, stderr: '' });
|
||||
|
||||
test('#550: shell chain is split only on real && operators', () => {
|
||||
assert.deepEqual(splitAndChain('tsc && fix && node --test test/x.test.mjs'),
|
||||
['tsc', 'fix', 'node --test test/x.test.mjs']);
|
||||
assert.deepEqual(splitAndChain('node --test --test-name-pattern="a && b" test/x.test.mjs'),
|
||||
['node --test --test-name-pattern="a && b" test/x.test.mjs']);
|
||||
assert.deepEqual(guardPhases('tsc && fix && node --test test/x.test.mjs'), {
|
||||
setup: ['tsc', 'fix'], oracle: 'node --test test/x.test.mjs', proof: 'assertion',
|
||||
});
|
||||
});
|
||||
|
||||
test('#550 fixture: clean setup failure never starts the named assertion', () => {
|
||||
const called = [];
|
||||
const outcome = runGuardPhases('prepare && node --test named.test.mjs', {
|
||||
execute: (command) => {
|
||||
called.push(command);
|
||||
return result(1, 'command not found: prepare');
|
||||
},
|
||||
});
|
||||
assert.equal(outcome.kind, MUTATION_OUTCOME.SETUP);
|
||||
assert.deepEqual(called, ['prepare']);
|
||||
assert.equal(isProofOutcome(outcome), false);
|
||||
});
|
||||
|
||||
test('#550 fixture: mutant-induced compile failure is setup, not a killed assertion', () => {
|
||||
const called = [];
|
||||
const outcome = runGuardPhases('npx tsc -p tsconfig.test.json && node --test named.test.mjs', {
|
||||
execute: (command) => {
|
||||
called.push(command);
|
||||
return result(2, 'error TS2322: mutant does not type-check');
|
||||
},
|
||||
});
|
||||
assert.equal(outcome.kind, MUTATION_OUTCOME.SETUP);
|
||||
assert.deepEqual(called, ['npx tsc -p tsconfig.test.json']);
|
||||
assert.equal(isProofOutcome(outcome), false);
|
||||
});
|
||||
|
||||
test('#550 fixture: real named assertion failure is reusable proof', () => {
|
||||
const outcome = runGuardPhases('prepare && node --test named.test.mjs', {
|
||||
execute: (command) => command === 'prepare'
|
||||
? result(0) : result(1, 'not ok 1 - named assertion\ncode: ERR_ASSERTION'),
|
||||
});
|
||||
assert.deepEqual({ kind: outcome.kind, proof: outcome.proof }, {
|
||||
kind: MUTATION_OUTCOME.ASSERTION_KILLED, proof: 'assertion',
|
||||
});
|
||||
assert.equal(isProofOutcome(outcome), true);
|
||||
assert.equal(isProofOutcome({ ...outcome, proof: 'setup' }), false,
|
||||
'поддельный proof не переносит killed outcome в ledger');
|
||||
const quotedFixture = runGuardPhases('node --test classifier.test.mjs', {
|
||||
execute: () => result(1,
|
||||
"AssertionError: expected setup-failure, got ERR_MODULE_NOT_FOUND\ncode: 'ERR_ASSERTION'"),
|
||||
});
|
||||
assert.equal(quotedFixture.kind, MUTATION_OUTCOME.ASSERTION_KILLED,
|
||||
'слово из fixture внутри AssertionError не маскирует реальное падение теста');
|
||||
});
|
||||
|
||||
test('#550 fixture: green named assertion means the mutant survived', () => {
|
||||
const outcome = runGuardPhases('node --test named.test.mjs', {
|
||||
execute: () => result(0, 'ok 1 - named assertion'),
|
||||
});
|
||||
assert.equal(outcome.kind, MUTATION_OUTCOME.SURVIVED);
|
||||
assert.equal(isProofOutcome(outcome), false);
|
||||
});
|
||||
|
||||
test('#550 fixture: oracle load/collection errors are setup failures', () => {
|
||||
for (const output of [
|
||||
'Error [ERR_MODULE_NOT_FOUND]: Cannot find package x',
|
||||
'ERROR collecting tests_backend/test_x.py',
|
||||
'collected 0 items',
|
||||
]) {
|
||||
const outcome = runGuardPhases('node --test named.test.mjs', {
|
||||
execute: () => result(1, output),
|
||||
});
|
||||
assert.equal(outcome.kind, MUTATION_OUTCOME.SETUP, output);
|
||||
}
|
||||
});
|
||||
|
||||
test('#550 fixture: timeout and cancellation are infrastructure interruptions', () => {
|
||||
const timeout = runGuardPhases('node --test named.test.mjs', {
|
||||
execute: () => ({ status: null, signal: 'SIGTERM', error: { code: 'ETIMEDOUT' } }),
|
||||
});
|
||||
const cancelled = runGuardPhases('node --test named.test.mjs', {
|
||||
execute: () => ({ status: null, signal: 'SIGINT' }),
|
||||
});
|
||||
assert.equal(timeout.kind, MUTATION_OUTCOME.INTERRUPTED);
|
||||
assert.equal(timeout.detail, 'ETIMEDOUT');
|
||||
assert.equal(cancelled.kind, MUTATION_OUTCOME.INTERRUPTED);
|
||||
});
|
||||
|
||||
test('#550 fixture: invalid patch and preparation exception are different outcomes', () => {
|
||||
const invalid = runMutationLifecycle({
|
||||
apply: () => { throw new Error('anchor found 0 times'); },
|
||||
prepare: () => assert.fail('prepare must not run'),
|
||||
guard: 'node --test named.test.mjs',
|
||||
execute: () => assert.fail('oracle must not run'),
|
||||
});
|
||||
const setup = runMutationLifecycle({
|
||||
apply: () => {},
|
||||
prepare: () => { throw new Error('rollup failed'); },
|
||||
guard: 'node --test named.test.mjs',
|
||||
execute: () => assert.fail('oracle must not run'),
|
||||
});
|
||||
assert.equal(invalid.kind, MUTATION_OUTCOME.INVALID);
|
||||
assert.equal(setup.kind, MUTATION_OUTCOME.SETUP);
|
||||
});
|
||||
|
||||
test('#550: compile-time proof is explicit and cannot hide in an assertion chain', () => {
|
||||
const compile = runGuardPhases('npx tsc -p tsconfig.type-witness.json', {
|
||||
proof: 'compile', execute: () => result(2, 'expected type error'),
|
||||
});
|
||||
assert.deepEqual({ kind: compile.kind, proof: compile.proof }, {
|
||||
kind: MUTATION_OUTCOME.COMPILE_KILLED, proof: 'compile',
|
||||
});
|
||||
assert.equal(isProofOutcome(compile), true);
|
||||
assert.throws(() => guardPhases('prepare && tsc', 'compile'), /one command/);
|
||||
});
|
||||
Reference in New Issue
Block a user