import { describe, expect, it } from "vitest";
import {
accept,
activeState,
labelQuery,
queryFromUrl,
quoteValue,
resolveAuthors,
statesOf,
suggest,
tokenize,
valueProblem,
withoutStates,
withState
} from "#lib/ticketQuery.ts";
const kinds = (q: string) =>
tokenize(q).map((t) =>
t.kind === "qualifier"
? `${t.negated ? "-" : ""}${t.key}:${t.values.map((v) => v.text).join(",")}`
: `${t.negated ? "-" : ""}${t.kind}(${t.text})`
);
describe("tokenize", () => {
it("splits words, phrases and qualifiers like bobbin's lexer", () => {
expect(kinds(`kelp "sea urchin" -squid is:open,merged label:"good first issue"`)).toEqual([
"word(kelp)",
"phrase(sea urchin)",
"-word(squid)",
"is:open,merged",
"label:good first issue"
]);
});
it("keeps spans that cover the source", () => {
const q = ` kelp -is:open "a b"`;
expect(tokenize(q).map((t) => q.slice(t.start, t.end))).toEqual([
"kelp",
"-is:open",
`"a b"`
]);
});
it("leaves out keys the list pages do not need", () => {
for (const q of ["type:pull", "created:>=2026-01-01", "in:title"]) {
const [token] = tokenize(q);
expect(token, q).toMatchObject({ kind: "qualifier", known: false });
}
expect(suggest("ty", 2)).toBeNull();
expect(suggest("i", 1)!.items.map((i) => i.label)).toEqual(["is:"]);
});
it("treats a lone dash, colons in values and unknown keys like bobbin", () => {
expect(kinds("- author:did:plc:x https://x.y")).toEqual([
"word(-)",
"author:did:plc:x",
"https://x.y"
]);
const [unknown] = tokenize("foo:bar");
expect(unknown).toMatchObject({ kind: "qualifier", known: false });
});
it("tolerates unterminated phrases and escapes", () => {
expect(kinds(`"say \\"hi\\"" "open ended`)).toEqual([
`phrase(say "hi")`,
"phrase(open ended)"
]);
});
it("keeps empty value slots so they can be flagged", () => {
const [token] = tokenize("label:a,,b");
expect(token.kind === "qualifier" && token.values.map((v) => v.text)).toEqual([
"a",
"",
"b"
]);
});
});
describe("valueProblem", () => {
it("accepts what bobbin accepts", () => {
for (const [key, value] of [
["is", "merged"],
["sort", "oldest"],
["label", "anything goes"]
] as const)
expect(valueProblem(key, value), `${key}:${value}`).toBeNull();
});
it("flags what bobbin rejects", () => {
for (const [key, value] of [
["is", "nope"],
["is", "issue"],
["sort", "sideways"],
["label", ""]
] as const)
expect(valueProblem(key, value), `${key}:${value}`).not.toBeNull();
});
});
describe("state tabs", () => {
it("reads the state the query narrows to", () => {
expect(statesOf("kelp")).toBeNull();
expect(activeState("is:open kelp")).toBe("open");
expect(activeState("is:merged")).toBe("completed");
expect(activeState("is:open,closed")).toBeNull();
expect(activeState("-is:open")).toBeNull();
expect(activeState("type:issue")).toBeNull();
});
it("rewrites only the state clauses", () => {
expect(withState("is:open kelp label:bug", "closed")).toBe("is:closed kelp label:bug");
expect(withState("kelp", "completed", true)).toBe("is:merged kelp");
expect(withState("is:open,closed -is:open kelp", "open")).toBe("is:open kelp");
expect(withoutStates("is:open")).toBe("");
expect(withState("", "open")).toBe("is:open");
});
it("leaves other qualifiers alone", () => {
expect(withState("type:issue kelp", "closed")).toBe("is:closed type:issue kelp");
});
});
describe("urls", () => {
const at = (search: string) => new URL(`http://web.test/a/b/issues${search}`);
it("defaults to open and honours old links", () => {
expect(queryFromUrl(at(""))).toBe("is:open");
expect(queryFromUrl(at("?state=closed"))).toBe("is:closed");
expect(queryFromUrl(at("?label=good%20first%20issue"))).toBe(
`is:open label:"good first issue"`
);
expect(queryFromUrl(at("?q=%20kelp%20"))).toBe("kelp");
expect(queryFromUrl(at("?q="))).toBe("");
});
it("quotes values the lexer would split", () => {
expect(quoteValue("bug")).toBe("bug");
expect(quoteValue("good first issue")).toBe(`"good first issue"`);
expect(quoteValue(`a"b`)).toBe(`"a\\"b"`);
expect(quoteValue("a,b")).toBe(`"a,b"`);
expect(labelQuery("needs review")).toBe(`is:open label:"needs review"`);
});
});
describe("suggest", () => {
it("offers every key in empty space", () => {
const s = suggest("kelp ", 5)!;
expect(s.items.map((i) => i.label)).toContain("label:");
expect([s.from, s.to]).toEqual([5, 5]);
});
it("completes a key prefix", () => {
const s = suggest("kelp la", 7)!;
expect(s.items.map((i) => i.insert)).toEqual(["label:"]);
expect(accept("kelp la", s, s.items[0])).toEqual({ query: "kelp label:", caret: 11 });
});
it("completes a negated key prefix after the dash", () => {
const s = suggest("-la", 3)!;
expect(s.from).toBe(1);
expect(accept("-la", s, s.items[0]).query).toBe("-label:");
});
it("offers state values, merged only on pulls", () => {
expect(suggest("is:", 3)!.items.map((i) => i.label)).toEqual(["open", "closed"]);
expect(suggest("is:", 3, { isPull: true })!.items.map((i) => i.label)).toEqual([
"open",
"merged",
"closed"
]);
expect(suggest("is:cl", 5)!.items.map((i) => i.label)).toEqual(["closed"]);
});
it("quotes label names and skips ones already listed", () => {
const labels = ["bug", "good first issue"];
const s = suggest("label:bug,", 10, { labels })!;
expect(s.items.map((i) => i.insert)).toEqual([`"good first issue"`]);
expect(accept("label:bug,", s, s.items[0])).toEqual({
query: `label:bug,"good first issue" `,
caret: 29
});
});
it("spaces off the next token when completing mid-query", () => {
const s = suggest("is:o kelp", 4)!;
expect(accept("is:o kelp", s, s.items[0]).query).toBe("is:open kelp");
});
it("stays quiet inside phrases, unknown keys and exact matches", () => {
expect(suggest(`"kelp`, 3)).toBeNull();
expect(suggest("foo:b", 5)).toBeNull();
expect(suggest("is:open", 7)).toBeNull();
expect(suggest("is", 2)!.items.map((i) => i.label)).toEqual(["is:"]);
});
});
describe("resolveAuthors", () => {
const dids: Record<string, string> = { "alice.tngl.sh": "did:plc:alice" };
const resolve = async (handle: string) => dids[handle];
it("swaps handles for dids and keeps the rest", async () => {
expect(
await resolveAuthors("kelp author:@alice.tngl.sh,did:plc:bob is:open", resolve)
).toEqual({
query: "kelp author:did:plc:alice,did:plc:bob is:open",
unresolved: []
});
});
it("reports handles that do not resolve", async () => {
expect(await resolveAuthors("-author:ghost.tngl.sh", resolve)).toEqual({
query: "-author:ghost.tngl.sh",
unresolved: ["ghost.tngl.sh"]
});
});
});