chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1c85091186
184 changed files with 33303 additions and 0 deletions
14
packages/board/package.json
Normal file
14
packages/board/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "@go-learn/board",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Отрисовка доски Го на Canvas 2D: чистая геометрия, рендер состояния, редьюсер тач-ввода с подтверждением, тонкий DOM-адаптер.",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@go-learn/core": "0.1.0"
|
||||
}
|
||||
}
|
||||
43
packages/board/src/dom.ts
Normal file
43
packages/board/src/dom.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Тонкий DOM-адаптер: настройка канвы под devicePixelRatio и перевод
|
||||
* pointer-событий в InputEvent. Логики здесь нет — вся в geometry/input.
|
||||
* Юнит-тестами не покрывается (проверка — смоук на этапе 5).
|
||||
*/
|
||||
import type { InputEvent } from './input.js';
|
||||
|
||||
/**
|
||||
* Выставляет canvas.width/height = cssSize × devicePixelRatio и
|
||||
* ctx.setTransform(dpr, …), чтобы рисовать в логических CSS px.
|
||||
* Возвращает применённый dpr.
|
||||
*/
|
||||
export function setupCanvas(canvas: HTMLCanvasElement, cssSize: number): number {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(cssSize * dpr);
|
||||
canvas.height = Math.round(cssSize * dpr);
|
||||
canvas.style.width = `${cssSize}px`;
|
||||
canvas.style.height = `${cssSize}px`;
|
||||
canvas.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return dpr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывается на pointerdown канвы и вызывает onEvent с событием 'tap'
|
||||
* в координатах канвы (CSS px). Возвращает функцию отписки.
|
||||
*/
|
||||
export function attachPointerInput(
|
||||
canvas: HTMLCanvasElement,
|
||||
onEvent: (event: InputEvent) => void,
|
||||
): () => void {
|
||||
const handler = (event: PointerEvent): void => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
onEvent({
|
||||
type: 'tap',
|
||||
px: event.clientX - rect.left,
|
||||
py: event.clientY - rect.top,
|
||||
});
|
||||
};
|
||||
canvas.addEventListener('pointerdown', handler);
|
||||
return (): void => {
|
||||
canvas.removeEventListener('pointerdown', handler);
|
||||
};
|
||||
}
|
||||
124
packages/board/src/geometry.test.ts
Normal file
124
packages/board/src/geometry.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Тесты геометрии: хоси, roundtrip pixelToPoint∘pointToPixel, отклонение
|
||||
* курсора дальше половины клетки, поля с координатами и без.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BoardSize, Point } from '@go-learn/core';
|
||||
import { computeGeometry, hoshiPoints, pixelToPoint, pointToPixel } from './geometry.js';
|
||||
|
||||
function hasPoint(points: readonly Point[], x: number, y: number): boolean {
|
||||
return points.some((p) => p.x === x && p.y === y);
|
||||
}
|
||||
|
||||
describe('hoshiPoints', () => {
|
||||
it('19×19: 9 точек, углы на линиях 3-3 (0-based)', () => {
|
||||
const points = hoshiPoints(19);
|
||||
expect(points).toHaveLength(9);
|
||||
for (const x of [3, 9, 15]) {
|
||||
for (const y of [3, 9, 15]) expect(hasPoint(points, x, y)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('13×13: 5 точек — углы 3-3 и центр', () => {
|
||||
const points = hoshiPoints(13);
|
||||
expect(points).toHaveLength(5);
|
||||
for (const [x, y] of [
|
||||
[3, 3],
|
||||
[9, 3],
|
||||
[6, 6],
|
||||
[3, 9],
|
||||
[9, 9],
|
||||
]) {
|
||||
expect(hasPoint(points, x ?? 0, y ?? 0)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('9×9: 5 точек — углы 2-2 и центр', () => {
|
||||
const points = hoshiPoints(9);
|
||||
expect(points).toHaveLength(5);
|
||||
for (const [x, y] of [
|
||||
[2, 2],
|
||||
[6, 2],
|
||||
[4, 4],
|
||||
[2, 6],
|
||||
[6, 6],
|
||||
]) {
|
||||
expect(hasPoint(points, x ?? 0, y ?? 0)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeGeometry', () => {
|
||||
it('сетка + два поля ровно заполняют квадрат канвы', () => {
|
||||
for (const size of [9, 13, 19] as const) {
|
||||
for (const show of [true, false]) {
|
||||
const geo = computeGeometry(size, 600, show);
|
||||
expect(geo.padding * 2 + geo.cell * (size - 1)).toBeCloseTo(600, 10);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('padding с координатами больше, чем без', () => {
|
||||
const withCoords = computeGeometry(19, 600, true);
|
||||
const without = computeGeometry(19, 600, false);
|
||||
expect(withCoords.padding).toBeGreaterThan(without.padding);
|
||||
});
|
||||
|
||||
it('крайний камень не обрезается: padding ≥ cell/2', () => {
|
||||
for (const size of [9, 13, 19] as const) {
|
||||
expect(computeGeometry(size, 300, false).padding).toBeGreaterThanOrEqual(
|
||||
computeGeometry(size, 300, false).cell / 2,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundtrip pixelToPoint∘pointToPixel', () => {
|
||||
it('каждое пересечение возвращается в себя', () => {
|
||||
for (const size of [9, 13, 19] as const) {
|
||||
const geo = computeGeometry(size, 570, true);
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const px = pointToPixel(geo, { x, y });
|
||||
expect(pixelToPoint(geo, px.x, px.y)).toEqual({ x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('сдвиг меньше половины клетки — ближайшее пересечение', () => {
|
||||
const geo = computeGeometry(9, 450, false);
|
||||
const center = pointToPixel(geo, { x: 4, y: 4 });
|
||||
const hit = pixelToPoint(geo, center.x + geo.cell * 0.4, center.y - geo.cell * 0.4);
|
||||
expect(hit).toEqual({ x: 4, y: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pixelToPoint: отклонения', () => {
|
||||
const geo = computeGeometry(9, 450, false);
|
||||
|
||||
it('дальше половины клетки за краем доски — null', () => {
|
||||
const corner = pointToPixel(geo, { x: 8, y: 8 });
|
||||
expect(pixelToPoint(geo, corner.x + geo.cell * 0.6, corner.y)).toBeNull();
|
||||
expect(pixelToPoint(geo, corner.x, corner.y + geo.cell * 0.6)).toBeNull();
|
||||
});
|
||||
|
||||
it('за пределами канвы — null', () => {
|
||||
expect(pixelToPoint(geo, -10, 100)).toBeNull();
|
||||
expect(pixelToPoint(geo, 100, geo.pixelSize + 10)).toBeNull();
|
||||
});
|
||||
|
||||
it('в пределах половины клетки от крайнего пересечения — не null', () => {
|
||||
const edge = pointToPixel(geo, { x: 8, y: 0 });
|
||||
expect(pixelToPoint(geo, edge.x + geo.cell * 0.4, edge.y)).toEqual({ x: 8, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('размер доски в геометрии', () => {
|
||||
it('size пробрасывается из аргумента', () => {
|
||||
const sizes: BoardSize[] = [9, 13, 19];
|
||||
for (const size of sizes) {
|
||||
expect(computeGeometry(size, 500, true).size).toBe(size);
|
||||
}
|
||||
});
|
||||
});
|
||||
71
packages/board/src/geometry.ts
Normal file
71
packages/board/src/geometry.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Геометрия доски: перевод пересечений в пиксели канвы и обратно.
|
||||
* Чистый модуль: без DOM, без состояния, без Date.now()/Math.random().
|
||||
*
|
||||
* Канва — логический квадрат pixelSize×pixelSize (CSS px); сетка занимает
|
||||
* (size−1)·cell, остаток поровну уходит в поля padding с четырёх сторон.
|
||||
*/
|
||||
import type { BoardSize, Point } from '@go-learn/core';
|
||||
|
||||
export interface BoardGeometry {
|
||||
readonly size: BoardSize;
|
||||
readonly pixelSize: number; // логический квадрат канвы в CSS px
|
||||
readonly padding: number; // поля (под координаты, если включены)
|
||||
readonly cell: number; // шаг сетки в CSS px
|
||||
}
|
||||
|
||||
/**
|
||||
* Геометрия доски под квадрат pixelSize.
|
||||
* Поля считаются от шага сетки: с координатами — целая клетка (место под
|
||||
* буквы/числа), без — полклетки с запасом, чтобы крайние камни не резались.
|
||||
*/
|
||||
export function computeGeometry(
|
||||
size: BoardSize,
|
||||
pixelSize: number,
|
||||
showCoordinates: boolean,
|
||||
): BoardGeometry {
|
||||
const marginCells = showCoordinates ? 1 : 0.55;
|
||||
const cell = pixelSize / (size - 1 + marginCells * 2);
|
||||
const padding = marginCells * cell;
|
||||
return { size, pixelSize, padding, cell };
|
||||
}
|
||||
|
||||
/** Центр пересечения в CSS px (от левого верхнего угла канвы). */
|
||||
export function pointToPixel(geo: BoardGeometry, p: Point): { x: number; y: number } {
|
||||
return { x: geo.padding + p.x * geo.cell, y: geo.padding + p.y * geo.cell };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ближайшее пересечение к точке касания; null — курсор вне доски либо
|
||||
* дальше половины клетки от любого пересечения.
|
||||
*/
|
||||
export function pixelToPoint(geo: BoardGeometry, px: number, py: number): Point | null {
|
||||
const x = Math.round((px - geo.padding) / geo.cell);
|
||||
const y = Math.round((py - geo.padding) / geo.cell);
|
||||
if (x < 0 || x >= geo.size || y < 0 || y >= geo.size) return null;
|
||||
const dx = Math.abs(px - (geo.padding + x * geo.cell));
|
||||
const dy = Math.abs(py - (geo.padding + y * geo.cell));
|
||||
if (dx > geo.cell / 2 || dy > geo.cell / 2) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Хоси (звёздные пункты): 19×19 — 9 точек (линии 3/9/15 в 0-based),
|
||||
* 13×13 и 9×9 — по 5 (углы + центр).
|
||||
*/
|
||||
export function hoshiPoints(size: BoardSize): readonly Point[] {
|
||||
if (size === 19) {
|
||||
const lines = [3, 9, 15];
|
||||
return lines.flatMap((y) => lines.map((x) => ({ x, y })));
|
||||
}
|
||||
const edge = size === 13 ? 3 : 2;
|
||||
const center = (size - 1) / 2;
|
||||
const far = size - 1 - edge;
|
||||
return [
|
||||
{ x: edge, y: edge },
|
||||
{ x: far, y: edge },
|
||||
{ x: center, y: center },
|
||||
{ x: edge, y: far },
|
||||
{ x: far, y: far },
|
||||
];
|
||||
}
|
||||
13
packages/board/src/index.ts
Normal file
13
packages/board/src/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Публичный API пакета доски (контракт — docs/INTERFACES.md).
|
||||
* Геометрия, ввод и рендер — чистые функции без DOM; DOM — только dom.ts.
|
||||
* Типа PointKey в @go-learn/core нет: ключи множеств/карт — string формата
|
||||
* pointKey ("x,y"), см. комментарии у RenderOptions.
|
||||
*/
|
||||
export type { BoardGeometry } from './geometry.js';
|
||||
export { computeGeometry, hoshiPoints, pixelToPoint, pointToPixel } from './geometry.js';
|
||||
export type { BoardTheme, RenderOptions, TerritoryMap } from './render.js';
|
||||
export { DEAD_STONE_ALPHA, render } from './render.js';
|
||||
export type { InputEvent, InputResult, InputState } from './input.js';
|
||||
export { reduceInput } from './input.js';
|
||||
export { attachPointerInput, setupCanvas } from './dom.js';
|
||||
79
packages/board/src/input.test.ts
Normal file
79
packages/board/src/input.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Тесты редьюсера тач-ввода: вся таблица переходов.
|
||||
* tap → pixelToPoint; pending/commit/cancel/перенос по контракту.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Point } from '@go-learn/core';
|
||||
import { computeGeometry, pointToPixel } from './geometry.js';
|
||||
import type { InputEvent, InputState } from './input.js';
|
||||
import { reduceInput } from './input.js';
|
||||
|
||||
const geo = computeGeometry(9, 450, false);
|
||||
const IDLE: InputState = { pending: null };
|
||||
|
||||
function tap(point: Point): InputEvent {
|
||||
const { x, y } = pointToPixel(geo, point);
|
||||
return { type: 'tap', px: x, py: y };
|
||||
}
|
||||
|
||||
const A: Point = { x: 3, y: 3 };
|
||||
const B: Point = { x: 5, y: 6 };
|
||||
|
||||
describe('reduceInput: tap', () => {
|
||||
it('тап при pending=null → pending=точка, commit=null', () => {
|
||||
const result = reduceInput(IDLE, tap(A), geo);
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.state.pending).toEqual(A);
|
||||
});
|
||||
|
||||
it('повторный тап по той же точке → commit=точка, pending=null', () => {
|
||||
const result = reduceInput({ pending: A }, tap(A), geo);
|
||||
expect(result.commit).toEqual(A);
|
||||
expect(result.state.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('тап по другой точке → перенос pending, commit=null', () => {
|
||||
const result = reduceInput({ pending: A }, tap(B), geo);
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.state.pending).toEqual(B);
|
||||
});
|
||||
|
||||
it('тап мимо доски → состояние не меняется, commit=null', () => {
|
||||
const miss: InputEvent = { type: 'tap', px: -20, py: 450 + 20 };
|
||||
const fromIdle = reduceInput(IDLE, miss, geo);
|
||||
expect(fromIdle.state).toBe(IDLE);
|
||||
expect(fromIdle.commit).toBeNull();
|
||||
const withPending: InputState = { pending: A };
|
||||
const fromPending = reduceInput(withPending, miss, geo);
|
||||
expect(fromPending.state).toBe(withPending);
|
||||
expect(fromPending.commit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduceInput: confirm', () => {
|
||||
it('confirm при pending → commit=pending, pending=null', () => {
|
||||
const result = reduceInput({ pending: A }, { type: 'confirm' }, geo);
|
||||
expect(result.commit).toEqual(A);
|
||||
expect(result.state.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('confirm без pending → commit=null, pending=null', () => {
|
||||
const result = reduceInput(IDLE, { type: 'confirm' }, geo);
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.state.pending).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduceInput: cancel', () => {
|
||||
it('cancel при pending → сброс, commit=null', () => {
|
||||
const result = reduceInput({ pending: A }, { type: 'cancel' }, geo);
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.state.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('cancel без pending → commit=null, pending=null', () => {
|
||||
const result = reduceInput(IDLE, { type: 'cancel' }, geo);
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.state.pending).toBeNull();
|
||||
});
|
||||
});
|
||||
38
packages/board/src/input.ts
Normal file
38
packages/board/src/input.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Тач-ввод с подтверждением: чистый редьюсер, без DOM.
|
||||
* Первый тап — фантом (pending); второй тап по той же точке или confirm —
|
||||
* ход (commit); тап по другой точке — перенос фантома; cancel — сброс.
|
||||
*/
|
||||
import type { Point } from '@go-learn/core';
|
||||
import type { BoardGeometry } from './geometry.js';
|
||||
import { pixelToPoint } from './geometry.js';
|
||||
|
||||
export type InputEvent =
|
||||
| { readonly type: 'tap'; readonly px: number; readonly py: number }
|
||||
| { readonly type: 'confirm' }
|
||||
| { readonly type: 'cancel' };
|
||||
|
||||
export interface InputState {
|
||||
readonly pending: Point | null;
|
||||
}
|
||||
|
||||
export interface InputResult {
|
||||
readonly state: InputState;
|
||||
readonly commit: Point | null; // не-null — пользователь подтвердил ход
|
||||
}
|
||||
|
||||
function samePoint(a: Point, b: Point): boolean {
|
||||
return a.x === b.x && a.y === b.y;
|
||||
}
|
||||
|
||||
/** Единственная точка изменения состояния ввода; неизменяемый переход. */
|
||||
export function reduceInput(state: InputState, event: InputEvent, geo: BoardGeometry): InputResult {
|
||||
if (event.type === 'cancel') return { state: { pending: null }, commit: null };
|
||||
if (event.type === 'confirm') return { state: { pending: null }, commit: state.pending };
|
||||
const point = pixelToPoint(geo, event.px, event.py);
|
||||
if (point === null) return { state, commit: null };
|
||||
if (state.pending !== null && samePoint(state.pending, point)) {
|
||||
return { state: { pending: null }, commit: point };
|
||||
}
|
||||
return { state: { pending: point }, commit: null };
|
||||
}
|
||||
254
packages/board/src/render.test.ts
Normal file
254
packages/board/src/render.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* Тесты рендера: мок CanvasRenderingContext2D с записью вызовов.
|
||||
* Проверяем только инварианты, не каждый вызов:
|
||||
* — число камней = числу непустых клеток BoardState;
|
||||
* — фантом рисуется с globalAlpha = phantomOpacity;
|
||||
* — метка последнего хода — только при lastMove;
|
||||
* — подписи LB и фигуры TR/SQ/CR приходят из markup;
|
||||
* — fillRect-заливка — только фон и territory-ключи;
|
||||
* — мёртвые камни рисуются с пониженной альфой (DEAD_STONE_ALPHA).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BoardState, NodeMarkup, Point } from '@go-learn/core';
|
||||
import { applyMove, createBoard } from '@go-learn/core';
|
||||
import type { BoardTheme, RenderOptions } from './render.js';
|
||||
import { DEAD_STONE_ALPHA, render } from './render.js';
|
||||
|
||||
interface PathOp {
|
||||
readonly kind: 'arc' | 'moveTo' | 'lineTo';
|
||||
}
|
||||
|
||||
interface PaintRecord {
|
||||
readonly color: string;
|
||||
readonly globalAlpha: number;
|
||||
readonly path: readonly PathOp[];
|
||||
}
|
||||
|
||||
interface RectRecord {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly w: number;
|
||||
readonly h: number;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
interface TextRecord {
|
||||
readonly text: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly color: string;
|
||||
readonly font: string;
|
||||
}
|
||||
|
||||
/** Рекордер вызовов CanvasRenderingContext2D (только нужный рендеру набор). */
|
||||
class CtxRecorder {
|
||||
fillStyle = '#000000';
|
||||
strokeStyle = '#000000';
|
||||
globalAlpha = 1;
|
||||
lineWidth = 1;
|
||||
font = '10px sans-serif';
|
||||
textAlign = 'start';
|
||||
textBaseline = 'alphabetic';
|
||||
readonly canvas = { width: 600, height: 600 } as HTMLCanvasElement;
|
||||
readonly fills: PaintRecord[] = [];
|
||||
readonly strokes: PaintRecord[] = [];
|
||||
readonly fillRects: RectRecord[] = [];
|
||||
readonly strokeRects: RectRecord[] = [];
|
||||
readonly texts: TextRecord[] = [];
|
||||
saves = 0;
|
||||
restores = 0;
|
||||
private path: PathOp[] = [];
|
||||
private readonly stack: number[] = [];
|
||||
|
||||
save(): void {
|
||||
this.saves += 1;
|
||||
this.stack.push(this.globalAlpha);
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
this.restores += 1;
|
||||
this.globalAlpha = this.stack.pop() ?? 1;
|
||||
}
|
||||
|
||||
setTransform(): void {}
|
||||
scale(): void {}
|
||||
setLineDash(): void {}
|
||||
beginPath(): void {
|
||||
this.path = [];
|
||||
}
|
||||
arc(): void {
|
||||
this.path.push({ kind: 'arc' });
|
||||
}
|
||||
moveTo(): void {
|
||||
this.path.push({ kind: 'moveTo' });
|
||||
}
|
||||
lineTo(): void {
|
||||
this.path.push({ kind: 'lineTo' });
|
||||
}
|
||||
getTransform(): DOMMatrix {
|
||||
return { a: 1 } as DOMMatrix;
|
||||
}
|
||||
fill(): void {
|
||||
this.fills.push({ color: this.fillStyle, globalAlpha: this.globalAlpha, path: this.path });
|
||||
}
|
||||
stroke(): void {
|
||||
this.strokes.push({
|
||||
color: this.strokeStyle,
|
||||
globalAlpha: this.globalAlpha,
|
||||
path: this.path,
|
||||
});
|
||||
}
|
||||
fillRect(x: number, y: number, w: number, h: number): void {
|
||||
this.fillRects.push({ x, y, w, h, color: this.fillStyle });
|
||||
}
|
||||
strokeRect(x: number, y: number, w: number, h: number): void {
|
||||
this.strokeRects.push({ x, y, w, h, color: this.strokeStyle });
|
||||
}
|
||||
fillText(text: string, x: number, y: number): void {
|
||||
this.texts.push({ text, x, y, color: this.fillStyle, font: this.font });
|
||||
}
|
||||
}
|
||||
|
||||
/** Различимые цвета для инвариантов (остальное — дефолтная тёмная тема). */
|
||||
const THEME: Partial<BoardTheme> = {
|
||||
blackStone: '#111111',
|
||||
whiteStone: '#eeeeee',
|
||||
phantomOpacity: 0.4,
|
||||
lastMoveMarker: '#ff00ff',
|
||||
markupColor: '#00ff00',
|
||||
territoryBlack: 'rgba(1, 2, 3, 0.5)',
|
||||
territoryWhite: 'rgba(4, 5, 6, 0.5)',
|
||||
};
|
||||
|
||||
function play(state: BoardState, point: Point): BoardState {
|
||||
const result = applyMove(state, { kind: 'play', color: state.toPlay, point });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return result.state;
|
||||
}
|
||||
|
||||
/** Позиция 9×9 с тремя камнями: чёрные (2,2) и (4,4), белый (6,2). */
|
||||
function threeStones(): BoardState {
|
||||
let state = createBoard(9);
|
||||
state = play(state, { x: 2, y: 2 });
|
||||
state = play(state, { x: 6, y: 2 });
|
||||
return play(state, { x: 4, y: 4 });
|
||||
}
|
||||
|
||||
function draw(state: BoardState, options: RenderOptions = {}): CtxRecorder {
|
||||
const rec = new CtxRecorder();
|
||||
render(rec as unknown as CanvasRenderingContext2D, state, {
|
||||
theme: THEME,
|
||||
...options,
|
||||
});
|
||||
return rec;
|
||||
}
|
||||
|
||||
function stoneFills(rec: CtxRecorder, alpha: number): PaintRecord[] {
|
||||
return rec.fills.filter(
|
||||
(f) => (f.color === '#111111' || f.color === '#eeeeee') && f.globalAlpha === alpha,
|
||||
);
|
||||
}
|
||||
|
||||
describe('render: камни', () => {
|
||||
it('число нарисованных камней = числу непустых клеток', () => {
|
||||
const rec = draw(threeStones());
|
||||
expect(stoneFills(rec, 1)).toHaveLength(3);
|
||||
expect(rec.saves).toBe(rec.restores);
|
||||
});
|
||||
|
||||
it('мёртвые камни приглушены альфой DEAD_STONE_ALPHA', () => {
|
||||
const rec = draw(threeStones(), { dead: new Set(['2,2', '6,2']) });
|
||||
expect(stoneFills(rec, DEAD_STONE_ALPHA)).toHaveLength(2);
|
||||
expect(stoneFills(rec, 1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render: фантом и последний ход', () => {
|
||||
it('фантом рисуется с globalAlpha = phantomOpacity', () => {
|
||||
const rec = draw(threeStones(), { phantom: { point: { x: 0, y: 0 }, color: 'black' } });
|
||||
const phantom = rec.fills.filter((f) => f.color === '#111111' && f.globalAlpha === 0.4);
|
||||
expect(phantom).toHaveLength(1);
|
||||
expect(stoneFills(rec, 1)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('метка последнего хода — только при lastMove', () => {
|
||||
const without = draw(threeStones());
|
||||
expect(without.strokes.filter((s) => s.color === '#ff00ff')).toHaveLength(0);
|
||||
const withMarker = draw(threeStones(), { lastMove: { x: 4, y: 4 } });
|
||||
const marker = withMarker.strokes.filter((s) => s.color === '#ff00ff');
|
||||
expect(marker).toHaveLength(1);
|
||||
expect(marker[0]?.path.some((op) => op.kind === 'arc')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render: разметка из markup', () => {
|
||||
const markup: NodeMarkup = {
|
||||
labels: [
|
||||
{ point: { x: 1, y: 1 }, text: 'A' },
|
||||
{ point: { x: 2, y: 2 }, text: 'Б' },
|
||||
],
|
||||
triangles: [{ x: 3, y: 5 }],
|
||||
squares: [{ x: 5, y: 3 }],
|
||||
circles: [{ x: 7, y: 7 }],
|
||||
};
|
||||
|
||||
it('подписи LB присутствуют', () => {
|
||||
const rec = draw(threeStones(), { markup });
|
||||
const texts = rec.texts.map((t) => t.text);
|
||||
expect(texts).toContain('A');
|
||||
expect(texts).toContain('Б');
|
||||
});
|
||||
|
||||
it('SQ/TR/CR рисуются цветом markupColor по одной фигуре', () => {
|
||||
const rec = draw(threeStones(), { markup });
|
||||
const squares = rec.strokeRects.filter((r) => r.color === '#00ff00');
|
||||
expect(squares).toHaveLength(1);
|
||||
const shapes = rec.strokes.filter((s) => s.color === '#00ff00');
|
||||
const lineTos = (s: PaintRecord): number => s.path.filter((op) => op.kind === 'lineTo').length;
|
||||
expect(shapes.filter((s) => lineTos(s) === 3)).toHaveLength(1); // TR
|
||||
expect(shapes.filter((s) => s.path.some((op) => op.kind === 'arc'))).toHaveLength(1); // CR
|
||||
});
|
||||
});
|
||||
|
||||
describe('render: заливка территории', () => {
|
||||
it('fillRect — только фон и territory-ключи, цвет по владельцу', () => {
|
||||
const rec = draw(threeStones(), {
|
||||
territory: { black: new Set(['0,0', '1,0']), white: new Set(['8,8']) },
|
||||
});
|
||||
expect(rec.fillRects).toHaveLength(4); // фон + 3 клетки территории
|
||||
expect(rec.fillRects.filter((r) => r.color === 'rgba(1, 2, 3, 0.5)')).toHaveLength(2);
|
||||
expect(rec.fillRects.filter((r) => r.color === 'rgba(4, 5, 6, 0.5)')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('заливка совпадает с координатами ключа "1,0"', () => {
|
||||
const rec = draw(createBoard(9), { territory: { black: new Set(['1,0']), white: new Set() } });
|
||||
const cell = 600 / (8 + 2); // size 9, координаты включены
|
||||
const rect = rec.fillRects.find((r) => r.color === 'rgba(1, 2, 3, 0.5)');
|
||||
expect(rect?.x).toBeCloseTo(cell + cell - cell / 2, 6);
|
||||
expect(rect?.y).toBeCloseTo(cell - cell / 2, 6);
|
||||
expect(rect?.w).toBeCloseTo(cell, 6);
|
||||
});
|
||||
|
||||
it('без territory — единственный fillRect (фон)', () => {
|
||||
expect(draw(threeStones()).fillRects).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render: номера ходов', () => {
|
||||
it('номера рисуются только при showMoveNumbers и только на занятых клетках', () => {
|
||||
const moveNumbers = new Map([
|
||||
['2,2', 1],
|
||||
['6,6', 5],
|
||||
]);
|
||||
// Координаты выключены, чтобы их числа не совпадали с номерами ходов.
|
||||
const off = draw(threeStones(), { moveNumbers, showCoordinates: false });
|
||||
expect(off.texts.filter((t) => t.text === '1')).toHaveLength(0);
|
||||
const on = draw(threeStones(), {
|
||||
showMoveNumbers: true,
|
||||
moveNumbers,
|
||||
showCoordinates: false,
|
||||
});
|
||||
expect(on.texts.filter((t) => t.text === '1')).toHaveLength(1);
|
||||
expect(on.texts.filter((t) => t.text === '5')).toHaveLength(0); // клетка пуста
|
||||
});
|
||||
});
|
||||
333
packages/board/src/render.ts
Normal file
333
packages/board/src/render.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* Рендер доски на Canvas 2D: чистая функция состояния. Каждый кадр рисуется
|
||||
* целиком из аргументов (state + options); render ничего не хранит и не
|
||||
* читает, кроме ctx. CanvasRenderingContext2D — только тип параметра.
|
||||
*
|
||||
* Порядок слоёв: фон доски → заливка территории → сетка → хоси → координаты →
|
||||
* разметка SQ/TR/CR → камни → метка последнего хода → номера ходов → фантом →
|
||||
* подписи LB.
|
||||
*
|
||||
* Логический размер канвы выводится из ctx: pixelSize = canvas.width / dpr,
|
||||
* где dpr — масштаб текущего трансформа (его выставляет setupCanvas).
|
||||
*/
|
||||
import type { BoardState, Color, NodeMarkup, Point } from '@go-learn/core';
|
||||
import { cellAt, pointKey } from '@go-learn/core';
|
||||
import type { BoardGeometry } from './geometry.js';
|
||||
import { computeGeometry, hoshiPoints, pointToPixel } from './geometry.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
/** Непрозрачность приглушения мёртвых камней (помеченных в options.dead). */
|
||||
export const DEAD_STONE_ALPHA = 0.45;
|
||||
|
||||
export interface BoardTheme {
|
||||
readonly boardBackground: string;
|
||||
readonly lineColor: string;
|
||||
readonly blackStone: string;
|
||||
readonly whiteStone: string;
|
||||
readonly blackStoneEdge: string;
|
||||
readonly whiteStoneEdge: string;
|
||||
readonly coordinateColor: string;
|
||||
readonly markupColor: string;
|
||||
readonly lastMoveMarker: string;
|
||||
readonly phantomOpacity: number; // 0..1
|
||||
readonly territoryBlack: string; // заливка с альфой
|
||||
readonly territoryWhite: string;
|
||||
}
|
||||
|
||||
/** Заливка территории при подсчёте: ключи pointKey ("x,y") по цветам. */
|
||||
export interface TerritoryMap {
|
||||
readonly black: ReadonlySet<string>;
|
||||
readonly white: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
readonly theme?: Partial<BoardTheme>; // дефолт — тёмная тема
|
||||
readonly showCoordinates?: boolean; // дефолт true
|
||||
readonly showMoveNumbers?: boolean; // дефолт false
|
||||
readonly moveNumbers?: ReadonlyMap<string, number>; // ключ pointKey ("x,y") → номер хода
|
||||
readonly lastMove?: Point | null;
|
||||
readonly phantom?: { readonly point: Point; readonly color: Color } | null;
|
||||
readonly markup?: NodeMarkup; // из @go-learn/core
|
||||
readonly territory?: TerritoryMap; // заливка при подсчёте
|
||||
readonly dead?: ReadonlySet<string>; // ключи pointKey ("x,y"); приглушаются (пониженная непрозрачность)
|
||||
}
|
||||
|
||||
/**
|
||||
* Дефолтная светлая тема «васи» (этап 10): светлое дерево гобана, коричневая
|
||||
* сетка, почти чёрный и молочный камни с читаемой обводкой; цвета совпадают
|
||||
* с токенами сайта (Layout.astro), разметка — терракота, последний ход —
|
||||
* индиго. Константы — часть дизайна (контракт docs/INTERFACES.md).
|
||||
*/
|
||||
const DEFAULT_THEME: BoardTheme = {
|
||||
boardBackground: '#e6c48a',
|
||||
lineColor: '#8a6a3f',
|
||||
blackStone: '#211e1a',
|
||||
whiteStone: '#f6f2e8',
|
||||
blackStoneEdge: '#4a453c',
|
||||
whiteStoneEdge: '#b9ac93',
|
||||
coordinateColor: '#8a6a3f',
|
||||
markupColor: '#a84e2c',
|
||||
lastMoveMarker: '#3d4f7c',
|
||||
phantomOpacity: 0.5,
|
||||
territoryBlack: 'rgba(20, 15, 10, 0.45)',
|
||||
territoryWhite: 'rgba(236, 229, 216, 0.35)',
|
||||
};
|
||||
|
||||
/** Буквы координат без «I» (стандарт Го). */
|
||||
const LETTERS = 'ABCDEFGHJKLMNOPQRST';
|
||||
|
||||
function resolveTheme(partial: Partial<BoardTheme> | undefined): BoardTheme {
|
||||
return { ...DEFAULT_THEME, ...partial };
|
||||
}
|
||||
|
||||
/** Разбор ключа pointKey ("x,y"); null — формат не совпал. */
|
||||
function parseKey(key: string): Point | null {
|
||||
const [xs, ys] = key.split(',');
|
||||
const x = Number(xs);
|
||||
const y = Number(ys);
|
||||
if (!Number.isInteger(x) || !Number.isInteger(y)) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Контрастный цвет текста поверх клетки (камня или доски). */
|
||||
function contrastOn(state: BoardState, point: Point, theme: BoardTheme): string {
|
||||
const cell = cellAt(state, point);
|
||||
if (cell === 'black') return theme.whiteStone;
|
||||
if (cell === 'white') return theme.blackStone;
|
||||
return theme.markupColor;
|
||||
}
|
||||
|
||||
export function render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: BoardState,
|
||||
options: RenderOptions = {},
|
||||
): void {
|
||||
const theme = resolveTheme(options.theme);
|
||||
const showCoordinates = options.showCoordinates ?? true;
|
||||
const dpr = ctx.getTransform().a || 1; // масштаб, выставленный setupCanvas
|
||||
const geo = computeGeometry(state.size, ctx.canvas.width / dpr, showCoordinates);
|
||||
ctx.save();
|
||||
drawBackground(ctx, theme, geo.pixelSize);
|
||||
if (options.territory !== undefined) drawTerritory(ctx, geo, options.territory, theme);
|
||||
drawGrid(ctx, geo, theme);
|
||||
drawHoshi(ctx, geo, theme);
|
||||
if (showCoordinates) drawCoordinates(ctx, geo, theme);
|
||||
if (options.markup !== undefined) drawMarkupShapes(ctx, geo, options.markup, theme);
|
||||
drawStones(ctx, state, geo, theme, options.dead);
|
||||
if (options.lastMove != null) drawLastMove(ctx, geo, options.lastMove, theme);
|
||||
if ((options.showMoveNumbers ?? false) && options.moveNumbers !== undefined) {
|
||||
drawMoveNumbers(ctx, state, geo, options.moveNumbers, theme);
|
||||
}
|
||||
if (options.phantom != null) drawPhantom(ctx, geo, options.phantom, theme);
|
||||
if (options.markup !== undefined) drawLabels(ctx, state, geo, options.markup, theme);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBackground(ctx: CanvasRenderingContext2D, theme: BoardTheme, pixelSize: number): void {
|
||||
ctx.fillStyle = theme.boardBackground;
|
||||
ctx.fillRect(0, 0, pixelSize, pixelSize);
|
||||
}
|
||||
|
||||
/** Заливка территории: клетка вокруг пересечения, цвет — по владельцу. */
|
||||
function drawTerritory(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
geo: BoardGeometry,
|
||||
territory: TerritoryMap,
|
||||
theme: BoardTheme,
|
||||
): void {
|
||||
const paint = (keys: ReadonlySet<string>, fill: string): void => {
|
||||
ctx.fillStyle = fill;
|
||||
for (const key of keys) {
|
||||
const point = parseKey(key);
|
||||
if (point === null) continue;
|
||||
const { x, y } = pointToPixel(geo, point);
|
||||
ctx.fillRect(x - geo.cell / 2, y - geo.cell / 2, geo.cell, geo.cell);
|
||||
}
|
||||
};
|
||||
paint(territory.black, theme.territoryBlack);
|
||||
paint(territory.white, theme.territoryWhite);
|
||||
}
|
||||
|
||||
function drawGrid(ctx: CanvasRenderingContext2D, geo: BoardGeometry, theme: BoardTheme): void {
|
||||
const start = geo.padding;
|
||||
const end = geo.padding + (geo.size - 1) * geo.cell;
|
||||
ctx.strokeStyle = theme.lineColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < geo.size; i += 1) {
|
||||
const pos = geo.padding + i * geo.cell;
|
||||
ctx.moveTo(start, pos);
|
||||
ctx.lineTo(end, pos);
|
||||
ctx.moveTo(pos, start);
|
||||
ctx.lineTo(pos, end);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawHoshi(ctx: CanvasRenderingContext2D, geo: BoardGeometry, theme: BoardTheme): void {
|
||||
ctx.fillStyle = theme.lineColor;
|
||||
const radius = Math.max(2, geo.cell * 0.09);
|
||||
for (const point of hoshiPoints(geo.size)) {
|
||||
const { x, y } = pointToPixel(geo, point);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/** Буквы (без «I») снизу слева направо, числа слева, 1 внизу. */
|
||||
function drawCoordinates(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
geo: BoardGeometry,
|
||||
theme: BoardTheme,
|
||||
): void {
|
||||
const fontSize = Math.max(9, Math.round(geo.cell * 0.34));
|
||||
ctx.fillStyle = theme.coordinateColor;
|
||||
ctx.font = `${fontSize}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const bottom = geo.padding + (geo.size - 1) * geo.cell;
|
||||
for (let i = 0; i < geo.size; i += 1) {
|
||||
const pos = geo.padding + i * geo.cell;
|
||||
ctx.fillText(LETTERS[i] ?? '', pos, bottom + geo.padding * 0.55);
|
||||
ctx.fillText(String(geo.size - i), geo.padding * 0.45, pos);
|
||||
}
|
||||
}
|
||||
|
||||
function drawTriangle(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - r);
|
||||
ctx.lineTo(x + r * 0.87, y + r * 0.5);
|
||||
ctx.lineTo(x - r * 0.87, y + r * 0.5);
|
||||
ctx.lineTo(x, y - r);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** Разметка SQ/TR/CR — контурными фигурами, под камнями. */
|
||||
function drawMarkupShapes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
geo: BoardGeometry,
|
||||
markup: NodeMarkup,
|
||||
theme: BoardTheme,
|
||||
): void {
|
||||
ctx.strokeStyle = theme.markupColor;
|
||||
ctx.lineWidth = Math.max(1.5, geo.cell * 0.06);
|
||||
const r = geo.cell * 0.32;
|
||||
for (const point of markup.squares) {
|
||||
const { x, y } = pointToPixel(geo, point);
|
||||
ctx.strokeRect(x - r, y - r, r * 2, r * 2);
|
||||
}
|
||||
for (const point of markup.triangles) {
|
||||
const { x, y } = pointToPixel(geo, point);
|
||||
drawTriangle(ctx, x, y, r);
|
||||
}
|
||||
for (const point of markup.circles) {
|
||||
const { x, y } = pointToPixel(geo, point);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, TAU);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/** Один камень: круг чуть меньше полклетки, тонкая обводка edge-цветом. */
|
||||
function drawStone(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
geo: BoardGeometry,
|
||||
point: Point,
|
||||
color: Color,
|
||||
theme: BoardTheme,
|
||||
alpha: number,
|
||||
): void {
|
||||
const { x, y } = pointToPixel(geo, point);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, geo.cell / 2 - 1, 0, TAU);
|
||||
ctx.fillStyle = color === 'black' ? theme.blackStone : theme.whiteStone;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = color === 'black' ? theme.blackStoneEdge : theme.whiteStoneEdge;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawStones(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: BoardState,
|
||||
geo: BoardGeometry,
|
||||
theme: BoardTheme,
|
||||
dead: ReadonlySet<string> | undefined,
|
||||
): void {
|
||||
for (let y = 0; y < state.size; y += 1) {
|
||||
for (let x = 0; x < state.size; x += 1) {
|
||||
const point = { x, y };
|
||||
const cell = cellAt(state, point);
|
||||
if (cell === 'empty') continue;
|
||||
const alpha = dead?.has(pointKey(point)) === true ? DEAD_STONE_ALPHA : 1;
|
||||
drawStone(ctx, geo, point, cell, theme, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Метка последнего хода: контрастное кольцо, различимое на обоих цветах. */
|
||||
function drawLastMove(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
geo: BoardGeometry,
|
||||
lastMove: Point,
|
||||
theme: BoardTheme,
|
||||
): void {
|
||||
const { x, y } = pointToPixel(geo, lastMove);
|
||||
ctx.strokeStyle = theme.lastMoveMarker;
|
||||
ctx.lineWidth = Math.max(2, geo.cell * 0.07);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, geo.cell * 0.22, 0, TAU);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** Номера ходов — только на занятых клетках, контрастом к цвету камня. */
|
||||
function drawMoveNumbers(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: BoardState,
|
||||
geo: BoardGeometry,
|
||||
moveNumbers: ReadonlyMap<string, number>,
|
||||
theme: BoardTheme,
|
||||
): void {
|
||||
ctx.font = `${Math.max(8, Math.round(geo.cell * 0.4))}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const [key, num] of moveNumbers) {
|
||||
const point = parseKey(key);
|
||||
if (point === null || cellAt(state, point) === 'empty') continue;
|
||||
ctx.fillStyle = contrastOn(state, point, theme);
|
||||
const { x, y } = pointToPixel(geo, point);
|
||||
ctx.fillText(String(num), x, y);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPhantom(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
geo: BoardGeometry,
|
||||
phantom: { readonly point: Point; readonly color: Color },
|
||||
theme: BoardTheme,
|
||||
): void {
|
||||
drawStone(ctx, geo, phantom.point, phantom.color, theme, theme.phantomOpacity);
|
||||
}
|
||||
|
||||
/** Подписи LB — поверх всех слоёв, контрастом к содержимому клетки. */
|
||||
function drawLabels(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: BoardState,
|
||||
geo: BoardGeometry,
|
||||
markup: NodeMarkup,
|
||||
theme: BoardTheme,
|
||||
): void {
|
||||
ctx.font = `bold ${Math.max(9, Math.round(geo.cell * 0.42))}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const label of markup.labels) {
|
||||
ctx.fillStyle = contrastOn(state, label.point, theme);
|
||||
const { x, y } = pointToPixel(geo, label.point);
|
||||
ctx.fillText(label.text, x, y);
|
||||
}
|
||||
}
|
||||
59
packages/board/src/wiring.test.ts
Normal file
59
packages/board/src/wiring.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Wiring-тест (gotcha-green-modules-dead-system): orphan-check публичного API
|
||||
* packages/board — каждая экспортируемая функция упоминается хотя бы раз вне
|
||||
* своего определения (в другом модуле пакета или в тесте).
|
||||
*
|
||||
* Допустимые «внешние» входные точки: setupCanvas и attachPointerInput —
|
||||
* DOM-адаптер для будущего UI (этап 5), юнит-тестами не покрывается
|
||||
* (см. plan-phase-2, матрица «чего НЕ покрывает ни один тест»).
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const srcDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Входные точки DOM-адаптера: вызываются будущим UI, не тестами. */
|
||||
const EXTERNAL_ENTRY_POINTS = new Set(['setupCanvas', 'attachPointerInput']);
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return readdirSync(srcDir)
|
||||
.filter((name) => name.endsWith('.ts'))
|
||||
.map((name) => join(srcDir, name));
|
||||
}
|
||||
|
||||
/** Имена, экспортируемые из index.ts (типы пропускаем). */
|
||||
function exportedNames(): string[] {
|
||||
const index = readFileSync(join(srcDir, 'index.ts'), 'utf8');
|
||||
const names: string[] = [];
|
||||
const pattern = /export\s+(?!type)\{([^}]*)\}\s*from/g;
|
||||
for (const match of index.matchAll(pattern)) {
|
||||
const body = match[1] ?? '';
|
||||
for (const part of body.split(',')) {
|
||||
const name = part.trim();
|
||||
if (name !== '') names.push(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe('orphan-check: публичный API доски связан с кодом', () => {
|
||||
it('каждый экспорт упоминается вне своего определения', () => {
|
||||
const files = sourceFiles().filter((file) => !file.endsWith('index.ts'));
|
||||
const contents = files.map((file) => readFileSync(file, 'utf8'));
|
||||
const missing: string[] = [];
|
||||
for (const name of exportedNames()) {
|
||||
if (EXTERNAL_ENTRY_POINTS.has(name)) continue;
|
||||
const usage = new RegExp(`\\b${name}\\b`);
|
||||
const mentions = contents.filter((content) => usage.test(content)).length;
|
||||
// Определение + минимум одно использование — минимум два файла;
|
||||
// упоминание в любом *.test.ts тоже засчитывается.
|
||||
const inTests = contents.some(
|
||||
(content, index) => files[index]?.endsWith('.test.ts') === true && usage.test(content),
|
||||
);
|
||||
if (mentions < 2 && !inTests) missing.push(name);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
16
packages/board/tsconfig.json
Normal file
16
packages/board/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
// Намеренно БЕЗ references на ../core: гейт typecheck — `tsc -b --noEmit`,
|
||||
// а TS 5.9 в build-режиме с --noEmit отклоняет любую цепочку project
|
||||
// references с чистого состояния (TS6310 "may not disable emit" — CLI-флаг
|
||||
// распространяется на referenced-проект). Импорт @go-learn/core резолвится
|
||||
// через workspace-симлинк (main: src/index.ts) — типы видны и так.
|
||||
// Корневой tsconfig.json по-прежнему ссылается на packages/board.
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue