Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
38 changes: 37 additions & 1 deletion e2e/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const { test: base, expect } = require('@playwright/test');

const { startHarnessServer } = require('./harness/server');
const { createObjModHost } = require('./harness/objmodHost');
const { createW3iHost, createWpmHost } = require('./harness/mapEditorHosts');
const { createW3iHost, createWpmHost, createMmpHost, createW3cHost, createW3rHost } = require('./harness/mapEditorHosts');
const { root } = require('./harness/tsLoader');

/** Mirrors the webview API surface the shipped code uses. State lives in sessionStorage so it
Expand Down Expand Up @@ -127,6 +127,42 @@ const test = base.extend({
});
for (const host of opened) host.dispose();
},

/** Opens the editable .mmp minimap-icon editor. */
openMmp: async ({ page, server }, use) => {
const opened = [];
await use(async (options = {}) => {
const host = await createMmpHost({ origin: server.origin, ...options });
opened.push(host);
const wiring = await attachPageToHost(page, server, host);
return { host, page, ...wiring };
});
for (const host of opened) host.dispose();
},

/** Opens the editable .w3c camera editor. */
openW3c: async ({ page, server }, use) => {
const opened = [];
await use(async (options = {}) => {
const host = await createW3cHost({ origin: server.origin, ...options });
opened.push(host);
const wiring = await attachPageToHost(page, server, host);
return { host, page, ...wiring };
});
for (const host of opened) host.dispose();
},

/** Opens the editable .w3r region editor. */
openW3r: async ({ page, server }, use) => {
const opened = [];
await use(async (options = {}) => {
const host = await createW3rHost({ origin: server.origin, ...options });
opened.push(host);
const wiring = await attachPageToHost(page, server, host);
return { host, page, ...wiring };
});
for (const host of opened) host.dispose();
},
});

module.exports = { test, expect, root };
47 changes: 45 additions & 2 deletions e2e/harness/makeFixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,57 @@ function buildWpm() {
return serializeWpm({ version: 0, width, height, data, tail: Buffer.alloc(0) });
}

/** Writes a fresh temp dir containing war3map.w3i / .wts / .wpm and returns its path. */
function buildMmp() {
const w = new BinWriter(8 + 4 * 16);
w.writeI32(0); // version
w.writeI32(4); // icon count
const icon = (type, x, y, blue, green, red, alpha = 0xff) => {
w.writeI32(type); w.writeI32(x); w.writeI32(y);
w.writeU8(blue); w.writeU8(green); w.writeU8(red); w.writeU8(alpha);
};
icon(0, -1024, 512, 0xff, 0xff, 0xff); // default gold mine color
icon(1, 256, -768, 0x40, 0x80, 0xc0);
icon(2, 1024, 2048, 0xff, 0xff, 0xff, 0x80);
icon(77, 0, 0, 0x10, 0x20, 0x30);
return w.toBuffer();
}

function buildW3c() {
const w = new BinWriter(256);
w.writeI32(0); w.writeI32(2);
const camera = (name, values) => {
for (const value of values) w.writeF32(value);
w.writeString(name);
};
camera('Overview', [128, 256, 32, 90, 304, 1650, 0, 70, 5000, 0]);
camera('Boss Arena', [-512, 1024, 64, 180, 280, 2200, 3, 75, 6000, 1]);
return w.toBuffer();
}

function buildW3r() {
const w = new BinWriter(256);
w.writeI32(5); w.writeI32(2);
const region = (name, minX, maxX, minY, maxY, index, weather, sound, blue, green, red, endToken = 0) => {
w.writeF32(minX); w.writeF32(maxX); w.writeF32(minY); w.writeF32(maxY);
w.writeString(name); w.writeI32(index); w.writeId(weather); w.writeString(sound);
w.writeU8(blue); w.writeU8(green); w.writeU8(red); w.writeU8(endToken);
};
region('Spawn Area', -128, 256, -64, 384, 3, 'NULL', 'Sound\\Environment\\GrasslandDay', 0x40, 0x80, 0xc0);
region('Boss Room', 512, 1024, 768, 1280, 7, 'SNOW', '', 0x10, 0x20, 0x30, 1);
return w.toBuffer();
}

/** Writes a fresh temp dir containing the editable map-data fixtures and returns its path. */
function makeMapFixtureDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-e2e-map-'));
fs.writeFileSync(path.join(dir, 'war3map.w3i'), buildW3i());
fs.writeFileSync(path.join(dir, 'war3map.wts'), WTS, 'utf8');
fs.writeFileSync(path.join(dir, 'war3map.wpm'), buildWpm());
fs.writeFileSync(path.join(dir, 'war3map.mmp'), buildMmp());
fs.writeFileSync(path.join(dir, 'war3map.w3c'), buildW3c());
fs.writeFileSync(path.join(dir, 'war3map.w3r'), buildW3r());
fs.writeFileSync(path.join(dir, 'wurst.build'), 'projectName = wurst-e2e\n');
return dir;
}

module.exports = { makeMapFixtureDir, buildW3i, buildWpm, WTS, W3I_VERSION };
module.exports = { makeMapFixtureDir, buildW3i, buildWpm, buildMmp, buildW3c, buildW3r, WTS, W3I_VERSION };
34 changes: 30 additions & 4 deletions e2e/harness/mapEditorHosts.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
'use strict';

/**
* Harnesses for the two editable map-data formats whose webview JS ships as an inline `<script>`
* inside the host TypeScript: `.w3i` (W3iEditorProvider in mapDataPreview.ts) and `.wpm`
* (WpmEditorProvider in wpmPreview.ts).
* Harnesses for the editable map-data formats whose webview JS ships as an inline `<script>`
* inside the host TypeScript: `.w3i`, `.mmp`, `.w3c`, `.w3r`, and `.wpm`.
*
* Until now that inline script was only checked by parsing it with `vm.Script` and grepping the
* surrounding source for expected substrings — neither of which can tell whether the thing actually
Expand All @@ -19,6 +18,9 @@ const { makeMapFixtureDir } = require('./makeFixtures');

const W3I_INTERNALS = `export const __e2e = { W3iEditorProvider, W3iDocument, renderW3iEditor };`;
const WPM_INTERNALS = `export const __e2e = { WpmEditorProvider, WpmDocument, buildWpmHtml };`;
const MMP_INTERNALS = `export const __e2e = { MmpEditorProvider, MmpDocument, renderMmpEditor, parseMmpFile };`;
const W3C_INTERNALS = `export const __e2e = { W3cEditorProvider, W3cDocument, renderW3cEditor, parseW3cFile };`;
const W3R_INTERNALS = `export const __e2e = { W3rEditorProvider, W3rDocument, renderW3rEditor, parseW3rFile };`;

async function createEditorHost(opts, sourceFile, internals, fileName, providerFactory, isDirty) {
const fixtureDir = opts.fixtureDir || makeMapFixtureDir();
Expand Down Expand Up @@ -79,4 +81,28 @@ function createWpmHost(opts) {
);
}

module.exports = { createW3iHost, createWpmHost };
function createMmpHost(opts) {
return createEditorHost(
opts, 'src/features/mapDataPreview.ts', MMP_INTERNALS, 'war3map.mmp',
(e2e) => new e2e.MmpEditorProvider(),
(doc) => doc.currentRevision !== doc.savedRevision,
);
}

function createW3cHost(opts) {
return createEditorHost(
opts, 'src/features/mapDataPreview.ts', W3C_INTERNALS, 'war3map.w3c',
(e2e) => new e2e.W3cEditorProvider(),
(doc) => doc.currentRevision !== doc.savedRevision,
);
}

function createW3rHost(opts) {
return createEditorHost(
opts, 'src/features/mapDataPreview.ts', W3R_INTERNALS, 'war3map.w3r',
(e2e) => new e2e.W3rEditorProvider(),
(doc) => doc.currentRevision !== doc.savedRevision,
);
}

module.exports = { createW3iHost, createWpmHost, createMmpHost, createW3cHost, createW3rHost };
3 changes: 3 additions & 0 deletions e2e/harness/vscodeLauncher.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ const EDITOR_ASSOCIATIONS = {
'*.w3q': 'wurst.objModPreview',
'*.w3i': 'wurst.w3iEditor',
'*.wpm': 'wurst.wpmPreview',
'*.mmp': 'wurst.mmpEditor',
'*.w3c': 'wurst.w3cEditor',
'*.w3r': 'wurst.w3rEditor',
};

function writeUserSettings(userDataDir, settings) {
Expand Down
81 changes: 81 additions & 0 deletions e2e/specs/mmp-editor.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';

/** The editable .mmp minimap/lobby-preview icon list. */

const { test, expect } = require('../fixtures');

test('renders icon types, coordinates, colors, and the default-color state', async ({ openMmp }) => {
const { page, host, pageErrors } = await openMmp();

await expect(page.locator('#mmpCount')).toHaveText('4');
await expect(page.locator('[data-row="0"] [data-field="type"]')).toHaveValue('0');
await expect(page.locator('[data-row="0"] [data-field="x"]')).toHaveValue('-1024');
await expect(page.locator('[data-row="0"] [data-default-color]')).toBeChecked();
await expect(page.locator('[data-row="0"] [data-field="color"]')).toBeDisabled();
await expect(page.locator('.mmp-footer-hint')).toContainText('no custom tint');
await expect(page.locator('[data-row="1"] .type-label')).toHaveText('Neutral Building / House');
await expect(page.locator('[data-row="3"] .type-label')).toHaveText('Unknown / custom (77)');
await expect(page.locator('[data-row="3"] [data-field="type"]')).toHaveValue('77');
expect(host.doc.file.icons[1].red).toBe(0xc0);
expect(pageErrors).toEqual([]);
});

test('removing an icon is undoable and redoable', async ({ openMmp }) => {
const { page, host } = await openMmp();

await page.click('[data-row="1"] [data-remove]');
await expect.poll(() => host.doc.file.icons.length).toBe(3);
await expect(page.locator('#mmpCount')).toHaveText('3');
expect(host.editLabels).toEqual(['Remove minimap icon']);
expect(host.isDirty).toBe(true);

host.undo();
await expect.poll(() => host.doc.file.icons.length).toBe(4);
await expect(page.locator('#mmpCount')).toHaveText('4');
expect(host.isDirty).toBe(false);

host.redo();
await expect.poll(() => host.doc.file.icons.length).toBe(3);
await expect(page.locator('#mmpCount')).toHaveText('3');
});

test('editing coordinates, type, and color fields updates the icon', async ({ openMmp }) => {
const { page, host } = await openMmp();

await page.selectOption('[data-row="0"] [data-field="type"]', '2');
await page.fill('[data-row="0"] [data-field="x"]', '1234');
await page.locator('[data-row="0"] [data-field="x"]').blur();
await page.uncheck('[data-row="0"] [data-default-color]');
await page.fill('[data-row="0"] [data-field="color"]', '#123456');
await page.locator('[data-row="0"] [data-field="color"]').blur();
await page.fill('[data-row="0"] [data-field="alpha"]', '96');
await page.locator('[data-row="0"] [data-field="alpha"]').blur();

await expect.poll(() => host.doc.file.icons[0].type).toBe(2);
expect(host.doc.file.icons[0]).toMatchObject({ x: 1234, red: 0x12, green: 0x34, blue: 0x56, alpha: 96 });
await expect(page.locator('[data-row="0"] .type-label')).toHaveText('Player Start');
expect(host.editLabels).toEqual(['Edit icon type', 'Edit icon x', 'Edit icon color', 'Edit icon alpha']);
});

test('adding an icon and saving round-trips the edited list', async ({ openMmp }) => {
const { page, host } = await openMmp();

await page.click('[data-add]');
await expect.poll(() => host.doc.file.icons.length).toBe(5);
await page.selectOption('[data-row="4"] [data-field="type"]', '2');
await page.locator('[data-row="4"] [data-field="type"]').blur();
await page.fill('[data-row="4"] [data-field="x"]', '900');
await page.locator('[data-row="4"] [data-field="x"]').blur();
await page.fill('[data-row="4"] [data-field="y"]', '-450');
await page.locator('[data-row="4"] [data-field="y"]').blur();
await page.uncheck('[data-row="4"] [data-default-color]');
await page.fill('[data-row="4"] [data-field="color"]', '#abcdef');
await page.locator('[data-row="4"] [data-field="color"]').blur();

await host.save();
expect(host.isDirty).toBe(false);
const reparsed = host.internals.parseMmpFile(host.readFile());
expect(reparsed.error).toBeUndefined();
expect(reparsed.icons).toHaveLength(5);
expect(reparsed.icons[4]).toMatchObject({ type: 2, x: 900, y: -450, red: 0xab, green: 0xcd, blue: 0xef, alpha: 0xff });
});
104 changes: 104 additions & 0 deletions e2e/specs/w3c-w3r-editors.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use strict';

const { test, expect } = require('../fixtures');

test('camera editor exposes named fields and supports edit, undo, and save', async ({ openW3c }) => {
const { page, host, pageErrors } = await openW3c();

await expect(page.locator('#editorCount')).toHaveText('2');
await expect(page.locator('[data-row="0"] [data-field="name"]')).toHaveValue('Overview');
await expect(page.locator('[data-row="0"] [data-field="targetX"]')).toHaveValue('128');
await expect(page.locator('[data-row="1"] [data-field="name"]')).toHaveValue('Boss Arena');
expect(pageErrors).toEqual([]);

await page.fill('[data-row="0"] [data-field="name"]', 'Edited Overview');
await page.locator('[data-row="0"] [data-field="name"]').blur();
await page.fill('[data-row="0"] [data-field="distance"]', '1900');
await page.locator('[data-row="0"] [data-field="distance"]').blur();
await expect.poll(() => host.doc.file.cameras[0].distance).toBe(1900);
expect(host.isDirty).toBe(true);

host.undo();
expect(host.doc.file.cameras[0].distance).toBe(1650);
host.undo();
expect(host.doc.file.cameras[0].name).toBe('Overview');
expect(host.isDirty).toBe(false);

host.redo();
host.redo();
await host.save();
const reparsed = host.internals.parseW3cFile(host.readFile());
expect(reparsed.cameras[0]).toMatchObject({ name: 'Edited Overview', distance: 1900 });
});

test('camera editor can add and remove camera records', async ({ openW3c }) => {
const { page, host } = await openW3c();

await page.click('[data-add]');
await expect.poll(() => host.doc.file.cameras.length).toBe(3);
await expect(page.locator('[data-row="2"] [data-field="name"]')).toHaveValue('New Camera');
await page.click('[data-row="2"] [data-remove]');
await expect.poll(() => host.doc.file.cameras.length).toBe(2);
expect(host.editLabels).toEqual(['Add camera', 'Remove camera']);
});

test('camera editor accepts fractional float32 values', async ({ openW3c }) => {
const { page, host } = await openW3c();

await page.fill('[data-row="0"] [data-field="targetX"]', '0.1');
await page.locator('[data-row="0"] [data-field="targetX"]').blur();
await expect(page.locator('[data-row="0"] [data-field="targetX"]')).toHaveValue(Math.fround(0.1).toString());
await host.save();

const reparsed = host.internals.parseW3cFile(host.readFile());
expect(reparsed.cameras[0].targetX).toBe(Math.fround(0.1));
});

test('region editor exposes bounds, environment, color, and round-trips edits', async ({ openW3r }) => {
const { page, host, pageErrors } = await openW3r();

await expect(page.locator('#editorCount')).toHaveText('2');
await expect(page.locator('[data-row="0"] [data-field="name"]')).toHaveValue('Spawn Area');
await expect(page.locator('[data-row="0"] [data-field="minX"]')).toHaveValue('-128');
await expect(page.locator('[data-row="0"] [data-field="weatherId"]')).toHaveValue('NULL');
await expect(page.locator('[data-row="0"] [data-field="color"]')).toHaveValue('#c08040');
expect(pageErrors).toEqual([]);

await page.fill('[data-row="0"] [data-field="name"]', 'Edited Area');
await page.locator('[data-row="0"] [data-field="name"]').blur();
await page.fill('[data-row="0"] [data-field="maxY"]', '512');
await page.locator('[data-row="0"] [data-field="maxY"]').blur();
await page.fill('[data-row="0"] [data-field="weatherId"]', 'RAIN');
await page.locator('[data-row="0"] [data-field="weatherId"]').blur();
await page.fill('[data-row="0"] [data-field="color"]', '#abcdef');
await page.locator('[data-row="0"] [data-field="color"]').blur();
await expect.poll(() => host.doc.file.regions[0].maxY).toBe(512);
expect(host.doc.file.regions[0]).toMatchObject({ name: 'Edited Area', weatherId: 'RAIN', red: 0xab, green: 0xcd, blue: 0xef });

await host.save();
const reparsed = host.internals.parseW3rFile(host.readFile());
expect(reparsed.regions[0]).toMatchObject({ name: 'Edited Area', maxY: 512, weatherId: 'RAIN', red: 0xab, green: 0xcd, blue: 0xef });
});

test('region editor can add and remove region records', async ({ openW3r }) => {
const { page, host } = await openW3r();

await page.click('[data-add]');
await expect.poll(() => host.doc.file.regions.length).toBe(3);
await expect(page.locator('[data-row="2"] [data-field="name"]')).toHaveValue('New Region');
await page.click('[data-row="2"] [data-remove]');
await expect.poll(() => host.doc.file.regions.length).toBe(2);
expect(host.editLabels).toEqual(['Add region', 'Remove region']);
});

test('region editor accepts fractional float32 bounds', async ({ openW3r }) => {
const { page, host } = await openW3r();

await page.fill('[data-row="0"] [data-field="minX"]', '12.345');
await page.locator('[data-row="0"] [data-field="minX"]').blur();
await expect(page.locator('[data-row="0"] [data-field="minX"]')).toHaveValue(Math.fround(12.345).toString());
await host.save();

const reparsed = host.internals.parseW3rFile(host.readFile());
expect(reparsed.regions[0].minX).toBe(Math.fround(12.345));
});
Loading
Loading