mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
ci(i18n): fix the two guards that keep the crowdin sync failing
Two independent guards were failing the workflow on healthy input. Retention read "exported value equals the English source" as a lost translation, but exports are built with skipUntranslatedStrings, so Crowdin omits a key entirely once it stops carrying a translation. Anything present in the export is by definition translated, and the equality check only ever fired on correct work: Spanish "Error", "Feb" and "Cookie", French "Description", Portuguese "Readwise". Retention now compares presence alone, which is what absence already meant. The same inference lived in a legacyComplete branch that would have silently stopped protecting the 2,318 keys whose translation legitimately equals the source once any locale reached full translation. Scheduled runs had no way out of that. The only escape hatch is allowed_translation_losses, a workflow_dispatch input that is never persisted, so every schedule rediscovered the same losses and failed until someone dispatched the run by hand. The slot count rule then only runs for the keys it is given, and the sync passed none, so its rejection filter was weaker than the locale validation running on its output one step later. An invalid ja message survived the filter, reached the catalog, and failed the next step instead of being dropped and reported. Move the source scan into locale-source-keys.mjs so the sync and validate-locales.mjs derive the same keys.
This commit is contained in:
@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { assertCrowdinTargetConfiguration, validateCrowdinTargetConfiguration } from './locale-configuration.mjs'
|
||||
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
||||
import { collectSourceMessageKeys } from './locale-source-keys.mjs'
|
||||
import {
|
||||
TARGET_CATALOGS,
|
||||
assertSafeDownloadUrl,
|
||||
@@ -26,11 +28,11 @@ afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function createCatalogFixture(targetCatalogs, currentCatalogs = new Map()) {
|
||||
async function createCatalogFixture(targetCatalogs, currentCatalogs = new Map(), referenceCatalog = { common: { save: 'Save' } }) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'bookorbit-crowdin-sync-'))
|
||||
temporaryDirectories.push(directory)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(path.join(directory, 'en.json'), `${JSON.stringify({ common: { save: 'Save' } }, null, 2)}\n`)
|
||||
await writeFile(path.join(directory, 'en.json'), `${JSON.stringify(referenceCatalog, null, 2)}\n`)
|
||||
await Promise.all(
|
||||
targetCatalogs.map(({ locale }) =>
|
||||
writeFile(path.join(directory, `${locale}.json`), `${JSON.stringify(currentCatalogs.get(locale) ?? {}, null, 2)}\n`),
|
||||
@@ -84,7 +86,7 @@ describe('Crowdin translation synchronization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('protects real translations in complete legacy catalogs without treating English fallbacks as translations', () => {
|
||||
it('protects a complete catalog as strictly as a sparse one', () => {
|
||||
const referenceMessages = new Map([
|
||||
['common.save', 'Save'],
|
||||
['common.cancel', 'Cancel'],
|
||||
@@ -97,6 +99,7 @@ describe('Crowdin translation synchronization', () => {
|
||||
|
||||
expect(findTranslationLosses({ locale: 'cs', reference: referenceMessages, current, exported })).toEqual([
|
||||
{ locale: 'cs', key: 'common.save', reason: 'missing from Crowdin export' },
|
||||
{ locale: 'cs', key: 'common.cancel', reason: 'missing from Crowdin export' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -112,16 +115,69 @@ describe('Crowdin translation synchronization', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects translations that Crowdin replaces with English source text', () => {
|
||||
const referenceMessages = new Map([['common.save', 'Save']])
|
||||
const current = new Map([['common.save', 'Uložit']])
|
||||
const exported = new Map([['common.save', 'Save']])
|
||||
it.each([
|
||||
['es', 'bookDock.tab.error', 'Error', 'error'],
|
||||
['es', 'book.detail.readingLog.months.feb', 'Feb', 'febrero'],
|
||||
['es', 'settings.metadata.providers.fields.cookie', 'Cookie', 'galleta'],
|
||||
['fr', 'settings.metadata.fields.description', 'Description', 'Descriptif'],
|
||||
['pt', 'settings.integrations.tabs.readwise', 'Readwise', 'Leitura'],
|
||||
])('keeps %s:%s when Crowdin translates it as the English source text', (locale, key, source, currentMessage) => {
|
||||
expect(
|
||||
findTranslationLosses({
|
||||
locale,
|
||||
reference: new Map([[key, source]]),
|
||||
current: new Map([[key, currentMessage]]),
|
||||
exported: new Map([[key, source]]),
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
expect(findTranslationLosses({ locale: 'cs', reference: referenceMessages, current, exported })).toEqual([
|
||||
{ locale: 'cs', key: 'common.save', reason: 'replaced by English source text' },
|
||||
it('reports why catalog validation dropped a key instead of a bare absence', () => {
|
||||
expect(
|
||||
findTranslationLosses({
|
||||
locale: 'cs',
|
||||
reference: new Map([['common.save', 'Save']]),
|
||||
current: new Map([['common.save', 'Uložit']]),
|
||||
exported: new Map(),
|
||||
rejected: new Map([['common.save', ['cs: Unicode em dash is not allowed in common.save']]]),
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
locale: 'cs',
|
||||
key: 'common.save',
|
||||
reason: 'rejected by catalog validation - cs: Unicode em dash is not allowed in common.save',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores catalog keys that English no longer defines', () => {
|
||||
expect(
|
||||
findTranslationLosses({
|
||||
locale: 'cs',
|
||||
reference: new Map([['common.save', 'Save']]),
|
||||
current: new Map([['common.removed', 'Odstraněno']]),
|
||||
exported: new Map(),
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('needs no acknowledgement when Crowdin corrects a translation to the English source', () => {
|
||||
const referenceMessages = new Map([['bookDock.tab.error', 'Error']])
|
||||
const currentCatalogs = new Map(TARGET_CATALOGS.map(({ locale }) => [locale, new Map()]))
|
||||
const exportedCatalogs = new Map(TARGET_CATALOGS.map(({ locale }) => [locale, new Map([['bookDock.tab.error', 'Error']])]))
|
||||
currentCatalogs.set('es', new Map([['bookDock.tab.error', 'error']]))
|
||||
|
||||
expect(() => assertTranslationRetention({ reference: referenceMessages, currentCatalogs, exportedCatalogs })).not.toThrow()
|
||||
expect(() =>
|
||||
assertTranslationRetention({
|
||||
reference: referenceMessages,
|
||||
currentCatalogs,
|
||||
exportedCatalogs,
|
||||
allowedLosses: parseAllowedTranslationLosses('es:bookDock.tab.error'),
|
||||
}),
|
||||
).toThrow('es:bookDock.tab.error - acknowledgement does not match an exported loss')
|
||||
})
|
||||
|
||||
it('requires exact acknowledgements for intentional translation losses', () => {
|
||||
const referenceMessages = new Map([['common.save', 'Save']])
|
||||
const currentCatalogs = new Map(TARGET_CATALOGS.map(({ locale }) => [locale, new Map()]))
|
||||
@@ -154,7 +210,7 @@ describe('Crowdin translation synchronization', () => {
|
||||
expect(() => parseAllowedTranslationLosses('cs:common.save,cs:common.save')).toThrow('Duplicate translation loss acknowledgement')
|
||||
})
|
||||
|
||||
it('requests untranslated-string omission and downloads the bounded export', async () => {
|
||||
it('omits untranslated strings from the export so retention can read absence as loss', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
@@ -354,4 +410,81 @@ describe('Crowdin translation synchronization', () => {
|
||||
).rejects.toThrow('missing from Crowdin export')
|
||||
await expect(access(outputDirectory)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('writes a Crowdin correction that replaces a translation with the English source text', async () => {
|
||||
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
|
||||
const currentCatalogs = new Map([[targetCatalogs[0].locale, { common: { save: 'Ulozit' } }]])
|
||||
const catalogDirectory = await createCatalogFixture(targetCatalogs, currentCatalogs)
|
||||
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Save' } }]])
|
||||
|
||||
await expect(
|
||||
syncCrowdinTranslations({
|
||||
token: 'secret',
|
||||
fetchImpl: createSynchronizationFetch({ catalogs }),
|
||||
catalogDirectory,
|
||||
outputDirectory,
|
||||
targetCatalogs,
|
||||
assertTargetConfiguration: async () => {},
|
||||
}),
|
||||
).resolves.toEqual({ rejections: [] })
|
||||
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[0].locale}.json`), 'utf8'))).toEqual({
|
||||
common: { save: 'Save' },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a slot count violation so the catalog it writes passes locale validation', async () => {
|
||||
const key = 'tools.bulkRename.confirmDialog.body'
|
||||
const referenceCatalog = { tools: { bulkRename: { confirmDialog: { body: '{count, plural, one {Rename # file} other {Rename # files}}' } } } }
|
||||
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
|
||||
const catalogDirectory = await createCatalogFixture(targetCatalogs, new Map(), referenceCatalog)
|
||||
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||
const catalogs = new Map([
|
||||
[
|
||||
targetCatalogs[0].languageId,
|
||||
{ tools: { bulkRename: { confirmDialog: { body: '{count, plural, one {Rename # file} few {Rename # files} other {Rename files}}' } } } },
|
||||
],
|
||||
[targetCatalogs[1].languageId, {}],
|
||||
])
|
||||
|
||||
const { rejections } = await syncCrowdinTranslations({
|
||||
token: 'secret',
|
||||
fetchImpl: createSynchronizationFetch({ identifiers: [key], catalogs }),
|
||||
catalogDirectory,
|
||||
outputDirectory,
|
||||
targetCatalogs,
|
||||
assertTargetConfiguration: async () => {},
|
||||
collectMessageKeys: async () => ({ keys: new Set([key]), slotCountKeys: new Set([key]) }),
|
||||
})
|
||||
|
||||
expect(rejections).toEqual([
|
||||
{
|
||||
locale: targetCatalogs[0].locale,
|
||||
key,
|
||||
errors: [`${targetCatalogs[0].locale}: slot count message ${key} must render exactly one # in every branch`],
|
||||
},
|
||||
])
|
||||
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[0].locale}.json`), 'utf8'))).toEqual({})
|
||||
|
||||
const written = new Map([['en', flattenCatalog(referenceCatalog)]])
|
||||
for (const { locale } of targetCatalogs) {
|
||||
written.set(locale, flattenCatalog(JSON.parse(await readFile(path.join(outputDirectory, `${locale}.json`), 'utf8'))))
|
||||
}
|
||||
expect(validateCatalogs({ catalogs: written, slotCountKeys: new Set([key]) })).toEqual([])
|
||||
})
|
||||
|
||||
it('scans the same sources locale validation scans', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'bookorbit-locale-source-'))
|
||||
temporaryDirectories.push(directory)
|
||||
await mkdir(path.join(directory, '__tests__'), { recursive: true })
|
||||
await writeFile(path.join(directory, 'Widget.vue'), '<template><IcuCountText keypath="tools.bulkRename.confirmDialog.body" /></template>')
|
||||
await writeFile(path.join(directory, 'helper.ts'), "export const label = t('common.save')\n")
|
||||
await writeFile(path.join(directory, 'helper.spec.ts'), "t('ignored.spec')\n")
|
||||
await writeFile(path.join(directory, '__tests__', 'helper.ts'), "t('ignored.tests')\n")
|
||||
|
||||
await expect(collectSourceMessageKeys(directory)).resolves.toEqual({
|
||||
keys: new Set(['tools.bulkRename.confirmDialog.body', 'common.save']),
|
||||
slotCountKeys: new Set(['tools.bulkRename.confirmDialog.body']),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user