/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
tests/editor.test.ts
535 строк
20 KB
docNemo
feat(units)!: метрические единицы в модели и хранении
03 авг 2026, 14:51
03 авг 2026, 14:51
f1b5073
Код
Авторство
О чём код?
import { describe, expect, it } from 'vitest'; import { hexNeighbor, hexSpiral, type HexCoord, type HexDirection } from '../src/core/hex'; import { Biome, ExplorationState, FeatureType, HexMap, SpecialZone } from '../src/core/map-model'; import { beginBiomeStroke, DEFAULT_BRUSH, paintBiomeAt, type BrushSettings, } from '../src/editor/brush'; import { clearEdges, connect, hasEdge, setEdge, toggleEdge } from '../src/editor/edges'; import { EditHistory } from '../src/editor/history'; import { clearAllOverrides, regeneratePreservingOverrides } from '../src/editor/regenerate'; import { countIncompatibleEdges, findIncompatibleEdges, TransitionMode, } from '../src/editor/transitions'; import { generateMap, type GenerationRequest } from '../src/generation/pipeline'; import { BiomeTable } from '../src/presets/biome-table'; function request(overrides: Partial<GenerationRequest> = {}): GenerationRequest { return { worldSeed: 'editor', atlasX: 0, atlasY: 0, columns: 20, rows: 20, archetype: 'mainland', climatePreset: 'temperate', biomePalette: null, hexScaleKm: 6, ...overrides, }; } /** Однородная карта заданного биома — удобная песочница для проверки кисти. */ function uniformMap(biome: Biome, size = 11): { map: HexMap; table: BiomeTable } { const map = new HexMap(size, size); for (const index of map.indices()) { map.setBiome(index, biome); map.setElevation(index, 0.6); } return { map, table: new BiomeTable() }; } const CENTRE: HexCoord = { q: 2, r: 5 }; describe('кисть', () => { it('красит один гекс при нулевом радиусе', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const transaction = history.begin('paint'); const result = paintBiomeAt(map, table, transaction, CENTRE, Biome.BorealForest); expect(result.painted).toHaveLength(1); expect(map.getBiome(map.indexOf(CENTRE))).toBe(Biome.BorealForest); }); it('радиус 2 покрывает центр и два кольца', () => { const { map, table } = uniformMap(Biome.Tundra, 21); const history = new EditHistory(map); const settings: BrushSettings = { ...DEFAULT_BRUSH, radius: 2 }; const centre: HexCoord = { q: 5, r: 8 }; const result = paintBiomeAt( map, table, history.begin('paint'), centre, Biome.BorealForest, settings, ); // 1 + 3·2·3 = 19 гексов, если все попали в границы карты. expect(result.painted).toHaveLength(hexSpiral(centre, 2).length); }); it('протягивание красит все пройденные гексы одной операцией', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const transaction = history.begin('stroke'); const stroke = beginBiomeStroke(map, table, transaction, Biome.BorealForest); for (let step = 0; step < 5; step += 1) { stroke.extendTo({ q: step, r: 5 }); } const result = stroke.finish(); history.push(transaction); expect(result.painted).toHaveLength(5); expect(history.labels()).toEqual(['stroke']); }); it('окрашенные гексы помечаются как ручная правка', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const result = paintBiomeAt(map, table, history.begin('paint'), CENTRE, Biome.BorealForest); for (const index of result.painted) { expect(map.isOverridden(index)).toBe(true); } }); it('гексы за пределами карты пропускаются', () => { const { map, table } = uniformMap(Biome.Tundra, 5); const history = new EditHistory(map); const settings: BrushSettings = { ...DEFAULT_BRUSH, radius: 3 }; const result = paintBiomeAt( map, table, history.begin('paint'), { q: 0, r: 0 }, Biome.BorealForest, settings, ); for (const index of result.painted) { expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeLessThan(map.size); } }); }); describe('режимы контроля стыков', () => { const settingsFor = (mode: TransitionMode): BrushSettings => ({ ...DEFAULT_BRUSH, mode }); it('свободный режим применяет правку и молчит', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const result = paintBiomeAt( map, table, history.begin('paint'), CENTRE, Biome.Desert, settingsFor(TransitionMode.Free), ); expect(map.getBiome(map.indexOf(CENTRE))).toBe(Biome.Desert); expect(result.warnings).toEqual([]); expect(result.smoothed).toEqual([]); // Соседи не тронуты — стык остаётся несовместимым, и это выбор пользователя. expect(map.getBiome(map.indexOf(hexNeighbor(CENTRE, 0)))).toBe(Biome.Tundra); }); it('предупреждающий режим применяет правку и подсвечивает стыки', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const result = paintBiomeAt( map, table, history.begin('paint'), CENTRE, Biome.Desert, settingsFor(TransitionMode.Warn), ); expect(map.getBiome(map.indexOf(CENTRE))).toBe(Biome.Desert); expect(result.smoothed).toEqual([]); // Пустыня посреди тундры несовместима со всеми шестью соседями. expect(result.warnings).toHaveLength(6); }); it('предупреждающий режим молчит при допустимом стыке', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const result = paintBiomeAt( map, table, history.begin('paint'), CENTRE, Biome.Grassland, settingsFor(TransitionMode.Warn), ); expect(table.areCompatible(Biome.Grassland, Biome.Tundra)).toBe(true); expect(result.warnings).toEqual([]); }); it('сглаживающий режим достраивает переход до совместимости', () => { const { map, table } = uniformMap(Biome.Tundra, 21); const history = new EditHistory(map); const centre: HexCoord = { q: 5, r: 8 }; const result = paintBiomeAt( map, table, history.begin('paint'), centre, Biome.Desert, settingsFor(TransitionMode.Smooth), ); expect(map.getBiome(map.indexOf(centre))).toBe(Biome.Desert); expect(result.smoothed.length).toBeGreaterThan(0); // Ради этого режим и существует: после правки несовместимых стыков не остаётся. expect(countIncompatibleEdges(map, table)).toBe(0); }); it('сглаживание вставляет именно промежуточные биомы', () => { const { map, table } = uniformMap(Biome.Tundra, 21); const history = new EditHistory(map); const centre: HexCoord = { q: 5, r: 8 }; paintBiomeAt( map, table, history.begin('paint'), centre, Biome.Desert, settingsFor(TransitionMode.Smooth), ); // Кольцо вокруг пустыни должно быть с ней совместимо и при этом не тундрой: // расстояние тундра–пустыня в таблице больше единицы. for (const neighbour of hexSpiral(centre, 1).slice(1)) { const index = map.indexOf(neighbour); if (index < 0) continue; expect(table.areCompatible(Biome.Desert, map.getBiome(index))).toBe(true); } }); it('сглаживание помечает перекрашенных соседей как ручную правку', () => { const { map, table } = uniformMap(Biome.Tundra, 21); const history = new EditHistory(map); const result = paintBiomeAt( map, table, history.begin('paint'), { q: 5, r: 8 }, Biome.Desert, settingsFor(TransitionMode.Smooth), ); for (const index of result.smoothed) { expect(map.isOverridden(index)).toBe(true); } }); it('поиск несовместимых стыков не дублирует рёбра', () => { const { map, table } = uniformMap(Biome.Tundra); const centre = map.indexOf(CENTRE); map.setBiome(centre, Biome.Desert); const neighbours = [centre, ...hexSpiral(CENTRE, 1).map((c) => map.indexOf(c))]; const edges = findIncompatibleEdges( map, table, neighbours.filter((i) => i >= 0), ); const keys = edges.map(({ from, to }) => (from < to ? `${from}:${to}` : `${to}:${from}`)); expect(new Set(keys).size).toBe(keys.length); }); }); describe('отмена и повтор', () => { it('протягивание отменяется одним действием', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const transaction = history.begin('stroke'); const stroke = beginBiomeStroke(map, table, transaction, Biome.BorealForest); const painted: number[] = []; for (let step = 0; step < 10; step += 1) { stroke.extendTo({ q: step, r: 5 }); const index = map.indexOf({ q: step, r: 5 }); // Часть координат выходит за правый край карты — такие кисть пропускает. if (index >= 0) painted.push(index); } stroke.finish(); history.push(transaction); expect(history.undo()).not.toBeNull(); for (const index of painted) { expect(map.getBiome(index)).toBe(Biome.Tundra); expect(map.isOverridden(index)).toBe(false); } }); it('повтор возвращает изменения', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const transaction = history.begin('paint'); paintBiomeAt(map, table, transaction, CENTRE, Biome.BorealForest); history.push(transaction); history.undo(); expect(map.getBiome(map.indexOf(CENTRE))).toBe(Biome.Tundra); history.redo(); expect(map.getBiome(map.indexOf(CENTRE))).toBe(Biome.BorealForest); }); it('отмена возвращает и перекрашенных сглаживанием соседей', () => { const { map, table } = uniformMap(Biome.Tundra, 21); const history = new EditHistory(map); const transaction = history.begin('paint'); const result = paintBiomeAt(map, table, transaction, { q: 5, r: 8 }, Biome.Desert, { ...DEFAULT_BRUSH, mode: TransitionMode.Smooth, }); history.push(transaction); expect(result.smoothed.length).toBeGreaterThan(0); history.undo(); for (const index of map.indices()) { expect(map.getBiome(index)).toBe(Biome.Tundra); } }); it('пустая операция в историю не попадает', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const transaction = history.begin('noop'); // Красим тундрой по тундре — состояние не меняется. paintBiomeAt(map, table, transaction, CENTRE, Biome.Tundra, { ...DEFAULT_BRUSH, mode: TransitionMode.Free, }); // Пометка ручной правки — это изменение, поэтому операция не пуста. // Проверяем сам механизм на действительно пустой транзакции. const empty = history.begin('really-noop'); empty.touch(0); expect(history.push(empty)).toBeNull(); expect(history.canUndo).toBe(false); void transaction; }); it('новая правка обесценивает ветку повтора', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const first = history.begin('first'); paintBiomeAt(map, table, first, CENTRE, Biome.BorealForest); history.push(first); history.undo(); expect(history.canRedo).toBe(true); const second = history.begin('second'); paintBiomeAt(map, table, second, { q: 4, r: 4 }, Biome.Grassland); history.push(second); expect(history.canRedo).toBe(false); }); it('история ограничена заданным пределом', () => { const { map, table } = uniformMap(Biome.Tundra); const history = new EditHistory(map, 3); for (let step = 0; step < 6; step += 1) { const transaction = history.begin(`op-${step}`); paintBiomeAt(map, table, transaction, { q: step, r: 5 }, Biome.BorealForest); history.push(transaction); } expect(history.labels()).toHaveLength(3); }); it('отмена и повтор на пустой истории безопасны', () => { const { map } = uniformMap(Biome.Tundra); const history = new EditHistory(map); expect(history.undo()).toBeNull(); expect(history.redo()).toBeNull(); }); }); describe('редактирование рёбер', () => { it('река помечается с обеих сторон', () => { const { map } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const index = map.indexOf(CENTRE); const change = setEdge(map, history.begin('river'), index, 0, 'rivers', true); expect(change).not.toBeNull(); expect(hasEdge(map, index, 0, 'rivers')).toBe(true); const neighbourIndex = map.indexOf(hexNeighbor(CENTRE, 0)); expect(hasEdge(map, neighbourIndex, 3, 'rivers')).toBe(true); }); it('снятие ребра убирает его с обеих сторон', () => { const { map } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const index = map.indexOf(CENTRE); setEdge(map, history.begin('add'), index, 1, 'roads', true); setEdge(map, history.begin('remove'), index, 1, 'roads', false); expect(hasEdge(map, index, 1, 'roads')).toBe(false); expect(hasEdge(map, map.indexOf(hexNeighbor(CENTRE, 1)), 4, 'roads')).toBe(false); }); it('переключение меняет состояние на противоположное', () => { const { map } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const index = map.indexOf(CENTRE); toggleEdge(map, history.begin('t'), index, 2, 'rivers'); expect(hasEdge(map, index, 2, 'rivers')).toBe(true); toggleEdge(map, history.begin('t'), index, 2, 'rivers'); expect(hasEdge(map, index, 2, 'rivers')).toBe(false); }); it('протягивание к соседу создаёт непрерывное русло', () => { const { map } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const transaction = history.begin('drag'); const from = CENTRE; const to = hexNeighbor(from, 1); const change = connect(map, transaction, from, to, 'rivers'); expect(change?.present).toBe(true); expect(hasEdge(map, map.indexOf(from), 1, 'rivers')).toBe(true); expect(hasEdge(map, map.indexOf(to), 4, 'rivers')).toBe(true); }); it('протягивание к не-соседу отвергается', () => { const { map } = uniformMap(Biome.Tundra); const history = new EditHistory(map); expect(connect(map, history.begin('drag'), CENTRE, { q: 9, r: 9 }, 'rivers')).toBeNull(); }); it('ребро на краю карты не имеет второй стороны и не создаётся', () => { const { map } = uniformMap(Biome.Tundra, 5); const history = new EditHistory(map); const corner = map.index(4, 0); // Направление 0 — восток; у правого столбца соседа нет. expect(setEdge(map, history.begin('edge'), corner, 0, 'rivers', true)).toBeNull(); }); it('очистка снимает все рёбра слоя вместе с отражениями', () => { const { map } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const index = map.indexOf(CENTRE); for (const direction of [0, 1, 2] as HexDirection[]) { setEdge(map, history.begin('add'), index, direction, 'roads', true); } clearEdges(map, history.begin('clear'), index, 'roads'); expect(map.roads[index]).toBe(0); for (const direction of [0, 1, 2] as HexDirection[]) { expect(hasEdge(map, map.indexOf(hexNeighbor(CENTRE, direction)), direction, 'roads')).toBe( false, ); } }); it('правка ребра отменяется целиком', () => { const { map } = uniformMap(Biome.Tundra); const history = new EditHistory(map); const transaction = history.begin('river'); const index = map.indexOf(CENTRE); setEdge(map, transaction, index, 0, 'rivers', true); history.push(transaction); history.undo(); expect(map.rivers[index]).toBe(0); expect(map.rivers[map.indexOf(hexNeighbor(CENTRE, 0))]).toBe(0); }); }); describe('перегенерация с сохранением правок', () => { it('переопределённые гексы переживают перегенерацию', () => { const original = generateMap(request({ worldSeed: 'keep' })).map; // Ставим город вручную и запоминаем его состояние целиком. const index = original.index(10, 10); original.setFeature(index, FeatureType.City); original.setBiome(index, Biome.Grassland); original.setSpecial(index, SpecialZone.None); original.updateSparse(index, { name: 'Кёльхорн', journalId: 'j1' }); original.markOverridden(index); const result = regeneratePreservingOverrides(original, request({ worldSeed: 'different' })); expect(result.preserved).toBe(1); expect(result.map.getFeature(index)).toBe(FeatureType.City); expect(result.map.getBiome(index)).toBe(Biome.Grassland); expect(result.map.getSparse(index)).toEqual({ name: 'Кёльхорн', journalId: 'j1' }); expect(result.map.isOverridden(index)).toBe(true); }); it('остальная карта генерируется заново', () => { const original = generateMap(request({ worldSeed: 'keep' })).map; original.markOverridden(original.index(10, 10)); const result = regeneratePreservingOverrides(original, request({ worldSeed: 'different' })); expect(result.map.biome).not.toEqual(original.biome); }); it('сброс правок возвращает карту под полную перегенерацию', () => { const original = generateMap(request({ worldSeed: 'reset' })).map; original.markOverridden(original.index(5, 5)); original.markOverridden(original.index(6, 6)); expect(clearAllOverrides(original)).toBe(2); const result = regeneratePreservingOverrides(original, request({ worldSeed: 'reset' })); expect(result.preserved).toBe(0); }); it('перенос на карту другого размера отвергается', () => { const original = generateMap(request({ columns: 20, rows: 20 })).map; expect(() => regeneratePreservingOverrides(original, request({ columns: 30, rows: 30 })), ).toThrow(); }); it('состояния изученности переопределённых гексов сохраняются', () => { const original = generateMap(request({ worldSeed: 'explored' })).map; const index = original.index(8, 8); original.raiseExploration(index, ExplorationState.Visited); original.markOverridden(index); const result = regeneratePreservingOverrides(original, request({ worldSeed: 'other' })); expect(result.map.getExploration(index)).toBe(ExplorationState.Visited); }); });