diff --git a/docs/moderation/fixtures/index.html b/docs/moderation/fixtures/index.html
new file mode 100644
index 00000000..c5d151d5
--- /dev/null
+++ b/docs/moderation/fixtures/index.html
@@ -0,0 +1,317 @@
+
+
+
+
+ Moderation Fixtures • bitvid
+
+
+
+
+
+
+
+ Moderation Test Fixtures
+
+ These scenarios mirror the QA vectors in docs/moderation/testing.md so
+ automated checks can assert blur thresholds, autoplay blocking, and local
+ override persistence.
+
+
+
+
+
diff --git a/docs/moderation/testing.md b/docs/moderation/testing.md
index ebf42d84..baca76e6 100644
--- a/docs/moderation/testing.md
+++ b/docs/moderation/testing.md
@@ -2,15 +2,23 @@
## Quick checklist
- [ ] F1 set cached and used for “trusted reports”.
+ - Automated coverage: `tests/moderation/trusted-report-count.test.mjs` (run via `npm run test:unit`).
- [ ] Blur at ≥3 F1 reports of `nudity`; autoplay blocked at ≥2.
+ - Automated coverage: `tests/visual/moderation.spec.ts` blur and autoplay scenarios (run via `npm run test:visual`).
- [ ] “Show anyway” works and records only local pref.
+ - Automated coverage: `tests/visual/moderation.spec.ts` show-anyway persistence scenario (run via `npm run test:visual`).
- [ ] Mute list (10000) downranks/hides consistently.
+ - Manual: subscribe to fixture B and verify muted author disappears from feeds after refresh.
- [ ] Admin lists (30000) take effect only when subscribed.
+ - Manual: load fixture C, toggle subscription, and confirm the hard-hide only applies while subscribed.
- [ ] COUNT fallback shows placeholders and doesn’t crash UI.
+ - Manual: point the client at a relay without COUNT support and confirm placeholders render instead of errors.
- [ ] Comment threads render with NIP-10; moderation badges show in context.
+ - Manual: open a reported thread and confirm badges + reply ordering remain intact.
- [ ] Reports from muted reporters are ignored.
+ - Automated coverage: `tests/moderation/trusted-report-count.test.mjs` (muted reporter case).
-## Fixtures (create on test relays)
+## Fixtures (create on test relays / served in `/docs/moderation/fixtures/`)
### A) NIP-56 report events
Create three distinct F1 reporter keys; each sends:
@@ -66,6 +74,7 @@ Expected: when viewer subscribes to this list, author is hard-hidden.
## Automated checks (suggested)
-* Unit test `trustedReportCount`.
+* `npm run test:unit` — covers `tests/moderation/trusted-report-count.test.mjs` for deduping and mute handling.
+* `npm run test:visual` — runs Playwright fixtures in `tests/visual/moderation.spec.ts` against `/docs/moderation/fixtures/index.html`.
* Integration test: emit events over a local relay; assert UI state.
* Regression test: ensure Home feed ignores reputation gating.
diff --git a/tests/moderation/trusted-report-count.test.mjs b/tests/moderation/trusted-report-count.test.mjs
new file mode 100644
index 00000000..6f068ce0
--- /dev/null
+++ b/tests/moderation/trusted-report-count.test.mjs
@@ -0,0 +1,154 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { ModerationService } from "../../js/services/moderationService.js";
+
+const nip19 = {
+ npubEncode(hex) {
+ if (typeof hex !== "string" || !hex) {
+ throw new Error("invalid hex");
+ }
+ return `npub${hex}`;
+ },
+ decode(value) {
+ if (typeof value !== "string" || !value.startsWith("npub")) {
+ throw new Error("invalid npub");
+ }
+ return { type: "npub", data: value.slice(4) };
+ },
+};
+
+function withMockedNostrTools(t) {
+ const hadWindow = typeof globalThis.window !== "undefined";
+ const previousWindow = hadWindow ? globalThis.window : undefined;
+ if (!hadWindow) {
+ globalThis.window = {};
+ }
+ const previousGlobalTools = globalThis.NostrTools;
+ const previousWindowTools = globalThis.window?.NostrTools;
+
+ globalThis.NostrTools = { nip19 };
+ if (globalThis.window) {
+ globalThis.window.NostrTools = globalThis.NostrTools;
+ }
+
+ t.after(() => {
+ if (typeof previousGlobalTools === "undefined") {
+ delete globalThis.NostrTools;
+ } else {
+ globalThis.NostrTools = previousGlobalTools;
+ }
+
+ if (globalThis.window) {
+ if (typeof previousWindowTools === "undefined") {
+ delete globalThis.window.NostrTools;
+ } else {
+ globalThis.window.NostrTools = previousWindowTools;
+ }
+ }
+
+ if (!hadWindow) {
+ delete globalThis.window;
+ } else {
+ globalThis.window = previousWindow;
+ }
+ });
+}
+
+function createReport({ id, reporter, eventId, createdAt, type = "nudity" }) {
+ return {
+ kind: 1984,
+ id,
+ pubkey: reporter,
+ created_at: createdAt,
+ tags: [
+ ["e", eventId],
+ ["report", type],
+ ],
+ content: "fixture report",
+ };
+}
+
+test("trustedReportCount dedupes multiple reports from the same trusted reporter", (t) => {
+ withMockedNostrTools(t);
+
+ const eventId = "f".repeat(64);
+ const reporterHex = "a".repeat(64);
+
+ const service = new ModerationService({
+ logger: () => {},
+ userBlocks: {
+ async ensureLoaded() {},
+ isBlocked() {
+ return false;
+ },
+ },
+ });
+
+ service.trustedContacts = new Set([reporterHex]);
+
+ const firstReport = createReport({
+ id: "1".repeat(64),
+ reporter: reporterHex,
+ eventId,
+ createdAt: 1_700_000_000,
+ });
+
+ const duplicateReport = createReport({
+ id: "2".repeat(64),
+ reporter: reporterHex,
+ eventId,
+ createdAt: 1_700_000_500,
+ });
+
+ service.ingestReportEvent(firstReport);
+ service.ingestReportEvent(duplicateReport);
+
+ const summary = service.getTrustedReportSummary(eventId);
+ assert.equal(summary.types.nudity.total, 1);
+ assert.equal(summary.types.nudity.trusted, 1);
+ assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
+});
+
+test("trustedReportCount ignores reports from muted or blocked reporters", (t) => {
+ withMockedNostrTools(t);
+
+ const eventId = "e".repeat(64);
+ const mutedReporter = "b".repeat(64);
+ const trustedReporter = "c".repeat(64);
+
+ const service = new ModerationService({
+ logger: () => {},
+ userBlocks: {
+ async ensureLoaded() {},
+ isBlocked(pubkey) {
+ return pubkey === mutedReporter;
+ },
+ },
+ });
+
+ service.trustedContacts = new Set([mutedReporter, trustedReporter]);
+
+ const mutedReport = createReport({
+ id: "3".repeat(64),
+ reporter: mutedReporter,
+ eventId,
+ createdAt: 1_700_000_100,
+ });
+
+ const trustedReport = createReport({
+ id: "4".repeat(64),
+ reporter: trustedReporter,
+ eventId,
+ createdAt: 1_700_000_200,
+ });
+
+ service.ingestReportEvent(mutedReport);
+ service.ingestReportEvent(trustedReport);
+
+ const summary = service.getTrustedReportSummary(eventId);
+ assert.equal(summary.types.nudity.total, 1);
+ assert.equal(summary.types.nudity.trusted, 1);
+ assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
+ const reporters = service.getTrustedReporters(eventId, "nudity");
+ assert.deepEqual(reporters.map((entry) => entry.pubkey), [trustedReporter]);
+});
diff --git a/tests/visual/moderation.spec.ts b/tests/visual/moderation.spec.ts
new file mode 100644
index 00000000..450f6ddc
--- /dev/null
+++ b/tests/visual/moderation.spec.ts
@@ -0,0 +1,100 @@
+import { expect, test } from "@playwright/test";
+
+const FIXTURE_URL = "/docs/moderation/fixtures/index.html";
+const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
+const SENTINEL_KEY = "__moderationFixturesInit__";
+
+function setupStorageReset(page) {
+ return page.addInitScript(({ storageKey, sentinelKey }) => {
+ try {
+ const session = window.sessionStorage;
+ const local = window.localStorage;
+ if (session && !session.getItem(sentinelKey)) {
+ local?.removeItem(storageKey);
+ session.setItem(sentinelKey, "1");
+ }
+ } catch {
+ /* ignore reset failures in tests */
+ }
+ }, { storageKey: STORAGE_KEY, sentinelKey: SENTINEL_KEY });
+}
+
+test.beforeEach(async ({ page }) => {
+ await setupStorageReset(page);
+});
+
+test.afterEach(async ({ page }) => {
+ try {
+ await page.evaluate(({ key, sentinel }) => {
+ window.localStorage?.removeItem(key);
+ window.sessionStorage?.removeItem(sentinel);
+ }, { key: STORAGE_KEY, sentinel: SENTINEL_KEY });
+ } catch {
+ // ignore cleanup errors when page already closed
+ }
+});
+
+test.describe("moderation fixtures", () => {
+ test("blur fixture masks thumbnails and blocks autoplay", async ({ page }) => {
+ await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
+
+ const blurCard = page.locator('[data-test-id="blur-threshold"]');
+ const thumbnail = blurCard.locator('img[data-video-thumbnail]');
+
+ await expect(blurCard).toHaveAttribute("data-moderation-report-count", "3");
+ await expect(blurCard).toHaveAttribute("data-autoplay-policy", "blocked");
+ await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
+ await expect(blurCard).not.toHaveAttribute(
+ "data-moderation-override",
+ "show-anyway"
+ );
+ });
+
+ test("autoplay fixture disables preview without blurring", async ({ page }) => {
+ await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
+
+ const autoplayCard = page.locator('[data-test-id="autoplay-threshold"]');
+ const thumbnail = autoplayCard.locator('img[data-video-thumbnail]');
+
+ await expect(autoplayCard).toHaveAttribute("data-moderation-report-count", "2");
+ await expect(autoplayCard).toHaveAttribute("data-autoplay-policy", "blocked");
+ await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
+ });
+
+ test("show anyway override persists across reloads", async ({ page }) => {
+ await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
+
+ const overrideCard = page.locator('[data-test-id="show-anyway"]');
+ const thumbnail = overrideCard.locator('img[data-video-thumbnail]');
+ const showAnywayButton = overrideCard.getByRole("button", { name: "Show anyway" });
+
+ await expect(overrideCard).toHaveAttribute("data-autoplay-policy", "blocked");
+ await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
+ await expect(showAnywayButton).toBeVisible();
+
+ await showAnywayButton.click();
+
+ await expect(overrideCard).toHaveAttribute(
+ "data-moderation-override",
+ "show-anyway"
+ );
+ await expect(overrideCard).not.toHaveAttribute("data-autoplay-policy", "blocked");
+ await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
+ await expect(overrideCard.getByRole("button", { name: "Show anyway" })).toHaveCount(0);
+
+ await page.reload({ waitUntil: "networkidle" });
+
+ const reloadedCard = page.locator('[data-test-id="show-anyway"]');
+ const reloadedThumbnail = reloadedCard.locator('img[data-video-thumbnail]');
+
+ await expect(reloadedCard).toHaveAttribute(
+ "data-moderation-override",
+ "show-anyway"
+ );
+ await expect(reloadedCard).not.toHaveAttribute("data-autoplay-policy", "blocked");
+ await expect(reloadedThumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
+ await expect(
+ reloadedCard.getByRole("button", { name: "Show anyway" })
+ ).toHaveCount(0);
+ });
+});