Files
houseplan-card/test/test-chunk.test.mjs
T
Claude af62bd4c64 infra: local contour — pre-push gate:small by default, sandbox bootstrap, test:chunk
- .githooks/pre-push + scripts/pre-push-gate.mjs --hook: gate:small runs by
  default for issue/* branches with an executable diff; C/D-only diffs and
  non-issue refs are skipped, HP_PREPUSH_GATE=0 turns it off, =1 forces it.
  A non-HEAD push with an executable diff is rejected (the gate checks the
  working tree). The gate runs with every GIT_* variable removed: git gives
  hooks GIT_DIR (and GIT_CONFIG_PARAMETERS with the pusher's -c), and unit
  tests that `git init` temporary repositories otherwise write into the real
  one — seen on the first live run. The manual #343 mode is unchanged.
- scripts/sandbox-bootstrap.sh: idempotent worktree / deps / chromium (npm
  @sparticuz/chromium@152.0.0 + Playwright shims) / bundle / check steps,
  no owner paths or credentials.
- scripts/test-chunk.mjs, npm run test:chunk -- N/M: round-robin over
  test/*.test.mjs sorted by code point.
- Tests, seven mutants, docs/DEVELOPMENT.md «Локальный контур за 5 минут»,
  docs/TESTING.md. package.json changed → bundle rebuilt (fingerprint input).

Issue: #633
User-Visible: no
2026-09-24 05:00:45 +03:00

52 lines
3.1 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { chunkFiles, listTestFiles, parseChunk } from '../scripts/test-chunk.mjs';
// #633. Полный набор юнитов в песочнице агента не укладывается в одну команду
// (≈3 мин), и его резали руками. `npm run test:chunk -- N/M` делит детерминированно;
// эти тесты держат главное свойство: M частей вместе — каждый файл ровно один раз.
test('N/M разбирается, невозможная часть — ошибка, а не пустой прогон (#633)', () => {
assert.deepEqual(parseChunk('2/6'), { index: 2, total: 6 });
assert.deepEqual(parseChunk(' 1/1 '), { index: 1, total: 1 });
for (const bad of ['0/4', '5/4', '1/0', '2', '', undefined, 'a/b', '-1/4', '1/4/2']) {
assert.throws(() => parseChunk(bad), /N\/M|не существует/, String(bad));
}
});
test('каждый test/*.test.mjs ровно в одной части при любом M (#633 AC3)', () => {
const files = listTestFiles();
assert.ok(files.length > 10, 'набор тестов найден');
assert.ok(files.includes('test/test-chunk.test.mjs'));
assert.ok(files.every((file) => /^test\/[^/]+\.test\.mjs$/.test(file)));
for (const total of [1, 2, 3, 4, 6, 7, files.length, files.length + 3]) {
const seen = new Map();
for (let index = 1; index <= total; index += 1) {
for (const file of chunkFiles(files, index, total)) seen.set(file, (seen.get(file) ?? 0) + 1);
}
assert.deepEqual([...seen.keys()].sort(), [...files].sort(), `M=${total}: покрыты все файлы`);
assert.ok([...seen.values()].every((count) => count === 1), `M=${total}: ни один файл не попал дважды`);
}
});
test('деление детерминировано и не зависит от порядка входа; части равны ±1 (#633)', () => {
const files = Array.from({ length: 23 }, (_, i) => `test/f${String(i).padStart(2, '0')}.test.mjs`);
const shuffled = [...files].reverse();
for (let index = 1; index <= 4; index += 1) {
assert.deepEqual(chunkFiles(shuffled, index, 4), chunkFiles(files, index, 4));
}
assert.deepEqual(chunkFiles(files, 1, 4).slice(0, 3), ['test/f00.test.mjs', 'test/f04.test.mjs', 'test/f08.test.mjs']);
const sizes = [1, 2, 3, 4].map((index) => chunkFiles(files, index, 4).length);
assert.ok(Math.max(...sizes) - Math.min(...sizes) <= 1, sizes.join(','));
// Сортировка по коду символов, не по локали: одна и та же на любой машине.
assert.deepEqual(chunkFiles(['test/b.test.mjs', 'test/B.test.mjs', 'test/a.test.mjs'], 1, 1),
['test/B.test.mjs', 'test/a.test.mjs', 'test/b.test.mjs']);
});
test('npm run test:chunk ведёт в scripts/test-chunk.mjs (#633)', () => {
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
assert.equal(pkg.scripts['test:chunk'], 'node scripts/test-chunk.mjs');
});