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('locale/_base/translations.suml'); const typeFlexibleKeys = new Set(['home.generator.alt']); interface CustomMatchers { toMatchBaseTranslationSchema(): R; toBeValidPath(paths: RegExp[], key: string): R; } declare module 'vitest' { interface Assertion extends CustomMatchers {} } 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(`locale/${code}/config.suml`); const translations = await loadSuml(`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 });