diff --git a/frontend/public/i18n/de.json b/frontend/public/i18n/de.json
index 1d8625caf..3408314a4 100644
--- a/frontend/public/i18n/de.json
+++ b/frontend/public/i18n/de.json
@@ -2,16 +2,21 @@
"PCTS_LOGO_ALT": "Puzzle PCTS Logo",
"LOGOUT": "Abmelden",
"HELP": "Hilfe",
+
"DEGREE": {
"MODEL_NAME": "Ausbildung",
- "NAME": "Bezeichnung"
+ "MODEL_NAME_PLURAL": "Ausbildungen",
+ "NAME": "Bezeichnung",
+ "DEGREE_TYPE_NAME": "Ausbildungsart"
},
"EXPERIENCE": {
"MODEL_NAME": "Berufs- und Lebenserfahrung",
+ "MODEL_NAME_PLURAL": "Berufs- und Lebenserfahrungen",
"COMMENT": "Beschreibung"
},
"CERTIFICATE": {
"MODEL_NAME": "Zertifikat",
+ "MODEL_NAME_PLURAL": "Zertifikate",
"COMPLETED_AT": "Abgeschlossen am",
"CERTIFICATE_TYPE": "Art des Zertifikats",
"VALID_UNTIL": "Gültig bis",
@@ -19,6 +24,7 @@
},
"LEADERSHIP_EXPERIENCE": {
"MODEL_NAME": "Führungserfahrung",
+ "MODEL_NAME_PLURAL": "Führungserfahrungen",
"LEADERSHIP_EXPERIENCE_TYPE": "Art der Führungserfahrung",
"COMMENT": "Beschreibung"
},
@@ -26,7 +32,14 @@
"MODEL_NAME": "Role"
},
"CALCULATION": {
- "POINTS": "Punkte"
+ "MODEL_NAME": "PCTS-Berechnung",
+ "POINTS": "PCTS-Punkte",
+ "STATE": "Status",
+ "ACTIVE": "Aktiv",
+ "DRAFT": "Entwurf",
+ "ARCHIVED": "Archiviert",
+ "PUBLICIZED_BY": "Veröffentlicht von",
+ "PUBLICATION_DATE": "Veröffentlicht am"
},
"FORM": {
@@ -90,37 +103,20 @@
"EX_MEMBER": "Ex-Member"
},
"CV": {
- "DEGREE": {
- "MODEL_NAME": "Ausbildung",
- "DEGREE_TYPE_NAME": "Ausbildungsart",
- "DATE_RANGE": "Zeitraum"
- },
- "EXPERIENCE": {
- "MODEL_NAME": "Berufs- und Lebenserfahrung",
+ "CUSTOM_TABLE_ATTRIBUTES": {
"DATE_RANGE": "Zeitraum",
- "WORK_NAME": "Position",
- "EXPERIENCE_TYPE_NAME": "Erfahrungsart"
- },
- "CERTIFICATE": {
- "MODEL_NAME": "Zertifikate",
- "CERTIFICATE_TYPE_NAME": "Zertifikatstyp"
- },
- "LEADERSHIP_EXPERIENCE": {
- "MODEL_NAME": "Führungserfahrung"
- },
- "ADD_ENTRY": "Eintrag hinzufügen"
- },
- "CALCULATION": {
- "MODEL_NAME": "PCTS-Berechnung",
- "POINTS": "PCTS-Punkte",
- "STATE": "Status",
- "ACTIVE": "Aktiv",
- "DRAFT": "Entwurf",
- "ARCHIVED": "Archiviert",
- "PUBLICIZED_BY": "Veröffentlicht von",
- "PUBLICATION_DATE": "Veröffentlicht am"
+ "ADD_ENTRY": "Eintrag hinzufügen",
+ "EXPERIENCE": {
+ "WORK_NAME": "Position",
+ "EXPERIENCE_TYPE_NAME": "Erfahrungsart"
+ },
+ "CERTIFICATE": {
+ "CERTIFICATE_TYPE_NAME": "Zertifikatstyp"
+ }
+ }
}
},
+
"ERROR": {
"NOT_AUTHENTICATED": "Benutzer ist nicht oder falsch angemeldet.",
"NOT_ALLOWED": "Der angemeldete Benutzer hat keine Berechtigung diese Aktion auszuführen.",
@@ -139,8 +135,11 @@
"DEFAULT": "Ein unerwarteter Fehler ist aufgetreten.",
"CLOSE": "Schliessen"
},
- "POST": "{{OBJECT}} wurde erfolgreich erstellt.",
- "PUT": "{{OBJECT}} wurde erfolgreich aktualisiert.",
- "DELETE": "{{OBJECT}} wurde erfolgreich gelöscht.",
- "Object": "Objekt"
+
+ "NOTIFICATION": {
+ "POST": "{{OBJECT}} wurde erfolgreich erstellt.",
+ "PUT": "{{OBJECT}} wurde erfolgreich aktualisiert.",
+ "DELETE": "{{OBJECT}} wurde erfolgreich gelöscht.",
+ "Object": "Objekt"
+ }
}
diff --git a/frontend/src/app/core/success-interceptor/success-interceptor.ts b/frontend/src/app/core/success-interceptor/success-interceptor.ts
index dfefc1d97..b5accdd9f 100644
--- a/frontend/src/app/core/success-interceptor/success-interceptor.ts
+++ b/frontend/src/app/core/success-interceptor/success-interceptor.ts
@@ -16,7 +16,7 @@ export const successInterceptor: HttpInterceptorFn = (req, next) => {
return next(req)
.pipe(tap((event) => {
if (event instanceof HttpResponse && event.ok) {
- const message: string = translate.instant(req.method, { OBJECT: translate.instant(`${getObjectKeyFromUrl(req.url)}.MODEL_NAME`) });
+ const message: string = translate.instant('NOTIFICATION.' + req.method, { OBJECT: translate.instant(`${getObjectKeyFromUrl(req.url)}.MODEL_NAME`) });
toastService.showToasts([message], 'success');
}
}));
diff --git a/frontend/src/app/features/member/detail-view/generic-cv-content/generic-cv-content.component.html b/frontend/src/app/features/member/detail-view/generic-cv-content/generic-cv-content.component.html
index dec518147..fbb5a5411 100644
--- a/frontend/src/app/features/member/detail-view/generic-cv-content/generic-cv-content.component.html
+++ b/frontend/src/app/features/member/detail-view/generic-cv-content/generic-cv-content.component.html
@@ -1,6 +1,6 @@
-
{{ "MODEL_NAME" | scopedTranslation }}
+ {{ "MODEL_NAME_PLURAL" | scopedTranslation }}
@if (table(); as ds) {
diff --git a/frontend/src/app/features/member/detail-view/member-detail-view.component.html b/frontend/src/app/features/member/detail-view/member-detail-view.component.html
index 9891a7bda..5c5ba6526 100644
--- a/frontend/src/app/features/member/detail-view/member-detail-view.component.html
+++ b/frontend/src/app/features/member/detail-view/member-detail-view.component.html
@@ -80,7 +80,7 @@
-
+
{
+const { fromPrefix } = jest.mocked(TranslationKeyPath);
+const { candidatesFor, fullyQualified } = jest.mocked(TranslationKeyPath.prototype);
+const translationKeyPathMock = { fromPrefix,
+ candidatesFor,
+ fullyQualified };
+
+/** Deliberately unrelated to PREFIX/KEY so no assertion can pass by identity. */
+const PREFIX = 'PREFIX.SCOPE';
+const KEY = 'THE_KEY';
+const FIRST = 'ALPHA';
+const SECOND = 'BRAVO';
+const THIRD = 'CHARLIE';
+const CANDIDATES: readonly string[] = [FIRST,
+ SECOND,
+ THIRD];
+const FALLBACK = 'ZULU';
+
+describe('ScopedTranslationCoreService', () => {
let service: ScopedTranslationCoreService;
+ const translateServiceMock = {
+ instant: jest.fn()
+ };
+
+ const fromPrefixMock = TranslationKeyPath.fromPrefix as unknown as jest.Mock;
+
+ /** Every candidate misses: ngx-translate echoes the key back. */
+ const missAll = (): void => {
+ translateServiceMock.instant.mockImplementation((candidate: string) => candidate);
+ };
+
+ /** Only `winner` resolves, to `value`. */
+ const resolveOnly = (winner: string, value: unknown): void => {
+ translateServiceMock.instant.mockImplementation((candidate: string) => (candidate === winner ? value : candidate));
+ };
+
beforeEach(() => {
+ jest.resetAllMocks();
+
+ translationKeyPathMock.candidatesFor.mockReturnValue(CANDIDATES);
+ translationKeyPathMock.fullyQualified.mockReturnValue(FALLBACK);
+ translationKeyPathMock.fromPrefix.mockReturnValue(TranslationKeyPath.prototype);
+
TestBed.configureTestingModule({
- providers: [provideTranslateService(),
- ScopedTranslationCoreService]
+ providers: [ScopedTranslationCoreService,
+ { provide: TranslateService,
+ useValue: translateServiceMock }]
});
+
service = TestBed.inject(ScopedTranslationCoreService);
});
it('should be created', () => {
- service = TestBed.inject(ScopedTranslationCoreService);
expect(service)
.toBeTruthy();
});
- it('instant should call proper methods ', () => {
- const prefix = 'THIS.IS.A.TEST.PREFIX';
- const key = 'TEST.KEY';
- const variationMock = { prefix,
- key };
- const keyListMock = ['THIS',
- 'TEST'];
- const translationMock = 'Translation';
-
- const stringVariationsSpy = jest.spyOn(service as any, 'getStringVariations')
- .mockReturnValue([variationMock]);
- const getKeyListSpy = jest.spyOn(service as any, 'getKeyList')
- .mockReturnValue(keyListMock);
- const getTranslationSpy = jest.spyOn(service as any, 'getTranslation')
- .mockReturnValue(translationMock);
-
- service.instant('TEST.KEY', {}, prefix);
-
- expect(stringVariationsSpy)
- .toHaveBeenCalledWith(prefix, key);
- expect(getKeyListSpy)
- .toHaveBeenCalledWith(prefix, key);
- expect(getTranslationSpy)
- .toHaveBeenCalledWith(keyListMock, {});
- stringVariationsSpy.mockRestore();
- getKeyListSpy.mockRestore();
- getTranslationSpy.mockRestore();
+ describe('delegation to the key path', () => {
+ beforeEach(() => resolveOnly(FIRST, 'value'));
+
+ it('builds the path from the given prefix', () => {
+ service.instant(KEY, undefined, PREFIX);
+
+ expect(fromPrefixMock)
+ .toHaveBeenCalledWith(PREFIX);
+ });
+
+ it('defaults the prefix to the root scope', () => {
+ service.instant(KEY);
+
+ expect(fromPrefixMock)
+ .toHaveBeenCalledWith('');
+ });
+
+ it('asks the path for candidates of the requested key', () => {
+ service.instant(KEY, undefined, PREFIX);
+
+ expect(translationKeyPathMock.candidatesFor)
+ .toHaveBeenCalledWith(KEY);
+ });
+
+ it('does not ask for the fallback on a hit', () => {
+ service.instant(KEY, undefined, PREFIX);
+
+ expect(translationKeyPathMock.fullyQualified).not.toHaveBeenCalled();
+ });
});
+ describe('candidate probing', () => {
+ it('probes candidates in the order the path returned them', () => {
+ resolveOnly(THIRD, 'value');
+
+ service.instant(KEY, undefined, PREFIX);
+
+ expect(translateServiceMock.instant.mock.calls)
+ .toEqual([[FIRST],
+ [SECOND],
+ [THIRD]]);
+ });
+
+ it('probes without params, so a miss never interpolates', () => {
+ resolveOnly(THIRD, 'value');
+
+ service.instant(KEY, { name: 'Minder' }, PREFIX);
- describe('getStringVariations', () => {
- const testCases = [{
- prefix: 'A.B.C',
- key: 'D.E',
- expected: [{ prefix: 'A.B.C',
- key: 'D.E' },
- { prefix: 'A.B',
- key: 'C.D.E' },
- { prefix: 'A',
- key: 'B.C.D.E' }],
- desc: '3 variations'
- },
- {
- prefix: 'A',
- key: 'B',
- expected: [{ prefix: 'A',
- key: 'B' }],
- desc: '1 variation'
- },
- {
- prefix: '',
- key: 'B',
- expected: [{ prefix: '',
- key: 'B' }],
- desc: '1 variation'
- }];
-
- it.each(testCases)('should return $desc for prefix "$prefix" and key "$key"', ({ prefix, key, expected }) => {
- const result = service['getStringVariations'](prefix, key);
- expect(result)
- .toEqual(expected);
+ const probes = translateServiceMock.instant.mock.calls.slice(0, 3);
+ expect(probes.every((call) => call.length === 1))
+ .toBe(true);
+ });
+
+ it('stops at the first hit', () => {
+ resolveOnly(SECOND, 'value');
+
+ service.instant(KEY, undefined, PREFIX);
+
+ expect(translateServiceMock.instant).not.toHaveBeenCalledWith(THIRD);
+ });
+
+ it('returns the resolved translation', () => {
+ resolveOnly(SECOND, 'Abbrechen');
+
+ expect(service.instant(KEY, undefined, PREFIX))
+ .toBe('Abbrechen');
});
});
- describe('getKeyList', () => {
- const testCases = [{
- prefix: 'A.B.C',
- suffix: 'D.E',
- expected: [
- 'A.B.C.D.E',
- 'A.B.D.E',
- 'A.D.E',
- 'D.E'
- ],
- desc: 'A.B.C.D.E, A.B.D.E, A.D.E, D.E'
- },
- {
- prefix: 'A',
- suffix: 'B',
- expected: ['A.B',
- 'B'],
- desc: 'A.B, B'
- },
- {
- prefix: '',
- suffix: 'B',
- expected: ['B'],
- desc: 'B'
- }];
-
- it.each(testCases)('should return [$desc] for prefix "$prefix" and suffix "$suffix"', ({ prefix, suffix, expected }) => {
- const result = service['getKeyList'](prefix, suffix);
- expect(result)
- .toEqual(expected);
+ describe('miss detection', () => {
+ it('treats an echoed key as a miss and moves on', () => {
+ resolveOnly(SECOND, 'value');
+
+ expect(service.instant(KEY, undefined, PREFIX))
+ .toBe('value');
+ expect(translateServiceMock.instant)
+ .toHaveBeenCalledWith(SECOND);
+ });
+
+ it.each([
+ ['a subtree object',
+ { ACTION: 'löschen' }],
+ ['an array',
+ ['a',
+ 'b']],
+ ['null',
+ null],
+ ['a number',
+ 42]
+ ])('skips a candidate resolving to %s', (_desc, nonString) => {
+ translateServiceMock.instant.mockImplementation((candidate: string) => {
+ if (candidate === FIRST) {
+ return nonString;
+ }
+
+ return candidate === SECOND ? 'value' : candidate;
+ });
+
+ expect(service.instant(KEY, undefined, PREFIX))
+ .toBe('value');
+ });
+ });
+
+ describe('interpolation', () => {
+ it('re-reads only the winner with params', () => {
+ const params = { name: 'Minder' };
+ translateServiceMock.instant.mockImplementation((candidate: string, given?: unknown) => {
+ if (candidate !== SECOND) {
+ return candidate;
+ }
+
+ return given === undefined ? 'Hallo {{name}}' : 'Hallo Minder';
+ });
+
+ expect(service.instant(KEY, params, PREFIX))
+ .toBe('Hallo Minder');
+ expect(translateServiceMock.instant.mock.calls)
+ .toEqual([[FIRST],
+ [SECOND],
+ [SECOND,
+ params]]);
+ });
+
+ it('returns the probe directly when no params are given', () => {
+ resolveOnly(FIRST, 'Wert');
+
+ expect(service.instant(KEY, undefined, PREFIX))
+ .toBe('Wert');
+ expect(translateServiceMock.instant)
+ .toHaveBeenCalledTimes(1);
+ });
+
+ it('re-reads on empty params, since undefined is the only opt-out', () => {
+ const params = {};
+ resolveOnly(FIRST, 'Wert');
+
+ service.instant(KEY, params, PREFIX);
+
+ expect(translateServiceMock.instant.mock.calls)
+ .toEqual([[FIRST],
+ [FIRST,
+ params]]);
+ });
+ });
+
+ describe('total miss', () => {
+ let warn: jest.SpyInstance;
+
+ beforeEach(() => {
+ warn = jest.spyOn(console, 'warn')
+ .mockImplementation(() => undefined);
+ missAll();
+ });
+
+ afterEach(() => warn.mockRestore());
+
+ it('returns the fully qualified key', () => {
+ expect(service.instant(KEY, undefined, PREFIX))
+ .toBe(FALLBACK);
+ expect(translationKeyPathMock.fullyQualified)
+ .toHaveBeenCalledWith(KEY);
+ });
+
+ it('warns once, naming the key and every candidate tried', () => {
+ service.instant(KEY, undefined, PREFIX);
+
+ expect(warn)
+ .toHaveBeenCalledTimes(1);
+
+ const [message,
+ logged] = warn.mock.calls[0];
+ expect(message)
+ .toContain(FALLBACK);
+ expect(logged)
+ .toEqual(CANDIDATES);
+ });
+
+ it('does not probe again while reporting', () => {
+ service.instant(KEY, undefined, PREFIX);
+
+ expect(translateServiceMock.instant)
+ .toHaveBeenCalledTimes(CANDIDATES.length);
+ });
+
+ it('stays silent on a hit', () => {
+ resolveOnly(FIRST, 'value');
+
+ service.instant(KEY, undefined, PREFIX);
+
+ expect(warn).not.toHaveBeenCalled();
});
});
});
diff --git a/frontend/src/app/shared/services/scoped-translation-core.service.ts b/frontend/src/app/shared/services/scoped-translation-core.service.ts
index bfb85c726..8437b475d 100644
--- a/frontend/src/app/shared/services/scoped-translation-core.service.ts
+++ b/frontend/src/app/shared/services/scoped-translation-core.service.ts
@@ -1,98 +1,64 @@
-import { inject, Injectable } from '@angular/core';
+import { inject, Injectable, isDevMode } from '@angular/core';
import { InterpolationParameters, TranslateService } from '@ngx-translate/core';
+import { TranslationKeyPath } from './translation-key-path';
-interface Variation {
- prefix: string;
- key: string;
-}
-
@Injectable({ providedIn: 'root' })
export class ScopedTranslationCoreService {
- private readonly I18N_KEY_SEPARATOR = '.';
-
private readonly translateService = inject(TranslateService);
public instant(key: string, params?: InterpolationParameters, i18nPrefix = ''): string {
- const keyList = this.generateKeyHierarchy(key, i18nPrefix);
- return this.getTranslation(keyList, params);
- }
-
- /**
- * Creates a prioritized, unique list of all possible translation keys.
- */
- private generateKeyHierarchy(key: string, i18nPrefix: string): string[] {
- const variations = this.getStringVariations(i18nPrefix, key);
- const allKeys = variations.flatMap((v) => this.getKeyList(v.prefix, v.key));
- // De-duplicate the list to avoid redundant checks
- return [...new Set(allKeys)];
- }
-
- /**
- * Generates variations of prefix/key combinations.
- * Example: prefix 'a.b.c' and key 'd'
- * -> [ { p: 'a.b.c', k: 'd' }, { p: 'a.b', k: 'c.d' }, { p: 'a', k: 'b.c.d' } ]
- */
- private getStringVariations(inputPrefix: string, inputKey: string): Variation[] {
- const separator = this.I18N_KEY_SEPARATOR;
- const prefixParts = inputPrefix.split(separator);
+ const path = TranslationKeyPath.fromPrefix(i18nPrefix);
+ const candidates = path.candidatesFor(key);
+ const translation = this.firstTranslation(candidates, params);
- const variations: Variation[] = [{ prefix: inputPrefix,
- key: inputKey }];
-
- for (let i = prefixParts.length - 1; i > 0; i--) {
- const prefix = prefixParts.slice(0, i)
- .join(separator);
- const keySuffix = prefixParts.slice(i)
- .join(separator);
-
- variations.push({
- prefix,
- key: `${keySuffix}${separator}${inputKey}`
- });
+ if (translation !== undefined) {
+ return translation;
}
- return variations;
+ this.reportMiss(path.fullyQualified(key), candidates);
+ return path.fullyQualified(key);
}
- /**
- * Creates a list of keys by progressively shortening the prefix.
- * Example: prefix 'a.b.c' and suffix 'd'
- * -> [ 'a.b.c.d', 'a.b.d', 'a.d', 'd' ]
- */
- private getKeyList(prefix: string, suffix: string): string[] {
- if (!prefix) {
- return [suffix];
+ private firstTranslation(candidates: readonly string[],
+ params?: InterpolationParameters): string | undefined {
+ for (const candidate of candidates) {
+ const translation = this.lookup(candidate, params);
+
+ if (translation !== undefined) {
+ return translation;
+ }
}
- const separator = this.I18N_KEY_SEPARATOR;
- const keys: string[] = [];
- const prefixParts = prefix.split(separator);
+ return undefined;
+ }
- while (prefixParts.length > 0) {
- const currentPrefix = prefixParts.join(separator);
- keys.push(`${currentPrefix}${separator}${suffix}`);
- prefixParts.pop();
+ private reportMiss(fullyQualified: string, candidates: readonly string[]): void {
+ if (isDevMode()) {
+ console.warn(`[i18n] unresolved key (${fullyQualified}). Tried, in order:`, candidates.map((c) => ({ [c]: this.lookup(c) })));
}
-
- keys.push(suffix);
- return keys;
}
/**
- * Finds the first key in the list that has a valid translation.
+ * Anti-corruption boundary for two ngx-translate quirks. Absence is reported
+ * as `undefined` so no caller has to know about either.
+ *
+ * 1. `instant` signals "not found" by echoing the key back. This breaks if
+ * a custom MissingTranslationHandler returns anything other than the key.
+ * 2. `instant` returns the raw subtree when the key names a node, e.g.
+ * `FORM.DELETE` → `{ ACTION: 'löschen' }`. That is not a translation and
+ * must not win the lookup.
+ *
+ * Probing without params keeps interpolation off the miss path; only the
+ * winner is re-read with them.
*/
- private getTranslation(keyList: string[], params?: InterpolationParameters): string {
- for (const key of keyList) {
- const translation = this.translateService.instant(key, params);
+ private lookup(candidate: string, params?: InterpolationParameters): string | undefined {
+ const probe: unknown = this.translateService.instant(candidate);
- // instant() returns the key itself if no translation is found
- if (translation !== key) {
- return translation;
- }
+ if (typeof probe !== 'string' || probe === candidate) {
+ return undefined;
}
- // Fallback to the first (most specific) key if none are found
- return keyList[0] ?? '';
+ return params === undefined ? probe : this.translateService.instant(candidate, params);
}
}
diff --git a/frontend/src/app/shared/services/translation-key-path.spec.ts b/frontend/src/app/shared/services/translation-key-path.spec.ts
new file mode 100644
index 000000000..3a03b7a56
--- /dev/null
+++ b/frontend/src/app/shared/services/translation-key-path.spec.ts
@@ -0,0 +1,219 @@
+import { TranslationKeyPath } from './translation-key-path';
+
+describe('TranslationKeyPath', () => {
+ const candidates = (prefix: string, key: string): readonly string[] => TranslationKeyPath.fromPrefix(prefix)
+ .candidatesFor(key);
+
+ describe('candidatesFor', () => {
+ it.each([
+ [
+ 'no prefix',
+ '',
+ 'X',
+ ['X']
+ ],
+ [
+ 'depth 1',
+ 'A',
+ 'X',
+ ['A.X',
+ 'X']
+ ],
+ [
+ 'depth 2',
+ 'A.B',
+ 'X',
+ [
+ 'A.B.X',
+ 'B.X',
+ 'A.X',
+ 'X'
+ ]
+ ],
+ [
+ 'depth 3',
+ 'A.B.C',
+ 'X',
+ [
+ 'A.B.C.X',
+ 'B.C.X',
+ 'A.B.X',
+ 'C.X',
+ 'A.X',
+ 'X'
+ ]
+ ],
+ [
+ 'depth 4',
+ 'A.B.C.D',
+ 'X',
+ [
+ 'A.B.C.D.X',
+ 'B.C.D.X',
+ 'A.B.C.X',
+ 'C.D.X',
+ 'A.B.X',
+ 'D.X',
+ 'A.X',
+ 'X'
+ ]
+ ],
+ [
+ 'dotted key stays intact',
+ 'A.B',
+ 'C.D',
+ [
+ 'A.B.C.D',
+ 'B.C.D',
+ 'A.C.D',
+ 'C.D'
+ ]
+ ]
+ ])('%s', (
+ _desc, prefix, key, expected
+ ) => {
+ expect(candidates(prefix, key))
+ .toEqual(expected);
+ });
+
+ it.each([
+ ['double separator',
+ 'A..B'],
+ ['trailing separator',
+ 'A.B.'],
+ ['leading separator',
+ '.A.B'],
+ ['both ends',
+ '.A..B.']
+ ])('normalizes %s to A.B', (_desc, prefix) => {
+ expect(candidates(prefix, 'X'))
+ .toEqual([
+ 'A.B.X',
+ 'B.X',
+ 'A.X',
+ 'X'
+ ]);
+ });
+
+ it.each([['empty string',
+ ''],
+ ['separator only',
+ '.'],
+ ['separators only',
+ '...']])('treats %s as the root scope', (_desc, prefix) => {
+ expect(candidates(prefix, 'X'))
+ .toEqual(['X']);
+ });
+
+ it('starts with the fully qualified key', () => {
+ for (const prefix of [
+ '',
+ 'A',
+ 'A.B',
+ 'A.B.C'
+ ]) {
+ const path = TranslationKeyPath.fromPrefix(prefix);
+ expect(path.candidatesFor('X')[0])
+ .toBe(path.fullyQualified('X'));
+ }
+ });
+
+ it('ends with the bare key', () => {
+ for (const prefix of [
+ '',
+ 'A',
+ 'A.B',
+ 'A.B.C',
+ 'A.B.C.D'
+ ]) {
+ const result = candidates(prefix, 'X');
+ expect(result[result.length - 1])
+ .toBe('X');
+ }
+ });
+
+ it('never repeats a candidate', () => {
+ for (const prefix of [
+ '',
+ 'A',
+ 'A.B',
+ 'A.B.C',
+ 'A.B.C.D',
+ 'A.A.A'
+ ]) {
+ const result = candidates(prefix, 'X');
+ expect(new Set(result).size)
+ .toBe(result.length);
+ }
+ });
+
+ it('is ordered by descending scope length', () => {
+ const lengths = candidates('A.B.C.D', 'X')
+ .map((c) => c.split('.').length);
+ expect(lengths)
+ .toEqual([...lengths].sort((a, b) => b - a));
+ });
+
+ it('grows linearly: 2n candidates at depth n', () => {
+ // Guards against a regression to the old n²/2 cross product.
+ expect(candidates('A.B.C.D.E.F', 'X'))
+ .toHaveLength(12);
+ });
+
+ it('never invents a segment', () => {
+ const scope = ['A',
+ 'B',
+ 'C'];
+ for (const candidate of candidates(scope.join('.'), 'X')) {
+ expect(scope)
+ .toEqual(expect.arrayContaining(candidate.split('.')
+ .slice(0, -1)));
+ }
+ });
+
+ it('returns a frozen array', () => {
+ expect(Object.isFrozen(candidates('A.B', 'X')))
+ .toBe(true);
+ });
+ });
+
+ describe('interior scopes are unreachable', () => {
+ /*
+ * Deliberate limitation, not an oversight. `B` sits between a leading and
+ * a trailing run of A.B.C, so no hoist and no generalization isolates it.
+ * A shared block must live at the JSON root or on the prefix's leading
+ * path — this is why FORM.BUTTONS.CANCEL cannot serve MEMBER.FORM.ADD.
+ */
+ it('omits the interior single segment', () => {
+ expect(candidates('A.B.C', 'X')).not.toContain('B.X');
+ });
+
+ it('omits gapped scopes', () => {
+ expect(candidates('A.B.C', 'X')).not.toContain('A.C.X');
+ });
+ });
+
+ describe('fullyQualified', () => {
+ it.each([
+ ['A.B.C',
+ 'X',
+ 'A.B.C.X'],
+ ['A',
+ 'X',
+ 'A.X'],
+ ['',
+ 'X',
+ 'X'],
+ ['A..B',
+ 'X',
+ 'A.B.X'],
+ ['A.B',
+ 'C.D',
+ 'A.B.C.D']
+ ])('prefix "%s" + key "%s"', (prefix, key, expected) => {
+ expect(TranslationKeyPath.fromPrefix(prefix)
+ .fullyQualified(key))
+ .toBe(expected);
+ });
+ });
+});
diff --git a/frontend/src/app/shared/services/translation-key-path.ts b/frontend/src/app/shared/services/translation-key-path.ts
new file mode 100644
index 000000000..15e9801d2
--- /dev/null
+++ b/frontend/src/app/shared/services/translation-key-path.ts
@@ -0,0 +1,95 @@
+const SEGMENT_SEPARATOR = '.';
+
+/** A prefix, or any truncation of one, as ordered segments. */
+type Scopes = readonly string[];
+
+function pathOf(scope: Scopes, key: string): string {
+ return [...scope,
+ key].join(SEGMENT_SEPARATOR);
+}
+
+/**
+ * One position within a prefix, and the two equally specific scopes that
+ * position marks the boundary of.
+ *
+ * Within `MEMBER.CV.DEGREE`, position 1 (`CV`) yields:
+ * hoisted -> CV.DEGREE (the two innermost segments)
+ * generalized -> MEMBER.CV (the two outermost segments)
+ *
+ * Both retain two of three segments, so neither may outrank the other.
+ */
+class TranslatePathSegment {
+ public constructor(private readonly scope: Scopes,
+ private readonly index: number) {}
+
+ /** The segment this position is anchored on. */
+ public get segment(): string {
+ return this.scope[this.index];
+ }
+
+ /** How many segments survive here — the same count in either direction. */
+ private get survivingCount(): number {
+ return this.scope.length - this.index;
+ }
+
+ /** Sheds the outermost segments: MEMBER.CV.DEGREE -> CV.DEGREE -> DEGREE */
+ public hoistedCandidate(key: string): string {
+ return pathOf(this.scope.slice(this.index), key);
+ }
+
+ /** Sheds the innermost segments: MEMBER.CV.DEGREE -> MEMBER.CV -> MEMBER */
+ public generalizedCandidate(key: string): string {
+ return pathOf(this.scope.slice(0, this.survivingCount), key);
+ }
+}
+
+/**
+ * An i18n scope prefix such as `MEMBER.CV.DEGREE`, and the candidate keys
+ * derivable from it.
+ *
+ * Empty segments are dropped, so `A..B` and `A.B.` both normalize to `A.B`.
+ */
+export class TranslationKeyPath {
+ private readonly segments: readonly TranslatePathSegment[];
+
+ private constructor(private readonly scope: Scopes) {
+ this.segments = scope.map((_, index) => new TranslatePathSegment(scope, index));
+ }
+
+ public static fromPrefix(prefix: string): TranslationKeyPath {
+ const scope = prefix
+ .split(SEGMENT_SEPARATOR)
+ .filter((segment) => segment.length > 0);
+
+ return new TranslationKeyPath(scope);
+ }
+
+ /**
+ * Every key worth trying for `key`, most specific first.
+ *
+ * `MEMBER.CV.DEGREE` + `MODEL_NAME` yields, in order:
+ * MEMBER.CV.DEGREE.MODEL_NAME
+ * CV.DEGREE.MODEL_NAME
+ * MEMBER.CV.MODEL_NAME
+ * DEGREE.MODEL_NAME
+ * MEMBER.MODEL_NAME
+ * MODEL_NAME
+ *
+ * Offering both of a position's candidates before moving to the next is what
+ * stops a generic key from outranking a specific one. The bare key is
+ * appended last: no segment survives there, so no position can represent it.
+ */
+ public candidatesFor(key: string): readonly string[] {
+ const candidatesBySegment = this.segments.map((segment) => [segment.hoistedCandidate(key),
+ segment.generalizedCandidate(key)]);
+ const candidates = candidatesBySegment.flat();
+ candidates.push(key);
+
+ return Object.freeze([...new Set(candidates)]);
+ }
+
+ /** The unabbreviated key, rendered verbatim when nothing resolves. */
+ public fullyQualified(key: string): string {
+ return pathOf(this.scope, key);
+ }
+}