Skip to content

Commit d79a7fc

Browse files
authored
Polish object editor asset browsing (#100)
1 parent aaeb498 commit d79a7fc

11 files changed

Lines changed: 311 additions & 82 deletions

File tree

scripts/objmod-thumbnail-e2e.js

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* WURST_OBJMOD_E2E_MAX_MS max warm per-thumbnail lifecycle, default 200ms
1717
* WURST_OBJMOD_E2E_TIMEOUT_MS total wait timeout, default 90000
1818
* WURST_OBJMOD_E2E_FONT_ONLY stop after verifying a configured tooltip font
19+
* WURST_OBJMOD_E2E_FONT optional local .ttf copied into the generated fixture and configured
1920
*/
2021

2122
const assert = require('assert');
@@ -57,6 +58,22 @@ function writeGeneratedObjmodFixture() {
5758
const { serializeObjMod } = require('casc-ts/formats');
5859
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-objmod-fixture-'));
5960
fs.writeFileSync(path.join(dir, 'wurst.build'), 'projectName = objmod-e2e\n');
61+
const importedModelsDir = path.join(dir, 'imports', 'units');
62+
fs.mkdirSync(importedModelsDir, { recursive: true });
63+
const validModelFixture = path.join(root, 'wc3data', 'melon.mdx');
64+
assert.ok(fs.existsSync(validModelFixture), `Missing valid model fixture: ${validModelFixture}`);
65+
for (const name of ['Footman.mdx', 'FootmanPortrait.mdx', 'CaptainFootman.mdx', 'confirmation.mdx', 'AltarOfKings.mdx']) {
66+
fs.copyFileSync(validModelFixture, path.join(importedModelsDir, name));
67+
}
68+
const localFont = process.env.WURST_OBJMOD_E2E_FONT;
69+
if (localFont) {
70+
assert.ok(fs.existsSync(localFont), `WURST_OBJMOD_E2E_FONT does not exist: ${localFont}`);
71+
const fontName = 'tooltip-e2e.ttf';
72+
fs.copyFileSync(localFont, path.join(dir, fontName));
73+
const settingsDir = path.join(dir, '.vscode');
74+
fs.mkdirSync(settingsDir);
75+
fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({ 'wurst.objModTooltipFont': fontName }));
76+
}
6077
const main = {
6178
version: 3,
6279
ext: '.w3a',
@@ -536,24 +553,30 @@ async function assertObjmodEditorBasics(client, sessionId, contextId) {
536553
btn.click();
537554
var cozyHeight = row.getBoundingClientRect().height;
538555
var cozy = document.body.classList.contains('density-cozy');
539-
var cozyLabel = btn.textContent;
556+
var cozyChecked = btn.getAttribute('aria-checked');
557+
var role = btn.getAttribute('role');
558+
var label = btn.getAttribute('aria-label');
540559
btn.click();
541560
return {
542561
startedCozy: startedCozy,
543562
compactHeight: compactHeight,
544563
cozyHeight: cozyHeight,
545564
cozy: cozy,
546-
cozyLabel: cozyLabel,
565+
cozyChecked: cozyChecked,
566+
role: role,
567+
label: label,
547568
restored: document.body.classList.contains('density-cozy'),
548-
restoredLabel: btn.textContent,
569+
restoredChecked: btn.getAttribute('aria-checked'),
549570
};
550571
})()`);
551572
assert.ok(density, 'objmod header should expose a density toggle beside the save badge');
552573
assert.equal(density.startedCozy, false, 'compact should be the default density');
553574
assert.equal(density.cozy, true, 'clicking the density toggle should switch to the spacious scale');
554-
assert.equal(density.cozyLabel, 'spacious', 'the density toggle should name the mode it is currently in');
575+
assert.equal(density.role, 'switch', 'the density control should expose itself as a switch');
576+
assert.equal(density.label, 'Spacious density', 'the density switch should have a clear accessible name');
577+
assert.equal(density.cozyChecked, 'true', 'the density switch should expose spacious as checked');
555578
assert.equal(density.restored, false, 'clicking the density toggle again should return to compact');
556-
assert.equal(density.restoredLabel, 'compact', 'the density toggle label should follow the mode back');
579+
assert.equal(density.restoredChecked, 'false', 'the density switch should expose compact as unchecked');
557580
assert.ok(
558581
density.cozyHeight > density.compactHeight,
559582
`spacious browse rows should be taller than compact ones, got ${density.cozyHeight} vs ${density.compactHeight}`,
@@ -672,6 +695,38 @@ async function assertObjmodEditorBasics(client, sessionId, contextId) {
672695
15000,
673696
);
674697
assert.ok(assetState.visible.some((slot) => /LordaeronTree/i.test(slot.model)), 'LordaeronTree should appear as a model thumbnail slot');
698+
699+
await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("footman")');
700+
const searchState = await waitForEval(
701+
client,
702+
sessionId,
703+
contextId,
704+
'window.__wurstModelThumbDebug.state()',
705+
(value) => value && Array.isArray(value.assetBrowserResults) && value.assetBrowserResults.some((entry) => /footman/i.test(entry.label)),
706+
'ranked footman asset search results',
707+
15000,
708+
);
709+
const searchResults = searchState.assetBrowserResults;
710+
assert.ok(searchResults.length > 0, 'footman search should return useful model results');
711+
assert.ok(
712+
searchResults.every((entry) => /footm[ae]n/i.test(`${entry.label} ${entry.value}`)),
713+
`footman search should not contain unrelated fuzzy noise: ${JSON.stringify(searchResults)}`,
714+
);
715+
for (let i = 1; i < searchResults.length; i++) {
716+
assert.ok(
717+
searchResults[i - 1].score <= searchResults[i].score,
718+
`asset search scores should be sorted by relevance: ${JSON.stringify(searchResults)}`,
719+
);
720+
}
721+
if (generatedFixtureDir) {
722+
const fixtureScore = (name) => searchResults.find((entry) => entry.label.toLowerCase() === name.toLowerCase())?.score;
723+
assert.equal(fixtureScore('Footman.mdx'), 0, 'exact filename search result should rank first');
724+
assert.equal(fixtureScore('FootmanPortrait.mdx'), 10, 'filename prefix search result should rank after exact matches');
725+
assert.equal(fixtureScore('CaptainFootman.mdx'), 20, 'filename substring search result should rank after prefix matches');
726+
assert.equal(fixtureScore('confirmation.mdx'), undefined, 'scattered letters in confirmation.mdx must not match footman');
727+
assert.equal(fixtureScore('AltarOfKings.mdx'), undefined, 'unrelated model names must not match footman');
728+
}
729+
log(`footman search returned ${searchResults.length} relevance-sorted results; restoring the unfiltered catalog`);
675730
await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("")');
676731
}
677732

scripts/test-fuzzy.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ const src = fs.readFileSync(srcPath, 'utf8');
1616
const js = ts.transpileModule(src, { compilerOptions: { module: 'commonjs', target: 'es2020' } }).outputText;
1717
const mod = { exports: {} };
1818
new Function('exports', 'module', js)(mod.exports, mod);
19-
const { fuzzyMatch } = mod.exports;
19+
const { fuzzyMatch, assetSearchScore } = mod.exports;
2020
assert.strictEqual(typeof fuzzyMatch, 'function', 'fuzzyMatch should be exported');
21+
assert.strictEqual(typeof assetSearchScore, 'function', 'assetSearchScore should be exported');
2122

2223
let passed = 0;
2324
function ok(query, text, expected, msg) {
@@ -57,4 +58,28 @@ ok('xyzqq', 'Graveyard', false);
5758
// threshold stays low — not loose
5859
ok('catapult', 'Graveyard', false, 'too many edits');
5960

61+
const footmanCandidates = [
62+
{ label: 'confirmation.mdx', value: 'imports\\other\\ui\\confirmation.mdx' },
63+
{ label: 'FirePandarenBrewmaster.mdx', value: 'imports\\hero\\FirePandarenBrewmaster.mdx' },
64+
{ label: 'CaptainFootman.mdx', value: 'imports\\units\\CaptainFootman.mdx' },
65+
{ label: 'FootmanPortrait.mdx', value: 'imports\\units\\FootmanPortrait.mdx' },
66+
{ label: 'Footman.mdx', value: 'imports\\units\\Footman.mdx' },
67+
{ label: 'AltarOfKings - altarofkings', value: 'buildings\\human\\AltarOfKings\\AltarOfKings.mdx' },
68+
];
69+
const footmanResults = footmanCandidates
70+
.map((item, index) => ({ ...item, index, score: assetSearchScore('footman', item.label, item.value) }))
71+
.filter((item) => Number.isFinite(item.score))
72+
.sort((a, b) => a.score - b.score || a.index - b.index);
73+
assert.deepStrictEqual(
74+
footmanResults.map((item) => item.label),
75+
['Footman.mdx', 'FootmanPortrait.mdx', 'CaptainFootman.mdx'],
76+
'asset search should exclude scattered-letter noise and rank exact, prefix, then substring matches',
77+
);
78+
assert.deepStrictEqual(
79+
footmanResults.map((item) => item.score),
80+
[0, 10, 20],
81+
'asset relevance scores should be deterministic',
82+
);
83+
passed += 2;
84+
6085
console.log(`fuzzy unit tests passed (${passed} assertions)`);

scripts/test-webview.js

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,11 @@ async function testFolderModeMapAssetResolution() {
509509
roots.some((candidate) => path.resolve(candidate) === path.resolve(imported)),
510510
'folder-mode map import directory should be a candidate root'
511511
);
512+
const gathered = await mod.gatherImportedAssets(docPath);
513+
assert.equal(gathered.icon.length, 1, 'the same imported texture reached through nested candidate roots must appear once');
514+
assert.equal(gathered.icon[0].value, 'BrutalLord.blp', 'the most specific asset root should provide the useful WC3-relative path');
515+
assert.equal(gathered.model.length, 1, 'the same imported model reached through nested candidate roots must appear once');
516+
assert.equal(gathered.model[0].value, 'BrutalLord.mdx');
512517
const resolved = await mod.resolveAssetPathWithCasc('BrutalLord.blp', roots, 'texture');
513518
assert.equal(path.resolve(resolved), path.resolve(texturePath));
514519
const resolvedFromWrongTextureExt = await mod.resolveAssetPathWithCasc('BrutalLord.tif', roots, 'texture');
@@ -717,10 +722,13 @@ function testAssetBrowserForwardsModelTextures() {
717722
const src = fs.readFileSync(path.join(root, 'src/features/assetLinks.ts'), 'utf8');
718723
const match = src.match(/<script>\r?\n([\s\S]*?)\r?\n<\/script>`/);
719724
assert.ok(match, 'asset browser inline script should be present');
720-
const script = match[1].replace(
721-
'var initial = ${initialJson};',
722-
"var initial = { activeTab: 'model', tabs: { icon: [], model: [] }, currentValue: '' };"
723-
);
725+
const script = match[1]
726+
.replace(
727+
'var initial = ${initialJson};',
728+
"var initial = { activeTab: 'model', tabs: { icon: [], model: [] }, currentValue: '' };"
729+
)
730+
.replace('${fuzzyMatch.toString()}', 'function fuzzyMatch() { return false; }')
731+
.replace('${assetSearchScore.toString()}', 'function assetSearchScore() { return Number.POSITIVE_INFINITY; }');
724732
// eslint-disable-next-line sonarjs/constructor-for-side-effects -- constructed only to validate the extracted inline script parses (throws SyntaxError otherwise); the instance itself is unused on purpose.
725733
new vm.Script(script);
726734
assert.ok(
@@ -743,6 +751,14 @@ function testAssetBrowserForwardsModelTextures() {
743751
!/type === 'requestTextures'\)\s*return/.test(script),
744752
'asset browser must not silently drop model texture requests'
745753
);
754+
assert.ok(
755+
script.includes('assetSearchScore(query, item.label, item.value, item.detail)'),
756+
'code and object-data asset pickers should share the relevance scorer'
757+
);
758+
assert.ok(
759+
!script.includes('text.indexOf(q[i], pos)'),
760+
'asset search must not regress to scattered-letter subsequence matching'
761+
);
746762
}
747763

748764
function testThumbnailLifecycleGuards() {
@@ -778,6 +794,7 @@ function testThumbnailLifecycleGuards() {
778794
assert.ok(objmod.includes('fetch(initial.thumbnailWorkerUri'), 'the worker bundle must be fetched before creating its Blob URL');
779795
assert.ok(!objmod.includes('new Worker(initial.thumbnailWorkerUri)'), 'VS Code resource URLs cannot be passed directly to the Worker constructor');
780796
assert.ok(assetBrowser.includes('modelThumbEnsureInit()'), 'opening or selecting the model asset browser should prewarm the thumbnail worker');
797+
assert.ok(assetBrowser.includes("import { assetSearchScore } from '../../features/preview/fuzzy'"), 'objmod asset search should use the shared relevance scorer');
781798
const ensureInit = /export function modelThumbEnsureInit\(\) \{([\s\S]*?)\n\}/.exec(objmod)?.[1] || '';
782799
assert.ok(!ensureInit.includes('mpvViewer()'), 'worker startup failure must not fall back to rendering on the objmod UI thread');
783800
assert.ok(webpack.includes("mdxThumbnailWorker: './src/webview/mdxThumbnailWorker.ts'"), 'the isolated thumbnail worker must be bundled');
@@ -921,6 +938,9 @@ function testObjModTooltipFontWiring() {
921938
assert.ok(!host.includes("Buffer.from(bytes).toString('base64')"), 'project fonts should not be embedded as large data URLs');
922939
assert.ok(host.includes(".tt-collapsed-box,\n.tt-preview {"), 'the custom font should be assigned only to tooltip boxes');
923940
assert.ok(!host.includes('.tt-collapsed-box *'), 'the custom font should not use descendant-wide override selectors');
941+
assert.ok(host.includes('td.value { font-family: var(--font); }'), 'ordinary field values should use the VS Code UI font');
942+
assert.ok(host.includes('.tt-empty { color: var(--muted); font-style: normal; }'), 'ordinary empty values should not be italicized');
943+
assert.ok(host.includes('.tt-collapsed-box .tt-empty,'), 'only framed WC3 text may retain the italic empty placeholder');
924944
assert.ok(packageJson.includes('wurst.objModTooltipFont'), 'the tooltip font setting should be contributed by the extension');
925945
assert.equal(packageData.contributes.configuration.properties['wurst.objModTooltipFont'].default, '', 'the tooltip font setting must default to disabled');
926946
}
@@ -944,10 +964,41 @@ function testObjModTooltipPreviewHeaders() {
944964
const detailsPanel = fs.readFileSync(path.join(root, 'src/webview/objModEditor/detailsPanel.ts'), 'utf8');
945965

946966
assert.ok(fieldDisplay.includes("replace(/^(?:unit|building)\\s*\\/\\s*/i, '')"), 'unit and building tooltip markers should be hidden in previews');
967+
assert.ok(fieldDisplay.includes("label === 'name' || /\\bnames?$/"), 'player-facing name fields should use the framed WC3 text editor');
947968
assert.ok(detailsPanel.includes('renderWc3Colors(original)'), 'tooltip editing should restore the unmodified raw value');
948969
assert.ok(detailsPanel.includes('renderWc3Colors(tooltipPreviewText(value, isTooltipTemplateField(mod)))'), 'tooltip collapse should restore the cleaned preview only for tooltip fields');
949970
}
950971

972+
function testObjModDensityAndTreeStyling() {
973+
const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8');
974+
const webview = fs.readFileSync(path.join(root, 'src/webview/objModEditorWebview.ts'), 'utf8');
975+
976+
assert.ok(host.includes('id="density-toggle" class="density-toggle" role="switch"'), 'density must render as an obvious switch control');
977+
assert.ok(host.includes('aria-label="Spacious density"'), 'density switch should have an unambiguous accessible name');
978+
assert.ok(host.includes('class="density-track"'), 'density switch should expose an animated visual track');
979+
assert.ok(host.includes('body.density-cozy .density-thumb { transform: translateX(14px)'), 'density switch thumb should animate between states');
980+
assert.ok(host.includes('@media (prefers-reduced-motion: reduce)'), 'density animation should respect reduced-motion preferences');
981+
assert.ok(webview.includes("setAttribute('aria-checked', String(cozy))"), 'density switch must expose its current state accessibly');
982+
assert.ok(host.includes('font-size: 11px;\n font-weight: 500;\n color: var(--muted);'), 'nested tree headings should share one typography baseline');
983+
assert.ok(!host.includes('.race-heading {\n padding: var(--tree-heading-py) var(--ind-group) var(--tree-heading-py) var(--ind-race);\n color: var(--fg);\n font-size: 12px;'), 'race headings should not introduce a third font treatment');
984+
}
985+
986+
function testImportedAssetDedupeSafety() {
987+
const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8');
988+
const support = fs.readFileSync(path.join(root, 'src/features/imageAssetSupport.ts'), 'utf8');
989+
const e2e = fs.readFileSync(path.join(root, 'scripts/objmod-thumbnail-e2e.js'), 'utf8');
990+
991+
assert.ok(!support.includes('hashImportedAsset'), 'asset dedupe must not mistake size+mtime metadata for a content hash');
992+
assert.ok(!host.includes('opt.hash'), 'distinct imported files must not collapse through metadata collisions');
993+
assert.ok(host.includes("if (opt.source === 'import')"), 'imports in different folders should remain separate after exact-path dedupe');
994+
assert.ok(
995+
support.indexOf('if (seenFile.has(fileKey)) continue;') < support.indexOf('budget--;'),
996+
'duplicate physical assets must not consume the imported-asset scan budget',
997+
);
998+
assert.ok(e2e.includes("path.join(root, 'wc3data', 'melon.mdx')"), 'generated thumbnail search controls should copy a valid model fixture');
999+
assert.ok(!e2e.includes("'objmod search fixture'"), 'generated thumbnail fixtures must not contain fake text model bytes');
1000+
}
1001+
9511002
function testObjModEditorTypeAndRecoveryGuards() {
9521003
const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8');
9531004
const webviewFiles = [
@@ -993,6 +1044,8 @@ async function main() {
9931044
testObjModTooltipFontWiring();
9941045
testObjModTooltipWidthWiring();
9951046
testObjModTooltipPreviewHeaders();
1047+
testObjModDensityAndTreeStyling();
1048+
testImportedAssetDedupeSafety();
9961049
console.log('webview harness tests passed');
9971050
}
9981051

src/features/assetLinks.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { isSoundAssetPath, playSoundInline } from './soundPreview';
99
import { buildPage, ICON_INLINE_CSS, PREVIEW_ICON_CSP } from './webviewShared';
1010
import { escapeHtml } from './webviewUtils';
1111
import { showWarningWithLogs } from './diagnostics';
12+
import { assetSearchScore, fuzzyMatch } from './preview/fuzzy';
1213

1314
// Asset file extensions we want to linkify inside string literals
1415
const ASSET_EXTS = new Set([
@@ -224,7 +225,7 @@ function dedupeAssetOptions(options: readonly ValueOption[]): ValueOption[] {
224225
const seen = new Set<string>();
225226
const out: ValueOption[] = [];
226227
for (const option of options) {
227-
const key = option.value.toLowerCase();
228+
const key = option.value.replace(/\//g, '\\').toLowerCase();
228229
if (seen.has(key)) continue;
229230
seen.add(key);
230231
out.push(option);
@@ -292,22 +293,20 @@ ${ICON_INLINE_CSS}
292293
var modelJob = null;
293294
var modelInited = false;
294295
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
295-
function fuzzy(q, text) {
296-
q = String(q || '').toLowerCase().trim();
297-
if (!q) return true;
298-
text = String(text || '').toLowerCase();
299-
var pos = 0;
300-
for (var i = 0; i < q.length; i++) {
301-
pos = text.indexOf(q[i], pos);
302-
if (pos < 0) return false;
303-
pos++;
304-
}
305-
return true;
306-
}
296+
var fuzzyMatch = ${fuzzyMatch.toString()};
297+
var assetSearchScore = ${assetSearchScore.toString()};
307298
function list() {
308299
var items = (initial.tabs[activeTab] || []);
309300
if (!query) return items.slice(0, 500);
310-
return items.filter(function (item) { return fuzzy(query, item.label + ' ' + item.detail + ' ' + item.value); }).slice(0, 500);
301+
return items.map(function (item, index) {
302+
return { item: item, index: index, score: assetSearchScore(query, item.label, item.value, item.detail) };
303+
}).filter(function (entry) {
304+
return Number.isFinite(entry.score);
305+
}).sort(function (a, b) {
306+
return a.score - b.score || a.index - b.index;
307+
}).slice(0, 500).map(function (entry) {
308+
return entry.item;
309+
});
311310
}
312311
function render() {
313312
document.querySelectorAll('.tab').forEach(function (btn) { btn.classList.toggle('active', btn.getAttribute('data-tab') === activeTab); });

0 commit comments

Comments
 (0)