mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-25 14:09:03 -04:00

the #shared alias used by Nuxt cannot be easily disabled and to prevent breackage with jiti, we make use of it
140 lines
5.1 KiB
TypeScript
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 });
|