lib/assistant-context.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import {
applyContextSelection,
contextChips,
SELECTED_ITEMS_CHIP_KEY,
SELECTION_CHIP_KEY,
} from "./assistant-context";
import { MAX_SELECTION_LENGTH, normalizeSelectionText } from "./assistant-selection";
import type { AssistantPageContext } from "./assistant-types";
/**
* Numo's context seen as pills, and the REVERSE path: turn off
* the eye of a pill must remove from the sent context exactly the fields
* that it represented.
*
* This reverse path can break silently. Adding a context kind requires both a
* chip and its field-table entry. Adding only the chip makes the interface say
* Numo ignored the context even though the request still carries it. The final
* test protects every current and future context kind from that drift.
*/
// The real translator brings nothing here: what is verified is the PATH of a
// field into its chip, not the sentence rendered inside it.
const t = ((key: string) => key) as unknown as Parameters<
typeof contextChips
>[1]["t"];
/** A context that carries one chip of every ambient kind. */
const everything: AssistantPageContext = {
projectId: "p1",
inbox: true,
settings: "project",
issueId: "i1",
issueIdentifier: "MIN-42",
issueTitle: "Un ticket",
pullRequestId: "pr1",
prNumber: 42,
prState: "open",
prHeadRef: "feature/min-42",
prBaseRef: "main",
prRunId: "run1",
objectiveId: "o1",
objectiveName: "Un objectif",
feedbackId: "f1",
feedbackTitle: "Un retour",
routineId: "r1",
routineTitle: "Audit sécurité",
viewId: "v1",
viewName: "Une vue",
cycleId: "c1",
cycleLabel: "6–19 juil",
selection: { text: "A passage selected in the page" },
selectedItems: [
{ kind: "page", id: "page-1", label: "Spec" },
{ kind: "page", id: "page-2", label: "Roadmap" },
],
};
describe("contextChips — routine", () => {
it("uses the open routine title", () => {
const chips = contextChips(
{ projectId: "p1", routineId: "r1", routineTitle: "Audit sécurité" },
{ t },
);
const routine = chips.find((c) => c.key === "routine");
expect(routine).toMatchObject({ kind: "routine", label: "Audit sécurité" });
});
it("falls back to a generic label when the title is missing", () => {
const chips = contextChips({ routineId: "r1" }, { t });
expect(chips.find((c) => c.key === "routine")?.label).toBe(
"contextRoutine",
);
});
it("does not add a routine chip without an open routine", () => {
const chips = contextChips({ projectId: "p1" }, { t });
expect(chips.some((c) => c.key === "routine")).toBe(false);
});
});
describe("admin dashboard pill (MIN-829)", () => {
it("names the admin tab the assistant was opened from", () => {
const chips = contextChips({ admin: "models" }, { t });
expect(chips.find((c) => c.key === "admin")).toMatchObject({ kind: "admin", label: "contextAdminModels" });
});
it("drops the admin context when its pill is turned off", () => {
const sent = applyContextSelection({ admin: "costs", projectId: "p1" }, new Set(["admin"]));
expect(sent?.admin).toBeUndefined();
expect(sent?.projectId).toBe("p1");
});
});
describe("applyContextSelection", () => {
it("removes both the routine id and title", () => {
const sent = applyContextSelection(everything, new Set(["routine"]));
expect(sent?.routineId).toBeUndefined();
expect(sent?.routineTitle).toBeUndefined();
// The rest does not change: one extinguished pill does not extinguish others.
expect(sent?.feedbackId).toBe("f1");
expect(sent?.issueId).toBe("i1");
});
it("keeps the routine while its pill is lit", () => {
const sent = applyContextSelection(everything, new Set(["feedback"]));
expect(sent?.routineId).toBe("r1");
});
it("removes every selected pull request field together", () => {
const sent = applyContextSelection(
everything,
new Set(["pull_request"]),
);
expect(sent?.pullRequestId).toBeUndefined();
expect(sent?.prNumber).toBeUndefined();
expect(sent?.prState).toBeUndefined();
expect(sent?.prHeadRef).toBeUndefined();
expect(sent?.prBaseRef).toBeUndefined();
expect(sent?.prRunId).toBeUndefined();
expect(sent?.issueId).toBe("i1");
});
/**
* The safeguard: EACH ambient pill must have something to extinguish. A
* nature added without its entry in the table would pass all the
* tests above and fail here.
*/
it("actually turns off each pill in a complete context", () => {
for (const chip of contextChips(everything, { t })) {
const sent = applyContextSelection(everything, new Set([chip.key]));
const before = Object.keys(everything).length;
const after = Object.keys(sent ?? {}).filter(
(k) => (sent as Record<string, unknown>)[k] !== undefined,
).length;
expect(
after,
`the “${chip.key}” chip does not remove a field from the sent context`,
).toBeLessThan(before);
}
});
});
describe("contextChips — inbox", () => {
it("renders and removes the ambient inbox context", () => {
const chips = contextChips({ inbox: true }, { t });
expect(chips).toContainEqual(
expect.objectContaining({
key: "inbox",
kind: "inbox",
label: "contextInbox",
}),
);
expect(
applyContextSelection({ inbox: true }, new Set(["inbox"])),
).toBeNull();
});
});
describe("contextChips — settings", () => {
it("distinguishes account settings from project settings", () => {
expect(contextChips({ settings: "account" }, { t })).toContainEqual(
expect.objectContaining({
key: "settings",
kind: "settings",
label: "contextAccountSettings",
}),
);
expect(
contextChips({ projectId: "p1", settings: "project" }, { t }),
).toContainEqual(
expect.objectContaining({
key: "settings",
kind: "settings",
label: "contextProjectSettings",
}),
);
});
it("removes the settings surface when its chip is disabled", () => {
expect(
applyContextSelection({ settings: "account" }, new Set(["settings"])),
).toBeNull();
});
});
describe("contextChips — page icons", () => {
it("uses the ambient page emoji when one is available", () => {
const chips = contextChips(
{
projectId: "p1",
pageId: "page-1",
pageTitle: "Launch guide",
pageIcon: "🚀",
},
{ t },
);
expect(chips.find((chip) => chip.kind === "page")).toMatchObject({
label: "Launch guide",
icon: "🚀",
});
});
it("preserves the emoji of a manually pinned page", () => {
const chips = contextChips(
{
pinned: [
{ kind: "page", id: "page-1", label: "Launch guide", icon: "🚀" },
],
},
{ t },
);
expect(chips.find((chip) => chip.kind === "page")).toMatchObject({
label: "Launch guide",
icon: "🚀",
pinned: true,
});
});
});
describe("contextChips — quoted selection", () => {
// Renders the values too: the excerpt is the point of this pill.
const render = ((key: string, values?: Record<string, string>) =>
`${key}:${values?.excerpt ?? values?.text ?? ""}`) as unknown as Parameters<
typeof contextChips
>[1]["t"];
it("labels the pill with a one-line excerpt and keeps the passage in its tooltip", () => {
const text = "First line of a long passage\nthat continues well beyond the excerpt length";
const chip = contextChips(
{ pageId: "page-1", pageTitle: "Spec", selection: { text } },
{ t: render },
).find((item) => item.key === SELECTION_CHIP_KEY);
expect(chip).toMatchObject({ kind: "selection", removable: true });
expect(chip?.pinned).toBeUndefined();
expect(chip?.label).toBe("contextSelection:First line of a long passage that contin…");
expect(chip?.tooltip).toBe(`contextSelectionTooltip:${text}`);
});
it("removes only the selection and keeps the page it was taken from", () => {
const ctx: AssistantPageContext = {
pageId: "page-1",
pageTitle: "Spec",
selection: { text: "Passage" },
};
expect(applyContextSelection(ctx, new Set([SELECTION_CHIP_KEY]))).toEqual({
pageId: "page-1",
pageTitle: "Spec",
});
});
it("shows no pill for a blank selection", () => {
const chips = contextChips({ selection: { text: " " } }, { t });
expect(chips.some((chip) => chip.key === SELECTION_CHIP_KEY)).toBe(false);
});
});
describe("selected records pill (MIN-873)", () => {
it("counts the selection on the pill and lists the titles in its tooltip", () => {
const render = ((key: string, values?: { count?: number }) =>
`${key}:${values?.count ?? ""}`) as unknown as Parameters<typeof contextChips>[1]["t"];
const chip = contextChips(
{
selectedItems: [
{ kind: "feedback", id: "f1", label: "Dark mode" },
{ kind: "feedback", id: "f2", label: "Export" },
],
},
{ t: render },
).find((item) => item.key === SELECTED_ITEMS_CHIP_KEY);
expect(chip).toMatchObject({
kind: "feedback",
label: "contextFeedbackSelected:2",
tooltip: "Dark mode, Export",
});
});
it("turning the pill off sends none of the selected records", () => {
const sent = applyContextSelection(
{ projectId: "p1", selectedItems: [{ kind: "routine", id: "r1", label: "Digest" }] },
new Set([SELECTED_ITEMS_CHIP_KEY]),
);
expect(sent).toEqual({ projectId: "p1" });
});
});
describe("normalizeSelectionText", () => {
it("caps a long passage with an ellipsis and keeps its line breaks", () => {
const text = normalizeSelectionText(` a\r\nb\u0007 ${"x".repeat(MAX_SELECTION_LENGTH)}`);
expect(text?.startsWith("a\nb ")).toBe(true);
expect(text?.endsWith("…")).toBe(true);
expect(text!.length).toBe(MAX_SELECTION_LENGTH + 1);
});
it("rejects what is not a non-blank string", () => {
expect(normalizeSelectionText(42)).toBeNull();
expect(normalizeSelectionText(" \n ")).toBeNull();
});
});