feat(editor): extract pure rankVocab module + tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 17:33:26 +02:00
parent 02d96b593e
commit 9ff93164d7
No known key found for this signature in database
2 changed files with 115 additions and 0 deletions

View 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);
}

View 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");
});