Newer
Older
rpg / packages / engine / tools / __tests__ / boundary.test.mjs
import { describe, expect, it } from 'vitest';
import { checkBoundary, importSpecs } from '../boundary.mjs';

/** Пути в test-параметрах пишутся со слэшем, приводим к системным сепараторам. */
const sep = process.platform === 'win32' ? '\\' : '/';
const files = (entries) => new Map(entries.map(([f, src]) => [f.replaceAll('/', sep), src]));

describe('importSpecs', () => {
    it('вытаскивает статические, side-effect, re-export и динамические импорты', () => {
        const src = [
            "import { a } from './a';",
            "import './b';",
            'import * as ns from "../c/d";',
            "const m = await import('./dynamic');",
            "export { x } from './reexport';"
        ].join('\n');
        expect(importSpecs(src)).toEqual(['./a', './b', '../c/d', './dynamic', './reexport']);
    });
});

describe('checkBoundary: ядро', () => {
    it('импорт из приложения — error', () => {
        const v = checkBoundary(files([
            ['packages/engine/src/core/x.ts', "import { hero } from '../../../apps/game/src/data/npcs';"]
        ]));
        expect(v).toEqual([
            expect.objectContaining({ severity: 'error', rule: 'engine-no-game' })
        ]);
    });

    it('относительный импорт мимо пакета — error', () => {
        const v = checkBoundary(files([
            ['packages/engine/src/core/x.ts', "import { t } from '../../../CLAUDE.md';"]
        ]));
        expect(v[0]).toMatchObject({ rule: 'engine-self-contained', severity: 'error' });
    });

    it('npm-импорты и внутренние относительные — чисто', () => {
        const v = checkBoundary(files([
            ['packages/engine/src/render/y.ts', "import { Container } from 'pixi.js';\nimport { a } from '../core/a';"]
        ]));
        expect(v).toEqual([]);
    });
});

describe('checkBoundary: приложение', () => {
    it('под-путь ядра мимо публичного API — error', () => {
        const v = checkBoundary(files([
            ['apps/game/src/systems/z.ts', "import { iso } from '@rpg/engine/math/iso';"]
        ]));
        expect(v[0]).toMatchObject({ rule: 'game-engine-public-api', severity: 'error', spec: '@rpg/engine/math/iso' });
    });

    it('публичное API разрешено: сам пакет и assets/*', () => {
        const v = checkBoundary(files([
            ['apps/game/src/main.ts', "import { Game } from '@rpg/engine';\nimport fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';"]
        ]));
        expect(v).toEqual([]);
    });

    it('относительный импорт в ядро мимо API — error', () => {
        const v = checkBoundary(files([
            ['apps/game/src/data/map.ts', "import { rleDecode } from '../../../packages/engine/src/map/mapFormat';"]
        ]));
        expect(v[0]).toMatchObject({ rule: 'game-self-contained', severity: 'error' });
    });

    it('npm-импорты и внутренние относительные — чисто', () => {
        const v = checkBoundary(files([
            ['apps/game/src/scenes/s.ts', "import * as PIXI from 'pixi.js';\nimport { AREAS } from '../data/locations';"]
        ]));
        expect(v).toEqual([]);
    });

    it('опции расширяют публичное API (tools-под-путь)', () => {
        const v = checkBoundary(files([
            ['apps/game/src/main.ts', "import { encodePng } from '@rpg/engine/tools/png.mjs';"]
        ]), { publicApi: ['@rpg/engine', '@rpg/engine/assets/', '@rpg/engine/tools/'] });
        expect(v).toEqual([]);
    });

    it('файлы вне пакетов игнорируются', () => {
        const v = checkBoundary(files([
            ['scripts/x.ts', "import { a } from '../../../packages/engine/src/index';"]
        ]));
        expect(v).toEqual([]);
    });
});