From 916664a0877f5cad4bf9a86976cfc9659bf6be1e Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 11 Aug 2026 13:30:41 +0200 Subject: [PATCH 1/2] Polish object editor asset browsing --- scripts/objmod-thumbnail-e2e.js | 63 ++++++++++++-- scripts/test-fuzzy.js | 27 +++++- scripts/test-webview.js | 54 +++++++++++- src/features/assetLinks.ts | 27 +++--- src/features/imageAssetSupport.ts | 31 +++---- src/features/objModPreview.ts | 106 ++++++++++++++++++----- src/features/preview/fuzzy.ts | 30 +++++++ src/webview/objModEditor/assetBrowser.ts | 22 ++--- src/webview/objModEditor/debugApi.ts | 9 ++ src/webview/objModEditor/fieldDisplay.ts | 9 +- src/webview/objModEditorWebview.ts | 4 +- 11 files changed, 300 insertions(+), 82 deletions(-) diff --git a/scripts/objmod-thumbnail-e2e.js b/scripts/objmod-thumbnail-e2e.js index d19ee2e..ec58dd5 100644 --- a/scripts/objmod-thumbnail-e2e.js +++ b/scripts/objmod-thumbnail-e2e.js @@ -16,6 +16,7 @@ * WURST_OBJMOD_E2E_MAX_MS max warm per-thumbnail lifecycle, default 200ms * WURST_OBJMOD_E2E_TIMEOUT_MS total wait timeout, default 90000 * WURST_OBJMOD_E2E_FONT_ONLY stop after verifying a configured tooltip font + * WURST_OBJMOD_E2E_FONT optional local .ttf copied into the generated fixture and configured */ const assert = require('assert'); @@ -57,6 +58,20 @@ function writeGeneratedObjmodFixture() { const { serializeObjMod } = require('casc-ts/formats'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-objmod-fixture-')); fs.writeFileSync(path.join(dir, 'wurst.build'), 'projectName = objmod-e2e\n'); + const importedModelsDir = path.join(dir, 'imports', 'units'); + fs.mkdirSync(importedModelsDir, { recursive: true }); + for (const name of ['Footman.mdx', 'FootmanPortrait.mdx', 'CaptainFootman.mdx', 'confirmation.mdx', 'AltarOfKings.mdx']) { + fs.writeFileSync(path.join(importedModelsDir, name), 'objmod search fixture'); + } + const localFont = process.env.WURST_OBJMOD_E2E_FONT; + if (localFont) { + assert.ok(fs.existsSync(localFont), `WURST_OBJMOD_E2E_FONT does not exist: ${localFont}`); + const fontName = 'tooltip-e2e.ttf'; + fs.copyFileSync(localFont, path.join(dir, fontName)); + const settingsDir = path.join(dir, '.vscode'); + fs.mkdirSync(settingsDir); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({ 'wurst.objModTooltipFont': fontName })); + } const main = { version: 3, ext: '.w3a', @@ -536,24 +551,30 @@ async function assertObjmodEditorBasics(client, sessionId, contextId) { btn.click(); var cozyHeight = row.getBoundingClientRect().height; var cozy = document.body.classList.contains('density-cozy'); - var cozyLabel = btn.textContent; + var cozyChecked = btn.getAttribute('aria-checked'); + var role = btn.getAttribute('role'); + var label = btn.getAttribute('aria-label'); btn.click(); return { startedCozy: startedCozy, compactHeight: compactHeight, cozyHeight: cozyHeight, cozy: cozy, - cozyLabel: cozyLabel, + cozyChecked: cozyChecked, + role: role, + label: label, restored: document.body.classList.contains('density-cozy'), - restoredLabel: btn.textContent, + restoredChecked: btn.getAttribute('aria-checked'), }; })()`); assert.ok(density, 'objmod header should expose a density toggle beside the save badge'); assert.equal(density.startedCozy, false, 'compact should be the default density'); assert.equal(density.cozy, true, 'clicking the density toggle should switch to the spacious scale'); - assert.equal(density.cozyLabel, 'spacious', 'the density toggle should name the mode it is currently in'); + assert.equal(density.role, 'switch', 'the density control should expose itself as a switch'); + assert.equal(density.label, 'Spacious density', 'the density switch should have a clear accessible name'); + assert.equal(density.cozyChecked, 'true', 'the density switch should expose spacious as checked'); assert.equal(density.restored, false, 'clicking the density toggle again should return to compact'); - assert.equal(density.restoredLabel, 'compact', 'the density toggle label should follow the mode back'); + assert.equal(density.restoredChecked, 'false', 'the density switch should expose compact as unchecked'); assert.ok( density.cozyHeight > density.compactHeight, `spacious browse rows should be taller than compact ones, got ${density.cozyHeight} vs ${density.compactHeight}`, @@ -672,6 +693,38 @@ async function assertObjmodEditorBasics(client, sessionId, contextId) { 15000, ); assert.ok(assetState.visible.some((slot) => /LordaeronTree/i.test(slot.model)), 'LordaeronTree should appear as a model thumbnail slot'); + + await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("footman")'); + const searchState = await waitForEval( + client, + sessionId, + contextId, + 'window.__wurstModelThumbDebug.state()', + (value) => value && Array.isArray(value.assetBrowserResults) && value.assetBrowserResults.some((entry) => /footman/i.test(entry.label)), + 'ranked footman asset search results', + 15000, + ); + const searchResults = searchState.assetBrowserResults; + assert.ok(searchResults.length > 0, 'footman search should return useful model results'); + assert.ok( + searchResults.every((entry) => /footm[ae]n/i.test(`${entry.label} ${entry.value}`)), + `footman search should not contain unrelated fuzzy noise: ${JSON.stringify(searchResults)}`, + ); + for (let i = 1; i < searchResults.length; i++) { + assert.ok( + searchResults[i - 1].score <= searchResults[i].score, + `asset search scores should be sorted by relevance: ${JSON.stringify(searchResults)}`, + ); + } + if (generatedFixtureDir) { + const fixtureScore = (name) => searchResults.find((entry) => entry.label.toLowerCase() === name.toLowerCase())?.score; + assert.equal(fixtureScore('Footman.mdx'), 0, 'exact filename search result should rank first'); + assert.equal(fixtureScore('FootmanPortrait.mdx'), 10, 'filename prefix search result should rank after exact matches'); + assert.equal(fixtureScore('CaptainFootman.mdx'), 20, 'filename substring search result should rank after prefix matches'); + assert.equal(fixtureScore('confirmation.mdx'), undefined, 'scattered letters in confirmation.mdx must not match footman'); + assert.equal(fixtureScore('AltarOfKings.mdx'), undefined, 'unrelated model names must not match footman'); + } + log(`footman search returned ${searchResults.length} relevance-sorted results`); await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("")'); } diff --git a/scripts/test-fuzzy.js b/scripts/test-fuzzy.js index d7295bb..76837d2 100644 --- a/scripts/test-fuzzy.js +++ b/scripts/test-fuzzy.js @@ -16,8 +16,9 @@ const src = fs.readFileSync(srcPath, 'utf8'); const js = ts.transpileModule(src, { compilerOptions: { module: 'commonjs', target: 'es2020' } }).outputText; const mod = { exports: {} }; new Function('exports', 'module', js)(mod.exports, mod); -const { fuzzyMatch } = mod.exports; +const { fuzzyMatch, assetSearchScore } = mod.exports; assert.strictEqual(typeof fuzzyMatch, 'function', 'fuzzyMatch should be exported'); +assert.strictEqual(typeof assetSearchScore, 'function', 'assetSearchScore should be exported'); let passed = 0; function ok(query, text, expected, msg) { @@ -57,4 +58,28 @@ ok('xyzqq', 'Graveyard', false); // threshold stays low — not loose ok('catapult', 'Graveyard', false, 'too many edits'); +const footmanCandidates = [ + { label: 'confirmation.mdx', value: 'imports\\other\\ui\\confirmation.mdx' }, + { label: 'FirePandarenBrewmaster.mdx', value: 'imports\\hero\\FirePandarenBrewmaster.mdx' }, + { label: 'CaptainFootman.mdx', value: 'imports\\units\\CaptainFootman.mdx' }, + { label: 'FootmanPortrait.mdx', value: 'imports\\units\\FootmanPortrait.mdx' }, + { label: 'Footman.mdx', value: 'imports\\units\\Footman.mdx' }, + { label: 'AltarOfKings - altarofkings', value: 'buildings\\human\\AltarOfKings\\AltarOfKings.mdx' }, +]; +const footmanResults = footmanCandidates + .map((item, index) => ({ ...item, index, score: assetSearchScore('footman', item.label, item.value) })) + .filter((item) => Number.isFinite(item.score)) + .sort((a, b) => a.score - b.score || a.index - b.index); +assert.deepStrictEqual( + footmanResults.map((item) => item.label), + ['Footman.mdx', 'FootmanPortrait.mdx', 'CaptainFootman.mdx'], + 'asset search should exclude scattered-letter noise and rank exact, prefix, then substring matches', +); +assert.deepStrictEqual( + footmanResults.map((item) => item.score), + [0, 10, 20], + 'asset relevance scores should be deterministic', +); +passed += 2; + console.log(`fuzzy unit tests passed (${passed} assertions)`); diff --git a/scripts/test-webview.js b/scripts/test-webview.js index dcd5a3e..9ab3aab 100644 --- a/scripts/test-webview.js +++ b/scripts/test-webview.js @@ -509,6 +509,11 @@ async function testFolderModeMapAssetResolution() { roots.some((candidate) => path.resolve(candidate) === path.resolve(imported)), 'folder-mode map import directory should be a candidate root' ); + const gathered = await mod.gatherImportedAssets(docPath); + assert.equal(gathered.icon.length, 1, 'the same imported texture reached through nested candidate roots must appear once'); + assert.equal(gathered.icon[0].value, 'BrutalLord.blp', 'the most specific asset root should provide the useful WC3-relative path'); + assert.equal(gathered.model.length, 1, 'the same imported model reached through nested candidate roots must appear once'); + assert.equal(gathered.model[0].value, 'BrutalLord.mdx'); const resolved = await mod.resolveAssetPathWithCasc('BrutalLord.blp', roots, 'texture'); assert.equal(path.resolve(resolved), path.resolve(texturePath)); const resolvedFromWrongTextureExt = await mod.resolveAssetPathWithCasc('BrutalLord.tif', roots, 'texture'); @@ -717,10 +722,13 @@ function testAssetBrowserForwardsModelTextures() { const src = fs.readFileSync(path.join(root, 'src/features/assetLinks.ts'), 'utf8'); const match = src.match(/