Skip to content

Commit 888b598

Browse files
committed
Add editable map metadata editors
1 parent cba60a9 commit 888b598

9 files changed

Lines changed: 1102 additions & 48 deletions

File tree

e2e/fixtures.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const { test: base, expect } = require('@playwright/test');
1414

1515
const { startHarnessServer } = require('./harness/server');
1616
const { createObjModHost } = require('./harness/objmodHost');
17-
const { createW3iHost, createWpmHost } = require('./harness/mapEditorHosts');
17+
const { createW3iHost, createWpmHost, createMmpHost, createW3cHost, createW3rHost } = require('./harness/mapEditorHosts');
1818
const { root } = require('./harness/tsLoader');
1919

2020
/** Mirrors the webview API surface the shipped code uses. State lives in sessionStorage so it
@@ -127,6 +127,42 @@ const test = base.extend({
127127
});
128128
for (const host of opened) host.dispose();
129129
},
130+
131+
/** Opens the editable .mmp minimap-icon editor. */
132+
openMmp: async ({ page, server }, use) => {
133+
const opened = [];
134+
await use(async (options = {}) => {
135+
const host = await createMmpHost({ origin: server.origin, ...options });
136+
opened.push(host);
137+
const wiring = await attachPageToHost(page, server, host);
138+
return { host, page, ...wiring };
139+
});
140+
for (const host of opened) host.dispose();
141+
},
142+
143+
/** Opens the editable .w3c camera editor. */
144+
openW3c: async ({ page, server }, use) => {
145+
const opened = [];
146+
await use(async (options = {}) => {
147+
const host = await createW3cHost({ origin: server.origin, ...options });
148+
opened.push(host);
149+
const wiring = await attachPageToHost(page, server, host);
150+
return { host, page, ...wiring };
151+
});
152+
for (const host of opened) host.dispose();
153+
},
154+
155+
/** Opens the editable .w3r region editor. */
156+
openW3r: async ({ page, server }, use) => {
157+
const opened = [];
158+
await use(async (options = {}) => {
159+
const host = await createW3rHost({ origin: server.origin, ...options });
160+
opened.push(host);
161+
const wiring = await attachPageToHost(page, server, host);
162+
return { host, page, ...wiring };
163+
});
164+
for (const host of opened) host.dispose();
165+
},
130166
});
131167

132168
module.exports = { test, expect, root };

e2e/harness/makeFixtures.js

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,57 @@ function buildWpm() {
106106
return serializeWpm({ version: 0, width, height, data, tail: Buffer.alloc(0) });
107107
}
108108

109-
/** Writes a fresh temp dir containing war3map.w3i / .wts / .wpm and returns its path. */
109+
function buildMmp() {
110+
const w = new BinWriter(8 + 4 * 16);
111+
w.writeI32(0); // version
112+
w.writeI32(4); // icon count
113+
const icon = (type, x, y, blue, green, red, alpha = 0xff) => {
114+
w.writeI32(type); w.writeI32(x); w.writeI32(y);
115+
w.writeU8(blue); w.writeU8(green); w.writeU8(red); w.writeU8(alpha);
116+
};
117+
icon(0, -1024, 512, 0xff, 0xff, 0xff); // default gold mine color
118+
icon(1, 256, -768, 0x40, 0x80, 0xc0);
119+
icon(2, 1024, 2048, 0xff, 0xff, 0xff, 0x80);
120+
icon(77, 0, 0, 0x10, 0x20, 0x30);
121+
return w.toBuffer();
122+
}
123+
124+
function buildW3c() {
125+
const w = new BinWriter(256);
126+
w.writeI32(0); w.writeI32(2);
127+
const camera = (name, values) => {
128+
for (const value of values) w.writeF32(value);
129+
w.writeString(name);
130+
};
131+
camera('Overview', [128, 256, 32, 90, 304, 1650, 0, 70, 5000, 0]);
132+
camera('Boss Arena', [-512, 1024, 64, 180, 280, 2200, 3, 75, 6000, 1]);
133+
return w.toBuffer();
134+
}
135+
136+
function buildW3r() {
137+
const w = new BinWriter(256);
138+
w.writeI32(5); w.writeI32(2);
139+
const region = (name, minX, maxX, minY, maxY, index, weather, sound, blue, green, red, endToken = 0) => {
140+
w.writeF32(minX); w.writeF32(maxX); w.writeF32(minY); w.writeF32(maxY);
141+
w.writeString(name); w.writeI32(index); w.writeId(weather); w.writeString(sound);
142+
w.writeU8(blue); w.writeU8(green); w.writeU8(red); w.writeU8(endToken);
143+
};
144+
region('Spawn Area', -128, 256, -64, 384, 3, 'NULL', 'Sound\\Environment\\GrasslandDay', 0x40, 0x80, 0xc0);
145+
region('Boss Room', 512, 1024, 768, 1280, 7, 'SNOW', '', 0x10, 0x20, 0x30, 1);
146+
return w.toBuffer();
147+
}
148+
149+
/** Writes a fresh temp dir containing the editable map-data fixtures and returns its path. */
110150
function makeMapFixtureDir() {
111151
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-e2e-map-'));
112152
fs.writeFileSync(path.join(dir, 'war3map.w3i'), buildW3i());
113153
fs.writeFileSync(path.join(dir, 'war3map.wts'), WTS, 'utf8');
114154
fs.writeFileSync(path.join(dir, 'war3map.wpm'), buildWpm());
155+
fs.writeFileSync(path.join(dir, 'war3map.mmp'), buildMmp());
156+
fs.writeFileSync(path.join(dir, 'war3map.w3c'), buildW3c());
157+
fs.writeFileSync(path.join(dir, 'war3map.w3r'), buildW3r());
115158
fs.writeFileSync(path.join(dir, 'wurst.build'), 'projectName = wurst-e2e\n');
116159
return dir;
117160
}
118161

119-
module.exports = { makeMapFixtureDir, buildW3i, buildWpm, WTS, W3I_VERSION };
162+
module.exports = { makeMapFixtureDir, buildW3i, buildWpm, buildMmp, buildW3c, buildW3r, WTS, W3I_VERSION };

e2e/harness/mapEditorHosts.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
'use strict';
22

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

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

2325
async function createEditorHost(opts, sourceFile, internals, fileName, providerFactory, isDirty) {
2426
const fixtureDir = opts.fixtureDir || makeMapFixtureDir();
@@ -79,4 +81,28 @@ function createWpmHost(opts) {
7981
);
8082
}
8183

82-
module.exports = { createW3iHost, createWpmHost };
84+
function createMmpHost(opts) {
85+
return createEditorHost(
86+
opts, 'src/features/mapDataPreview.ts', MMP_INTERNALS, 'war3map.mmp',
87+
(e2e) => new e2e.MmpEditorProvider(),
88+
(doc) => doc.currentRevision !== doc.savedRevision,
89+
);
90+
}
91+
92+
function createW3cHost(opts) {
93+
return createEditorHost(
94+
opts, 'src/features/mapDataPreview.ts', W3C_INTERNALS, 'war3map.w3c',
95+
(e2e) => new e2e.W3cEditorProvider(),
96+
(doc) => doc.currentRevision !== doc.savedRevision,
97+
);
98+
}
99+
100+
function createW3rHost(opts) {
101+
return createEditorHost(
102+
opts, 'src/features/mapDataPreview.ts', W3R_INTERNALS, 'war3map.w3r',
103+
(e2e) => new e2e.W3rEditorProvider(),
104+
(doc) => doc.currentRevision !== doc.savedRevision,
105+
);
106+
}
107+
108+
module.exports = { createW3iHost, createWpmHost, createMmpHost, createW3cHost, createW3rHost };

e2e/harness/vscodeLauncher.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ const EDITOR_ASSOCIATIONS = {
157157
'*.w3q': 'wurst.objModPreview',
158158
'*.w3i': 'wurst.w3iEditor',
159159
'*.wpm': 'wurst.wpmPreview',
160+
'*.mmp': 'wurst.mmpEditor',
161+
'*.w3c': 'wurst.w3cEditor',
162+
'*.w3r': 'wurst.w3rEditor',
160163
};
161164

162165
function writeUserSettings(userDataDir, settings) {

e2e/specs/mmp-editor.spec.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use strict';
2+
3+
/** The editable .mmp minimap/lobby-preview icon list. */
4+
5+
const { test, expect } = require('../fixtures');
6+
7+
test('renders icon types, coordinates, colors, and the default-color state', async ({ openMmp }) => {
8+
const { page, host, pageErrors } = await openMmp();
9+
10+
await expect(page.locator('#mmpCount')).toHaveText('4');
11+
await expect(page.locator('[data-row="0"] [data-field="type"]')).toHaveValue('0');
12+
await expect(page.locator('[data-row="0"] [data-field="x"]')).toHaveValue('-1024');
13+
await expect(page.locator('[data-row="0"] [data-default-color]')).toBeChecked();
14+
await expect(page.locator('[data-row="0"] [data-field="color"]')).toBeDisabled();
15+
await expect(page.locator('.mmp-footer-hint')).toContainText('no custom tint');
16+
await expect(page.locator('[data-row="1"] .type-label')).toHaveText('Neutral Building / House');
17+
await expect(page.locator('[data-row="3"] .type-label')).toHaveText('Unknown / custom (77)');
18+
await expect(page.locator('[data-row="3"] [data-field="type"]')).toHaveValue('77');
19+
expect(host.doc.file.icons[1].red).toBe(0xc0);
20+
expect(pageErrors).toEqual([]);
21+
});
22+
23+
test('removing an icon is undoable and redoable', async ({ openMmp }) => {
24+
const { page, host } = await openMmp();
25+
26+
await page.click('[data-row="1"] [data-remove]');
27+
await expect.poll(() => host.doc.file.icons.length).toBe(3);
28+
await expect(page.locator('#mmpCount')).toHaveText('3');
29+
expect(host.editLabels).toEqual(['Remove minimap icon']);
30+
expect(host.isDirty).toBe(true);
31+
32+
host.undo();
33+
await expect.poll(() => host.doc.file.icons.length).toBe(4);
34+
await expect(page.locator('#mmpCount')).toHaveText('4');
35+
expect(host.isDirty).toBe(false);
36+
37+
host.redo();
38+
await expect.poll(() => host.doc.file.icons.length).toBe(3);
39+
await expect(page.locator('#mmpCount')).toHaveText('3');
40+
});
41+
42+
test('editing coordinates, type, and color fields updates the icon', async ({ openMmp }) => {
43+
const { page, host } = await openMmp();
44+
45+
await page.selectOption('[data-row="0"] [data-field="type"]', '2');
46+
await page.fill('[data-row="0"] [data-field="x"]', '1234');
47+
await page.locator('[data-row="0"] [data-field="x"]').blur();
48+
await page.uncheck('[data-row="0"] [data-default-color]');
49+
await page.fill('[data-row="0"] [data-field="color"]', '#123456');
50+
await page.locator('[data-row="0"] [data-field="color"]').blur();
51+
await page.fill('[data-row="0"] [data-field="alpha"]', '96');
52+
await page.locator('[data-row="0"] [data-field="alpha"]').blur();
53+
54+
await expect.poll(() => host.doc.file.icons[0].type).toBe(2);
55+
expect(host.doc.file.icons[0]).toMatchObject({ x: 1234, red: 0x12, green: 0x34, blue: 0x56, alpha: 96 });
56+
await expect(page.locator('[data-row="0"] .type-label')).toHaveText('Player Start');
57+
expect(host.editLabels).toEqual(['Edit icon type', 'Edit icon x', 'Edit icon color', 'Edit icon alpha']);
58+
});
59+
60+
test('adding an icon and saving round-trips the edited list', async ({ openMmp }) => {
61+
const { page, host } = await openMmp();
62+
63+
await page.click('[data-add]');
64+
await expect.poll(() => host.doc.file.icons.length).toBe(5);
65+
await page.selectOption('[data-row="4"] [data-field="type"]', '2');
66+
await page.locator('[data-row="4"] [data-field="type"]').blur();
67+
await page.fill('[data-row="4"] [data-field="x"]', '900');
68+
await page.locator('[data-row="4"] [data-field="x"]').blur();
69+
await page.fill('[data-row="4"] [data-field="y"]', '-450');
70+
await page.locator('[data-row="4"] [data-field="y"]').blur();
71+
await page.uncheck('[data-row="4"] [data-default-color]');
72+
await page.fill('[data-row="4"] [data-field="color"]', '#abcdef');
73+
await page.locator('[data-row="4"] [data-field="color"]').blur();
74+
75+
await host.save();
76+
expect(host.isDirty).toBe(false);
77+
const reparsed = host.internals.parseMmpFile(host.readFile());
78+
expect(reparsed.error).toBeUndefined();
79+
expect(reparsed.icons).toHaveLength(5);
80+
expect(reparsed.icons[4]).toMatchObject({ type: 2, x: 900, y: -450, red: 0xab, green: 0xcd, blue: 0xef, alpha: 0xff });
81+
});

e2e/specs/w3c-w3r-editors.spec.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use strict';
2+
3+
const { test, expect } = require('../fixtures');
4+
5+
test('camera editor exposes named fields and supports edit, undo, and save', async ({ openW3c }) => {
6+
const { page, host, pageErrors } = await openW3c();
7+
8+
await expect(page.locator('#editorCount')).toHaveText('2');
9+
await expect(page.locator('[data-row="0"] [data-field="name"]')).toHaveValue('Overview');
10+
await expect(page.locator('[data-row="0"] [data-field="targetX"]')).toHaveValue('128');
11+
await expect(page.locator('[data-row="1"] [data-field="name"]')).toHaveValue('Boss Arena');
12+
expect(pageErrors).toEqual([]);
13+
14+
await page.fill('[data-row="0"] [data-field="name"]', 'Edited Overview');
15+
await page.locator('[data-row="0"] [data-field="name"]').blur();
16+
await page.fill('[data-row="0"] [data-field="distance"]', '1900');
17+
await page.locator('[data-row="0"] [data-field="distance"]').blur();
18+
await expect.poll(() => host.doc.file.cameras[0].distance).toBe(1900);
19+
expect(host.isDirty).toBe(true);
20+
21+
host.undo();
22+
expect(host.doc.file.cameras[0].distance).toBe(1650);
23+
host.undo();
24+
expect(host.doc.file.cameras[0].name).toBe('Overview');
25+
expect(host.isDirty).toBe(false);
26+
27+
host.redo();
28+
host.redo();
29+
await host.save();
30+
const reparsed = host.internals.parseW3cFile(host.readFile());
31+
expect(reparsed.cameras[0]).toMatchObject({ name: 'Edited Overview', distance: 1900 });
32+
});
33+
34+
test('camera editor can add and remove camera records', async ({ openW3c }) => {
35+
const { page, host } = await openW3c();
36+
37+
await page.click('[data-add]');
38+
await expect.poll(() => host.doc.file.cameras.length).toBe(3);
39+
await expect(page.locator('[data-row="2"] [data-field="name"]')).toHaveValue('New Camera');
40+
await page.click('[data-row="2"] [data-remove]');
41+
await expect.poll(() => host.doc.file.cameras.length).toBe(2);
42+
expect(host.editLabels).toEqual(['Add camera', 'Remove camera']);
43+
});
44+
45+
test('region editor exposes bounds, environment, color, and round-trips edits', async ({ openW3r }) => {
46+
const { page, host, pageErrors } = await openW3r();
47+
48+
await expect(page.locator('#editorCount')).toHaveText('2');
49+
await expect(page.locator('[data-row="0"] [data-field="name"]')).toHaveValue('Spawn Area');
50+
await expect(page.locator('[data-row="0"] [data-field="minX"]')).toHaveValue('-128');
51+
await expect(page.locator('[data-row="0"] [data-field="weatherId"]')).toHaveValue('NULL');
52+
await expect(page.locator('[data-row="0"] [data-field="color"]')).toHaveValue('#c08040');
53+
expect(pageErrors).toEqual([]);
54+
55+
await page.fill('[data-row="0"] [data-field="name"]', 'Edited Area');
56+
await page.locator('[data-row="0"] [data-field="name"]').blur();
57+
await page.fill('[data-row="0"] [data-field="maxY"]', '512');
58+
await page.locator('[data-row="0"] [data-field="maxY"]').blur();
59+
await page.fill('[data-row="0"] [data-field="weatherId"]', 'RAIN');
60+
await page.locator('[data-row="0"] [data-field="weatherId"]').blur();
61+
await page.fill('[data-row="0"] [data-field="color"]', '#abcdef');
62+
await page.locator('[data-row="0"] [data-field="color"]').blur();
63+
await expect.poll(() => host.doc.file.regions[0].maxY).toBe(512);
64+
expect(host.doc.file.regions[0]).toMatchObject({ name: 'Edited Area', weatherId: 'RAIN', red: 0xab, green: 0xcd, blue: 0xef });
65+
66+
await host.save();
67+
const reparsed = host.internals.parseW3rFile(host.readFile());
68+
expect(reparsed.regions[0]).toMatchObject({ name: 'Edited Area', maxY: 512, weatherId: 'RAIN', red: 0xab, green: 0xcd, blue: 0xef });
69+
});
70+
71+
test('region editor can add and remove region records', async ({ openW3r }) => {
72+
const { page, host } = await openW3r();
73+
74+
await page.click('[data-add]');
75+
await expect.poll(() => host.doc.file.regions.length).toBe(3);
76+
await expect(page.locator('[data-row="2"] [data-field="name"]')).toHaveValue('New Region');
77+
await page.click('[data-row="2"] [data-remove]');
78+
await expect.poll(() => host.doc.file.regions.length).toBe(2);
79+
expect(host.editLabels).toEqual(['Add region', 'Remove region']);
80+
});

0 commit comments

Comments
 (0)