mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
* fix(history): preserve chronology and clarify search matches Keep workspace names primary and highlight each searched field so results explain why they matched. Filter before cursor pagination and retain date buckets. Constrain shared subsequence matching to each whitespace-delimited word while preserving fuzzy abbreviations in Command Center and other consumers. * test(history): reuse response helper in pagination regression * test(history): express browser journey through named helpers * fix(app): start menu entering animation only once the surface is placed Reanimated's web entering path snapshots the element rect at animationend and writes top/left back when it cleans up the keyframe 750ms later. When the 150ms animation ends before anchor and content measurement resolve, that snapshot is the off-screen measuring position and the open menu jumps back to -9999. Remount the surface when its position resolves so the animation starts from the final position.
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
compareMatchScores,
|
|
fuzzyPolicyForToken,
|
|
matchRanges,
|
|
scoreMatch,
|
|
scorePathMatch,
|
|
scoreTextFields,
|
|
} from "./text-match.js";
|
|
|
|
function markUp(query: string, text: string, options?: Parameters<typeof scoreMatch>[2]): string {
|
|
const score = scoreMatch(query, text, options);
|
|
if (!score) return text;
|
|
let marked = "";
|
|
let cursor = 0;
|
|
for (const range of matchRanges(query, text, score)) {
|
|
marked += text.slice(cursor, range.start);
|
|
marked += `[${text.slice(range.start, range.start + range.length)}]`;
|
|
cursor = range.start + range.length;
|
|
}
|
|
return marked + text.slice(cursor);
|
|
}
|
|
|
|
describe("scoreMatch", () => {
|
|
it("returns tier 0 for empty query", () => {
|
|
expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 });
|
|
});
|
|
|
|
it("returns tier 0 for exact match ignoring case", () => {
|
|
expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 });
|
|
expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 });
|
|
expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 });
|
|
});
|
|
|
|
it("returns tier 1 for whole-word match at start", () => {
|
|
expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 });
|
|
});
|
|
|
|
it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => {
|
|
expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 });
|
|
expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 });
|
|
});
|
|
|
|
it("returns tier 2 for string prefix that does not complete a word", () => {
|
|
expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 });
|
|
});
|
|
|
|
it("returns tier 3 for word-boundary start that does not complete a word", () => {
|
|
expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 });
|
|
});
|
|
|
|
it("returns tier 4 for substring inside a word", () => {
|
|
expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 });
|
|
});
|
|
|
|
it("does not assemble one query word across whitespace-separated words", () => {
|
|
for (const separator of [" ", "\t", "\n", "\u00a0"]) {
|
|
expect(scoreMatch("terminal", `term${separator}inal`)).toBeNull();
|
|
}
|
|
expect(
|
|
scoreMatch("terminal", "Let me diagnose this problem in a diagnose this problem and", {
|
|
fuzzy: fuzzyPolicyForToken("terminal"),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("keeps subsequences within a word, including names and later words", () => {
|
|
expect(scoreMatch("trmnl", "Fix terminal resizing")).toEqual({ tier: 5, offset: 4, spread: 8 });
|
|
expect(scoreMatch("abc", "AgentBrowserConfig")?.tier).toBe(5);
|
|
expect(scoreMatch("pasbab", "paseo-babysit")?.tier).toBe(5);
|
|
expect(scoreMatch("confg", "check configuration")?.offset).toBe(6);
|
|
});
|
|
|
|
it("returns null when query is not found", () => {
|
|
expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull();
|
|
});
|
|
|
|
it("picks the best tier across multiple occurrences", () => {
|
|
expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 });
|
|
expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 });
|
|
});
|
|
|
|
it("prefers earlier offset when tiers are equal", () => {
|
|
expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 });
|
|
});
|
|
|
|
it("treats common separators as word boundaries", () => {
|
|
expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 });
|
|
expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 });
|
|
expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 });
|
|
expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 });
|
|
expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 });
|
|
expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 });
|
|
expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 });
|
|
});
|
|
|
|
it("scores PR-title-shaped text realistically", () => {
|
|
const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider";
|
|
expect(scoreMatch("pi", pr202)?.tier).toBe(1);
|
|
expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 });
|
|
expect(scoreMatch("replace", pr202)?.tier).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("scoreMatch typo tolerance", () => {
|
|
const typoTolerant = { fuzzy: { maxEdits: 2, transpositionsOnly: false } };
|
|
|
|
it("is off by default so exact pickers keep exact behavior", () => {
|
|
expect(scoreMatch("bulling", "add stripe billing")).toBeNull();
|
|
});
|
|
|
|
it("matches a transposition", () => {
|
|
expect(
|
|
scoreMatch("brnach", "brnach is not branch", {
|
|
fuzzy: { maxEdits: 1, transpositionsOnly: false },
|
|
})?.tier,
|
|
).toBe(1);
|
|
expect(
|
|
scoreMatch("brnach", "switch the branch", {
|
|
fuzzy: { maxEdits: 1, transpositionsOnly: false },
|
|
})?.tier,
|
|
).toBe(6);
|
|
});
|
|
|
|
it("matches a typo the subsequence tier cannot reach", () => {
|
|
// Dropped characters already land on the subsequence tier; the fuzzy tier
|
|
// exists for the ones that reorder or replace.
|
|
expect(scoreMatch("confg", "revamp configuration editor")?.tier).toBe(5);
|
|
expect(scoreMatch("confug", "revamp configuration editor")).toBeNull();
|
|
expect(scoreMatch("confug", "revamp configuration editor", typoTolerant)?.tier).toBe(6);
|
|
});
|
|
|
|
it("drops the subsequence tier when the caller opts out", () => {
|
|
// Callers that preselect a row need a near miss to read as no match, so they turn this off.
|
|
// Only the subsequence tier goes; every contiguous tier still scores the same.
|
|
expect(scoreMatch("confg", "revamp configuration editor", { subsequence: false })).toBeNull();
|
|
expect(scoreMatch("config", "revamp configuration editor", { subsequence: false })?.tier).toBe(
|
|
3,
|
|
);
|
|
});
|
|
|
|
it("reports the offset and edit distance of the word it matched", () => {
|
|
expect(scoreMatch("bulling", "add stripe billing", typoTolerant)).toEqual({
|
|
tier: 6,
|
|
offset: 11,
|
|
spread: 1,
|
|
});
|
|
});
|
|
|
|
it("ranks every exact tier ahead of a fuzzy hit", () => {
|
|
const fuzzy = scoreMatch("bilingg", "billing", typoTolerant);
|
|
const worstExact = scoreMatch("iln", "billing");
|
|
if (!fuzzy || !worstExact) throw new Error("expected both scores");
|
|
expect(compareMatchScores(worstExact, fuzzy)).toBeLessThan(0);
|
|
});
|
|
|
|
it("refuses to fuzzy-match beyond the budget", () => {
|
|
// "stpie" is two edits from "stripe": insert the r, then swap p and i.
|
|
expect(
|
|
scoreMatch("stpie", "add stripe billing", {
|
|
fuzzy: { maxEdits: 2, transpositionsOnly: false },
|
|
})?.tier,
|
|
).toBe(6);
|
|
expect(
|
|
scoreMatch("stpie", "add stripe billing", {
|
|
fuzzy: { maxEdits: 1, transpositionsOnly: false },
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
scoreMatch("zzzzzz", "add stripe billing", {
|
|
fuzzy: { maxEdits: 2, transpositionsOnly: false },
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("forgives only transpositions in short tokens, where a substitution matches too much", () => {
|
|
expect(fuzzyPolicyForToken("pi")).toBeNull();
|
|
expect(fuzzyPolicyForToken("main")).toEqual({ maxEdits: 1, transpositionsOnly: true });
|
|
expect(fuzzyPolicyForToken("stipe")).toEqual({ maxEdits: 1, transpositionsOnly: false });
|
|
expect(fuzzyPolicyForToken("configration")).toEqual({ maxEdits: 2, transpositionsOnly: false });
|
|
|
|
// The branch typo that has to work.
|
|
expect(scoreMatch("mian", "switch to main", { fuzzy: fuzzyPolicyForToken("mian") })?.tier).toBe(
|
|
6,
|
|
);
|
|
// Words one substitution from "main" stay out.
|
|
for (const nearMiss of ["mial", "rain", "maid"]) {
|
|
expect(
|
|
scoreMatch(nearMiss, "switch to main", { fuzzy: fuzzyPolicyForToken(nearMiss) }),
|
|
).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("finds a transposed short token at the head of a longer word", () => {
|
|
expect(
|
|
scoreMatch("mian", "feat/mainline-fix", { fuzzy: fuzzyPolicyForToken("mian") })?.tier,
|
|
).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe("scorePathMatch", () => {
|
|
it("matches a literal fragment anywhere in the complete path", () => {
|
|
expect(
|
|
scorePathMatch("skills/", "something/something-else/skills/paseo-advisor/SKILL.md"),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("fuzzy-matches spaced text against a compact path", () => {
|
|
expect(scorePathMatch("blank page editor", "blankpage/editor")).toEqual({
|
|
tier: 0,
|
|
offset: 0,
|
|
});
|
|
});
|
|
|
|
it("keeps the original path offset for compact matches", () => {
|
|
expect(scorePathMatch("blank page editor", "projects/blankpage/editor")).toEqual({
|
|
tier: 4,
|
|
offset: 9,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("scoreTextFields", () => {
|
|
it("requires every token to match some field", () => {
|
|
expect(scoreTextFields("stripe billing", ["add stripe billing system"])).not.toBeNull();
|
|
expect(scoreTextFields("stripe rosetta", ["add stripe billing system"])).toBeNull();
|
|
});
|
|
|
|
it("lets tokens match across different fields", () => {
|
|
expect(scoreTextFields("stripe main", ["add stripe billing", "main"])).not.toBeNull();
|
|
});
|
|
|
|
it("carries typo tolerance down to each token", () => {
|
|
expect(
|
|
scoreTextFields("stipe bulling", ["add stripe billing"], { typoTolerant: true }),
|
|
).not.toBeNull();
|
|
expect(scoreTextFields("stipe bulling", ["add stripe billing"])).toBeNull();
|
|
});
|
|
|
|
it("carries the subsequence opt-out down to each token", () => {
|
|
const fields = ["Label as Design"];
|
|
// Splitting on whitespace is what the opt-out keeps; only the run-together form goes.
|
|
expect(scoreTextFields("lab des", fields, { subsequence: false })).not.toBeNull();
|
|
expect(scoreTextFields("labdes", fields, { subsequence: false })).toBeNull();
|
|
expect(scoreTextFields("labdes", fields)).toBeNull();
|
|
expect(scoreTextFields("lbl dsgn", fields)).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("compareMatchScores", () => {
|
|
it("sorts by tier ascending", () => {
|
|
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
|
|
expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("tie-breaks by offset ascending at the same tier", () => {
|
|
expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0);
|
|
expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("matchRanges", () => {
|
|
it("marks a whole-word hit", () => {
|
|
expect(markUp("stripe", "add stripe billing")).toBe("add [stripe] billing");
|
|
});
|
|
|
|
it("marks a hit buried inside a word", () => {
|
|
expect(markUp("bill", "unbilled usage")).toBe("un[bill]ed usage");
|
|
});
|
|
|
|
it("marks the exact text when the whole field is the query", () => {
|
|
expect(markUp("main", "main")).toBe("[main]");
|
|
});
|
|
|
|
it("marks the scattered characters a subsequence walked", () => {
|
|
expect(markUp("confg", "configuration")).toBe("[conf]i[g]uration");
|
|
});
|
|
|
|
it("highlights only the word selected after an earlier partial subsequence", () => {
|
|
expect(markUp("confg", "check configuration")).toBe("check [conf]i[g]uration");
|
|
});
|
|
|
|
it("marks the whole word a typo resolved to", () => {
|
|
// The characters the user got wrong are not in the text to point at, so
|
|
// the word is the smallest honest thing to mark.
|
|
expect(markUp("bulling", "add stripe billing", { fuzzy: fuzzyPolicyForToken("bulling") })).toBe(
|
|
"add stripe [billing]",
|
|
);
|
|
});
|
|
|
|
it("marks a transposed short token", () => {
|
|
expect(markUp("mian", "switch to main", { fuzzy: fuzzyPolicyForToken("mian") })).toBe(
|
|
"switch to [main]",
|
|
);
|
|
});
|
|
|
|
it("returns nothing for an empty query", () => {
|
|
expect(matchRanges("", "anything", { tier: 0, offset: 0 })).toEqual([]);
|
|
});
|
|
});
|