Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 101 additions & 2 deletions foundations/core/packages/platform/src/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,22 @@
//

import type { Plugin, IntlString } from '../platform'
import platform, { plugin } from '../platform'
import platform, { getEmbeddedLabel, plugin } from '../platform'
import { Severity, Status } from '../status'

import { addStringsLoader, translate } from '../i18n'
import { addStringsLoader, loadPluginStrings, translate, translateCB } from '../i18n'
import { addEventListener, PlatformEvent, removeEventListener } from '../event'

function translateCBAsync (
message: IntlString,
params: Record<string, any>,
language: string | undefined
): Promise<string> {
return new Promise((resolve) => {
translateCB(message, params, language, resolve)
})
}

const testId = 'test-strings' as Plugin

const test = plugin(testId, {
Expand Down Expand Up @@ -112,4 +122,93 @@ describe('i18n', () => {
expect(translated).toBe(message)
removeEventListener(PlatformEvent, eventListener)
})

it('translateCB should match translate for loaded string', async () => {
const fromTranslate = await translate(test.string.loadingPlugin, { plugin: 'cb' })
const fromCB = await translateCBAsync(test.string.loadingPlugin, { plugin: 'cb' }, 'en')
expect(fromCB).toBe(fromTranslate)
})

it('translate and translateCB should return embedded label text', async () => {
const embedded = getEmbeddedLabel('Embedded copy')
expect(await translate(embedded, {})).toBe('Embedded copy')
expect(await translateCBAsync(embedded, {}, 'en')).toBe('Embedded copy')
})

it('loadPluginStrings(force) should clear format cache so strings still resolve', async () => {
await translate(test.string.loadingPlugin, { plugin: 'before' })
await loadPluginStrings('en', true)
const after = await translate(test.string.loadingPlugin, { plugin: 'after' })
expect(after).toContain('after')
})

it('translate with skipError should not broadcast platform status (no loader)', async () => {
const pluginId = 'plugin-skip-error-no-loader'
const message = `${pluginId}:string:any` as IntlString
let events = 0
const listener = async (): Promise<void> => {
events++
}
addEventListener(PlatformEvent, listener)
await translate(message, {}, 'en', true)
removeEventListener(PlatformEvent, listener)
expect(events).toBe(0)
})

it('translate should return message id and emit status when ICU format params are missing', async () => {
const fmtPlugin = 'i18n-icu-format-test' as Plugin
addStringsLoader(fmtPlugin, async () => ({
string: {
badPlural: '{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}}'
}
}))
const message = `${fmtPlugin}:string:badPlural` as IntlString
expect.assertions(2)
let gotStatus = false
const listener = async (_event: string, data: any): Promise<void> => {
if (data instanceof Status) {
gotStatus = true
}
}
addEventListener(PlatformEvent, listener)
const out = await translate(message, { days: 1, hours: 2 } as any, 'en')
removeEventListener(PlatformEvent, listener)
expect(out).toBe(message)
expect(gotStatus).toBe(true)
})

it('translateCB should resolve to message id when ICU format params are missing', async () => {
const fmtPlugin = 'i18n-icu-format-test-cb' as Plugin
addStringsLoader(fmtPlugin, async () => ({
string: {
badPlural: '{dias, plural, =0 {} other {#d}}'
}
}))
const message = `${fmtPlugin}:string:badPlural` as IntlString
expect.assertions(2)
let gotStatus = false
const listener = async (_event: string, data: any): Promise<void> => {
if (data instanceof Status) {
gotStatus = true
}
}
addEventListener(PlatformEvent, listener)
const out = await translateCBAsync(message, { days: 1 }, 'en')
removeEventListener(PlatformEvent, listener)
expect(out).toBe(message)
expect(gotStatus).toBe(true)
})

it('translateCB should defer to translate when translation is not yet cached', async () => {
const deferPlugin = 'i18n-defer-translate' as Plugin
const deferMsg = `${deferPlugin}:string:only` as IntlString
addStringsLoader(deferPlugin, async (locale: string) => {
if (locale === 'en') {
return { string: { only: 'Deferred {v}' } }
}
return { string: { only: 'Deferred {v}' } }
})
const out = await translateCBAsync(deferMsg, { v: 'ok' }, 'en')
expect(out).toBe('Deferred ok')
})
})
140 changes: 76 additions & 64 deletions foundations/core/packages/platform/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ async function setStatus (status: Status, skipError?: boolean): Promise<void> {
}
}

/** Notify platform and return a Status for load/resolve paths that do not use the per-message format cache. */
async function pipelineErrorToStatus (err: unknown, skipError?: boolean): Promise<Status> {
const status = unknownError(err)
await setStatus(status, skipError)
return status
}

/**
* On compile/resolve failure: cache failure for this intl id, notify platform, return `message` as UI fallback.
*/
async function handleIntlPipelineFailure (
err: unknown,
message: IntlString,
localeCache: Map<IntlString, IntlMessageFormat | Status>,
skipError?: boolean
): Promise<IntlString> {
const status = unknownError(err)
localeCache.set(message, status)
await setStatus(status, skipError)
return message
}

async function loadTranslationsForComponent (
plugin: Plugin,
locale: string,
Expand All @@ -88,9 +110,7 @@ async function loadTranslationsForComponent (
try {
return (await loader('en')) as Record<string, IntlString> | Status
} catch (err: any) {
const status = unknownError(err)
await setStatus(status, skipError)
return status
return await pipelineErrorToStatus(err, skipError)
}
}
}
Expand Down Expand Up @@ -150,9 +170,7 @@ async function getTranslation (
return messages[id.name] as IntlString
}
} catch (err) {
const status = unknownError(err)
await setStatus(status, skipError)
return status
return await pipelineErrorToStatus(err, skipError)
}
}

Expand All @@ -173,33 +191,29 @@ export async function translate<P extends Record<string, any>> (
if (!cache.has(locale)) {
cache.set(locale, localCache)
}
const compiled = localCache.get(message)
try {
const compiled = localCache.get(message)

if (compiled !== undefined) {
if (compiled instanceof Status) {
return message
}
return compiled.format(params)
} else {
try {
const id = _parseId(message)
if (id.component === _EmbeddedId) {
return id.name
}
const translation = getCachedTranslation(id, locale) ?? (await getTranslation(id, locale, skipError)) ?? message
if (translation instanceof Status) {
localCache.set(message, translation)
if (compiled !== undefined) {
if (compiled instanceof Status) {
return message
}
const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiled)
return compiled.format(params)
} catch (err) {
const status = unknownError(err)
await setStatus(status, skipError)
localCache.set(message, status)
}
const id = _parseId(message)
if (id.component === _EmbeddedId) {
return id.name
}
const translation = getCachedTranslation(id, locale) ?? (await getTranslation(id, locale, skipError)) ?? message
if (translation instanceof Status) {
localCache.set(message, translation)
return message
}
const compiledNew = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiledNew)
return compiledNew.format(params)
} catch (err) {
return await handleIntlPipelineFailure(err, message, localCache, skipError)
}
}
/**
Expand All @@ -217,46 +231,44 @@ export function translateCB<P extends Record<string, any>> (
if (!cache.has(locale)) {
cache.set(locale, localCache)
}
const compiled = localCache.get(message)
try {
const compiled = localCache.get(message)

if (compiled !== undefined) {
if (compiled instanceof Status) {
resolve(message)
return
}
resolve(compiled.format(params))
} else {
let id: _IdInfo
try {
id = _parseId(message)
if (id.component === _EmbeddedId) {
resolve(id.name)
if (compiled !== undefined) {
if (compiled instanceof Status) {
resolve(message)
return
}
resolve(compiled.format(params))
} else {
let id: _IdInfo
try {
id = _parseId(message)
if (id.component === _EmbeddedId) {
resolve(id.name)
return
}
} catch (err) {
void handleIntlPipelineFailure(err, message, localCache, skipError)
return
}
const translation = getCachedTranslation(id, locale)
if (translation === undefined || translation instanceof Status) {
void translate(message, params, language, skipError)
.then((res) => {
resolve(res)
})
.catch((err) => {
void handleIntlPipelineFailure(err, message, localCache, skipError).then(resolve)
})
return
}
} catch (err) {
const status = unknownError(err)
void setStatus(status, skipError)
localCache.set(message, status)
resolve(message)
return
}
const translation = getCachedTranslation(id, locale)
if (translation === undefined || translation instanceof Status) {
void translate(message, params, language)
.then((res) => {
resolve(res)
})
.catch((err) => {
const status = unknownError(err)
void setStatus(status, skipError)
localCache.set(message, status)
resolve(message)
})
return
}

const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiled)
resolve(compiled.format(params))
const compiledNew = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiledNew)
resolve(compiledNew.format(params))
}
} catch (err) {
void handleIntlPipelineFailure(err, message, localCache, skipError).then(resolve)
}
}
2 changes: 1 addition & 1 deletion plugins/time-assets/lang/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"Scheduled": "Programado",
"Schedule": "Programa",
"WithoutProject": "Sin proyecto",
"TotalGroupTime": "{días, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}",
"TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
"Tasks": "Tareas",
"WorkSlot": "Intervalo de trabajo",
"WorkItem": "Elemento de trabajo",
Expand Down
2 changes: 1 addition & 1 deletion plugins/time-assets/lang/pt-br.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"Scheduled": "Agendado",
"Schedule": "Agenda",
"WithoutProject": "Sem projeto",
"TotalGroupTime": "{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}",
"TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
"Tasks": "Tarefas",
"WorkSlot": "Intervalo de trabalho",
"WorkItem": "Item de trabalho",
Expand Down
2 changes: 1 addition & 1 deletion plugins/time-assets/lang/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"Scheduled": "Agendado",
"Schedule": "Agenda",
"WithoutProject": "Sem projeto",
"TotalGroupTime": "{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}",
"TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
"Tasks": "Tarefas",
"WorkSlot": "Intervalo de trabalho",
"WorkItem": "Item de trabalho",
Expand Down
Loading