mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
test(iso-overlay): match the virtual probe by TypeScript path key (#777)
On Windows the fixture-type test failed with "Cannot read properties of undefined (reading 'text')". TypeScript normalizes root file names before it asks the compiler host (C:\repo\test\probe.mjs becomes C:/repo/test/probe.mjs), while the host compared that name with the node:path string by ===. The probe was never served, the program had no probe file, and the verdict read undefined.text. The module-resolution filter compared dirname(containing) with TEST_DIR the same way, so on Windows it let ../test-build/* and node:* imports resolve. The host now compares names by the compiler's own key: '/' separators and the host's getCanonicalFileName (case-insensitive where the file system is). readFile serves the probe as well. A source file the check depends on that is missing from the program now fails with its name, the compiler's own diagnostic and a truncated list of the files the program knows, instead of a TypeError. Portability witness, runnable on Linux: the program builder takes the path implementation, root and file system, and a new test builds the same program over this checkout seen as C:\houseplan-card\ through path.win32 with case-insensitive names. It asserts that the probe is found, that the dead-field verdict equals the native one, and that nothing under test-build is looked up. With the old === comparisons it is red (probe missing, test-build looked up); with the old lookup it reproduces the Windows TypeError. The dead-field and direct-call witnesses are unchanged. Issue: #777 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import nodePath from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
@@ -21,11 +21,17 @@ import ts from 'typescript';
|
||||
// тип `OverlayFitFixture`, хелпер `overlayFit`. До этого литерал шёл прямо в
|
||||
// функцию из `test-build`, и возвращённый `stageSize: null` (поле удалила #741)
|
||||
// оставлял проверку зелёной.
|
||||
//
|
||||
// #777: имена файлов хост сравнивает ключом TypeScript, а не строкой.
|
||||
// Компилятор нормализует корневые имена (`C:\…\test\x.mjs` → `C:/…/test/x.mjs`)
|
||||
// и только потом спрашивает хост, а `node:path` на Windows строит имена через
|
||||
// `\`. Строковое сравнение не узнавало виртуальный зонд — на Windows его не
|
||||
// было в программе, и тест падал `TypeError` на `probe.text`; тем же сравнением
|
||||
// фильтр разрешения модулей пропускал `../test-build/*` в программу.
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||||
const TEST_DIR = join(ROOT, 'test');
|
||||
const FIXTURE_FILES = ['iso-scene-render.test.mjs', 'iso-stage6.test.mjs'].map((name) => join(TEST_DIR, name));
|
||||
const PROBE = join(TEST_DIR, '__iso-overlay-fixture-probe.mjs');
|
||||
const FIXTURE_NAMES = ['iso-scene-render.test.mjs', 'iso-stage6.test.mjs'];
|
||||
const PROBE_NAME = '__iso-overlay-fixture-probe.mjs';
|
||||
/** «Object literal may only specify known properties…» — с подсказкой и без. */
|
||||
const EXCESS_PROPERTY = new Set([2353, 2561]);
|
||||
/** Входы сцены и поле записи, которых у боевых типов нет (#714, #724, #732). */
|
||||
@@ -40,15 +46,28 @@ const CHECKED_CALLS = new Map([
|
||||
['resolveIsoOverlayFitEnvelope', 'OverlayFitFixture'],
|
||||
['overlayFit', 'OverlayFitFixture'],
|
||||
]);
|
||||
const tagOf = (file) => basename(file).replace(/\W/g, '_');
|
||||
const tagOf = (name) => name.replace(/\W/g, '_');
|
||||
|
||||
/** Файлы программы в форме путей `path`: `node:path` платформы или `path.win32` (#777). */
|
||||
function fixtureLayout(path, root) {
|
||||
const testDir = path.join(root, 'test');
|
||||
return {
|
||||
path,
|
||||
root,
|
||||
testDir,
|
||||
config: path.join(root, 'tsconfig.json'),
|
||||
fixtureFiles: FIXTURE_NAMES.map((name) => path.join(testDir, name)),
|
||||
probe: path.join(testDir, PROBE_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
/** Зонд: каждое мёртвое поле — отдельный литерал (TypeScript называет одно лишнее поле на литерал). */
|
||||
function probeSource() {
|
||||
const lines = [];
|
||||
const typed = (type, name, body) => lines.push(`/** @type {${type}} */`, `export const ${name} = { ${body} };`);
|
||||
for (const file of FIXTURE_FILES) {
|
||||
const scene = `import('./${basename(file)}').OverlaySceneFixture`;
|
||||
const tag = tagOf(file);
|
||||
for (const name of FIXTURE_NAMES) {
|
||||
const scene = `import('./${name}').OverlaySceneFixture`;
|
||||
const tag = tagOf(name);
|
||||
typed(scene, `${tag}_known`, 'space: null, structure: null, positionOf: null');
|
||||
DEAD_SCENE_FIELDS.forEach((field, index) => typed(scene, `${tag}_scene${index}`, `${field}: null`));
|
||||
}
|
||||
@@ -61,62 +80,116 @@ function probeSource() {
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function fixtureProgram() {
|
||||
const config = ts.readConfigFile(join(ROOT, 'tsconfig.json'), ts.sys.readFile).config;
|
||||
const { options: base } = ts.parseJsonConfigFileContent(config, ts.sys, ROOT);
|
||||
/** Хост компилятора поверх `sys` — те члены, которые `ts.createCompilerHost` берёт из `ts.sys`. */
|
||||
function compilerHost(options, sys) {
|
||||
const host = ts.createCompilerHost(options, true);
|
||||
if (sys === ts.sys) return host;
|
||||
return Object.assign(host, {
|
||||
getCurrentDirectory: () => sys.getCurrentDirectory(),
|
||||
useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames,
|
||||
getCanonicalFileName: sys.useCaseSensitiveFileNames ? (name) => name : (name) => name.toLowerCase(),
|
||||
fileExists: (name) => sys.fileExists(name),
|
||||
readFile: (name) => sys.readFile(name),
|
||||
directoryExists: (name) => sys.directoryExists(name),
|
||||
getDirectories: (name) => sys.getDirectories(name),
|
||||
realpath: (name) => sys.realpath(name),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Программа фикстур и зонда. `path` и `root` — форма путей checkout'а, `sys` —
|
||||
* его файловая система; по умолчанию — эта платформа. Свидетель #777 передаёт
|
||||
* тот же репозиторий в форме Windows.
|
||||
*/
|
||||
function fixtureProgram({ path = nodePath, root = ROOT, sys = ts.sys } = {}) {
|
||||
const at = fixtureLayout(path, root);
|
||||
const config = ts.readConfigFile(at.config, sys.readFile).config;
|
||||
const { options: base } = ts.parseJsonConfigFileContent(config, sys, root);
|
||||
const options = {
|
||||
...base, allowJs: true, checkJs: true, noEmit: true, incremental: false, types: [],
|
||||
};
|
||||
const host = ts.createCompilerHost(options, true);
|
||||
const host = compilerHost(options, sys);
|
||||
// #777: ключ имени — как у самого компилятора: разделитель `/` и
|
||||
// `getCanonicalFileName` хоста (без учёта регистра там, где ФС его не учитывает).
|
||||
const key = (name) => host.getCanonicalFileName(path.normalize(name).replaceAll(path.sep, '/'));
|
||||
const probeKey = key(at.probe);
|
||||
const isProbe = (name) => key(name) === probeKey;
|
||||
const probe = probeSource();
|
||||
const getSourceFile = host.getSourceFile.bind(host);
|
||||
host.getSourceFile = (name, languageVersion, ...rest) => (name === PROBE
|
||||
const { getSourceFile, fileExists, readFile } = host;
|
||||
host.getSourceFile = (name, languageVersion, ...rest) => (isProbe(name)
|
||||
? ts.createSourceFile(name, probe, languageVersion, true)
|
||||
: getSourceFile(name, languageVersion, ...rest));
|
||||
const fileExists = host.fileExists.bind(host);
|
||||
host.fileExists = (name) => name === PROBE || fileExists(name);
|
||||
: getSourceFile.call(host, name, languageVersion, ...rest));
|
||||
host.fileExists = (name) => isProbe(name) || fileExists.call(host, name);
|
||||
host.readFile = (name) => (isProbe(name) ? probe : readFile.call(host, name));
|
||||
// Тесты исполняют `../test-build/*.js` и `node:*`; для типов фикстур нужны
|
||||
// только JSDoc-импорты `../src/*` и файлы фикстур для зонда. Остальное не
|
||||
// разрешается — компилятор не тянет test-build и @types/node.
|
||||
const testDirKey = key(at.testDir);
|
||||
host.resolveModuleNameLiterals = (literals, containing, _redirect, opts) => literals.map((literal) => {
|
||||
const spec = literal.text;
|
||||
if (dirname(containing) === TEST_DIR && !spec.startsWith('../src/') && !spec.startsWith('./')) {
|
||||
if (key(path.dirname(containing)) === testDirKey && !spec.startsWith('../src/') && !spec.startsWith('./')) {
|
||||
return { resolvedModule: undefined };
|
||||
}
|
||||
return ts.resolveModuleName(spec, containing, opts, host);
|
||||
});
|
||||
return ts.createProgram([...FIXTURE_FILES, PROBE], options, host);
|
||||
const program = ts.createProgram([...at.fixtureFiles, at.probe], options, host);
|
||||
return { program, checker: program.getTypeChecker(), at };
|
||||
}
|
||||
|
||||
const program = fixtureProgram();
|
||||
const checker = program.getTypeChecker();
|
||||
const where = (diagnostic) => {
|
||||
/** Путь для сообщения — от корня checkout'а, через `/`. */
|
||||
const relative = (at, name) => at.path.relative(at.root, name).replaceAll(at.path.sep, '/');
|
||||
const where = (at, diagnostic) => {
|
||||
const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||
return `${diagnostic.file.fileName.slice(ROOT.length)}:${line + 1}`;
|
||||
return `${relative(at, diagnostic.file.fileName)}:${line + 1}`;
|
||||
};
|
||||
const message = (diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ');
|
||||
|
||||
test('#732 AC2: фикстуры оверлеев не передают полей, которых нет у боевых типов', () => {
|
||||
const excess = FIXTURE_FILES.flatMap((file) => program.getSemanticDiagnostics(program.getSourceFile(file)))
|
||||
.filter((diagnostic) => EXCESS_PROPERTY.has(diagnostic.code))
|
||||
.map((diagnostic) => `${where(diagnostic)} ${message(diagnostic)}`);
|
||||
assert.deepEqual(excess, [], 'поле, которого никто не читает, фикстура не передаёт');
|
||||
});
|
||||
/**
|
||||
* #777 AC2: файл, без которого проверка теряет смысл. Нет в программе — ошибка
|
||||
* с его именем, словами компилятора и файлами, которые программа знает, а не
|
||||
* `undefined` дальше по коду.
|
||||
*/
|
||||
function sourceFileOf({ program, at }, name) {
|
||||
const source = program.getSourceFile(name);
|
||||
if (source) return source;
|
||||
// Сначала корни программы, затем файлы checkout'а, затем зависимости.
|
||||
const roots = new Set(program.getRootFileNames().map((root) => program.getSourceFile(root)));
|
||||
const rank = (file) => (roots.has(file) ? 0 : relative(at, file.fileName).startsWith('..') ? 2 : 1);
|
||||
const known = program.getSourceFiles()
|
||||
.filter((file) => !program.isSourceFileDefaultLibrary(file))
|
||||
.sort((a, b) => rank(a) - rank(b))
|
||||
.map((file) => relative(at, file.fileName));
|
||||
const LIMIT = 12;
|
||||
const listed = known.slice(0, LIMIT).join(', ') + (known.length > LIMIT ? `, … и ещё ${known.length - LIMIT}` : '');
|
||||
const compiler = program.getOptionsDiagnostics()
|
||||
.map(message).filter((text) => text.includes(at.path.basename(name))).slice(0, 2);
|
||||
throw new Error(`файла ${name} нет в программе TypeScript — хост не узнал имя, которым его спросил компилятор`
|
||||
+ `${compiler.length ? ` (${compiler.join('; ')})` : ''}. Программа знает ${known.length} файлов, кроме `
|
||||
+ `библиотек: ${listed || '—'}`);
|
||||
}
|
||||
|
||||
test('#732 AC2: тип фикстуры — ключи боевого входа; зум, размер сцены, слои, выделение и радиус земли — не входы', () => {
|
||||
const probe = program.getSourceFile(PROBE);
|
||||
/** #732 AC2: лишние поля в файлах фикстур. */
|
||||
function fixtureExcess(env) {
|
||||
return env.at.fixtureFiles.flatMap((file) => env.program.getSemanticDiagnostics(sourceFileOf(env, file)))
|
||||
.filter((diagnostic) => EXCESS_PROPERTY.has(diagnostic.code))
|
||||
.map((diagnostic) => `${where(env.at, diagnostic)} ${message(diagnostic)}`);
|
||||
}
|
||||
|
||||
/** #732 AC2, #754: зонд принимает поля боевых входов и отвергает каждое мёртвое поле. */
|
||||
function assertProbeVerdict(env) {
|
||||
const probe = sourceFileOf(env, env.at.probe);
|
||||
const rejected = new Map();
|
||||
for (const diagnostic of program.getSemanticDiagnostics(probe)) {
|
||||
for (const diagnostic of env.program.getSemanticDiagnostics(probe)) {
|
||||
const name = probe.text.slice(0, diagnostic.start).match(/export const (\w+) = [^\n]*$/)?.[1];
|
||||
assert.ok(EXCESS_PROPERTY.has(diagnostic.code), `${where(diagnostic)} ${message(diagnostic)}`);
|
||||
assert.ok(EXCESS_PROPERTY.has(diagnostic.code), `${where(env.at, diagnostic)} ${message(diagnostic)}`);
|
||||
rejected.set(name, message(diagnostic));
|
||||
}
|
||||
for (const file of FIXTURE_FILES) {
|
||||
const tag = tagOf(file);
|
||||
for (const fixture of FIXTURE_NAMES) {
|
||||
const tag = tagOf(fixture);
|
||||
assert.ok(!rejected.has(`${tag}_known`), `${tag}: поля боевого входа принимаются`);
|
||||
DEAD_SCENE_FIELDS.forEach((field, index) => {
|
||||
assert.match(rejected.get(`${tag}_scene${index}`) || '', new RegExp(`'${field}' does not exist in type`),
|
||||
`${file.slice(ROOT.length)}: OverlaySceneFixture отвергает ${field} — тип разрешился в боевой вход, а не в any`);
|
||||
`test/${fixture}: OverlaySceneFixture отвергает ${field} — тип разрешился в боевой вход, а не в any`);
|
||||
});
|
||||
}
|
||||
assert.ok(!rejected.has('entry_known'), 'поля записи принимаются');
|
||||
@@ -129,14 +202,16 @@ test('#732 AC2: тип фикстуры — ключи боевого входа
|
||||
assert.match(rejected.get(`fit${index}`) || '', new RegExp(`'${field}' does not exist in type 'OverlayFitFixture'`),
|
||||
`OverlayFitFixture отвергает ${field} — тип разрешился в боевой вход, а не в any`);
|
||||
});
|
||||
});
|
||||
return rejected;
|
||||
}
|
||||
|
||||
test('#732 AC2: каждая фикстура сцены и окна оверлеев доходит до своей функции через проверяемый тип', () => {
|
||||
/** #732 AC2: вызовы построителя сцены и окна оверлеев и те из них, чей аргумент не проверен типом фикстуры. */
|
||||
function fixtureCalls(env) {
|
||||
const aliasOf = (type) => type?.aliasSymbol?.name ?? null;
|
||||
const unchecked = [];
|
||||
const calls = new Map();
|
||||
for (const file of FIXTURE_FILES) {
|
||||
const source = program.getSourceFile(file);
|
||||
for (const file of env.at.fixtureFiles) {
|
||||
const source = sourceFileOf(env, file);
|
||||
const visit = (node) => {
|
||||
const expected = ts.isCallExpression(node) && ts.isIdentifier(node.expression)
|
||||
&& CHECKED_CALLS.get(node.expression.text);
|
||||
@@ -147,18 +222,83 @@ test('#732 AC2: каждая фикстура сцены и окна оверл
|
||||
// прочее — своим объявленным типом; литерал прямо в функцию из
|
||||
// test-build не проверяется ничем.
|
||||
const type = arg && ts.isObjectLiteralExpression(arg)
|
||||
? checker.getContextualType(arg) : arg && checker.getTypeAtLocation(arg);
|
||||
? env.checker.getContextualType(arg) : arg && env.checker.getTypeAtLocation(arg);
|
||||
if (aliasOf(type) !== expected) {
|
||||
const { line } = source.getLineAndCharacterOfPosition(node.getStart());
|
||||
unchecked.push(`${file.slice(ROOT.length)}:${line + 1} ${node.getText().slice(0, 80)}`);
|
||||
unchecked.push(`${relative(env.at, file)}:${line + 1} ${node.getText().slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
}
|
||||
return { calls, unchecked };
|
||||
}
|
||||
|
||||
/**
|
||||
* #777: этот же репозиторий, каким его видит Windows: корень `C:\houseplan-card\`
|
||||
* в форме `path.win32`, имена файлов без учёта регистра. Читает настоящий диск;
|
||||
* что вне корня (библиотеки TypeScript, цели симлинков), идёт как есть. Помнит
|
||||
* каждое имя, о котором спросили, — так видно, куда ходило разрешение модулей.
|
||||
*/
|
||||
function windowsCheckout(disk = ROOT) {
|
||||
const root = 'C:\\houseplan-card\\';
|
||||
const prefix = 'c:/houseplan-card/';
|
||||
const lookups = [];
|
||||
const toDisk = (name) => {
|
||||
lookups.push(name);
|
||||
const slashed = name.replaceAll('\\', '/');
|
||||
return slashed.toLowerCase().startsWith(prefix) ? disk + slashed.slice(prefix.length) : name;
|
||||
};
|
||||
const fromDisk = (name) => (name.startsWith(disk) ? root + name.slice(disk.length).replaceAll('/', '\\') : name);
|
||||
const sys = {
|
||||
...ts.sys,
|
||||
useCaseSensitiveFileNames: false,
|
||||
getCurrentDirectory: () => root,
|
||||
fileExists: (name) => ts.sys.fileExists(toDisk(name)),
|
||||
readFile: (name, encoding) => ts.sys.readFile(toDisk(name), encoding),
|
||||
directoryExists: (name) => ts.sys.directoryExists(toDisk(name)),
|
||||
getDirectories: (name) => ts.sys.getDirectories(toDisk(name)),
|
||||
realpath: (name) => fromDisk(ts.sys.realpath(toDisk(name))),
|
||||
readDirectory: (name, ...rest) => ts.sys.readDirectory(toDisk(name), ...rest).map(fromDisk),
|
||||
};
|
||||
return { path: nodePath.win32, root, sys, lookups };
|
||||
}
|
||||
|
||||
const native = fixtureProgram();
|
||||
|
||||
test('#732 AC2: фикстуры оверлеев не передают полей, которых нет у боевых типов', () => {
|
||||
assert.deepEqual(fixtureExcess(native), [], 'поле, которого никто не читает, фикстура не передаёт');
|
||||
});
|
||||
|
||||
test('#732 AC2: тип фикстуры — ключи боевого входа; зум, размер сцены, слои, выделение и радиус земли — не входы', () => {
|
||||
assertProbeVerdict(native);
|
||||
});
|
||||
|
||||
test('#732 AC2: каждая фикстура сцены и окна оверлеев доходит до своей функции через проверяемый тип', () => {
|
||||
const { calls, unchecked } = fixtureCalls(native);
|
||||
assert.ok(calls.get('OverlaySceneFixture') >= 20, `нашлись вызовы построителя сцены (${calls.get('OverlaySceneFixture')})`);
|
||||
assert.ok(calls.get('OverlayFitFixture') >= 4, `нашлись вызовы окна оверлеев (${calls.get('OverlayFitFixture')})`);
|
||||
assert.deepEqual(unchecked, [], 'фикстура сцены — литерал в overlayScene или объявление OverlaySceneFixture, '
|
||||
+ 'фикстура окна оверлеев — литерал в overlayFit или объявление OverlayFitFixture');
|
||||
});
|
||||
|
||||
test('#777: на путях Windows (C:\\…, имена без учёта регистра) зонд находится и судит так же, а test-build не разрешается', () => {
|
||||
const checkout = windowsCheckout();
|
||||
const windows = fixtureProgram(checkout);
|
||||
assert.equal(sourceFileOf(windows, windows.at.probe).text, probeSource(), 'зонд — виртуальный файл хоста, а не файл с диска');
|
||||
assert.deepEqual(assertProbeVerdict(windows), assertProbeVerdict(native), 'мёртвые поля отвергаются теми же словами');
|
||||
assert.deepEqual(checkout.lookups.filter((name) => /[\\/]test-build(?:[\\/]|$)/i.test(name)), [],
|
||||
'импорты test-build из файлов фикстур не разрешаются и на Windows');
|
||||
});
|
||||
|
||||
test('#777 AC2: зонда нет в программе — ошибка с именем файла и файлами программы, а не TypeError', () => {
|
||||
const absent = nodePath.join(native.at.testDir, '__absent-overlay-probe.mjs');
|
||||
assert.throws(() => assertProbeVerdict({ ...native, at: { ...native.at, probe: absent } }), (error) => {
|
||||
assert.ok(!(error instanceof TypeError), `${error.name}: ${error.message}`);
|
||||
assert.ok(error.message.includes(absent), error.message);
|
||||
assert.match(error.message, /test\/iso-scene-render\.test\.mjs/);
|
||||
assert.match(error.message, /Программа знает \d+ файлов/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user