From 531cffc918cffd8844c997bfdeb4ae7faa1cd42e Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Sat, 10 Feb 2024 23:20:27 +0900 Subject: [PATCH 1/4] Refactor `deepLTranslate()` to use payloads --- src/sheetsl.ts | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/sheetsl.ts b/src/sheetsl.ts index 4754785..6a2796f 100644 --- a/src/sheetsl.ts +++ b/src/sheetsl.ts @@ -35,6 +35,16 @@ export interface DeepLSupportedLanguages { supports_formality: boolean; } +/** + * The request payload to the DeepL API for POST /v2/translate. + * @see https://www.deepl.com/docs-api/translate-text/ + */ +interface DeepLTranslationRequest { + text: string[]; + target_lang: string; + source_lang?: string; +} + /** * The response from the DeepL API for POST /v2/translate. * @see https://www.deepl.com/docs-api/translate-text/ @@ -343,36 +353,37 @@ export function deepLTranslate( targetLocale: string, ): string[] { const endpoint = 'translate'; - let sourceTextCasted: string; + let sourceTexts: string[]; if (!sourceText || sourceText.length === 0) { throw new Error(`[${ADDON_NAME}] Empty input.`); - } - if (Array.isArray(sourceText)) { - sourceTextCasted = sourceText - .map((text) => `text=${encodeURIComponent(text)}`) - .join('&'); + } else if (Array.isArray(sourceText)) { + sourceTexts = sourceText; } else { - sourceTextCasted = `text=${encodeURIComponent(sourceText)}`; + sourceTexts = [sourceText]; } - // console.log(`sourceTextCasted: ${sourceTextCasted}`); // API key const apiKey = getDeepLApiKey(); const baseUrl = getDeepLApiBaseUrl(apiKey); // Call the DeepL API - let url = - baseUrl + - endpoint + - `?auth_key=${apiKey}&target_lang=${targetLocale}&${sourceTextCasted}`; + const url = baseUrl + endpoint; + const payload: DeepLTranslationRequest = { + text: sourceTexts, + target_lang: targetLocale, + }; if (sourceLocale) { - url += `&source_lang=${sourceLocale}`; + payload.source_lang = sourceLocale; } - // console.log(`url: ${url}`); + const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = { + method: 'post', + contentType: 'application/json', + headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, + payload: JSON.stringify(payload), + muteHttpExceptions: true, + }; // Call the DeepL API translate request - const response = handleDeepLErrors( - UrlFetchApp.fetch(url, { muteHttpExceptions: true }), - ); + const response = handleDeepLErrors(UrlFetchApp.fetch(url, options)); const translatedTextObj = JSON.parse( response.getContentText(), @@ -381,7 +392,6 @@ export function deepLTranslate( (translationsResponse: DeepLTranslationObj): string => translationsResponse.text, ); - // console.log(`translatedText: ${JSON.stringify(translatedText)}`); return translatedText; } @@ -428,6 +438,7 @@ export function handleDeepLErrors( response: GoogleAppsScript.URL_Fetch.HTTPResponse, ): GoogleAppsScript.URL_Fetch.HTTPResponse { const responseCode = response.getResponseCode(); + console.log(`responseCode: ${responseCode}`, response.getContentText()); if (responseCode === 429) { throw new Error( `[${ADDON_NAME}] Too Many Requests: Try again after some time.`, From c1d3572315182fcba468083f57f3dfe4f8d0a58b Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Sat, 10 Feb 2024 23:27:48 +0900 Subject: [PATCH 2/4] Rename function name `translateSelectedRange()` `translateRange` -> `translateSelectedRange` --- src/sheetsl.ts | 11 +++++++---- ...ange.test.ts => translateSelectedRange.test.ts} | 14 +++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) rename tests/{translateRange.test.ts => translateSelectedRange.test.ts} (97%) diff --git a/src/sheetsl.ts b/src/sheetsl.ts index 6a2796f..641d058 100644 --- a/src/sheetsl.ts +++ b/src/sheetsl.ts @@ -91,7 +91,7 @@ function onOpen(): void { .addItem('Set Language', 'setLanguage'), ) .addSeparator() - .addItem('Translate', 'translateRange') + .addItem('Translate', 'translateSelectedRange') .addToUi(); } @@ -265,18 +265,20 @@ export function setLanguage(): void { * Translate the selected cell range using DeepL API * and paste the result in the adjacent range. */ -export function translateRange(): void { +export function translateSelectedRange(): void { const ui = SpreadsheetApp.getUi(); const activeSheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); const selectedRange = activeSheet.getActiveRange(); const userProperties = PropertiesService.getUserProperties().getProperties(); try { if (!userProperties[UP_KEY_TARGET_LOCALE]) { + // If the target language is not set, throw an error throw new Error( `[${ADDON_NAME}] Target Language Unavailable: Set the target language in Settings > Set Language of the add-on menu.`, ); } if (!selectedRange) { + // If no cell is selected, throw an error throw new Error(`[${ADDON_NAME}] Select cells to translate.`); } // Check target range, i.e., the range where translated texts will be placed @@ -288,6 +290,7 @@ export function translateRange(): void { selectedRangeNumCol, ); if (!targetRange.isBlank()) { + // If the target range is not empty, ask the user whether to proceed and overwrite the contents const alertOverwrite = ui.alert( 'Translated text(s) will be pasted in the cell(s) to the right of the currently selected range. This target area is not empty.\nContinuing this process will overwrite the contents.\n\nAre you sure you want to continue?', ui.ButtonSet.OK_CANCEL, @@ -299,8 +302,8 @@ export function translateRange(): void { // Get the source text const sourceTextArr = selectedRange.getValues(); - // console.log(`sourceTextArr: ${JSON.stringify(sourceTextArr)}`); + /* const translatedText = sourceTextArr.map((row) => row.map((cellValue: string | number | boolean) => { if (cellValue === '') { @@ -327,7 +330,7 @@ export function translateRange(): void { } }), ); - // console.log(`translatedText: ${JSON.stringify(translatedText)}`); + */ // Set translated text in target range targetRange.setValues(translatedText); diff --git a/tests/translateRange.test.ts b/tests/translateSelectedRange.test.ts similarity index 97% rename from tests/translateRange.test.ts rename to tests/translateSelectedRange.test.ts index 3070edb..23606c3 100644 --- a/tests/translateRange.test.ts +++ b/tests/translateSelectedRange.test.ts @@ -1,6 +1,6 @@ -import { translateRange } from '../src/sheetsl'; +import { translateSelectedRange } from '../src/sheetsl'; -describe('translateRange', () => { +describe('translateSelectedRange', () => { beforeEach(() => { global.UrlFetchApp = { fetch: jest.fn(() => ({ @@ -62,7 +62,7 @@ describe('translateRange', () => { })), sleep: jest.fn(), } as unknown as GoogleAppsScript.Utilities.Utilities; - translateRange(); + translateSelectedRange(); expect(global.Utilities.sleep).toHaveBeenCalledTimes(5); expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(5); expect(console.error).not.toHaveBeenCalled(); @@ -95,7 +95,7 @@ describe('translateRange', () => { getProperties: jest.fn(() => ({})), // mock target locale NOT set })), } as unknown as GoogleAppsScript.Properties.PropertiesService; - translateRange(); + translateSelectedRange(); expect(global.Utilities.sleep).not.toHaveBeenCalled(); expect(global.UrlFetchApp.fetch).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( @@ -122,7 +122,7 @@ describe('translateRange', () => { })), })), } as unknown as GoogleAppsScript.Properties.PropertiesService; - translateRange(); + translateSelectedRange(); expect(global.Utilities.sleep).not.toHaveBeenCalled(); expect(global.UrlFetchApp.fetch).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( @@ -180,7 +180,7 @@ describe('translateRange', () => { })), sleep: jest.fn(), } as unknown as GoogleAppsScript.Utilities.Utilities; - translateRange(); + translateSelectedRange(); expect(global.Utilities.sleep).not.toHaveBeenCalled(); expect(global.UrlFetchApp.fetch).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( @@ -233,7 +233,7 @@ describe('translateRange', () => { })), sleep: jest.fn(), } as unknown as GoogleAppsScript.Utilities.Utilities; - translateRange(); + translateSelectedRange(); expect(global.Utilities.sleep).not.toHaveBeenCalled(); expect(global.UrlFetchApp.fetch).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( From f2fb411eaba35511402b6a5020605822a1a08bb5 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Sun, 11 Feb 2024 23:28:07 +0900 Subject: [PATCH 3/4] Switch DeepL API translation request to payload --- src/sheetsl.ts | 135 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 39 deletions(-) diff --git a/src/sheetsl.ts b/src/sheetsl.ts index 641d058..82f5fb8 100644 --- a/src/sheetsl.ts +++ b/src/sheetsl.ts @@ -21,8 +21,12 @@ const DEEPL_API_VERSION = 'v2'; // DeepL API version const DEEPL_API_BASE_URL_FREE = `https://api-free.deepl.com/${DEEPL_API_VERSION}/`; const DEEPL_API_BASE_URL_PRO = `https://api.deepl.com/${DEEPL_API_VERSION}/`; -// Threshold value of the length of the text to translate, in bytes. See https://developers.google.com/apps-script/guides/services/quotas#current_limitations -const THRESHOLD_BYTES = 1900; +const MAX_TEXT_NUM = 50; // Maximum number of texts to translate in a single request +// Threshold value of the length of the text to translate, in bytes. +// From the DeepL API Doc: "The total request body size must not exceed 128 KiB (128 · 1024 bytes)." +// See https://www.deepl.com/docs-api/translate-text +// The constant part of the request body is approx. 200 bytes, so we'll set the limit to 127 * 1028 bytes with a margin +const THRESHOLD_BYTES = 127 * 1028; /** * The JavaScript object of a DeepL-supported language. @@ -40,7 +44,7 @@ export interface DeepLSupportedLanguages { * @see https://www.deepl.com/docs-api/translate-text/ */ interface DeepLTranslationRequest { - text: string[]; + text: (string | number)[]; target_lang: string; source_lang?: string; } @@ -303,37 +307,14 @@ export function translateSelectedRange(): void { // Get the source text const sourceTextArr = selectedRange.getValues(); - /* - const translatedText = sourceTextArr.map((row) => - row.map((cellValue: string | number | boolean) => { - if (cellValue === '') { - return ''; - } - const textBytes = getBlobBytes(encodeURIComponent(cellValue)); - if (textBytes > THRESHOLD_BYTES) { - const cellValueString = cellValue.toString(); - const truncatedCellValue = cellValueString.slice( - 0, - Math.floor((cellValueString.length * THRESHOLD_BYTES) / textBytes), - ); - throw new Error( - `[${ADDON_NAME}] Cell content length exceeds Google's limits. Please consider splitting the content into multiple cells. The following is the estimated maximum length of the cell in question:\n${truncatedCellValue}\n\nPlease note that this is a rough estimate and that the actual acceptable text length might differ slightly.`, - ); - } else { - Utilities.sleep(1000); // Interval to avoid concentrated access to API - // Cell-based translation - return deepLTranslate( - cellValue.toString(), - userProperties[UP_KEY_SOURCE_LOCALE], - userProperties[UP_KEY_TARGET_LOCALE], - )[0]; - } - }), - ); - */ - // Set translated text in target range - targetRange.setValues(translatedText); + targetRange.setValues( + translateRange( + sourceTextArr as (string | number)[][], + userProperties[UP_KEY_TARGET_LOCALE], + userProperties[UP_KEY_SOURCE_LOCALE], + ), + ); // Complete ui.alert('Complete: Translation has been completed.'); } catch (error) { @@ -342,22 +323,99 @@ export function translateSelectedRange(): void { } } +/** + * Translate the given 2-dimension array of texts using DeepL API + * and return the translated texts in the same format. + * @param sourceTextArr 2-dimension array of texts to translate + * @returns 2-dimension array of translated texts + * @see https://www.deepl.com/docs-api/translate-text + */ +export function translateRange( + sourceTextArr: (string | number)[][], + targetLocale: string, + sourceLocale?: string, +): string[][] { + const columnNumber = sourceTextArr[0].length; + const translatedRangeFlat = splitLongArray( + // Split the array into multiple arrays if the total length of the array exceeds the given maximum length + // or if the total length of the stringified array in bytes exceeds the given maximum bytes + sourceTextArr.flat(), + MAX_TEXT_NUM, + THRESHOLD_BYTES, + ) + .map((arr) => deepLTranslate(arr, targetLocale, sourceLocale)) + .flat(); + return translatedRangeFlat.reduce((acc, _, i, arr) => { + if (i % columnNumber === 0) { + acc.push(arr.slice(i, i + columnNumber)); + } + return acc; + }, [] as string[][]); +} + +/** + * Split the given array into multiple arrays + * if the total length of the array exceeds the given maximum length + * or if the total length of the stringified array in bytes exceeds the given maximum bytes. + * Execute this function recursively until the given array is within the given limits. + * @param originalArray The original array to split + * @param maxLength The maximum length of the array + * @param maxBytes The maximum length of the stringified array in bytes + * @returns An array of arrays. If the original array is within the given limits, the array will contain the original array. + */ +export function splitLongArray( + originalArray: T[], + maxLength: number, + maxBytes: number, +): T[][] { + const returnArray: T[][] = []; + if ( + originalArray.length <= maxLength && + getBlobBytes(JSON.stringify(originalArray)) <= maxBytes + ) { + returnArray.push(originalArray); + } else { + const halfLength = Math.floor(originalArray.length / 2); + const firstHalf = originalArray.slice(0, halfLength); + const secondHalf = originalArray.slice(halfLength); + [firstHalf, secondHalf].forEach((arr) => { + if (arr.length === 1 && getBlobBytes(JSON.stringify(arr)) > maxBytes) { + throw new Error( + `[${ADDON_NAME}] The following cell value exceeds the maximum length of the text to translate. Please consider splitting the content into multiple cells.:\n${String(arr[0])}`, + ); + } + if ( + arr.length <= maxLength && + getBlobBytes(JSON.stringify(arr)) <= maxBytes + ) { + returnArray.push(arr); + } else { + returnArray.push(...splitLongArray(arr, maxLength, maxBytes)); + } + }); + } + return returnArray; +} + /** * Call the DeepL API on the `translate` endpoint * @param sourceText Array of texts to translate - * @param sourceLocale The language of the source text * @param targetLocale The language to be translated into + * @param sourceLocale The language of the source text * @returns Array of translated texts. * @see https://www.deepl.com/docs-api/translate-text/ */ export function deepLTranslate( - sourceText: string | string[] | null | undefined, - sourceLocale: string | null | undefined, + sourceText: string | number | (string | number)[] | null | undefined, targetLocale: string, + sourceLocale?: string | null, ): string[] { const endpoint = 'translate'; - let sourceTexts: string[]; - if (!sourceText || sourceText.length === 0) { + let sourceTexts: (string | number)[]; + if ( + !sourceText || + (typeof sourceText === 'string' && sourceText.length === 0) + ) { throw new Error(`[${ADDON_NAME}] Empty input.`); } else if (Array.isArray(sourceText)) { sourceTexts = sourceText; @@ -441,7 +499,6 @@ export function handleDeepLErrors( response: GoogleAppsScript.URL_Fetch.HTTPResponse, ): GoogleAppsScript.URL_Fetch.HTTPResponse { const responseCode = response.getResponseCode(); - console.log(`responseCode: ${responseCode}`, response.getContentText()); if (responseCode === 429) { throw new Error( `[${ADDON_NAME}] Too Many Requests: Try again after some time.`, From 07c7b2a2c6bebd630f9dd4a723911cf1584b5f60 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Sun, 11 Feb 2024 23:29:27 +0900 Subject: [PATCH 4/4] Created or revised tests for the refactored code --- tests/deepLTranslate.test.ts | 10 ++-- tests/splitLongArray.test.ts | 87 ++++++++++++++++++++++++++++ tests/translateRange.test.ts | 63 ++++++++++++++++++++ tests/translateSelectedRange.test.ts | 73 ++++------------------- 4 files changed, 167 insertions(+), 66 deletions(-) create mode 100644 tests/splitLongArray.test.ts create mode 100644 tests/translateRange.test.ts diff --git a/tests/deepLTranslate.test.ts b/tests/deepLTranslate.test.ts index 1d66ec7..39bea72 100644 --- a/tests/deepLTranslate.test.ts +++ b/tests/deepLTranslate.test.ts @@ -17,22 +17,22 @@ describe('deepLTranslate', () => { const mockSourceObjects = [ { note: 'with source language specified', - sourceLang: 'EN-US', targetLang: 'DE', + sourceLang: 'EN-US', sourceText: 'Hello, World!', translatedText: 'Hallo, Welt!', }, { note: 'without source language specified', - sourceLang: null, targetLang: 'DE', + sourceLang: null, sourceText: 'Hello, World!', translatedText: 'Hallo, Welt!', }, { note: 'in an array of strings', - sourceLang: null, targetLang: 'DE', + sourceLang: null, sourceText: ['Hello, World!', 'Hello, World!'], translatedText: ['Hallo, Welt!', 'Hallo, Welt!'], }, @@ -53,7 +53,7 @@ describe('deepLTranslate', () => { getResponseCode: jest.fn(() => 200), })), } as unknown as GoogleAppsScript.URL_Fetch.UrlFetchApp; - const translated = deepLTranslate(sourceText, sourceLang, targetLang); + const translated = deepLTranslate(sourceText, targetLang, sourceLang); expect(translated).toStrictEqual( Array.isArray(translatedText) ? translatedText : [translatedText], ); @@ -78,7 +78,7 @@ describe('deepLTranslate', () => { 'should throw an error $note', // eslint-disable-next-line @typescript-eslint/no-unused-vars ({ note, sourceLang, targetLang, sourceText }) => { - expect(() => deepLTranslate(sourceText, sourceLang, targetLang)).toThrow( + expect(() => deepLTranslate(sourceText, targetLang, sourceLang)).toThrow( new Error('[SheetsL] Empty input.'), ); }, diff --git a/tests/splitLongArray.test.ts b/tests/splitLongArray.test.ts new file mode 100644 index 0000000..c160d90 --- /dev/null +++ b/tests/splitLongArray.test.ts @@ -0,0 +1,87 @@ +import { splitLongArray } from '../src/sheetsl'; + +const maxLength = 5; +const maxBytes = 128; + +describe('splitLongArray', () => { + beforeEach(() => { + global.Utilities = { + newBlob: jest.fn((text: string) => ({ + getBytes: jest.fn(() => new TextEncoder().encode(text)), + })), + } as unknown as GoogleAppsScript.Utilities.Utilities; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return the original array if it is less than or equal to maxLength and maxBytes', () => { + const shortArray = new Array(3).fill('short'); + const result = splitLongArray(shortArray, maxLength, maxBytes); + expect(result.length).toBe(1); + expect(result[0]).toEqual(shortArray); + }); + it('should return the original array split in half for a long array with small elements', () => { + const longArrayWithSmallElements = new Array(7).fill('short'); + const result = splitLongArray( + longArrayWithSmallElements, + maxLength, + maxBytes, + ); + expect(result.length).toBe(2); + expect(result[0]).toEqual(['short', 'short', 'short']); + expect(result[1]).toEqual(['short', 'short', 'short', 'short']); + }); + it('should return the original array split in three for a long array with small elements', () => { + const longArrayWithSmallElements = new Array(11).fill('short'); + const result = splitLongArray( + longArrayWithSmallElements, + maxLength, + maxBytes, + ); + expect(result.length).toBe(3); + expect(result[0]).toEqual(['short', 'short', 'short', 'short', 'short']); + expect(result[1]).toEqual(['short', 'short', 'short']); + expect(result[2]).toEqual(['short', 'short', 'short']); + }); + it('should return the original array split in three for a long array with long elements', () => { + const longArrayWithSmallElements = new Array(9).fill( + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + ); + const result = splitLongArray( + longArrayWithSmallElements, + maxLength, + maxBytes, + ); + expect(result.length).toBe(5); + expect(result[0]).toEqual([ + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + ]); + expect(result[1]).toEqual([ + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + ]); + expect(result[2]).toEqual([ + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + ]); + expect(result[3]).toEqual([ + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + ]); + expect(result[4]).toEqual([ + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + ]); + }); + it('should return an error if an element in the given array exceeds maxBytes by itself', () => { + const longArrayWithLargeElement = new Array(11).fill('short') as string[]; + longArrayWithLargeElement[0] = `# SheetsL - DeepL Translation for Google Sheets Google Sheets add-on to use DeepL translation. Translate the contents of the selected range and paste them in the range of cells adjacent to the original range.`; + expect(() => + splitLongArray(longArrayWithLargeElement, maxLength, maxBytes), + ).toThrow( + new Error( + `[SheetsL] The following cell value exceeds the maximum length of the text to translate. Please consider splitting the content into multiple cells.:\n${longArrayWithLargeElement[0]}`, + ), + ); + }); +}); diff --git a/tests/translateRange.test.ts b/tests/translateRange.test.ts new file mode 100644 index 0000000..29ad693 --- /dev/null +++ b/tests/translateRange.test.ts @@ -0,0 +1,63 @@ +import { translateRange } from '../src/sheetsl'; + +describe('translateRange', () => { + beforeEach(() => { + global.PropertiesService = { + // Mock getDeepLApiKey() + getUserProperties: jest.fn(() => ({ + getProperty: jest.fn(() => 'mockApiKey'), + })), + } as unknown as GoogleAppsScript.Properties.PropertiesService; + global.Utilities = { + // Mock the newBlob() method for splitLongArray() + newBlob: jest.fn(() => ({ + getBytes: jest.fn(() => []), + })), + } as unknown as GoogleAppsScript.Utilities.Utilities; + global.UrlFetchApp = { + fetch: jest.fn(() => ({ + // Mock the response for deepLTranslate() + getContentText: jest.fn(() => + JSON.stringify({ + translations: [ + { text: 'カラム 1' }, + { text: 'カラム 2' }, + { text: 'カラム 3' }, + { text: '行 1-1' }, + { text: '行 1-2' }, + { text: '行 1-3' }, + { text: '行 2-1' }, + { text: '行 2-2' }, + { text: '行 2-3' }, + { text: '行 3-1' }, + { text: '行 3-2' }, + { text: '行 3-3' }, + ], + }), + ), + getResponseCode: jest.fn(() => 200), // Mock the response code for handleDeepLErrors + })), + } as unknown as GoogleAppsScript.URL_Fetch.UrlFetchApp; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return the translated 2-dimensional array of strings', () => { + const mockSourceTextArray = [ + ['Column 1', 'Column 2', 'Column 3'], + ['Row 1-1', 'Row 1-2', 'Row 1-3'], + ['Row 2-1', 'Row 2-2', 'Row 2-3'], + ['Row 3-1', 'Row 3-2', 'Row 3-3'], + ]; + const mockTargetLocale = 'JA'; + const mockTranslatedTextArray = [ + ['カラム 1', 'カラム 2', 'カラム 3'], + ['行 1-1', '行 1-2', '行 1-3'], + ['行 2-1', '行 2-2', '行 2-3'], + ['行 3-1', '行 3-2', '行 3-3'], + ]; + expect(translateRange(mockSourceTextArray, mockTargetLocale)).toEqual( + mockTranslatedTextArray, + ); + }); +}); diff --git a/tests/translateSelectedRange.test.ts b/tests/translateSelectedRange.test.ts index 23606c3..780fc74 100644 --- a/tests/translateSelectedRange.test.ts +++ b/tests/translateSelectedRange.test.ts @@ -5,7 +5,17 @@ describe('translateSelectedRange', () => { global.UrlFetchApp = { fetch: jest.fn(() => ({ getContentText: jest.fn( - () => JSON.stringify({ translations: [{ text: 'Hallo, Welt!' }] }), // mock deepLTranslate + () => + JSON.stringify({ + translations: [ + { text: 'Hallo, Welt!' }, + { text: 'Hallo, Welt!' }, + { text: 'Hallo, Welt!' }, + { text: '' }, + { text: 'Hallo, Welt!' }, + { text: '12345' }, + ], + }), // mock deepLTranslate ), getResponseCode: jest.fn(() => 200), // mock handleDeepLErrors() to not throw errors })), @@ -60,11 +70,9 @@ describe('translateSelectedRange', () => { newBlob: jest.fn(() => ({ getBytes: jest.fn(() => [0, 1, 2, 3]), // mock blob < THRESHOLD_BYTES (1900) })), - sleep: jest.fn(), } as unknown as GoogleAppsScript.Utilities.Utilities; translateSelectedRange(); - expect(global.Utilities.sleep).toHaveBeenCalledTimes(5); - expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(5); + expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(1); expect(console.error).not.toHaveBeenCalled(); }); describe('should catch errors', () => { @@ -96,7 +104,6 @@ describe('translateSelectedRange', () => { })), } as unknown as GoogleAppsScript.Properties.PropertiesService; translateSelectedRange(); - expect(global.Utilities.sleep).not.toHaveBeenCalled(); expect(global.UrlFetchApp.fetch).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( expect.stringMatching( @@ -123,7 +130,6 @@ describe('translateSelectedRange', () => { })), } as unknown as GoogleAppsScript.Properties.PropertiesService; translateSelectedRange(); - expect(global.Utilities.sleep).not.toHaveBeenCalled(); expect(global.UrlFetchApp.fetch).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( expect.stringMatching( @@ -187,60 +193,5 @@ describe('translateSelectedRange', () => { expect.stringMatching(/^Error: \[SheetsL\] Translation canceled.\n/), ); }); - it('when a given text has a byte length that is larger than the threshold ', () => { - const mockSelectedRangeValues = [ - ['Hello, world!', 'Hello, world!'], - ['Hello, world!', ''], // empty cell - ['Hello, world!', 12345], // non-string cell - ]; - global.SpreadsheetApp = { - getActiveSpreadsheet: jest.fn(() => ({ - getActiveSheet: jest.fn(() => ({ - getActiveRange: jest.fn(() => ({ - getValues: jest.fn(() => mockSelectedRangeValues), - getRow: jest.fn(() => 1), - getColumn: jest.fn(() => 1), - getNumRows: jest.fn(() => mockSelectedRangeValues.length), - getNumColumns: jest.fn(() => mockSelectedRangeValues[0].length), - })), - getRange: jest.fn(() => ({ - isBlank: jest.fn(() => true), // mock target range is blank - setValues: jest.fn(), - })), - })), - })), - getUi: jest.fn(() => ({ - ButtonSet: { - OK_CANCEL: 'ok_cancel', - }, - alert: jest.fn(), - })), - } as unknown as GoogleAppsScript.Spreadsheet.SpreadsheetApp; - global.PropertiesService = { - getUserProperties: jest.fn(() => ({ - getProperties: jest.fn(() => ({ - targetLocale: 'DE', // mock target locale set - })), - getProperty: jest.fn((key) => { - if (key === 'deeplApiKey') return 'Sample-API-key:fx'; // mock getDeepLApiKey() - return null; - }), - })), - } as unknown as GoogleAppsScript.Properties.PropertiesService; - global.Utilities = { - newBlob: jest.fn(() => ({ - getBytes: jest.fn(() => new Array(2000) as number[]), // mock blob > THRESHOLD_BYTES (1900) - })), - sleep: jest.fn(), - } as unknown as GoogleAppsScript.Utilities.Utilities; - translateSelectedRange(); - expect(global.Utilities.sleep).not.toHaveBeenCalled(); - expect(global.UrlFetchApp.fetch).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringMatching( - /^Error: \[SheetsL\] Cell content length exceeds Google's limits. Please consider splitting the content into multiple cells./, - ), - ); - }); }); });