From 0f9144a05924b86e797b2b7004095a4130a2db45 Mon Sep 17 00:00:00 2001 From: catloversg <152669316+catloversg@users.noreply.github.com> Date: Thu, 9 Jan 2025 10:20:05 +0700 Subject: [PATCH] CODEBASE: Validate theme, editor theme, and styles (#1789) --- .../bitburner.userinterfacetheme.bnlvl0.md | 11 + .../bitburner.userinterfacetheme.bnlvl1.md | 11 + .../bitburner.userinterfacetheme.bnlvl2.md | 11 + .../bitburner.userinterfacetheme.bnlvl3.md | 11 + ...itburner.userinterfacetheme.maplocation.md | 11 + markdown/bitburner.userinterfacetheme.md | 5 + src/JsonSchema/Data/StockMarketSchema.ts | 29 +- src/JsonSchema/Data/StylesSchema.ts | 18 ++ src/JsonSchema/Data/ThemeSchema.ts | 286 ++++++++++++++++++ src/JsonSchema/JSONSchemaAssertion.ts | 34 +++ src/JsonSchema/JsonSchemaValidator.ts | 6 + src/NetscriptFunctions/UserInterface.ts | 101 ++----- src/ScriptEditor/NetscriptDefinitions.d.ts | 6 +- src/ScriptEditor/ScriptEditor.ts | 3 +- src/ScriptEditor/ui/ThemeEditorModal.tsx | 20 +- src/ScriptEditor/ui/Toolbar.tsx | 3 +- src/ScriptEditor/ui/themes.ts | 48 +-- src/Settings/Settings.ts | 38 ++- src/Themes/Themes.ts | 4 +- src/Themes/ui/ThemeEditorModal.tsx | 34 +-- .../jest/JsonSchema/EditorThemeSchema.test.ts | 70 +++++ test/jest/JsonSchema/HexColorRegex.test.ts | 91 ++++++ test/jest/JsonSchema/MainThemeSchema.test.ts | 43 +++ test/jest/JsonSchema/StylesSchema.test.ts | 40 +++ test/jest/Netscript/Singularity.test.ts | 54 +--- test/jest/Netscript/UserInterface.test.ts | 144 +++++++++ test/jest/Netscript/Utilities.ts | 50 +++ 27 files changed, 969 insertions(+), 213 deletions(-) create mode 100644 markdown/bitburner.userinterfacetheme.bnlvl0.md create mode 100644 markdown/bitburner.userinterfacetheme.bnlvl1.md create mode 100644 markdown/bitburner.userinterfacetheme.bnlvl2.md create mode 100644 markdown/bitburner.userinterfacetheme.bnlvl3.md create mode 100644 markdown/bitburner.userinterfacetheme.maplocation.md create mode 100644 src/JsonSchema/Data/StylesSchema.ts create mode 100644 src/JsonSchema/Data/ThemeSchema.ts create mode 100644 src/JsonSchema/JSONSchemaAssertion.ts create mode 100644 test/jest/JsonSchema/EditorThemeSchema.test.ts create mode 100644 test/jest/JsonSchema/HexColorRegex.test.ts create mode 100644 test/jest/JsonSchema/MainThemeSchema.test.ts create mode 100644 test/jest/JsonSchema/StylesSchema.test.ts create mode 100644 test/jest/Netscript/UserInterface.test.ts create mode 100644 test/jest/Netscript/Utilities.ts diff --git a/markdown/bitburner.userinterfacetheme.bnlvl0.md b/markdown/bitburner.userinterfacetheme.bnlvl0.md new file mode 100644 index 000000000..366bf7410 --- /dev/null +++ b/markdown/bitburner.userinterfacetheme.bnlvl0.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [bitburner](./bitburner.md) > [UserInterfaceTheme](./bitburner.userinterfacetheme.md) > [bnlvl0](./bitburner.userinterfacetheme.bnlvl0.md) + +## UserInterfaceTheme.bnlvl0 property + +**Signature:** + +```typescript +bnlvl0: string; +``` diff --git a/markdown/bitburner.userinterfacetheme.bnlvl1.md b/markdown/bitburner.userinterfacetheme.bnlvl1.md new file mode 100644 index 000000000..a64413369 --- /dev/null +++ b/markdown/bitburner.userinterfacetheme.bnlvl1.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [bitburner](./bitburner.md) > [UserInterfaceTheme](./bitburner.userinterfacetheme.md) > [bnlvl1](./bitburner.userinterfacetheme.bnlvl1.md) + +## UserInterfaceTheme.bnlvl1 property + +**Signature:** + +```typescript +bnlvl1: string; +``` diff --git a/markdown/bitburner.userinterfacetheme.bnlvl2.md b/markdown/bitburner.userinterfacetheme.bnlvl2.md new file mode 100644 index 000000000..94bbbaa50 --- /dev/null +++ b/markdown/bitburner.userinterfacetheme.bnlvl2.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [bitburner](./bitburner.md) > [UserInterfaceTheme](./bitburner.userinterfacetheme.md) > [bnlvl2](./bitburner.userinterfacetheme.bnlvl2.md) + +## UserInterfaceTheme.bnlvl2 property + +**Signature:** + +```typescript +bnlvl2: string; +``` diff --git a/markdown/bitburner.userinterfacetheme.bnlvl3.md b/markdown/bitburner.userinterfacetheme.bnlvl3.md new file mode 100644 index 000000000..d1c8c7cfa --- /dev/null +++ b/markdown/bitburner.userinterfacetheme.bnlvl3.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [bitburner](./bitburner.md) > [UserInterfaceTheme](./bitburner.userinterfacetheme.md) > [bnlvl3](./bitburner.userinterfacetheme.bnlvl3.md) + +## UserInterfaceTheme.bnlvl3 property + +**Signature:** + +```typescript +bnlvl3: string; +``` diff --git a/markdown/bitburner.userinterfacetheme.maplocation.md b/markdown/bitburner.userinterfacetheme.maplocation.md new file mode 100644 index 000000000..9459bc0ab --- /dev/null +++ b/markdown/bitburner.userinterfacetheme.maplocation.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [bitburner](./bitburner.md) > [UserInterfaceTheme](./bitburner.userinterfacetheme.md) > [maplocation](./bitburner.userinterfacetheme.maplocation.md) + +## UserInterfaceTheme.maplocation property + +**Signature:** + +```typescript +maplocation: string; +``` diff --git a/markdown/bitburner.userinterfacetheme.md b/markdown/bitburner.userinterfacetheme.md index 10ba38a13..e087ef439 100644 --- a/markdown/bitburner.userinterfacetheme.md +++ b/markdown/bitburner.userinterfacetheme.md @@ -19,6 +19,10 @@ interface UserInterfaceTheme | [backgroundprimary](./bitburner.userinterfacetheme.backgroundprimary.md) | | string | | | [backgroundsecondary](./bitburner.userinterfacetheme.backgroundsecondary.md) | | string | | | [black](./bitburner.userinterfacetheme.black.md) | | string | | +| [bnlvl0](./bitburner.userinterfacetheme.bnlvl0.md) | | string | | +| [bnlvl1](./bitburner.userinterfacetheme.bnlvl1.md) | | string | | +| [bnlvl2](./bitburner.userinterfacetheme.bnlvl2.md) | | string | | +| [bnlvl3](./bitburner.userinterfacetheme.bnlvl3.md) | | string | | | [button](./bitburner.userinterfacetheme.button.md) | | string | | | [cha](./bitburner.userinterfacetheme.cha.md) | | string | | | [combat](./bitburner.userinterfacetheme.combat.md) | | string | | @@ -32,6 +36,7 @@ interface UserInterfaceTheme | [infodark](./bitburner.userinterfacetheme.infodark.md) | | string | | | [infolight](./bitburner.userinterfacetheme.infolight.md) | | string | | | [int](./bitburner.userinterfacetheme.int.md) | | string | | +| [maplocation](./bitburner.userinterfacetheme.maplocation.md) | | string | | | [money](./bitburner.userinterfacetheme.money.md) | | string | | | [primary](./bitburner.userinterfacetheme.primary.md) | | string | | | [primarydark](./bitburner.userinterfacetheme.primarydark.md) | | string | | diff --git a/src/JsonSchema/Data/StockMarketSchema.ts b/src/JsonSchema/Data/StockMarketSchema.ts index 837166015..0a4bfbc2b 100644 --- a/src/JsonSchema/Data/StockMarketSchema.ts +++ b/src/JsonSchema/Data/StockMarketSchema.ts @@ -1,10 +1,4 @@ import { OrderType, PositionType } from "@enums"; -import { getKeyList } from "../../utils/helpers/getKeyList"; -import { Stock } from "../../StockMarket/Stock"; -import { Order } from "../../StockMarket/Order"; - -const stockObjectProperties = getKeyList(Stock); -const orderObjectProperties = getKeyList(Order); /** * It's intentional to not use JSONSchemaType here. The data structure of StockMarket is not suitable for the usage of @@ -98,7 +92,26 @@ export const StockMarketSchema = { type: "number", }, }, - required: [...stockObjectProperties], + required: [ + "b", + "cap", + "lastPrice", + "maxShares", + "mv", + "name", + "otlkMag", + "otlkMagForecast", + "playerAvgPx", + "playerAvgShortPx", + "playerShares", + "playerShortShares", + "price", + "shareTxForMovement", + "shareTxUntilMovement", + "spreadPerc", + "symbol", + "totalShares", + ], }, }, properties: { @@ -129,7 +142,7 @@ export const StockMarketSchema = { enum: [OrderType.LimitBuy, OrderType.LimitSell, OrderType.StopBuy, OrderType.StopSell], }, }, - required: [...orderObjectProperties], + required: ["pos", "price", "shares", "stockSymbol", "type"], }, }, }, diff --git a/src/JsonSchema/Data/StylesSchema.ts b/src/JsonSchema/Data/StylesSchema.ts new file mode 100644 index 000000000..b9d71705b --- /dev/null +++ b/src/JsonSchema/Data/StylesSchema.ts @@ -0,0 +1,18 @@ +export const StylesSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + lineHeight: { + type: "number", + }, + fontSize: { + type: "number", + }, + tailFontSize: { + type: "number", + }, + fontFamily: { + type: "string", + }, + }, +}; diff --git a/src/JsonSchema/Data/ThemeSchema.ts b/src/JsonSchema/Data/ThemeSchema.ts new file mode 100644 index 000000000..f34d2b26f --- /dev/null +++ b/src/JsonSchema/Data/ThemeSchema.ts @@ -0,0 +1,286 @@ +import { validEditorThemeBases } from "../../ScriptEditor/ui/themes"; +import type { ITheme } from "../../Themes/Themes"; +import { getRecordKeys } from "../../Types/Record"; + +/** + * VS code has a regex for checking hex colors at: https://github.com/microsoft/vscode/blob/1dd8c77ac79508a047235ceee0cba7ba7f049425/src/vs/editor/common/languages/supports/tokenization.ts#L153. + * + * We have to tweak it: + * - "#" must be the first character. + * - Allow 3-character hex colors (e.g., #fff). + * + * Explanation: +^ asserts position at start of the string +# matches the character # with index 35 (base 10) literally (case sensitive) +1st Capturing Group ((([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?)|([0-9A-Fa-f]{3})) + 1st Alternative (([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?) + 2nd Capturing Group (([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?) + 3rd Capturing Group ([0-9A-Fa-f]{6}) + Match a single character present in the list below [0-9A-Fa-f] + {6} matches the previous token exactly 6 times + 0-9 matches a single character in the range between 0 (index 48) and 9 (index 57) (case sensitive) + A-F matches a single character in the range between A (index 65) and F (index 70) (case sensitive) + a-f matches a single character in the range between a (index 97) and f (index 102) (case sensitive) + 4th Capturing Group ([0-9A-Fa-f]{2})? + ? matches the previous token between zero and one times, as many times as possible, giving back as needed (greedy) + Match a single character present in the list below [0-9A-Fa-f] + {2} matches the previous token exactly 2 times + 0-9 matches a single character in the range between 0 (index 48) and 9 (index 57) (case sensitive) + A-F matches a single character in the range between A (index 65) and F (index 70) (case sensitive) + a-f matches a single character in the range between a (index 97) and f (index 102) (case sensitive) + 2nd Alternative ([0-9A-Fa-f]{3}) + 5th Capturing Group ([0-9A-Fa-f]{3}) + Match a single character present in the list below [0-9A-Fa-f] + {3} matches the previous token exactly 3 times + 0-9 matches a single character in the range between 0 (index 48) and 9 (index 57) (case sensitive) + A-F matches a single character in the range between A (index 65) and F (index 70) (case sensitive) + a-f matches a single character in the range between a (index 97) and f (index 102) (case sensitive) +$ asserts position at the end of the string + */ +export const themeHexColorRegex = /^#((([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?)|([0-9A-Fa-f]{3}))$/; + +/** + * This regex is based on themeHexColorRegex. It removes the part of "#". When processing data of editor themes, we + * always add "#" to the hex value, so valid hex values cannot include "#" character. + */ +export const editorThemeHexColorRegex = /^((([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?)|([0-9A-Fa-f]{3}))$/; + +function getThemeSchemaProperties() { + const result: Record = { + primarylight: { + type: "string", + }, + primary: { + type: "string", + }, + primarydark: { + type: "string", + }, + successlight: { + type: "string", + }, + success: { + type: "string", + }, + successdark: { + type: "string", + }, + errorlight: { + type: "string", + }, + error: { + type: "string", + }, + errordark: { + type: "string", + }, + secondarylight: { + type: "string", + }, + secondary: { + type: "string", + }, + secondarydark: { + type: "string", + }, + warninglight: { + type: "string", + }, + warning: { + type: "string", + }, + warningdark: { + type: "string", + }, + infolight: { + type: "string", + }, + info: { + type: "string", + }, + infodark: { + type: "string", + }, + welllight: { + type: "string", + }, + well: { + type: "string", + }, + white: { + type: "string", + }, + black: { + type: "string", + }, + hp: { + type: "string", + }, + money: { + type: "string", + }, + hack: { + type: "string", + }, + combat: { + type: "string", + }, + cha: { + type: "string", + }, + int: { + type: "string", + }, + rep: { + type: "string", + }, + disabled: { + type: "string", + }, + backgroundprimary: { + type: "string", + }, + backgroundsecondary: { + type: "string", + }, + button: { + type: "string", + }, + maplocation: { + type: "string", + }, + bnlvl0: { + type: "string", + }, + bnlvl1: { + type: "string", + }, + bnlvl2: { + type: "string", + }, + bnlvl3: { + type: "string", + }, + }; + for (const key of getRecordKeys(result)) { + result[key].pattern = themeHexColorRegex.source; + } + return result; +} + +export const MainThemeSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: getThemeSchemaProperties(), +}; + +export const EditorThemeSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + common: { + type: "object", + properties: { + accent: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + bg: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + fg: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + }, + }, + syntax: { + type: "object", + properties: { + tag: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + entity: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + string: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + regexp: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + markup: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + keyword: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + comment: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + constant: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + error: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + }, + }, + ui: { + type: "object", + properties: { + line: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + panel: { + type: "object", + properties: { + bg: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + selected: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + border: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + }, + }, + selection: { + type: "object", + properties: { + bg: { + type: "string", + pattern: editorThemeHexColorRegex.source, + }, + }, + }, + }, + }, + base: { + type: "string", + /** + * Monaco checks the base theme at runtime. If the value is invalid, monaco will throw an error ("Error: Illegal + * theme base!") and crash the game. + */ + enum: validEditorThemeBases, + }, + inherit: { + type: "boolean", + }, + }, +}; diff --git a/src/JsonSchema/JSONSchemaAssertion.ts b/src/JsonSchema/JSONSchemaAssertion.ts new file mode 100644 index 000000000..292bca001 --- /dev/null +++ b/src/JsonSchema/JSONSchemaAssertion.ts @@ -0,0 +1,34 @@ +import type { ValidateFunction } from "ajv/dist/types"; +import type { IStyleSettings } from "../ScriptEditor/NetscriptDefinitions"; +import type { IScriptEditorTheme } from "../ScriptEditor/ui/themes"; +import type { ITheme } from "../Themes/Themes"; +import { JsonSchemaValidator } from "./JsonSchemaValidator"; + +function assertAndSanitize(data: unknown, validate: ValidateFunction): void { + if (!validate(data)) { + console.error("validate.errors:", validate.errors); + // validate.errors is an array of objects, so we need to use JSON.stringify. + throw new Error(JSON.stringify(validate.errors)); + } +} + +/** + * This function validates the unknown data and removes properties not defined in MainThemeSchema. + */ +export function assertAndSanitizeMainTheme(data: unknown): asserts data is ITheme { + assertAndSanitize(data, JsonSchemaValidator.MainTheme); +} + +/** + * This function validates the unknown data and removes properties not defined in EditorThemeSchema. + */ +export function assertAndSanitizeEditorTheme(data: unknown): asserts data is IScriptEditorTheme { + assertAndSanitize(data, JsonSchemaValidator.EditorTheme); +} + +/** + * This function validates the unknown data and removes properties not defined in StylesSchema. + */ +export function assertAndSanitizeStyles(data: unknown): asserts data is IStyleSettings { + assertAndSanitize(data, JsonSchemaValidator.Styles); +} diff --git a/src/JsonSchema/JsonSchemaValidator.ts b/src/JsonSchema/JsonSchemaValidator.ts index 9790b9781..c081e40aa 100644 --- a/src/JsonSchema/JsonSchemaValidator.ts +++ b/src/JsonSchema/JsonSchemaValidator.ts @@ -1,10 +1,16 @@ import Ajv from "ajv"; import { AllGangsSchema } from "./Data/AllGangsSchema"; import { StockMarketSchema } from "./Data/StockMarketSchema"; +import { StylesSchema } from "./Data/StylesSchema"; +import { EditorThemeSchema, MainThemeSchema } from "./Data/ThemeSchema"; const ajv = new Ajv(); +const ajvWithRemoveAdditionalOption = new Ajv({ removeAdditional: "all" }); export const JsonSchemaValidator = { AllGangs: ajv.compile(AllGangsSchema), StockMarket: ajv.compile(StockMarketSchema), + MainTheme: ajvWithRemoveAdditionalOption.compile(MainThemeSchema), + EditorTheme: ajvWithRemoveAdditionalOption.compile(EditorThemeSchema), + Styles: ajvWithRemoveAdditionalOption.compile(StylesSchema), }; diff --git a/src/NetscriptFunctions/UserInterface.ts b/src/NetscriptFunctions/UserInterface.ts index c387d5bd4..2abe5b58a 100644 --- a/src/NetscriptFunctions/UserInterface.ts +++ b/src/NetscriptFunctions/UserInterface.ts @@ -5,40 +5,10 @@ import { defaultTheme } from "../Themes/Themes"; import { defaultStyles } from "../Themes/Styles"; import { CONSTANTS } from "../Constants"; import { commitHash } from "../utils/helpers/commitHash"; -import { InternalAPI, NetscriptContext } from "../Netscript/APIWrapper"; +import { InternalAPI } from "../Netscript/APIWrapper"; import { Terminal } from "../../src/Terminal"; import { helpers } from "../Netscript/NetscriptHelpers"; -import { errorMessage } from "../Netscript/ErrorMessages"; - -/** Will probably remove the below function in favor of a different approach to object type assertion. - * This method cannot be used to handle optional properties. */ -export function assertObjectType( - ctx: NetscriptContext, - name: string, - obj: unknown, - desiredObject: T, -): asserts obj is T { - if (typeof obj !== "object" || obj === null) { - throw errorMessage( - ctx, - `Type ${obj === null ? "null" : typeof obj} provided for ${name}. Must be an object.`, - "TYPE", - ); - } - for (const [key, val] of Object.entries(desiredObject)) { - if (!Object.hasOwn(obj, key)) { - throw errorMessage(ctx, `Object provided for argument ${name} is missing required property ${key}.`, "TYPE"); - } - const objVal = (obj as Record)[key]; - if (typeof val !== typeof objVal) { - throw errorMessage( - ctx, - `Incorrect type ${typeof objVal} provided for property ${key} on ${name} argument. Should be type ${typeof val}.`, - "TYPE", - ); - } - } -} +import { assertAndSanitizeMainTheme, assertAndSanitizeStyles } from "../JsonSchema/JSONSchemaAssertion"; export function NetscriptUserInterface(): InternalAPI { return { @@ -54,52 +24,37 @@ export function NetscriptUserInterface(): InternalAPI { }, setTheme: (ctx) => (newTheme) => { - const themeValidator: Record = {}; - assertObjectType(ctx, "newTheme", newTheme, themeValidator); - const hex = /^(#)((?:[A-Fa-f0-9]{2}){3,4}|(?:[A-Fa-f0-9]{3}))$/; - const currentTheme = { ...Settings.theme }; - const errors: string[] = []; - for (const key of Object.keys(newTheme)) { - if (!currentTheme[key]) { - // Invalid key - errors.push(`Invalid key "${key}"`); - } else if (!hex.test(newTheme[key] ?? "")) { - errors.push(`Invalid color "${key}": ${newTheme[key]}`); - } else { - currentTheme[key] = newTheme[key]; - } - } - - if (errors.length === 0) { - Object.assign(Settings.theme, currentTheme); - ThemeEvents.emit(); - helpers.log(ctx, () => `Successfully set theme`); - } else { - helpers.log(ctx, () => `Failed to set theme. Errors: ${errors.join(", ")}`); + let newData: unknown; + try { + /** + * assertAndSanitizeMainTheme may mutate its parameter, so we have to clone the user-provided data here. + */ + newData = structuredClone(newTheme); + assertAndSanitizeMainTheme(newData); + } catch (error) { + helpers.log(ctx, () => `Failed to set theme. Errors: ${error}`); + return; } + Object.assign(Settings.theme, newData); + ThemeEvents.emit(); + helpers.log(ctx, () => `Successfully set theme`); }, setStyles: (ctx) => (newStyles) => { - const styleValidator: Record = {}; - assertObjectType(ctx, "newStyles", newStyles, styleValidator); - const currentStyles: Record = { ...Settings.styles }; - const errors: string[] = []; - for (const key of Object.keys(newStyles)) { - if (!currentStyles[key]) { - // Invalid key - errors.push(`Invalid key "${key}"`); - } else { - currentStyles[key] = newStyles[key]; - } - } - - if (errors.length === 0) { - Object.assign(Settings.styles, currentStyles); - ThemeEvents.emit(); - helpers.log(ctx, () => `Successfully set styles`); - } else { - helpers.log(ctx, () => `Failed to set styles. Errors: ${errors.join(", ")}`); + let newData: unknown; + try { + /** + * assertAndSanitizeStyles may mutate its parameter, so we have to clone the user-provided data here. + */ + newData = structuredClone(newStyles); + assertAndSanitizeStyles(newData); + } catch (error) { + helpers.log(ctx, () => `Failed to set styles. Errors: ${error}`); + return; } + Object.assign(Settings.styles, newData); + ThemeEvents.emit(); + helpers.log(ctx, () => `Successfully set styles`); }, resetTheme: (ctx) => () => { diff --git a/src/ScriptEditor/NetscriptDefinitions.d.ts b/src/ScriptEditor/NetscriptDefinitions.d.ts index f4f24cf3b..21e4e2a29 100644 --- a/src/ScriptEditor/NetscriptDefinitions.d.ts +++ b/src/ScriptEditor/NetscriptDefinitions.d.ts @@ -9619,7 +9619,6 @@ interface InvestmentOffer { * @public */ interface UserInterfaceTheme { - [key: string]: string | undefined; primarylight: string; primary: string; primarydark: string; @@ -9653,6 +9652,11 @@ interface UserInterfaceTheme { backgroundprimary: string; backgroundsecondary: string; button: string; + maplocation: string; + bnlvl0: string; + bnlvl1: string; + bnlvl2: string; + bnlvl3: string; } /** diff --git a/src/ScriptEditor/ScriptEditor.ts b/src/ScriptEditor/ScriptEditor.ts index be397469e..31af542e5 100644 --- a/src/ScriptEditor/ScriptEditor.ts +++ b/src/ScriptEditor/ScriptEditor.ts @@ -2,7 +2,7 @@ import type { ContentFilePath } from "../Paths/ContentFile"; import { EventEmitter } from "../utils/EventEmitter"; import * as monaco from "monaco-editor"; -import { loadThemes, makeTheme, sanitizeTheme } from "./ui/themes"; +import { loadThemes, makeTheme } from "./ui/themes"; import { Settings } from "../Settings/Settings"; import { NetscriptExtra } from "../NetscriptFunctions/Extra"; import * as enums from "../Enums"; @@ -132,7 +132,6 @@ export class ScriptEditor { }); // Load themes loadThemes(monaco.editor.defineTheme); - sanitizeTheme(Settings.EditorTheme); monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme)); } } diff --git a/src/ScriptEditor/ui/ThemeEditorModal.tsx b/src/ScriptEditor/ui/ThemeEditorModal.tsx index 516109cf3..907b65781 100644 --- a/src/ScriptEditor/ui/ThemeEditorModal.tsx +++ b/src/ScriptEditor/ui/ThemeEditorModal.tsx @@ -12,6 +12,7 @@ import { OptionSwitch } from "../../ui/React/OptionSwitch"; import { defaultMonacoTheme } from "./themes"; import { dialogBoxCreate } from "../../ui/React/DialogBox"; +import { assertAndSanitizeEditorTheme } from "../../JsonSchema/JSONSchemaAssertion"; type ColorEditorProps = { label: string; @@ -74,21 +75,18 @@ export function ThemeEditorModal(props: ThemeEditorProps): React.ReactElement { } function onThemeChange(event: React.ChangeEvent): void { + let themeData: unknown; try { - const importedTheme = JSON.parse(event.target.value) as typeof Settings.EditorTheme; - if (importedTheme == null) { - throw new Error("Theme data must not be null or undefined."); - } - if (typeof importedTheme !== "object") { - throw new Error(`Theme data is invalid.`); - } - Settings.EditorTheme = importedTheme; - props.onChange(); + themeData = JSON.parse(event.target.value); + assertAndSanitizeEditorTheme(themeData); } catch (error) { - console.error(`Theme data is invalid. Data: ${event.target.value}.`); console.error(error); - dialogBoxCreate(`Invalid theme. ${error}`); + console.error("Theme data is invalid. Data:", event.target.value); + dialogBoxCreate(`Invalid theme. Errors: ${error}.`); + return; } + Object.assign(Settings.EditorTheme, themeData); + props.onChange(); } const onResetToDefault = () => { diff --git a/src/ScriptEditor/ui/Toolbar.tsx b/src/ScriptEditor/ui/Toolbar.tsx index b1ea3e78f..32823a2dd 100644 --- a/src/ScriptEditor/ui/Toolbar.tsx +++ b/src/ScriptEditor/ui/Toolbar.tsx @@ -12,7 +12,7 @@ import Typography from "@mui/material/Typography"; import SettingsIcon from "@mui/icons-material/Settings"; -import { makeTheme, sanitizeTheme } from "./themes"; +import { makeTheme } from "./themes"; import { Modal } from "../../ui/React/Modal"; import { Page } from "../../ui/Router"; @@ -54,7 +54,6 @@ export function Toolbar({ editor, onSave }: IProps) { }; const onThemeChange = () => { - sanitizeTheme(Settings.EditorTheme); monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme)); }; diff --git a/src/ScriptEditor/ui/themes.ts b/src/ScriptEditor/ui/themes.ts index 41fbebe81..9be55b4cd 100644 --- a/src/ScriptEditor/ui/themes.ts +++ b/src/ScriptEditor/ui/themes.ts @@ -1,10 +1,13 @@ import type { editor } from "monaco-editor"; -import { getRecordKeys } from "../../Types/Record"; -import { Settings } from "../../Settings/Settings"; type DefineThemeFn = typeof editor.defineTheme; +export const validEditorThemeBases = ["vs", "vs-dark", "hc-black", "hc-light"] as const; + +/** + * If we change this interface, we must change EditorThemeSchema. + */ export interface IScriptEditorTheme { - base: "vs" | "vs-dark" | "hc-black"; + base: (typeof validEditorThemeBases)[number]; inherit: boolean; common: { accent: string; @@ -67,45 +70,6 @@ export const defaultMonacoTheme: IScriptEditorTheme = { }, }; -// Regex used for token color validation -// https://github.com/microsoft/vscode/blob/973684056e67153952f495fce93bf50d0ec0b892/src/vs/editor/common/languages/supports/tokenization.ts#L153 -const colorRegExp = /^#?([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$/; - -// Recursively sanitize the theme data to prevent errors -// Invalid data will be replaced with FF0000 (bright red) -export const sanitizeTheme = (theme: IScriptEditorTheme): void => { - if (typeof theme !== "object") { - Settings.EditorTheme = structuredClone(defaultMonacoTheme); - return; - } - for (const themeKey of getRecordKeys(theme)) { - if (typeof theme[themeKey] !== "object") { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete theme[themeKey]; - } - switch (themeKey) { - case "base": - if (!["vs-dark", "vs"].includes(theme.base)) theme.base = "vs-dark"; - continue; - case "inherit": - if (typeof theme.inherit !== "boolean") theme.inherit = true; - continue; - } - - const block = theme[themeKey]; - const repairBlock = >(block: T) => { - for (const [blockKey, blockValue] of Object.entries(block) as [keyof T, unknown][]) { - if (!blockValue || (typeof blockValue !== "string" && typeof blockValue !== "object")) - (block[blockKey] as string) = "FF0000"; - else if (typeof blockValue === "object") repairBlock(blockValue as Record); - else if (!blockValue.match(colorRegExp)) (block[blockKey] as string) = "FF0000"; - } - }; - // Type assertion is to something less specific. - repairBlock(block); - } -}; - export function makeTheme(theme: IScriptEditorTheme): editor.IStandaloneThemeData { const themeRules = [ { diff --git a/src/Settings/Settings.ts b/src/Settings/Settings.ts index 8a24cf090..76f8175b3 100644 --- a/src/Settings/Settings.ts +++ b/src/Settings/Settings.ts @@ -4,10 +4,16 @@ import { defaultStyles } from "../Themes/Styles"; import { CursorStyle, CursorBlinking, WordWrapOptions } from "../ScriptEditor/ui/Options"; import { defaultMonacoTheme } from "../ScriptEditor/ui/themes"; import { objectAssert } from "../utils/helpers/typeAssertion"; +import { + assertAndSanitizeEditorTheme, + assertAndSanitizeMainTheme, + assertAndSanitizeStyles, +} from "../JsonSchema/JSONSchemaAssertion"; /** - * This function won't be able to catch **all** invalid hostnames, and it's still fine. In order to validate a hostname - * properly, we need to import a good validation library or write one by ourselves. I think that it's unnecessary. + * This function won't be able to catch **all** invalid hostnames. In order to validate a hostname properly, we need to + * import a good validation library or write one by ourselves. Considering that we only need to catch common mistakes, + * it's not worth the effort. * * Some invalid hostnames that we don't catch: * - Invalid/missing TLD: "abc". @@ -160,15 +166,33 @@ export const Settings = { load(saveString: string) { const save: unknown = JSON.parse(saveString); objectAssert(save); - save.theme && Object.assign(Settings.theme, save.theme); - save.styles && Object.assign(Settings.styles, save.styles); save.overview && Object.assign(Settings.overview, save.overview); - save.EditorTheme && Object.assign(Settings.EditorTheme, save.EditorTheme); + try { + // Sanitize theme data. Invalid theme data may crash the game or make it stuck in the loading page. + assertAndSanitizeMainTheme(save.theme); + Object.assign(Settings.theme, save.theme); + } catch (error) { + console.error(error); + } + try { + // Sanitize editor theme data. Invalid editor theme data may crash the game when the player opens the script editor. + assertAndSanitizeEditorTheme(save.EditorTheme); + Object.assign(Settings.EditorTheme, save.EditorTheme); + } catch (error) { + console.error(error); + } + try { + // Sanitize styles. + assertAndSanitizeStyles(save.styles); + Object.assign(Settings.styles, save.styles); + } catch (error) { + console.error(error); + } Object.assign(Settings, save, { - theme: Settings.theme, - styles: Settings.styles, overview: Settings.overview, + theme: Settings.theme, EditorTheme: Settings.EditorTheme, + styles: Settings.styles, }); /** * The hostname and port of RFA have not been validated properly, so the save data may contain invalid data. In that diff --git a/src/Themes/Themes.ts b/src/Themes/Themes.ts index 661183b92..69c8b9f27 100644 --- a/src/Themes/Themes.ts +++ b/src/Themes/Themes.ts @@ -1,7 +1,9 @@ import * as predefined from "./data"; +/** + * If we change this interface, we must change MainThemeSchema and UserInterfaceTheme. + */ export interface ITheme { - [key: string]: string | undefined; primarylight: string; primary: string; primarydark: string; diff --git a/src/Themes/ui/ThemeEditorModal.tsx b/src/Themes/ui/ThemeEditorModal.tsx index f3c0b42f9..11a25943b 100644 --- a/src/Themes/ui/ThemeEditorModal.tsx +++ b/src/Themes/ui/ThemeEditorModal.tsx @@ -13,12 +13,13 @@ import HistoryIcon from "@mui/icons-material/History"; import { Color, ColorPicker } from "material-ui-color"; import { ThemeEvents } from "./Theme"; import { Settings } from "../../Settings/Settings"; -import { defaultTheme } from "../Themes"; +import { defaultTheme, type ITheme } from "../Themes"; import { UserInterfaceTheme } from "@nsdefs"; import { Router } from "../../ui/GameRoot"; import { Page } from "../../ui/Router"; import { ThemeCollaborate } from "./ThemeCollaborate"; import { dialogBoxCreate } from "../../ui/React/DialogBox"; +import { assertAndSanitizeMainTheme } from "../../JsonSchema/JSONSchemaAssertion"; interface IProps { open: boolean; @@ -26,9 +27,9 @@ interface IProps { } interface IColorEditorProps { - name: string; + name: keyof ITheme; color: string | undefined; - onColorChange: (name: string, value: string) => void; + onColorChange: (name: keyof ITheme, value: string) => void; defaultColor: string; } @@ -70,7 +71,7 @@ function ColorEditor({ name, onColorChange, color, defaultColor }: IColorEditorP } export function ThemeEditorModal(props: IProps): React.ReactElement { - const [customTheme, setCustomTheme] = useState>({ + const [customTheme, setCustomTheme] = useState>({ ...Settings.theme, }); @@ -81,27 +82,22 @@ export function ThemeEditorModal(props: IProps): React.ReactElement { } function onThemeChange(event: React.ChangeEvent): void { + let themeData: unknown; try { - const importedTheme = JSON.parse(event.target.value) as typeof Settings.theme; - if (importedTheme == null) { - throw new Error("Theme data must not be null or undefined."); - } - if (typeof importedTheme !== "object") { - throw new Error(`Theme data is invalid.`); - } - setCustomTheme(importedTheme); - for (const key of Object.keys(importedTheme)) { - Settings.theme[key] = importedTheme[key]; - } - ThemeEvents.emit(); + themeData = JSON.parse(event.target.value); + assertAndSanitizeMainTheme(themeData); } catch (error) { - console.error(`Theme data is invalid. Data: ${event.target.value}.`); console.error(error); - dialogBoxCreate(`Invalid theme. ${error}`); + console.error("Theme data is invalid. Data:", event.target.value); + dialogBoxCreate(`Invalid theme. Errors: ${error}.`); + return; } + Object.assign(Settings.theme, themeData); + ThemeEvents.emit(); + setCustomTheme(Settings.theme); } - function onColorChange(name: string, value: string): void { + function onColorChange(name: keyof ITheme, value: string): void { setCustomTheme((old: Record) => { old[name] = value; return old; diff --git a/test/jest/JsonSchema/EditorThemeSchema.test.ts b/test/jest/JsonSchema/EditorThemeSchema.test.ts new file mode 100644 index 000000000..1376ffb9b --- /dev/null +++ b/test/jest/JsonSchema/EditorThemeSchema.test.ts @@ -0,0 +1,70 @@ +import { assertAndSanitizeEditorTheme } from "../../../src/JsonSchema/JSONSchemaAssertion"; +import { JsonSchemaValidator } from "../../../src/JsonSchema/JsonSchemaValidator"; +import { defaultMonacoTheme } from "../../../src/ScriptEditor/ui/themes"; + +const invalidHexColor = "#FFFF"; + +function getCloneOfDefaultEditorTheme() { + return structuredClone(defaultMonacoTheme) as unknown as Record; +} + +/** + * This function does not support objects containing Map, Set, etc. It's till good for our purposes, though. + */ +function traverseObject( + object: Record, + keyPath: string[], + callback: (key: string, keyPath: string[]) => void, +): void { + for (const key of Object.getOwnPropertyNames(object)) { + callback(key, keyPath); + if (typeof object[key] === "object" && object[key] != null) { + traverseObject(object[key] as Record, [...keyPath, key], callback); + } + } +} + +describe("Valid", () => { + test("Default editor theme", () => { + expect(JsonSchemaValidator.EditorTheme(getCloneOfDefaultEditorTheme())).toStrictEqual(true); + }); + test("Partial theme", () => { + const theme = { + inherit: true, + }; + expect(JsonSchemaValidator.EditorTheme(theme)).toStrictEqual(true); + }); +}); + +describe("Invalid", () => { + const theme = getCloneOfDefaultEditorTheme(); + traverseObject(theme, [], (key, keyPath) => { + test(`Invalid [${keyPath}].${key}`, () => { + const theme = getCloneOfDefaultEditorTheme(); + let nestedObject = theme; + for (const outerKey of keyPath) { + if (typeof nestedObject[outerKey] !== "object" || nestedObject[outerKey] == null) { + throw new Error( + `Error occurred while traversing default editor theme. outerKey: ${outerKey}. keyPath: ${keyPath}. Theme: ${JSON.stringify( + theme, + )}`, + ); + } + nestedObject = nestedObject[outerKey] as Record; + } + nestedObject[key] = invalidHexColor; + expect(JsonSchemaValidator.EditorTheme(theme)).toStrictEqual(false); + }); + }); +}); + +describe("assertAndSanitizeEditorTheme", () => { + test("Unknown properties are removed", () => { + const theme = { + inherit: true, + unknownProperty: {}, + }; + assertAndSanitizeEditorTheme(theme); + expect(theme.unknownProperty).toStrictEqual(undefined); + }); +}); diff --git a/test/jest/JsonSchema/HexColorRegex.test.ts b/test/jest/JsonSchema/HexColorRegex.test.ts new file mode 100644 index 000000000..8458f9ec9 --- /dev/null +++ b/test/jest/JsonSchema/HexColorRegex.test.ts @@ -0,0 +1,91 @@ +import { editorThemeHexColorRegex, themeHexColorRegex } from "../../../src/JsonSchema/Data/ThemeSchema"; + +const validThemeHexColors = ["#FF0011", "#FF001122", "#FFF"]; + +const invalidThemeHexColors = [ + "qwe", + "", + "0", + String(null), + String(undefined), + String(NaN), + String(Infinity), + "FF0011", + "FF001122", + "FFF", + "#F", + "F", + "#FF", + "FF", + "#FFFF", + "FFFF", + "#FFFFF", + "FFFFF", + "#FF00112", + "FF00112", + "##FF0011", + "##FFF", +]; + +const validEditorThemeHexColors = ["FF0011", "FF001122", "FFF"]; + +const invalidEditorThemeHexColors = [ + "qwe", + "", + "0", + String(null), + String(undefined), + String(NaN), + String(Infinity), + "#FF0011", + "#FF001122", + "#FFF", + "#F", + "F", + "#FF", + "FF", + "#FFFF", + "FFFF", + "#FFFFF", + "FFFFF", + "#FF00112", + "FF00112", + "##FF0011", + "##FFF", +]; + +describe("Theme", () => { + describe("Valid", () => { + for (const validHexColor of validThemeHexColors) { + test(`Theme: Valid: ${validHexColor}`, () => { + expect(themeHexColorRegex.test(validHexColor)).toStrictEqual(true); + }); + } + }); + + describe("Invalid", () => { + for (const invalidHexColor of invalidThemeHexColors) { + test(`Theme: Invalid: ${invalidHexColor}`, () => { + expect(themeHexColorRegex.test(invalidHexColor)).toStrictEqual(false); + }); + } + }); +}); + +describe("Editor theme", () => { + describe("Valid", () => { + for (const validHexColor of validEditorThemeHexColors) { + test(`Editor theme: Valid: ${validHexColor}`, () => { + expect(editorThemeHexColorRegex.test(validHexColor)).toStrictEqual(true); + }); + } + }); + + describe("Invalid", () => { + for (const invalidHexColor of invalidEditorThemeHexColors) { + test(`Editor theme: Invalid: ${invalidHexColor}`, () => { + expect(editorThemeHexColorRegex.test(invalidHexColor)).toStrictEqual(false); + }); + } + }); +}); diff --git a/test/jest/JsonSchema/MainThemeSchema.test.ts b/test/jest/JsonSchema/MainThemeSchema.test.ts new file mode 100644 index 000000000..33d23e566 --- /dev/null +++ b/test/jest/JsonSchema/MainThemeSchema.test.ts @@ -0,0 +1,43 @@ +import { assertAndSanitizeMainTheme } from "../../../src/JsonSchema/JSONSchemaAssertion"; +import { JsonSchemaValidator } from "../../../src/JsonSchema/JsonSchemaValidator"; +import { defaultTheme } from "../../../src/Themes/Themes"; + +const validHexColor = "#FFF"; +const invalidHexColor = "#FFFF"; + +function getCloneOfDefaultMainTheme() { + return structuredClone(defaultTheme) as unknown as Record; +} + +describe("Valid", () => { + test("Default main theme", () => { + expect(JsonSchemaValidator.MainTheme(getCloneOfDefaultMainTheme())).toStrictEqual(true); + }); + test("Partial theme", () => { + const theme = { + primary: validHexColor, + }; + expect(JsonSchemaValidator.MainTheme(theme)).toStrictEqual(true); + }); +}); + +describe("Invalid", () => { + for (const key of Object.keys(defaultTheme)) { + test(`Invalid ${key}`, () => { + const theme = getCloneOfDefaultMainTheme(); + theme[key] = invalidHexColor; + expect(JsonSchemaValidator.MainTheme(theme)).toStrictEqual(false); + }); + } +}); + +describe("assertAndSanitizeMainTheme", () => { + test("Unknown properties are removed", () => { + const theme = { + primary: validHexColor, + unknownColor1: validHexColor, + }; + assertAndSanitizeMainTheme(theme); + expect(theme.unknownColor1).toStrictEqual(undefined); + }); +}); diff --git a/test/jest/JsonSchema/StylesSchema.test.ts b/test/jest/JsonSchema/StylesSchema.test.ts new file mode 100644 index 000000000..917a72405 --- /dev/null +++ b/test/jest/JsonSchema/StylesSchema.test.ts @@ -0,0 +1,40 @@ +import { assertAndSanitizeStyles } from "../../../src/JsonSchema/JSONSchemaAssertion"; +import { JsonSchemaValidator } from "../../../src/JsonSchema/JsonSchemaValidator"; +import { defaultStyles } from "../../../src/Themes/Styles"; + +function getCloneOfDefaultStyles() { + return structuredClone(defaultStyles) as unknown as Record; +} + +describe("Valid", () => { + test("Default styles", () => { + expect(JsonSchemaValidator.Styles(getCloneOfDefaultStyles())).toStrictEqual(true); + }); + test("Partial styles", () => { + const styles = { + fontSize: 15, + }; + expect(JsonSchemaValidator.Styles(styles)).toStrictEqual(true); + }); +}); + +describe("Invalid", () => { + for (const key of Object.keys(defaultStyles)) { + test(`Invalid ${key}`, () => { + const styles = getCloneOfDefaultStyles(); + styles[key] = {}; + expect(JsonSchemaValidator.Styles(styles)).toStrictEqual(false); + }); + } +}); + +describe("assertAndSanitizeStyles", () => { + test("Unknown properties are removed", () => { + const styles = { + fontSize: 15, + unknownStyle: 15, + }; + assertAndSanitizeStyles(styles); + expect(styles.unknownStyle).toStrictEqual(undefined); + }); +}); diff --git a/test/jest/Netscript/Singularity.test.ts b/test/jest/Netscript/Singularity.test.ts index b92bb26ff..ee36c846a 100644 --- a/test/jest/Netscript/Singularity.test.ts +++ b/test/jest/Netscript/Singularity.test.ts @@ -1,25 +1,10 @@ import { installAugmentations } from "../../../src/Augmentation/AugmentationHelpers"; import { blackOpsArray } from "../../../src/Bladeburner/data/BlackOperations"; import { AugmentationName } from "../../../src/Enums"; -import { WorkerScript } from "../../../src/Netscript/WorkerScript"; -import { NetscriptFunctions, type NSFull } from "../../../src/NetscriptFunctions"; -import type { ScriptFilePath } from "../../../src/Paths/ScriptFilePath"; -import { PlayerObject } from "../../../src/PersonObjects/Player/PlayerObject"; -import { Player, setPlayer } from "../../../src/Player"; -import { RunningScript } from "../../../src/Script/RunningScript"; -import { GetServerOrThrow, initForeignServers, prestigeAllServers } from "../../../src/Server/AllServers"; +import { Player } from "../../../src/Player"; +import { GetServerOrThrow } from "../../../src/Server/AllServers"; import { SpecialServers } from "../../../src/Server/data/SpecialServers"; -import { initSourceFiles } from "../../../src/SourceFile/SourceFiles"; -import { FormatsNeedToChange } from "../../../src/ui/formatNumber"; -import { Router } from "../../../src/ui/GameRoot"; - -function setupBasicTestingEnvironment(): void { - prestigeAllServers(); - setPlayer(new PlayerObject()); - Player.init(); - Player.sourceFiles.set(4, 3); - initForeignServers(Player.getHomeComputer()); -} +import { getNS, initGameEnvironment, setupBasicTestingEnvironment } from "./Utilities"; function setNumBlackOpsComplete(value: number): void { if (!Player.bladeburner) { @@ -28,37 +13,12 @@ function setNumBlackOpsComplete(value: number): void { Player.bladeburner.numBlackOpsComplete = value; } -function getNS(): NSFull { - const home = GetServerOrThrow(SpecialServers.Home); - home.maxRam = 1024; - const filePath = "test.js" as ScriptFilePath; - home.writeToScriptFile(filePath, ""); - const script = home.scripts.get(filePath); - if (!script) { - throw new Error("Invalid script"); - } - const runningScript = new RunningScript(script, 1024); - const workerScript = new WorkerScript(runningScript, 1, NetscriptFunctions); - const ns = workerScript.env.vars; - if (!ns) { - throw new Error("Invalid NS instance"); - } - return ns; -} - -// We need to patch this function. Some APIs call it, but it only works properly after the main UI is loaded. -Router.toPage = () => {}; - -/** - * In src\ui\formatNumber.ts, there are some variables that need to be initialized before other functions can be - * called. We have to call FormatsNeedToChange.emit() to initialize those variables. - */ -FormatsNeedToChange.emit(); - -initSourceFiles(); - const nextBN = 3; +beforeAll(() => { + initGameEnvironment(); +}); + describe("b1tflum3", () => { beforeEach(() => { setupBasicTestingEnvironment(); diff --git a/test/jest/Netscript/UserInterface.test.ts b/test/jest/Netscript/UserInterface.test.ts new file mode 100644 index 000000000..f3ec05c5e --- /dev/null +++ b/test/jest/Netscript/UserInterface.test.ts @@ -0,0 +1,144 @@ +import { IStyleSettings, UserInterfaceTheme } from "../../../src/ScriptEditor/NetscriptDefinitions"; +import { Settings } from "../../../src/Settings/Settings"; +import { defaultStyles } from "../../../src/Themes/Styles"; +import { defaultTheme } from "../../../src/Themes/Themes"; +import { getNS, initGameEnvironment, setupBasicTestingEnvironment } from "./Utilities"; + +const themeHexColor = "#abc"; +const fontFamily = "monospace"; + +beforeAll(() => { + initGameEnvironment(); +}); + +describe("setTheme", () => { + beforeEach(() => { + setupBasicTestingEnvironment(); + Settings.theme = { ...defaultTheme }; + }); + + describe("Success", () => { + test("Full theme", () => { + const ns = getNS(); + const newTheme = ns.ui.getTheme(); + newTheme.primary = themeHexColor; + ns.ui.setTheme(newTheme); + const result = ns.ui.getTheme(); + expect(result.primary).toStrictEqual(themeHexColor); + expect(result.secondary).toStrictEqual(defaultTheme.secondary); + }); + test("Partial theme", () => { + const ns = getNS(); + const newTheme = { + primary: themeHexColor, + }; + ns.ui.setTheme(newTheme as unknown as UserInterfaceTheme); + const result = ns.ui.getTheme(); + expect(result.primary).toStrictEqual(themeHexColor); + expect(result.secondary).toStrictEqual(defaultTheme.secondary); + }); + test("Unknown property", () => { + const ns = getNS(); + const newTheme = { + primary: themeHexColor, + unknownProperty: themeHexColor, + }; + ns.ui.setTheme(newTheme as unknown as UserInterfaceTheme); + const result = ns.ui.getTheme(); + expect(result.primary).toStrictEqual(themeHexColor); + expect(result.secondary).toStrictEqual(defaultTheme.secondary); + + // "unknownProperty" of newTheme is not changed. + expect(newTheme.unknownProperty).toStrictEqual(themeHexColor); + + // "unknownProperty" is ignored when being processed. + expect((result as unknown as { unknownProperty: unknown }).unknownProperty).toBeUndefined(); + }); + }); + + describe("Failure", () => { + test("Full theme", () => { + const ns = getNS(); + const newTheme = ns.ui.getTheme(); + newTheme.primary = ""; + ns.ui.setTheme(newTheme); + const result = ns.ui.getTheme(); + expect(result.primary).toStrictEqual(defaultTheme.primary); + }); + test("Partial theme", () => { + const ns = getNS(); + const newTheme = { + primary: "", + }; + ns.ui.setTheme(newTheme as unknown as UserInterfaceTheme); + const result = ns.ui.getTheme(); + expect(result.primary).toStrictEqual(defaultTheme.primary); + }); + }); +}); + +describe("setStyles", () => { + beforeEach(() => { + setupBasicTestingEnvironment(); + Settings.styles = { ...defaultStyles }; + }); + + describe("Success", () => { + test("Full styles", () => { + const ns = getNS(); + const newStyles = ns.ui.getStyles(); + newStyles.fontFamily = fontFamily; + ns.ui.setStyles(newStyles); + const result = ns.ui.getStyles(); + expect(result.fontFamily).toStrictEqual(fontFamily); + expect(result.fontSize).toStrictEqual(defaultStyles.fontSize); + }); + test("Partial styles", () => { + const ns = getNS(); + const newStyles = { + fontFamily: fontFamily, + }; + ns.ui.setStyles(newStyles as unknown as IStyleSettings); + const result = ns.ui.getStyles(); + expect(result.fontFamily).toStrictEqual(fontFamily); + expect(result.fontSize).toStrictEqual(defaultStyles.fontSize); + }); + test("Unknown property", () => { + const ns = getNS(); + const newStyles = { + fontFamily: fontFamily, + unknownProperty: themeHexColor, + }; + ns.ui.setStyles(newStyles as unknown as IStyleSettings); + const result = ns.ui.getStyles(); + expect(result.fontFamily).toStrictEqual(fontFamily); + expect(result.fontSize).toStrictEqual(defaultStyles.fontSize); + + // "unknownProperty" of newStyles is not changed. + expect(newStyles.unknownProperty).toStrictEqual(themeHexColor); + + // "unknownProperty" is ignored when being processed. + expect((result as unknown as { unknownProperty: unknown }).unknownProperty).toBeUndefined(); + }); + }); + + describe("Failure", () => { + test("Full styles", () => { + const ns = getNS(); + const newStyles = ns.ui.getStyles(); + (newStyles.fontFamily as unknown) = 123; + ns.ui.setStyles(newStyles); + const result = ns.ui.getStyles(); + expect(result.fontFamily).toStrictEqual(defaultStyles.fontFamily); + }); + test("Partial styles", () => { + const ns = getNS(); + const newStyles = { + fontFamily: 123, + }; + ns.ui.setStyles(newStyles as unknown as IStyleSettings); + const result = ns.ui.getStyles(); + expect(result.fontFamily).toStrictEqual(defaultStyles.fontFamily); + }); + }); +}); diff --git a/test/jest/Netscript/Utilities.ts b/test/jest/Netscript/Utilities.ts new file mode 100644 index 000000000..414f0f173 --- /dev/null +++ b/test/jest/Netscript/Utilities.ts @@ -0,0 +1,50 @@ +import { WorkerScript } from "../../../src/Netscript/WorkerScript"; +import { NetscriptFunctions, type NSFull } from "../../../src/NetscriptFunctions"; +import type { ScriptFilePath } from "../../../src/Paths/ScriptFilePath"; +import { PlayerObject } from "../../../src/PersonObjects/Player/PlayerObject"; +import { Player, setPlayer } from "../../../src/Player"; +import { RunningScript } from "../../../src/Script/RunningScript"; +import { GetServerOrThrow, initForeignServers, prestigeAllServers } from "../../../src/Server/AllServers"; +import { SpecialServers } from "../../../src/Server/data/SpecialServers"; +import { initSourceFiles } from "../../../src/SourceFile/SourceFiles"; +import { FormatsNeedToChange } from "../../../src/ui/formatNumber"; +import { Router } from "../../../src/ui/GameRoot"; + +export function initGameEnvironment() { + // We need to patch this function. Some APIs call it, but it only works properly after the main UI is loaded. + Router.toPage = () => {}; + + /** + * In src\ui\formatNumber.ts, there are some variables that need to be initialized before other functions can be + * called. We have to call FormatsNeedToChange.emit() to initialize those variables. + */ + FormatsNeedToChange.emit(); + + initSourceFiles(); +} + +export function setupBasicTestingEnvironment(): void { + prestigeAllServers(); + setPlayer(new PlayerObject()); + Player.init(); + Player.sourceFiles.set(4, 3); + initForeignServers(Player.getHomeComputer()); +} + +export function getNS(): NSFull { + const home = GetServerOrThrow(SpecialServers.Home); + home.maxRam = 1024; + const filePath = "test.js" as ScriptFilePath; + home.writeToScriptFile(filePath, ""); + const script = home.scripts.get(filePath); + if (!script) { + throw new Error("Invalid script"); + } + const runningScript = new RunningScript(script, 1024); + const workerScript = new WorkerScript(runningScript, 1, NetscriptFunctions); + const ns = workerScript.env.vars; + if (!ns) { + throw new Error("Invalid NS instance"); + } + return ns; +}