PronounsPage/test/locale/translations.nuxt.test.ts
Valentyne Stigloher 10180aa6a3 (refactor) use #shared alias instead of ~~/shared
the #shared alias used by Nuxt cannot be easily disabled and to prevent breackage with jiti, we make use of it
2025-08-17 18:56:02 +02:00

140 lines
5.1 KiB
TypeScript

import type { SyncExpectationResult, MatcherState } from '@vitest/expect';
import pathToRegexp from 'path-to-regexp';
import { beforeAll, describe, expect, test, vi } from 'vitest';
import type { RouteRecordRaw } from 'vue-router';
import { deepGet, deepListKeys } from '#shared/helpers.ts';
import routerOptions from '~/router.options.ts';
import { loadConfig } from '~/src/data.ts';
import type { Config } from '~~/locale/config.ts';
import allLocales from '~~/locale/locales.ts';
import type { Translations } from '~~/locale/translations.ts';
import { loadSuml } from '~~/server/loader.ts';
const baseTranslations = await loadSuml<Translations>('locale/_base/translations.suml');
const typeFlexibleKeys = new Set(['home.generator.alt']);
interface CustomMatchers<R> {
toMatchBaseTranslationSchema(): R;
toBeValidPath(paths: RegExp[], key: string): R;
}
declare module 'vitest' {
interface Assertion<T> extends CustomMatchers<T> {}
}
function specificTypeOf(value: unknown): string | null {
if (typeof value === 'object') {
if (value === null) {
return null;
} else if (Array.isArray(value)) {
return 'array';
} else {
return 'object';
}
} else {
return typeof value;
}
}
function toMatchBaseTranslationSchema(actual: Translations): SyncExpectationResult {
const messages = recursivelyValidateSchema(actual, baseTranslations);
if (messages.length > 0) {
return {
message: () => `expected translations to match schema of base translations\n\n${messages.join('\n')}`,
pass: false,
};
} else {
return {
message: () => 'expected translations to mismatch schema of base translations',
pass: true,
};
}
}
function recursivelyValidateSchema(actual: Translations, base: Translations, parentKey: string = ''): string[] {
const messages = [];
for (const [property, value] of Object.entries(actual)) {
const key = parentKey ? `${parentKey}.${property}` : property;
if (base[property] === undefined || base[property] === null) {
continue;
}
if (value !== null && !typeFlexibleKeys.has(key) && specificTypeOf(value) !== specificTypeOf(base[property])) {
messages.push(`${key} has type ${specificTypeOf(value)}, expected ${specificTypeOf(base[property])}`);
}
if (specificTypeOf(value) === 'object') {
messages.push(...recursivelyValidateSchema(value, base[property], key));
}
}
return messages;
}
function toBeValidPath(this: MatcherState, actual: string, paths: RegExp[], key: string): SyncExpectationResult {
const encoded = encodeURI(actual);
const pass = paths.some((matcher) => encoded.match(matcher));
if (pass) {
return {
message: () => `expected ${this.utils.printReceived(actual)} inside ${this.utils.printReceived(key)} ` +
'to not be a known internal link',
pass: true,
};
} else {
return {
message: () => `expected ${this.utils.printReceived(actual)} inside ${this.utils.printReceived(key)} ` +
'to be a known internal link',
pass: false,
};
}
}
vi.mock('~/src/data.ts');
const pathRegex = /\{(\/[^}]+?)(?:#[^}]+)?=[^}]+\}/g;
describe.each(allLocales)('translations for $code', async ({ code, published }) => {
const config = await loadSuml<Config>(`locale/${code}/config.suml`);
const translations = await loadSuml<Translations>(`locale/${code}/translations.suml`);
beforeAll(() => {
// for router.options.ts
vi.mocked(loadConfig).mockImplementation(async () => config);
});
test('match schema of base translations', () => {
expect(translations).toMatchBaseTranslationSchema();
});
test('contain valid internal links', async () => {
if (!published) {
// unpublished versions are ignored for now because they have a lot of errors
return;
}
const routes = await routerOptions.routes!(global.originalRoutes) as RouteRecordRaw[];
const paths = routes.filter((route) => route.name !== 'path').map((route) => pathToRegexp(route.path));
for (const key of deepListKeys(translations)) {
const translation = deepGet(translations, key);
if (typeof translation !== 'string') {
continue;
}
for (const match of translation.matchAll(pathRegex)) {
expect(match[1]).toBeValidPath(paths, key);
}
}
});
test('pronouns.ask.header contains translations for every config.pronouns.ask.routes', () => {
if (config.pronouns.ask) {
expect(Object.keys(deepGet(translations, 'pronouns.ask.header') ?? {})).toEqual(config.pronouns.ask.routes);
}
});
test('pronouns.ask.short contains translations for every config.pronouns.ask.routes', () => {
if (config.pronouns.ask) {
expect(Object.keys(deepGet(translations, 'pronouns.ask.short') ?? {})).toEqual(config.pronouns.ask.routes);
}
});
});
expect.extend({ toMatchBaseTranslationSchema, toBeValidPath });