Files
govoplan-campaign/webui/tests/aggregate-report-ui-structure.test.mjs

77 lines
5.3 KiB
JavaScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const api = readFileSync("src/api/campaigns.ts", "utf8");
const page = readFileSync("src/features/reports/AggregateReportsPage.tsx", "utf8");
const reportPage = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8");
const reviewPage = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8");
const operatorPage = readFileSync("src/features/operator/OperatorQueuePage.tsx", "utf8");
const deliveryMode = readFileSync("src/features/campaigns/utils/deliveryMode.ts", "utf8");
const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8");
assert(api.includes('"/api/v1/campaigns/aggregate-reports"'), "the report list uses the safe aggregate collection");
assert(api.includes("/api/v1/campaigns/aggregate-reports/${encodeURIComponent(campaignId)}"), "the selected report uses the safe aggregate projection");
assert(page.includes("listAggregateReportCampaigns"), "the page loads only the safe Campaign selector projection");
assert(page.includes("getAggregateCampaignReport"), "the page loads only the safe aggregate report projection");
assert(!page.includes("getCampaignReport"), "the aggregate page cannot load the recipient-aware full report");
assert(!page.includes("getCampaignJobs"), "the aggregate page cannot cache recipient jobs");
assert(!page.includes("getCampaignJobDetail"), "the aggregate page has no detail route");
assert(!page.includes("downloadCampaignJobsCsv"), "the aggregate page has no export path");
assert(!page.includes("localStorage"), "the aggregate page does not persist report data in local storage");
assert(!page.includes("sessionStorage"), "the aggregate page does not persist report data in session storage");
assert(page.includes("TableActionGroup"), "campaign selection uses the central icon-only table action group");
assert(page.includes("disabled: campaign.id === selectedFromUrl"), "the selected row action stays visible and disabled");
assert(page.includes('columnType: "from-list"'), "campaign status uses the stable shared list-filter model");
const expectedCampaignStatuses = [
"draft",
"validated",
"needs_review",
"ready_to_queue",
"queued",
"sending",
"sent",
"partially_completed",
"outcome_unknown",
"failed",
"cancelled",
"archived"
];
for (const status of expectedCampaignStatuses) {
assert(page.includes(`{ value: "${status}", label:`), `the aggregate list declares the ${status} status explicitly`);
}
const outcomeCardsStart = page.indexOf("Aggregate outcome cards are deliberately not filter shortcuts");
const outcomeCardsEnd = page.indexOf('<Card title="i18n:govoplan-campaign.population_and_exclusions', outcomeCardsStart);
assert(outcomeCardsStart >= 0 && outcomeCardsEnd > outcomeCardsStart, "the aggregate privacy boundary is documented beside the outcome cards");
assert(!page.slice(outcomeCardsStart, outcomeCardsEnd).includes("onClick"), "aggregate outcome cards cannot create unsuppressed filtered subgroups");
assert(!page.includes("{report.population.denominator_definition}"), "the known denominator contract uses a localized UI explanation");
assert(!page.includes("{report.privacy.rule}"), "the known privacy contract uses a localized UI explanation");
assert(page.includes("all_persisted_recipient_delivery_jobs_for_the_se.208e90cc"), "the denominator explanation is bilingual");
assert(page.includes("positive_counts_below_the_threshold_are_hidden_a.1dc97093"), "the suppression rule is bilingual");
const touchedSources = [page, reportPage, reviewPage, operatorPage, deliveryMode].join("\n");
const touchedKeys = new Set(touchedSources.match(/i18n:govoplan-campaign\.[a-z0-9_]+\.[a-f0-9]{8}/g) ?? []);
for (const key of touchedKeys) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const catalogEntries = [...translationCatalog.matchAll(new RegExp(`"${escapedKey}":\\s*("(?:[^"\\\\]|\\\\.)*")`, "g"))];
assert.equal(catalogEntries.length, 2, `${key} is present exactly once in both language catalogs`);
const english = JSON.parse(catalogEntries[0][1]);
const german = JSON.parse(catalogEntries[1][1]);
assert.notEqual(english.trim(), "", `${key} has a non-empty English translation`);
assert.notEqual(german.trim(), "", `${key} has a non-empty German translation`);
}
const sliceSections = [...translationCatalog.matchAll(/ "i18n:govoplan-campaign\.campaign_reports_available_to_you\.f14fa403":[\s\S]*? "i18n:govoplan-campaign\.worker_queue_committed_value0_message_s_and_publ\.24400d3c":.*\n/g)];
assert.equal(sliceSections.length, 2, "the current slice has one contiguous English and German catalog section");
const newEnglishEntries = [...sliceSections[0][0].matchAll(/"(i18n:govoplan-campaign\.[a-z0-9_]+\.[a-f0-9]{8})":\s*("(?:[^"\\]|\\.)*")/g)];
for (const [, key, encodedEnglish] of newEnglishEntries) {
const english = JSON.parse(encodedEnglish);
const digest = createHash("sha1").update(english).digest("hex").slice(0, 8);
const slug = english.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+/, "").slice(0, 48);
assert.equal(key, `i18n:govoplan-campaign.${slug}.${digest}`, `${key} uses the normalized source slug and exact SHA-1 suffix`);
assert(touchedKeys.has(key), `${key} is referenced by the current Campaign UI slices`);
}
console.log("aggregate report UI structure checks passed");