Skip to content

Commit

Permalink
Merge branch 'main' into upgrade
Browse files Browse the repository at this point in the history
  • Loading branch information
GeekyEggo committed Apr 23, 2024
2 parents 398d75a + dc01721 commit 1fc4f3e
Show file tree
Hide file tree
Showing 22 changed files with 702 additions and 324 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
- Update layout and manifest references to propagate from [`@elgato/schemas`](https://github.com/elgatosf/schemas).
- Localization lookup will now return the key if the resource is not defined.
- Update structure of JSON localizations.
- Update `State` type to allow for more than two states.

### ⬆️ Upgrading

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"typescript": "^5.2.2"
},
"dependencies": {
"@elgato/schemas": "^0.1.3",
"@elgato/schemas": "^0.3.1",
"ws": "^8.14.2"
}
}
10 changes: 5 additions & 5 deletions src/api/events/__tests__/action.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Coordinates, WillAppear, WillDisappear } from "..";
import { Coordinates, WillAppear, WillDisappear, type State } from "..";
import { Expect, TypesAreEqual } from "../../../../tests/utils";
import { Settings } from "../../__mocks__/events";

Expand All @@ -21,7 +21,7 @@ describe("action event types", () => {
readonly controller: "Encoder" | "Keypad";
readonly coordinates: Coordinates;
settings: Settings;
readonly state?: 0 | 1;
readonly state?: State;
};
}
| {
Expand All @@ -33,7 +33,7 @@ describe("action event types", () => {
readonly isInMultiAction: true;
readonly controller: "Keypad";
settings: Settings;
readonly state?: 0 | 1;
readonly state?: State;
};
}
>
Expand All @@ -57,7 +57,7 @@ describe("action event types", () => {
readonly controller: "Encoder" | "Keypad";
readonly coordinates: Coordinates;
settings: Settings;
readonly state?: 0 | 1;
readonly state?: State;
};
}
| {
Expand All @@ -69,7 +69,7 @@ describe("action event types", () => {
readonly isInMultiAction: true;
readonly controller: "Keypad";
settings: Settings;
readonly state?: 0 | 1;
readonly state?: State;
};
}
>
Expand Down
2 changes: 1 addition & 1 deletion src/api/events/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,4 @@ export type Coordinates = {
/**
* Possible states an action can be in. This only applies to actions that have multiple states defined in the plugin's manifest.json file.
*/
export type State = 0 | 1;
export type State = number;
124 changes: 124 additions & 0 deletions src/common/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { Language } from "../../api";
import { I18nProvider } from "../i18n";

jest.mock("../logging");

describe("I18nProvider", () => {
/**
* Asserts {@link I18nProvider} does not load locales unless they are requested.
*/
it("lazily evaluates locales", () => {
// Arrange, act.
const localeProvider = jest.fn();
new I18nProvider("en", localeProvider);

// Assert.
expect(localeProvider).toHaveBeenCalledTimes(0);
});

/**
* Asserts {@link I18nProvider} evaluates locales only once.
*/
it("loads locales once", () => {
// Arrange
const localeProvider = jest.fn().mockReturnValue(null);
const i18n = new I18nProvider("en", localeProvider);

// Act.
i18n.translate("Hello", "en");
i18n.translate("Hello", "en");
i18n.translate("Hello", "de");

// Assert.
expect(localeProvider).toHaveBeenCalledTimes(2);
expect(localeProvider).toHaveBeenNthCalledWith(1, "en");
expect(localeProvider).toHaveBeenNthCalledWith(2, "de");
});

/**
* Asserts {@link I18nProvider} evaluates locales only once.
*/
it("does not load unsupported locales", () => {
// Arrange
const localeProvider = jest.fn().mockReturnValue(null);
const i18n = new I18nProvider("en", localeProvider);

// Act.
// @ts-expect-error Testing unsupported language.
i18n.translate("Hello", "__");

// Assert.
expect(localeProvider).toHaveBeenCalledTimes(1);
expect(localeProvider).toHaveBeenCalledWith("en");
});

it("t is alias of translate", () => {
// Arrange.
const i18n = new I18nProvider("en", jest.fn());
const spyOnTranslate = jest.spyOn(i18n, "translate");

// Act.
i18n.t("test");
i18n.t("test", "de");

// Assert.
expect(spyOnTranslate).toHaveBeenCalledTimes(2);
expect(spyOnTranslate).toHaveBeenNthCalledWith(1, "test", "en");
expect(spyOnTranslate).toHaveBeenNthCalledWith(2, "test", "de");
});

describe("translating", () => {
const localeProvider = jest.fn().mockImplementation((language: Language) => {
switch (language) {
case "de":
return { Hello: "Hello welt" };
case "en":
return { Hello: "Hello world", Company: { Name: "Elgato" } };
default:
return null;
}
});

/**
* Asserts {@link I18nProvider} finds resources from the request language.
*/
it("find resources from the requested language", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.translate("Hello", "de")).toBe("Hello welt");
});

/**
* Asserts {@link I18nProvider} finds resources from the fallback language.
*/
it("finds resources from the default language", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.translate("Hello", "es")).toBe("Hello world");
});

/**
* Asserts {@link I18nProvider} returns the key for unknown resources.
*/
it("returns the key for unknown resources", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.translate("Goodbye")).toBe("Goodbye");
});

/**
* Asserts {@link I18nProvider} is capable of finding nested resources.
*/
it("translates nested properties", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.translate("Company.Name")).toBe("Elgato");
});

it("can translate with t alias", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.t("Company.Name")).toBe("Elgato");
});
});
});
79 changes: 79 additions & 0 deletions src/common/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { freeze, get } from "../utils";

/**
* Provides assertions for {@link freeze}.
*/
describe("freeze", () => {
it("top-level properties", () => {
// Arrange.
const obj = {
name: "Elgato"
};

// Act.
freeze(obj);

// Assert.
expect(() => (obj.name = "Other")).toThrowError();
expect(obj.name).toEqual("Elgato");
});

it("nested properties", () => {
// Arrange.
const obj = {
company: {
name: "Elgato"
}
};

// Act.
freeze(obj);

// Assert.
expect(() => (obj.company.name = "Other")).toThrowError();
expect(obj.company.name).toEqual("Elgato");
});

it("handles undefined", () => {
// Arrange, act.
const value = undefined;
freeze(value);

// Assert.
expect(value).toBeUndefined();
});

it("handles null", () => {
// Arrange, act.
const value = null;
freeze(value);

// Assert.
expect(value).toBeNull();
});
});

/**
* Provides assertions for {@link get}.
*/
describe("get", () => {
it("gets the value for a top-level path", () => {
const obj = { foo: "bar" };
expect(get("foo", obj)).toBe("bar");
});

it("gets the value for a nested path", () => {
const obj = { nested: { number: 13 } };
expect(get("nested.number", obj)).toBe(13);
});

it("handles falsy values", () => {
const obj = { falsy: false };
expect(get("falsy", obj)).toBe(false);
});

it("defaults to undefined", () => {
const obj = {};
expect(get("__unknown.__prop", obj)).toBe(undefined);
});
});
95 changes: 95 additions & 0 deletions src/common/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { supportedLanguages, type Language } from "../api";
import { JsonObject } from "../common/json";
import { freeze, get } from "./utils";

/**
* Internalization provider, responsible for managing localizations and translating resources.
*/
export class I18nProvider {
/**
* Default language to be used when a resource does not exist for the desired language.
*/
private static readonly DEFAULT_LANGUAGE: Language = "en";

/**
* Map of localized resources, indexed by their language.
*/
private readonly _translations: Map<Language, JsonObject | null> = new Map();

/**
* Initializes a new instance of the {@link I18nProvider} class.
* @param language The default language to be used when retrieving translations for a given key.
* @param readTranslations Function responsible for loading translations.
*/
constructor(
private readonly language: Language,
private readonly readTranslations: TranslationsReader
) {}

/**
* Translates the specified {@link key}, as defined within the resources for the {@link language}. When the key is not found, the default language is checked.
*
* Alias of `I18nProvider.translate(string, Language)`
* @param key Key of the translation.
* @param language Optional language to get the translation for; otherwise the default language.
* @returns The translation; otherwise the key.
*/
public t(key: string, language: Language = this.language): string {
return this.translate(key, language);
}

/**
* Translates the specified {@link key}, as defined within the resources for the {@link language}. When the key is not found, the default language is checked.
* @param key Key of the translation.
* @param language Optional language to get the translation for; otherwise the default language.
* @returns The translation; otherwise the key.
*/
public translate(key: string, language: Language = this.language): string {
// When the language and default are the same, only check the language.
if (language === I18nProvider.DEFAULT_LANGUAGE) {
return get(key, this.getTranslations(language))?.toString() || key;
}

// Otherwise check the language and default.
return get(key, this.getTranslations(language))?.toString() || get(key, this.getTranslations(I18nProvider.DEFAULT_LANGUAGE))?.toString() || key;
}

/**
* Gets the translations for the specified language.
* @param language Language whose translations are being retrieved.
* @returns The translations, otherwise `null`.
*/
private getTranslations(language: Language): JsonObject | null {
let translations = this._translations.get(language);

if (translations === undefined) {
translations = supportedLanguages.includes(language) ? this.readTranslations(language) : null;
freeze(translations);

this._translations.set(language, translations);
}

return translations;
}
}

/**
* Function responsible for providing localized resources.
* @param language The language whose resources should be retrieved.
* @returns Localized resources represented as a JSON object.
*/
export type TranslationsReader = (language: Language) => JsonObject | null;

/**
* Parses the localizations from the specified contents, or throws a `TypeError` when unsuccessful.
* @param contents Contents that represent the stringified JSON containing the localizations.
* @returns The localizations; otherwise a `TypeError`.
*/
export function parseLocalizations(contents: string): JsonObject {
const json = JSON.parse(contents);
if (json !== undefined && json !== null && typeof json === "object" && "Localization" in json) {
return json["Localization"] as JsonObject;
}

throw new TypeError(`Translations must be a JSON object nested under a property named "Localization"`);
}
Loading

0 comments on commit 1fc4f3e

Please sign in to comment.