feat(editor): extract pure rankVocab module + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
02d96b593e
commit
9ff93164d7
2 changed files with 115 additions and 0 deletions
37
l4d2web/scripts/editor-src/vocab-rank.js
Normal file
37
l4d2web/scripts/editor-src/vocab-rank.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
78
l4d2web/scripts/editor-src/vocab-rank.test.js
Normal file
78
l4d2web/scripts/editor-src/vocab-rank.test.js
Normal file
|
|
@ -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");
|
||||
});
|
||||
Loading…
Reference in a new issue