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
65 changes: 60 additions & 5 deletions scripts/objmod-thumbnail-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -57,6 +58,22 @@ 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 });
const validModelFixture = path.join(root, 'wc3data', 'melon.mdx');
assert.ok(fs.existsSync(validModelFixture), `Missing valid model fixture: ${validModelFixture}`);
for (const name of ['Footman.mdx', 'FootmanPortrait.mdx', 'CaptainFootman.mdx', 'confirmation.mdx', 'AltarOfKings.mdx']) {
fs.copyFileSync(validModelFixture, path.join(importedModelsDir, name));
}
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',
Expand Down Expand Up @@ -536,24 +553,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}`,
Expand Down Expand Up @@ -672,6 +695,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; restoring the unfiltered catalog`);
await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("")');
}

Expand Down
27 changes: 26 additions & 1 deletion scripts/test-fuzzy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)`);
61 changes: 57 additions & 4 deletions scripts/test-webview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -717,10 +722,13 @@ function testAssetBrowserForwardsModelTextures() {
const src = fs.readFileSync(path.join(root, 'src/features/assetLinks.ts'), 'utf8');
const match = src.match(/<script>\r?\n([\s\S]*?)\r?\n<\/script>`/);
assert.ok(match, 'asset browser inline script should be present');
const script = match[1].replace(
'var initial = ${initialJson};',
"var initial = { activeTab: 'model', tabs: { icon: [], model: [] }, currentValue: '' };"
);
const script = match[1]
.replace(
'var initial = ${initialJson};',
"var initial = { activeTab: 'model', tabs: { icon: [], model: [] }, currentValue: '' };"
)
.replace('${fuzzyMatch.toString()}', 'function fuzzyMatch() { return false; }')
.replace('${assetSearchScore.toString()}', 'function assetSearchScore() { return Number.POSITIVE_INFINITY; }');
// 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.
new vm.Script(script);
assert.ok(
Expand All @@ -743,6 +751,14 @@ function testAssetBrowserForwardsModelTextures() {
!/type === 'requestTextures'\)\s*return/.test(script),
'asset browser must not silently drop model texture requests'
);
assert.ok(
script.includes('assetSearchScore(query, item.label, item.value, item.detail)'),
'code and object-data asset pickers should share the relevance scorer'
);
assert.ok(
!script.includes('text.indexOf(q[i], pos)'),
'asset search must not regress to scattered-letter subsequence matching'
);
}

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

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

function testObjModDensityAndTreeStyling() {
const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8');
const webview = fs.readFileSync(path.join(root, 'src/webview/objModEditorWebview.ts'), 'utf8');

assert.ok(host.includes('id="density-toggle" class="density-toggle" role="switch"'), 'density must render as an obvious switch control');
assert.ok(host.includes('aria-label="Spacious density"'), 'density switch should have an unambiguous accessible name');
assert.ok(host.includes('class="density-track"'), 'density switch should expose an animated visual track');
assert.ok(host.includes('body.density-cozy .density-thumb { transform: translateX(14px)'), 'density switch thumb should animate between states');
assert.ok(host.includes('@media (prefers-reduced-motion: reduce)'), 'density animation should respect reduced-motion preferences');
assert.ok(webview.includes("setAttribute('aria-checked', String(cozy))"), 'density switch must expose its current state accessibly');
assert.ok(host.includes('font-size: 11px;\n font-weight: 500;\n color: var(--muted);'), 'nested tree headings should share one typography baseline');
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');
}

function testImportedAssetDedupeSafety() {
const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8');
const support = fs.readFileSync(path.join(root, 'src/features/imageAssetSupport.ts'), 'utf8');
const e2e = fs.readFileSync(path.join(root, 'scripts/objmod-thumbnail-e2e.js'), 'utf8');

assert.ok(!support.includes('hashImportedAsset'), 'asset dedupe must not mistake size+mtime metadata for a content hash');
assert.ok(!host.includes('opt.hash'), 'distinct imported files must not collapse through metadata collisions');
assert.ok(host.includes("if (opt.source === 'import')"), 'imports in different folders should remain separate after exact-path dedupe');
assert.ok(
support.indexOf('if (seenFile.has(fileKey)) continue;') < support.indexOf('budget--;'),
'duplicate physical assets must not consume the imported-asset scan budget',
);
assert.ok(e2e.includes("path.join(root, 'wc3data', 'melon.mdx')"), 'generated thumbnail search controls should copy a valid model fixture');
assert.ok(!e2e.includes("'objmod search fixture'"), 'generated thumbnail fixtures must not contain fake text model bytes');
}

function testObjModEditorTypeAndRecoveryGuards() {
const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8');
const webviewFiles = [
Expand Down Expand Up @@ -993,6 +1044,8 @@ async function main() {
testObjModTooltipFontWiring();
testObjModTooltipWidthWiring();
testObjModTooltipPreviewHeaders();
testObjModDensityAndTreeStyling();
testImportedAssetDedupeSafety();
console.log('webview harness tests passed');
}

Expand Down
27 changes: 13 additions & 14 deletions src/features/assetLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isSoundAssetPath, playSoundInline } from './soundPreview';
import { buildPage, ICON_INLINE_CSS, PREVIEW_ICON_CSP } from './webviewShared';
import { escapeHtml } from './webviewUtils';
import { showWarningWithLogs } from './diagnostics';
import { assetSearchScore, fuzzyMatch } from './preview/fuzzy';

// Asset file extensions we want to linkify inside string literals
const ASSET_EXTS = new Set([
Expand Down Expand Up @@ -224,7 +225,7 @@ function dedupeAssetOptions(options: readonly ValueOption[]): ValueOption[] {
const seen = new Set<string>();
const out: ValueOption[] = [];
for (const option of options) {
const key = option.value.toLowerCase();
const key = option.value.replace(/\//g, '\\').toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(option);
Expand Down Expand Up @@ -292,22 +293,20 @@ ${ICON_INLINE_CSS}
var modelJob = null;
var modelInited = false;
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function fuzzy(q, text) {
q = String(q || '').toLowerCase().trim();
if (!q) return true;
text = String(text || '').toLowerCase();
var pos = 0;
for (var i = 0; i < q.length; i++) {
pos = text.indexOf(q[i], pos);
if (pos < 0) return false;
pos++;
}
return true;
}
var fuzzyMatch = ${fuzzyMatch.toString()};
var assetSearchScore = ${assetSearchScore.toString()};
function list() {
var items = (initial.tabs[activeTab] || []);
if (!query) return items.slice(0, 500);
return items.filter(function (item) { return fuzzy(query, item.label + ' ' + item.detail + ' ' + item.value); }).slice(0, 500);
return items.map(function (item, index) {
return { item: item, index: index, score: assetSearchScore(query, item.label, item.value, item.detail) };
}).filter(function (entry) {
return Number.isFinite(entry.score);
}).sort(function (a, b) {
return a.score - b.score || a.index - b.index;
}).slice(0, 500).map(function (entry) {
return entry.item;
});
}
function render() {
document.querySelectorAll('.tab').forEach(function (btn) { btn.classList.toggle('active', btn.getAttribute('data-tab') === activeTab); });
Expand Down
Loading
Loading