diff --git a/resources/responses.ts b/resources/responses.ts index 366c691c8ec..3413624c8eb 100644 --- a/resources/responses.ts +++ b/resources/responses.ts @@ -31,6 +31,7 @@ import { TriggerOutput, } from '../types/trigger'; +import type { Lang } from './languages'; import Outputs from './outputs'; type TargetedResponseOutput = ResponseOutput; @@ -164,6 +165,28 @@ const staticResponse = (field: SevText, text: LocaleText): StaticResponseFunc => }; }; +export const combineLocaleText = ( + first: LocaleText, + ...rest: [sep: string, text: LocaleText][] +): LocaleText => { + const parts: [sep: string, text: LocaleText][] = [['', first], ...rest]; + const langs = new Set(['en']); + + for (const [, text] of parts) { + for (const lang of Object.keys(text)) + langs.add(lang as Lang); + } + + const result: Record = {}; + for (const lang of langs) { + result[lang] = parts + .map(([sep, text]) => `${sep}${text[lang] ?? text.en}`) + .join(''); + } + + return result as LocaleText; +}; + type SingleSevToResponseFunc = (sev?: Severity) => TargetedResponseFunc | StaticResponseFunc; type DoubleSevToResponseFunc = (targetSev?: Severity, otherSev?: Severity) => TargetedResponseFunc; type ResponsesMap = { @@ -625,6 +648,21 @@ export const Responses = { getTowers: (sev?: Severity) => staticResponse(defaultInfoText(sev), Outputs.getTowers), } as const; +// Example usage: +// { +// id: 'Some In => Out Mechanic', +// type: 'StartsUsing', +// netRegex: { id: '0000', source: 'Name' }, +// response: compose(Outputs.in, [' => ', Outputs.out])(), +// }, +export const compose = ( + first: LocaleText, + ...rest: [sep: string, text: LocaleText][] +): SingleSevToResponseFunc => { + return (sev?: Severity) => + staticResponse(defaultInfoText(sev), combineLocaleText(first, ...rest)); +}; + // Don't give `Responses` a type in its declaration so that it can be treated as more strict // than `ResponsesMap`, but do assert that its type is correct. This allows callers to know // which properties are defined in Responses without having to conditionally check for undefined. diff --git a/test/unittests/responses_test.ts b/test/unittests/responses_test.ts index 39ef9184983..d4940dd1512 100644 --- a/test/unittests/responses_test.ts +++ b/test/unittests/responses_test.ts @@ -1,7 +1,10 @@ import { assert } from 'chai'; +import Outputs from '../../resources/outputs'; import { builtInResponseStr, + combineLocaleText, + compose, Responses, severityList, severityMap, @@ -9,7 +12,13 @@ import { } from '../../resources/responses'; import { RaidbossData } from '../../types/data'; import { Matches } from '../../types/net_matches'; -import { Output, ResponseFunc, ResponseOutput } from '../../types/trigger'; +import { + LocaleText, + Output, + OutputStrings, + ResponseFunc, + ResponseOutput, +} from '../../types/trigger'; // test_trigger.js will validate the field names, so no need to do that here. @@ -27,6 +36,19 @@ const runResponseFunc = ( return func(empty as RaidbossData, empty as Matches, empty as Output); }; +const runResponseFuncWithOutputStrings = ( + func: ResponseFunc, +): [ResponseFuncOutput, OutputStrings] => { + const empty = {}; + const output: { responseOutputStrings: OutputStrings } = { + responseOutputStrings: {}, + }; + return [ + func(empty as RaidbossData, empty as Matches, output as Output), + output.responseOutputStrings, + ]; +}; + describe('response tests', () => { it('responses with default severity are valid', () => { for (const responseFunc of Object.values(Responses)) { @@ -106,4 +128,85 @@ describe('response tests', () => { } } }); + it('combineLocaleText combines localized text with fallbacks', () => { + const fallbackText: LocaleText = { + en: 'Fallback', + ja: 'Japanese Fallback', + }; + + assert.deepEqual( + combineLocaleText( + Outputs.in, + [' => ', Outputs.out], + [' + ', fallbackText], + ), + { + en: 'In => Out + Fallback', + de: 'Rein => Raus + Fallback', + fr: 'Intérieur => Extérieur + Fallback', + ja: '中へ => 外へ + Japanese Fallback', + cn: '靠近 => 远离 + Fallback', + ko: '안으로 => 밖으로 + Fallback', + tc: '靠近 => 遠離 + Fallback', + }, + ); + }); + it('compose returns a built-in static response', () => { + const responseFunc = compose(Outputs.in, [' => ', Outputs.out])(); + assert.include(responseFunc.toString(), outputStringSetterStr); + assert.include(responseFunc.toString(), builtInResponseStr); + + const [result, outputStrings] = runResponseFuncWithOutputStrings(responseFunc); + assert.isObject(result); + assert.property(result, 'infoText'); + assert.deepEqual(outputStrings.text, { + en: 'In => Out', + de: 'Rein => Raus', + fr: 'Intérieur => Extérieur', + ja: '中へ => 外へ', + cn: '靠近 => 远离', + ko: '안으로 => 밖으로', + tc: '靠近 => 遠離', + }); + }); + it('compose respects explicit severity and locale fallbacks', () => { + const fallbackText: LocaleText = { + en: 'Fallback', + ja: 'Japanese Fallback', + }; + const responseFunc = compose(Outputs.spread, [' 😗 ', fallbackText])('alarm'); + + const [result, outputStrings] = runResponseFuncWithOutputStrings(responseFunc); + assert.isObject(result); + assert.property(result, 'alarmText'); + assert.deepEqual(outputStrings.text, { + en: 'Spread 😗 Fallback', + de: 'Verteilen 😗 Fallback', + fr: 'Dispersez-vous 😗 Fallback', + ja: 'さんかい 😗 Japanese Fallback', + cn: '分散 😗 Fallback', + ko: '산개 😗 Fallback', + tc: '分散 😗 Fallback', + }); + }); + it('compose combines multiple pieces', () => { + const responseFunc = compose( + Outputs.in, + [' => ', Outputs.out], + [' + ', Outputs.spread], + )('alert'); + + const [result, outputStrings] = runResponseFuncWithOutputStrings(responseFunc); + assert.isObject(result); + assert.property(result, 'alertText'); + assert.deepEqual(outputStrings.text, { + en: 'In => Out + Spread', + de: 'Rein => Raus + Verteilen', + fr: 'Intérieur => Extérieur + Dispersez-vous', + ja: '中へ => 外へ + さんかい', + cn: '靠近 => 远离 + 分散', + ko: '안으로 => 밖으로 + 산개', + tc: '靠近 => 遠離 + 分散', + }); + }); });