diff --git a/frontend/src/test/antispamMenu.test.ts b/frontend/src/test/antispamMenu.test.ts new file mode 100644 index 0000000..f5558e7 --- /dev/null +++ b/frontend/src/test/antispamMenu.test.ts @@ -0,0 +1,57 @@ +// The antispam plugin's Actions-menu bundle (plugins/webmail_plugin_antispam). +// Plugin bundles are plain ESM with a documented mount contract, so they can be +// driven directly. This one earns a test: its decision table is small but has +// been wrong twice, most recently hiding "Mark as not spam" on exactly the +// message a user needs it for — one they had marked as spam by mistake. + +import { describe, expect, it } from "vitest"; + +// Plugin bundles are untyped plain ESM by contract (docs/plugins.md §8) and +// live outside the frontend package, so there is no declaration to find. +// @ts-expect-error - untyped plugin bundle +import mount from "../../../plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js"; + +function ctxWith(special_use: string | null, learned: string | null) { + return { + api: { + pluginFetch: async () => ({ + message_id: "m1", + special_use, + verdict: learned ? { learned } : null, + }), + }, + }; +} + +async function render(special_use: string | null, learned: string | null): Promise { + const el = document.createElement("div"); + mount(el, ctxWith(special_use, learned), { messageId: "m1" }); + await new Promise((r) => setTimeout(r, 0)); // the bundle fetches before rendering + return el.textContent ?? ""; +} + +describe("antispam Actions-menu item", () => { + it("offers Mark as Spam on untouched inbox mail", async () => { + expect(await render("inbox", null)).toBe("Mark as Spam"); + }); + + it("offers Mark as not spam in the spam folder", async () => { + expect(await render("junk", null)).toBe("Mark as not spam"); + }); + + it("still offers Mark as not spam for a message the user marked as spam", async () => { + // The regression: a message in the spam folder that was taught "spam" is + // precisely the one someone needs to correct, so the action must stay. + expect(await render("junk", "spam")).toBe("Mark as not spam"); + }); + + it("suppresses only the lesson already taught", async () => { + expect(await render("inbox", "spam")).toBe("Marked as spam"); + }); + + it("shows nothing where no move is a verdict about spam", async () => { + for (const folder of ["sent", "drafts", "trash", null]) { + expect(await render(folder, null)).toBe(""); + } + }); +}); diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js index 70ec52f..7faec9a 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js @@ -25,18 +25,21 @@ export default function mount(el, ctx, props) { } if (disposed || !data) return; - // Already taught: show it, greyed, rather than offering the same lesson. + // The folder decides which action is on offer — FIRST. A message sitting in + // the spam folder offers "not spam" no matter what it was taught, because + // that is exactly the message someone needs to correct. + const action = MEANINGFUL[data.special_use]; + if (!action) return; + + // Only suppress the lesson we already taught, never its opposite. const learned = data.verdict && data.verdict.learned; - if (learned) { + if (learned === action.verdict) { el.innerHTML = ``; return; } - const action = MEANINGFUL[data.special_use]; - if (!action) return; - el.innerHTML = ``; el.querySelector("button").addEventListener("click", async () => { try {