Files
feuerwerk/tests/core/tilemap-fill.test.ts
T
adminandClaude Opus 5.5 9b5eeaab57 Empty a deleted tile's cells on every tilemap (#268)
Deleting a tile left the cells painted with it in the tilemaps. They
drew nothing, blocked nothing and did not show in the delete dialog,
but still read as a tile for "Tile at" and stopped a fill.

- DeleteAssetCommand empties the cells of a deleted tile on every
  layer of every tilemap in the same step. Undo puts the tile and its
  cells back together.
- The delete dialog counts them, for example "3 cell(s) on 1
  tilemap(s) are painted with it and become empty." (cellsPainted).
- presentTile treats a cell naming a missing tile as empty. "Tile at"
  (World.tileAt) and floodFill use it, so cells already in existing
  files read and fill as empty. There is no migration.
- The brush, the rectangle and the fill check the tile in hand. A
  tile deleted after it was picked no longer paints new invisible
  cells, the editor says "Choose a tile to paint with." instead.
- Unchanged on purpose: the picker still reports such a cell as
  naming a tile that no longer exists, and erasing removes it.

Tests:
- ./check.sh: 195 files, 2823 tests, CHECK EXIT 0 (+6).
- GM_STEPS=39,40: ALL PASSED, 100 checks (4 new in 40t).
- Full e2e sum is now 1590 (1586 confirmed by CI on round 8, plus 4).
- Counter-checks, all fail as expected: 5 in unit tests (delete
  without emptying, undo without cells, fill and "Tile at" without
  presentTile, wrong map count) and 3 in the browser (dialog without
  count, delete without emptying, brush without the check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:00:22 +02:00

94 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The fill of a tilemap layer (#133): the cells reached from the one clicked through their four
* edge neighbours that hold the same tile – empty included – and nothing outside the grid.
*/
import { describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import type { TilemapLayer } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import { type Cell, cellKey, floodFill } from '../../src/core/model/tilemap';
/** A layer drawn as text: one row per line, `.` empty, any other letter the tile of that name. */
function layerOf(rows: string[]): TilemapLayer {
const cells: Record<string, string> = {};
rows.forEach((row, y) =>
[...row].forEach((c, x) => {
if (c !== '.') cells[cellKey(x, y)] = c;
}),
);
return { id: 'l', name: 'Layer', cells };
}
/** The filled cells as text again, `*` where the fill landed – easier to read than a list. */
function shown(rows: string[], filled: { x: number; y: number }[]): string[] {
const out = rows.map((row) => [...row]);
for (const { x, y } of filled) out[y]![x] = '*';
return out.map((row) => row.join(''));
}
const GRID = { columns: 5, rows: 4 };
/** A project with a tile for each of these letters, so a cell naming one of them names a real tile. */
const PROJECT = createEmptyProject('p', 'P');
for (const id of ['a', 'b', 'g', 'w']) PROJECT.assets.push(buildAsset('tile', id.toUpperCase(), id));
const fill = (props: Record<string, number>, layer: TilemapLayer, start: Cell, tileId: string) =>
floodFill(PROJECT, props, layer, start, tileId);
describe('filling a tilemap layer', () => {
it('takes the connected cells through edges, not through corners', () => {
const rows = ['..w..', '.w.w.', 'w...w', '.w.w.'];
const cells = fill(GRID, layerOf(rows), { x: 2, y: 2 }, 'g');
expect(shown(rows, cells)).toEqual(['..w..', '.w*w.', 'w***w', '.w*w.']);
});
it('fills a whole empty grid and stops at its edge', () => {
const cells = fill(GRID, layerOf([]), { x: 0, y: 0 }, 'g');
expect(cells).toHaveLength(20);
expect(cells.every(({ x, y }) => x >= 0 && y >= 0 && x < 5 && y < 4)).toBe(true);
});
it('replaces one tile with another and leaves the empty cells beside it', () => {
const rows = ['aa...', 'a.bb.', 'aaab.', '.....'];
const cells = fill(GRID, layerOf(rows), { x: 0, y: 0 }, 'b');
expect(shown(rows, cells)).toEqual(['**...', '*.bb.', '***b.', '.....']);
});
it('ignores cells outside the grid, even when they hold the same tile', () => {
const layer = layerOf(['aaaaa']);
layer.cells[cellKey(5, 0)] = 'a';
layer.cells[cellKey(-1, 0)] = 'a';
const cells = fill(GRID, layer, { x: 2, y: 0 }, 'g');
expect(cells.map((c) => c.x).sort()).toEqual([0, 1, 2, 3, 4]);
expect(fill(GRID, layer, { x: 5, y: 0 }, 'g')).toEqual([]);
});
it('changes nothing when the cell already holds the tile', () => {
expect(fill(GRID, layerOf(['aaaaa']), { x: 1, y: 0 }, 'a')).toEqual([]);
expect(fill(GRID, layerOf([]), { x: 1, y: 1 }, '')).toEqual([]);
});
it('can empty a region, too', () => {
const rows = ['aa.a.'];
const cells = fill(GRID, layerOf(rows), { x: 1, y: 0 }, '');
expect(shown(rows, cells)).toEqual(['**.a.']);
});
it('counts a cell naming a missing tile as empty, from wherever the fill starts (#268)', () => {
// `x` is no tile of the project: those cells draw nothing, so they belong to the empty region.
const rows = ['.x.w.', 'xx.w.', '...w.', 'wwww.'];
const want = ['***w.', '***w.', '***w.', 'wwww.'];
expect(shown(rows, fill(GRID, layerOf(rows), { x: 0, y: 0 }, 'g'))).toEqual(want);
expect(shown(rows, fill(GRID, layerOf(rows), { x: 1, y: 1 }, 'g'))).toEqual(want);
// Emptying from one changes nothing: it is empty already.
expect(fill(GRID, layerOf(rows), { x: 1, y: 0 }, '')).toEqual([]);
});
it('fills a large map in one go', () => {
const props = { columns: 300, rows: 300 };
const started = performance.now();
expect(fill(props, layerOf([]), { x: 150, y: 150 }, 'g')).toHaveLength(90_000);
expect(performance.now() - started).toBeLessThan(2000);
});
});