mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Give every notification event type words to render
The matrix derives each row's label from the event name, so a new event type gets a row whether or not anybody wrote the strings for it. That is how SECURITY_ALERT has been rendering its own raw key since v1.23: the forward i18n coverage test only sees literal call sites, and this key is assembled from a runtime value. Move the derivation out of the page so a test can compute the keys the page computes, assert both resolve in all six bundles, and name the missing one. Write the sign-in row's strings, and the strings for the delegated-dose row and its push body.
This commit is contained in:
1 parent
427e25d1ae
commit
696b4d7d1c
9 files changed
+202
-13
No files matched your search
@@ -15,6 +15,10 @@ import {
|
||||
EVENT_DEFAULT_ENABLED,
|
||||
type EventType,
|
||||
} from "@/lib/notifications/types";
|
||||
import {
|
||||
eventDescriptionTranslationKey,
|
||||
eventTranslationKey,
|
||||
} from "@/lib/notifications/event-labels";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -38,17 +42,6 @@ interface PreferencesData {
|
||||
eventTypes: string[];
|
||||
}
|
||||
|
||||
/** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */
|
||||
function eventTranslationKey(eventType: string): string {
|
||||
return (
|
||||
"notifications.event" +
|
||||
eventType
|
||||
.toLowerCase()
|
||||
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -265,7 +258,7 @@ export default function NotificationsPage() {
|
||||
<tbody>
|
||||
{eventTypes.map((eventType, idx) => {
|
||||
const nameKey = eventTranslationKey(eventType);
|
||||
const descKey = nameKey + "Desc";
|
||||
const descKey = eventDescriptionTranslationKey(eventType);
|
||||
return (
|
||||
<tr
|
||||
key={eventType}
|
||||
@@ -311,7 +304,7 @@ export default function NotificationsPage() {
|
||||
<div className="space-y-4 md:hidden">
|
||||
{eventTypes.map((eventType) => {
|
||||
const nameKey = eventTranslationKey(eventType);
|
||||
const descKey = nameKey + "Desc";
|
||||
const descKey = eventDescriptionTranslationKey(eventType);
|
||||
return (
|
||||
<div
|
||||
key={eventType}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Every event type the /notifications matrix renders has words to render.
|
||||
*
|
||||
* The matrix builds its rows from `EVENT_TYPES` and derives each row's label
|
||||
* and description key from the event name, so a new event type silently gets a
|
||||
* row whether or not anybody wrote the two strings for it. That is not a
|
||||
* theory: `SECURITY_ALERT` shipped in v1.23 and rendered the raw key
|
||||
* `notifications.eventSecurityAlert` in the matrix until v1.36.x, because
|
||||
* nothing anywhere asserted the pairing — the forward i18n coverage test only
|
||||
* sees literal `t("…")` call sites, and this key is assembled from a runtime
|
||||
* value.
|
||||
*
|
||||
* So this test walks the event list the page walks, computes the keys the page
|
||||
* computes, and asserts both resolve in every locale bundle.
|
||||
*
|
||||
* Mutation check: remove `notifications.eventSharedRecordIntakeDesc` from
|
||||
* `messages/pl.json` and this file reports
|
||||
* `pl is missing notifications.eventSharedRecordIntakeDesc`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import {
|
||||
eventDescriptionTranslationKey,
|
||||
eventTranslationKey,
|
||||
} from "@/lib/notifications/event-labels";
|
||||
|
||||
const MESSAGES_DIR = join(__dirname, "../../../../messages");
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolve(root: Record<string, unknown>, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
root,
|
||||
);
|
||||
}
|
||||
|
||||
const LOCALES = readdirSync(MESSAGES_DIR)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(/\.json$/, ""));
|
||||
|
||||
describe("notification event labels", () => {
|
||||
it("derives the key the matrix renders under", () => {
|
||||
expect(eventTranslationKey("MEDICATION_REMINDER")).toBe(
|
||||
"notifications.eventMedicationReminder",
|
||||
);
|
||||
expect(eventDescriptionTranslationKey("SHARED_RECORD_INTAKE")).toBe(
|
||||
"notifications.eventSharedRecordIntakeDesc",
|
||||
);
|
||||
});
|
||||
|
||||
it("has a label and a description for every event type, in every locale", () => {
|
||||
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
|
||||
expect(EVENT_TYPES.length).toBeGreaterThan(10);
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const locale of LOCALES) {
|
||||
const root = bundle(locale);
|
||||
for (const eventType of EVENT_TYPES) {
|
||||
for (const key of [
|
||||
eventTranslationKey(eventType),
|
||||
eventDescriptionTranslationKey(eventType),
|
||||
]) {
|
||||
const value = resolve(root, key);
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
missing.push(`${locale} is missing ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the delegated-dose push copy", () => {
|
||||
it("interpolates the same names in every locale", () => {
|
||||
const keys = [
|
||||
"notifications.sharedRecordIntake.title",
|
||||
"notifications.sharedRecordIntake.bodyTaken",
|
||||
"notifications.sharedRecordIntake.bodySkipped",
|
||||
];
|
||||
const placeholders: Record<string, string[]> = {
|
||||
"notifications.sharedRecordIntake.title": ["{name}"],
|
||||
"notifications.sharedRecordIntake.bodyTaken": ["{name}", "{medication}"],
|
||||
"notifications.sharedRecordIntake.bodySkipped": [
|
||||
"{name}",
|
||||
"{medication}",
|
||||
],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const root = bundle(locale);
|
||||
for (const key of keys) {
|
||||
const value = resolve(root, key);
|
||||
expect(typeof value, `${locale} ${key}`).toBe("string");
|
||||
for (const token of placeholders[key]) {
|
||||
expect(value as string, `${locale} ${key}`).toContain(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The i18n key an event type is rendered under.
|
||||
*
|
||||
* The /notifications matrix builds its rows from the event list the server
|
||||
* publishes, so the label for a new event type is never written at a call site
|
||||
* — it is derived here. Extracted from the page so the derivation can be
|
||||
* pinned by a test without rendering anything, and so the guard that asserts
|
||||
* every event type actually HAS its two strings (label + description, six
|
||||
* locales) computes the key exactly the way the page does rather than a way
|
||||
* that agrees with it today.
|
||||
*
|
||||
* `MEDICATION_REMINDER` → `notifications.eventMedicationReminder`; the
|
||||
* description key is that plus `Desc`.
|
||||
*/
|
||||
export function eventTranslationKey(eventType: string): string {
|
||||
return (
|
||||
"notifications.event" +
|
||||
eventType
|
||||
.toLowerCase()
|
||||
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/** The description key that sits under the label in the matrix row. */
|
||||
export function eventDescriptionTranslationKey(eventType: string): string {
|
||||
return `${eventTranslationKey(eventType)}Desc`;
|
||||
}
|
||||
Reference in new issue
Block a user