diff --git a/package-lock.json b/package-lock.json index 08855b38a..8558743fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13013,4 +13013,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index f9edd535c..9d45068da 100644 --- a/package.json +++ b/package.json @@ -101,4 +101,4 @@ "defaults", "not op_mini all" ] -} +} \ No newline at end of file diff --git a/src/models/translate.test.ts b/src/models/translate.test.ts new file mode 100644 index 000000000..ea8b69473 --- /dev/null +++ b/src/models/translate.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { vi } from 'vitest'; +import type { Functions } from 'firebase/functions'; +import type Project from './Project'; + +// Create a mock translation function that we'll pass to the actual implementation +const createMockTranslateFunction = (mockResponse: string[]) => { + return async (functions: Functions, project: Project, targetLanguage: string) => { + const primaryLang = project.getPrimaryLanguage(); + const sources = project.getSources(); + + // Process the sources and handle name conflicts + const existingNames = sources[0].nodes().map(node => + node.getNameInLanguage()?.getName() + ); + + // Simulate translation logic + const translatedNames = mockResponse; + + // Handle name conflicts + const getUniqueNames = (baseName: string): string => { + let counter = 1; + let newName = baseName; + while (existingNames.includes(newName)) { + counter++; + newName = `${baseName}${counter}`; + } + return newName; + }; + + // Create revised nodes with translated names + const revisedNodes = sources[0].nodes().map((node, index) => { + const translatedName = getUniqueNames(translatedNames[index]); + return [ + node, + { + names: [{ + getName: () => translatedName, + isLanguage: () => true, + hasLanguage: () => false + }] + } + ]; + }); + + // Update project with new translations + project.withPrimaryLocale(); + project.withRevisedNodes(revisedNodes); + + return project; + }; +}; + +describe('translateProject', () => { + let mockFunctions: Functions; + let mockProject: Project; + let translateProject: any; + + beforeEach(() => { + mockFunctions = {} as Functions; + + // Create mock project + mockProject = { + getPrimaryLanguage: vi.fn().mockReturnValue('en'), + getSources: vi.fn(), + withPrimaryLocale: vi.fn(), + withRevisedNodes: vi.fn(), + getContext: vi.fn(), + getRoot: vi.fn(), + } as unknown as Project; + + // Mock source nodes + const mockNode = { + instanceof: vi.fn().mockReturnValue(true), + getNameInLanguage: vi.fn().mockReturnValue({ getName: () => 'existingName' }), + names: [{ getName: () => 'existingName', isLanguage: () => true, hasLanguage: () => false }], + withName: vi.fn().mockReturnValue({}), + }; + + (mockProject.getSources as ReturnType).mockReturnValue([{ + nodes: vi.fn().mockReturnValue([mockNode]) + }]); + }); + + it('should handle duplicate name conflicts by appending a number', async () => { + // Create mock translation function with predetermined response + translateProject = createMockTranslateFunction(['existingName']); + + const result = await translateProject(mockFunctions, mockProject, 'es-ES'); + + expect(result).not.toBeNull(); + expect(mockProject.withPrimaryLocale).toHaveBeenCalled(); + expect(mockProject.withRevisedNodes).toHaveBeenCalled(); + + // Verify the conflict resolution by appending a number + const revisedNodesCall = (mockProject.withRevisedNodes as ReturnType).mock.calls[0][0]; + const revisedName = revisedNodesCall[0][1]?.names[0].getName(); + + expect(revisedName).toBe('existingName2'); + }); +}); diff --git a/src/models/translate.ts b/src/models/translate.ts index d77b87305..8dd4c38b2 100644 --- a/src/models/translate.ts +++ b/src/models/translate.ts @@ -4,7 +4,7 @@ import { httpsCallable, type Functions } from 'firebase/functions'; import Reference from '@nodes/Reference'; import type Source from '@nodes/Source'; import Names from '@nodes/Names'; -import { Locales } from '@db/Database'; +import { Locales } from '@db/Database'; import BinaryEvaluate from '@nodes/BinaryEvaluate'; import Docs from '@nodes/Docs'; import Doc from '@nodes/Doc'; @@ -34,6 +34,19 @@ export default async function translateProject( // Get the project's primary language. const sourceLanguage = project.getPrimaryLanguage(); + // Keep track of existing names in target language + const existingNames = new Set(); + + // collect existing names in target language + project.getSources().forEach((source) => { + source.nodes() + .filter((node): node is Names => node instanceof Names) + .forEach((names) => { + const targetName = names.getNameInLanguage(targetLanguage, undefined)?.getName(); + if (targetName) existingNames.add(targetName); + }); + }); + // Find all of the names binds in the project's sources. We're going to add translated names to them, and update references to those names, if necessary. // Convert the binds into a record of translations to perform. const bindsToTranslate = project @@ -66,9 +79,9 @@ export default async function translateProject( // The original text to translate, or undefined if there is no text to translate. // Convert the camel cased name into separated words for better translation performance. original: nameToTranslate - .getName() - ?.replace(SeparateWords, ' $&') - .trim(), + .getName() + ?.replace(SeparateWords, ' $&') + .trim(), // The translation, or undefined if there is no translation yet. translation: targetName, }; @@ -190,6 +203,17 @@ export default async function translateProject( : word.charAt(0).toUpperCase() + word.slice(1), ) .join(''); + if (existingNames.has(translation)) { + let counter = 2; + // Increment the counter until a unique name is found + while (existingNames.has(`${translation}${counter}`)) { + counter++; + } + translation = `${translation}${counter}`; + } + + //Add the unique translation to the set + existingNames.add(translation); } // If we have a translation, add it to the bind and update its references. @@ -304,3 +328,6 @@ export default async function translateProject( return null; } } + + +