Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Three tiers, cheapest first. Put a test in the cheapest tier that can actually c
- `e2e/harness/vscodeLauncher.js` pins `workbench.editorAssociations` in the temp profile. Without it a `.w3u`/`.w3a` passed on the command line opens in the *text* editor on a cold `--extensionDevelopmentPath` start, because the extension host has not registered its custom editors yet — and no webview is ever created.

### Editable binary formats
- `.imp` remains intentionally read-only: it is World Editor import-manager bookkeeping, not a useful standalone editing target for normal Wurst workflows.
- **.w3i is an editable custom editor** (`wurst.w3iEditor`, in `mapDataPreview.ts`) backed by `casc-ts` `parseW3i`/`serializeW3i`, which use a **parse-prefix + opaque-tail** model: only leading string/scalar fields are editable; players/forces/lists are preserved verbatim in `file.tail` (and parsed best-effort for display only). Every save passes a round-trip safety gate (`serializeValidatedW3i`). TRIGSTR-backed strings edit `war3map.wts`; inline strings edit the w3i bytes. The other map-data formats remain read-only under `wurst.mapDataPreview` (the old read-only `renderW3i`/`parseW3i` in that file are retained but no longer routed to).
- When adding a new editable binary format, mirror this: a casc-ts parser+serializer with a byte-exact round-trip test, a `CustomEditorProvider` with dirty tracking, and a serialize→re-parse→compare safety gate before any write.

Expand Down
1 change: 1 addition & 0 deletions e2e/harness/makeFixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function buildWpm() {
for (let y = 4; y < 8; y++) {
for (let x = 4; x < 8; x++) data[y * width + x] = 0x02; // walkability blocked
}
data[7 * width + 9] = 0xd0; // unfloatable + unamphibious + no-peon-harvest: picker must preserve every bit
return serializeWpm({ version: 0, width, height, data, tail: Buffer.alloc(0) });
}

Expand Down
58 changes: 58 additions & 0 deletions e2e/specs/wpm-editor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,47 @@ test('a drag paints a line of cells as one undo step', async ({ openWpm }) => {
await expect.poll(() => host.isDirty).toBe(false);
});

test('the brush size expands one gesture into a square of cells', async ({ openWpm }) => {
const { page, host } = await openWpm();
await page.click('#btnZoomFit');
await page.click('[data-tool="paint"]');
await page.fill('#brushSize', '3');
await expect(page.locator('#brushSizeValue')).toHaveText('3 × 3');

const box = await page.locator('#viewport').boundingBox();
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);

await expect.poll(() => host.editLabels.length).toBe(1);
const painted = Number(/Paint (\d+)/.exec(host.editLabels[0])[1]);
expect(painted).toBeGreaterThan(1);
expect(painted).toBeLessThanOrEqual(9);
});

test('line and fill tools produce one undoable edit each', async ({ openWpm }) => {
const { page, host } = await openWpm();
await page.click('#btnZoomFit');
const box = await page.locator('#viewport').boundingBox();

await page.click('[data-tool="line"]');
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width * 0.3, y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.7, y, { steps: 4 });
await page.mouse.up();
await expect.poll(() => host.editLabels.length).toBe(1);
expect(host.editLabels[0]).toMatch(/^Paint \d+ pathing cells$/);

await page.click('[data-tool="fill"]');
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height * 0.8);
await expect.poll(() => host.editLabels.length).toBe(2);
expect(host.editLabels[1]).toMatch(/^Paint \d+ pathing cells$/);

host.undo();
await expect.poll(() => host.editLabels.length).toBe(2);
host.undo();
await expect.poll(() => host.isDirty).toBe(false);
});

test('the erase tool clears flags instead of painting them', async ({ openWpm }) => {
const { page, host } = await openWpm();
await page.click('#btnZoomFit');
Expand Down Expand Up @@ -171,6 +212,23 @@ test('Alt+click picks up the brush flags from the cell under the cursor', async
await expect(page.locator('[data-tool="paint"]')).toHaveClass(/active/);
});

test('Alt-click samples the complete flag byte from a cell', async ({ openWpm }) => {
const { page } = await openWpm();
await page.click('#btnZoomFit');

const box = await page.locator('#viewport').boundingBox();
const cellSize = await page.locator('#wpmCanvas').evaluate((canvas) => Math.min(canvas.width / 16, canvas.height / 16));
await page.keyboard.down('Alt');
await page.mouse.click(box.x + box.width / 2 + cellSize, box.y + box.height / 2);
await page.keyboard.up('Alt');

await expect(page.locator('#brushValue')).toHaveText('0xD0');
await expect(page.locator('[data-brush-bit="16"]')).toBeChecked();
await expect(page.locator('[data-brush-bit="64"]')).toBeChecked();
await expect(page.locator('[data-brush-bit="128"]')).toBeChecked();
await expect(page.locator('[data-tool="paint"]')).toHaveClass(/active/);
});

test('saving writes bytes that re-parse to the painted map', async ({ openWpm }) => {
const { page, host } = await openWpm();
await page.click('#btnZoomFit');
Expand Down
18 changes: 18 additions & 0 deletions scripts/test-webview.js
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,8 @@ function testWpmEditorInlineScriptAndRecoveryGuards() {
.replace('${wpm.width}', '4')
.replace('${wpm.height}', '4')
.replace('${dataBase64}', 'AAAAAAAAAAAAAAAAAAAAAA==')
.replace('${JSON.stringify(colorTable)}', '[[0, 0, 0]]')
.replace('${JSON.stringify(WPM_FLAG_DEFS.map(({ bit, label }) => ({ bit, label })))}', '[]')
.replace(/\\`/g, '`')
.replace(/\\\$\{/g, '${');
// eslint-disable-next-line sonarjs/constructor-for-side-effects -- parsing the real inline script is the assertion.
Expand All @@ -1097,6 +1099,21 @@ function testWpmEditorInlineScriptAndRecoveryGuards() {
assert.ok(!host.includes('doc.editDepth'), 'WPM dirty tracking must not use branch-unsafe edit depth');
}

function testWpmFlagSemantics() {
const { WPM_FLAG_DEFS, WPM_KNOWN_VERSION, wpmCellRgb, wpmFlagLabels } = loadTsModuleWithMocks('src/features/wpmPreview.ts', {
vscode: {},
'casc-ts/formats': {},
'./diagnostics': {},
'./webviewShared': {},
'./webviewUtils': {},
});
assert.strictEqual(WPM_KNOWN_VERSION, 0);
assert.deepStrictEqual(WPM_FLAG_DEFS.map((definition) => definition.bit), [1, 2, 4, 8, 16, 32, 64, 128]);
assert.deepStrictEqual(wpmFlagLabels(0xd0), ['No Peon Harvest', 'No Water / Unfloatable', 'Unamphibious']);
assert.deepStrictEqual(wpmCellRgb(0x0a), [255, 0, 255], 'primary pathing colors must remain RGB channels');
assert.notDeepStrictEqual(wpmCellRgb(0x80), [0, 0, 0], 'amphibious cells must not render as an anonymous black cell');
}

async function main() {
testAssetPathNormalization();
testSignals();
Expand Down Expand Up @@ -1128,6 +1145,7 @@ async function main() {
testObjModDensityAndTreeStyling();
testImportedAssetDedupeSafety();
testLocalE2eFixturesRemainOptIn();
testWpmFlagSemantics();
console.log('webview harness tests passed');
}

Expand Down
Loading
Loading