diff --git a/test/iso-overlay-fixture-types.test.mjs b/test/iso-overlay-fixture-types.test.mjs index 6eea78d9..1307486e 100644 --- a/test/iso-overlay-fixture-types.test.mjs +++ b/test/iso-overlay-fixture-types.test.mjs @@ -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; + }); +});