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); + } +}