From 9ff93164d72fb2ff211527dc42668a31bc10b330 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 17:33:26 +0200 Subject: [PATCH] feat(editor): extract pure rankVocab module + tests Co-Authored-By: Claude Sonnet 4.6 --- l4d2web/scripts/editor-src/vocab-rank.js | 37 +++++++++ l4d2web/scripts/editor-src/vocab-rank.test.js | 78 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 l4d2web/scripts/editor-src/vocab-rank.js create mode 100644 l4d2web/scripts/editor-src/vocab-rank.test.js diff --git a/l4d2web/scripts/editor-src/vocab-rank.js b/l4d2web/scripts/editor-src/vocab-rank.js new file mode 100644 index 0000000..d1bd77c --- /dev/null +++ b/l4d2web/scripts/editor-src/vocab-rank.js @@ -0,0 +1,37 @@ +// Pure, dependency-free ranking of a vocabulary against a query string. +// Used by both the CodeMirror editor (via autocomplete.js) and the +// runtime console (via the vocab-rank bundle exposed on window). +// +// Score (lower = better): +// exact match → 0 +// prefix match → 1 + label.length (shorter prefix matches win) +// substring match → 10000 + indexOf (earlier substring beats later) +// no match → -1 (excluded) + +function score(query, label) { + if (label === query) return 0; + if (label.startsWith(query)) return 1 + label.length; + const i = label.indexOf(query); + if (i !== -1) return 10000 + i; + return -1; +} + +export function rankVocab(query, vocab, { limit = 50 } = {}) { + if (!query) return []; + const q = query.toLowerCase(); + + const entries = [ + ...vocab.cvars.map(e => ({ ...e, kind: "cvar" })), + ...vocab.commands.map(e => ({ ...e, kind: "command" })), + ]; + + const scored = []; + for (const e of entries) { + const s = score(q, e.name.toLowerCase()); + if (s === -1) continue; + scored.push([s, e]); + if (scored.length > limit * 4) break; + } + scored.sort((a, b) => a[0] - b[0]); + return scored.slice(0, limit).map(([, e]) => e); +} diff --git a/l4d2web/scripts/editor-src/vocab-rank.test.js b/l4d2web/scripts/editor-src/vocab-rank.test.js new file mode 100644 index 0000000..8b568ae --- /dev/null +++ b/l4d2web/scripts/editor-src/vocab-rank.test.js @@ -0,0 +1,78 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { rankVocab } from "./vocab-rank.js"; + +const vocab = { + cvars: [ + { name: "sv_cheats", desc: "Allow cheats" }, + { name: "sv_gravity" }, + { name: "mp_friendlyfire", desc: "Toggle FF" }, + ], + commands: [ + { name: "kick", desc: "Kick a player" }, + { name: "kickall", desc: "Kick everyone" }, + { name: "changelevel", desc: "Change map" }, + ], +}; + +test("exact match comes first", () => { + const out = rankVocab("kick", vocab); + assert.equal(out[0].name, "kick"); + assert.equal(out[1].name, "kickall"); +}); + +test("prefix matches beat substring matches", () => { + const out = rankVocab("sv_", vocab); + assert.equal(out[0].name, "sv_cheats"); + assert.equal(out[1].name, "sv_gravity"); + // mp_friendlyfire contains no "sv_" → should not appear + assert.ok(!out.some(e => e.name === "mp_friendlyfire")); +}); + +test("substring matches included after prefix matches", () => { + // "iendly" is a substring of mp_friendlyfire but a prefix of nothing + const out = rankVocab("iendly", vocab); + assert.equal(out.length, 1); + assert.equal(out[0].name, "mp_friendlyfire"); +}); + +test("kind is preserved on each result", () => { + const out = rankVocab("kick", vocab); + assert.equal(out[0].kind, "command"); + const sv = rankVocab("sv_cheats", vocab); + assert.equal(sv[0].kind, "cvar"); +}); + +test("desc is preserved when present", () => { + const out = rankVocab("kick", vocab); + assert.equal(out[0].desc, "Kick a player"); +}); + +test("desc is undefined when source had no desc", () => { + const out = rankVocab("sv_gravity", vocab); + assert.equal(out[0].desc, undefined); +}); + +test("results are capped at the configured limit", () => { + const big = { cvars: [], commands: [] }; + for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` }); + const out = rankVocab("cmd", big, { limit: 50 }); + assert.equal(out.length, 50); +}); + +test("default limit is 50", () => { + const big = { cvars: [], commands: [] }; + for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` }); + const out = rankVocab("cmd", big); + assert.equal(out.length, 50); +}); + +test("empty query returns no results", () => { + const out = rankVocab("", vocab); + assert.equal(out.length, 0); +}); + +test("case-insensitive match", () => { + const out = rankVocab("KICK", vocab); + assert.equal(out[0].name, "kick"); +});