From 9567e07dcf0316188cd09072f063e6a2c1d62215 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 22 Jul 2025 13:30:22 +0200 Subject: [PATCH 1/3] refactor(config meta): split into its own file (@miodec) (#6769) ### Description ### Checks - [ ] Adding quotes? - [ ] Make sure to include translations for the quotes in the description (or another comment) so we can verify their content. - [ ] Adding a language? - Make sure to follow the [languages documentation](https://github.com/monkeytypegame/monkeytype/blob/master/docs/LANGUAGES.md) - [ ] Add language to `packages/contracts/src/schemas/languages.ts` - [ ] Add language to exactly one group in `frontend/src/ts/constants/languages.ts` - [ ] Add language json file to `frontend/static/languages` - [ ] Adding a theme? - Make sure to follow the [themes documentation](https://github.com/monkeytypegame/monkeytype/blob/master/docs/THEMES.md) - [ ] Add theme to `packages/contracts/src/schemas/themes.ts` - [ ] Add theme to `frontend/src/ts/constants/themes.ts` - [ ] Add theme css file to `frontend/static/themes` - Also please add a screenshot of the theme, it would be extra awesome if you do so! - [ ] Adding a layout? - [ ] Make sure to follow the [layouts documentation](https://github.com/monkeytypegame/monkeytype/blob/master/docs/LAYOUTS.md) - [ ] Add layout to `packages/contracts/src/schemas/layouts.ts` - [ ] Add layout json file to `frontend/static/layouts` - [ ] Check if any open issues are related to this PR; if so, be sure to tag them below. - [ ] Make sure the PR title follows the Conventional Commits standard. (https://www.conventionalcommits.org for more info) - [ ] Make sure to include your GitHub username prefixed with @ inside parentheses at the end of the PR title. Closes # --- frontend/src/ts/config-metadata.ts | 635 ++++++++++++++++++++++++++++ frontend/src/ts/config.ts | 640 +---------------------------- 2 files changed, 647 insertions(+), 628 deletions(-) create mode 100644 frontend/src/ts/config-metadata.ts diff --git a/frontend/src/ts/config-metadata.ts b/frontend/src/ts/config-metadata.ts new file mode 100644 index 000000000..f3508f17a --- /dev/null +++ b/frontend/src/ts/config-metadata.ts @@ -0,0 +1,635 @@ +import * as DB from "./db"; +import * as Notifications from "./elements/notifications"; +import { isAuthenticated } from "./firebase"; +import { canSetFunboxWithConfig } from "./test/funbox/funbox-validation"; +import { isDevEnvironment, reloadAfter } from "./utils/misc"; +import * as ConfigSchemas from "@monkeytype/schemas/configs"; +import { roundTo1 } from "@monkeytype/util/numbers"; + +// type SetBlock = { +// [K in keyof ConfigSchemas.Config]?: ConfigSchemas.Config[K][]; +// }; + +// type RequiredConfig = { +// [K in keyof ConfigSchemas.Config]?: ConfigSchemas.Config[K]; +// }; + +export type ConfigMetadata = { + [K in keyof ConfigSchemas.Config]: { + /** + * Optional display string for the config key. + */ + displayString?: string; + /** + * Should the config change trigger a resize event? handled in ui.ts:108 + */ + triggerResize?: true; + + /** + * Is a test restart required after this config change? + */ + changeRequiresRestart: boolean; + /** + * Optional function that checks if the config value is blocked from being set. + * Returns true if setting the config value should be blocked. + * @param options - The options object containing the value being set and the current config. + */ + isBlocked?: (options: { + value: ConfigSchemas.Config[K]; + currentConfig: Readonly; + }) => boolean; + /** + * Optional function to override the value before setting it. + * Returns the modified value. + * @param options - The options object containing the value being set, the current value, and the current config. + * @returns The modified value to be set for the config key. + */ + overrideValue?: (options: { + value: ConfigSchemas.Config[K]; + currentValue: ConfigSchemas.Config[K]; + currentConfig: Readonly; + }) => ConfigSchemas.Config[K]; + /** + * Optional function to override other config values before this one is set. + * Returns an object with the config keys and their new values. + * @param options - The options object containing the value being set and the current config. + */ + overrideConfig?: (options: { + value: ConfigSchemas.Config[K]; + currentConfig: Readonly; + }) => Partial; + /** + * Optional function that is called after the config value is set. + * It can be used to perform additional actions, like reloading the page. + * @param options - The options object containing the nosave flag and the current config. + */ + afterSet?: (options: { + nosave: boolean; + currentConfig: Readonly; + }) => void; + }; +}; + +//todo: +// maybe have generic set somehow handle test restarting +// maybe add config group to each metadata object? all though its already defined in ConfigGroupsLiteral + +export const configMetadata: ConfigMetadata = { + // test + punctuation: { + changeRequiresRestart: true, + overrideValue: ({ value, currentConfig }) => { + if (currentConfig.mode === "quote") { + return false; + } + return value; + }, + }, + numbers: { + changeRequiresRestart: true, + overrideValue: ({ value, currentConfig }) => { + if (currentConfig.mode === "quote") { + return false; + } + return value; + }, + }, + words: { + displayString: "word count", + changeRequiresRestart: true, + }, + time: { + changeRequiresRestart: true, + displayString: "time", + }, + mode: { + changeRequiresRestart: true, + overrideConfig: ({ value }) => { + if (value === "custom" || value === "quote" || value === "zen") { + return { + numbers: false, + punctuation: false, + }; + } + return {}; + }, + afterSet: ({ currentConfig }) => { + if (currentConfig.mode === "zen" && currentConfig.paceCaret !== "off") { + Notifications.add(`Pace caret will not work with zen mode.`, 0); + } + }, + }, + quoteLength: { + displayString: "quote length", + changeRequiresRestart: true, + }, + language: { + displayString: "language", + changeRequiresRestart: true, + }, + burstHeatmap: { + displayString: "burst heatmap", + changeRequiresRestart: false, + }, + + // behavior + difficulty: { + changeRequiresRestart: true, + }, + quickRestart: { + displayString: "quick restart", + changeRequiresRestart: false, + }, + repeatQuotes: { + displayString: "repeat quotes", + changeRequiresRestart: false, + }, + blindMode: { + displayString: "blind mode", + changeRequiresRestart: false, + }, + alwaysShowWordsHistory: { + displayString: "always show words history", + changeRequiresRestart: false, + }, + singleListCommandLine: { + displayString: "single list command line", + changeRequiresRestart: false, + }, + minWpm: { + displayString: "min speed", + changeRequiresRestart: true, + }, + minWpmCustomSpeed: { + displayString: "min speed custom", + changeRequiresRestart: true, + }, + minAcc: { + displayString: "min accuracy", + changeRequiresRestart: true, + }, + minAccCustom: { + displayString: "min accuracy custom", + changeRequiresRestart: true, + }, + minBurst: { + displayString: "min burst", + changeRequiresRestart: true, + }, + minBurstCustomSpeed: { + displayString: "min burst custom speed", + changeRequiresRestart: true, + }, + britishEnglish: { + displayString: "british english", + changeRequiresRestart: true, + }, + funbox: { + changeRequiresRestart: true, + isBlocked: ({ value, currentConfig }) => { + for (const funbox of currentConfig.funbox) { + if (!canSetFunboxWithConfig(funbox, currentConfig)) { + Notifications.add( + `${value}" cannot be enabled with the current config`, + 0 + ); + return true; + } + } + return false; + }, + }, + customLayoutfluid: { + displayString: "custom layoutfluid", + changeRequiresRestart: true, + overrideValue: ({ value }) => { + return Array.from(new Set(value)); + }, + }, + customPolyglot: { + displayString: "custom polyglot", + changeRequiresRestart: false, + overrideValue: ({ value }) => { + return Array.from(new Set(value)); + }, + }, + + // input + freedomMode: { + changeRequiresRestart: false, + displayString: "freedom mode", + overrideConfig: ({ value }) => { + if (value) { + return { + confidenceMode: "off", + }; + } + return {}; + }, + }, + strictSpace: { + displayString: "strict space", + changeRequiresRestart: true, + }, + oppositeShiftMode: { + displayString: "opposite shift mode", + changeRequiresRestart: false, + }, + stopOnError: { + displayString: "stop on error", + changeRequiresRestart: true, + overrideConfig: ({ value }) => { + if (value !== "off") { + return { + confidenceMode: "off", + }; + } + return {}; + }, + }, + confidenceMode: { + displayString: "confidence mode", + changeRequiresRestart: false, + overrideConfig: ({ value }) => { + if (value !== "off") { + return { + freedomMode: false, + stopOnError: "off", + }; + } + return {}; + }, + }, + quickEnd: { + displayString: "quick end", + changeRequiresRestart: false, + }, + indicateTypos: { + displayString: "indicate typos", + changeRequiresRestart: false, + }, + hideExtraLetters: { + displayString: "hide extra letters", + changeRequiresRestart: false, + }, + lazyMode: { + displayString: "lazy mode", + changeRequiresRestart: true, + }, + layout: { + displayString: "layout", + changeRequiresRestart: true, + }, + codeUnindentOnBackspace: { + displayString: "code unindent on backspace", + changeRequiresRestart: true, + }, + + // sound + soundVolume: { + displayString: "sound volume", + changeRequiresRestart: false, + }, + playSoundOnClick: { + displayString: "play sound on click", + changeRequiresRestart: false, + }, + playSoundOnError: { + displayString: "play sound on error", + changeRequiresRestart: false, + }, + playTimeWarning: { + displayString: "play time warning", + changeRequiresRestart: false, + }, + + // caret + smoothCaret: { + displayString: "smooth caret", + changeRequiresRestart: false, + }, + caretStyle: { + displayString: "caret style", + changeRequiresRestart: false, + }, + paceCaret: { + displayString: "pace caret", + changeRequiresRestart: false, + isBlocked: ({ value }) => { + if (document.readyState === "complete") { + if ((value === "pb" || value === "tagPb") && !isAuthenticated()) { + Notifications.add( + `Pace caret "pb" and "tag pb" are unavailable without an account`, + 0 + ); + return true; + } + } + return false; + }, + }, + paceCaretCustomSpeed: { + displayString: "pace caret custom speed", + changeRequiresRestart: false, + }, + paceCaretStyle: { + displayString: "pace caret style", + changeRequiresRestart: false, + }, + repeatedPace: { + displayString: "repeated pace", + changeRequiresRestart: false, + }, + + // appearance + timerStyle: { + displayString: "timer style", + changeRequiresRestart: false, + }, + liveSpeedStyle: { + displayString: "live speed style", + changeRequiresRestart: false, + }, + liveAccStyle: { + displayString: "live accuracy style", + changeRequiresRestart: false, + }, + liveBurstStyle: { + displayString: "live burst style", + changeRequiresRestart: false, + }, + timerColor: { + displayString: "timer color", + changeRequiresRestart: false, + }, + timerOpacity: { + displayString: "timer opacity", + changeRequiresRestart: false, + }, + highlightMode: { + displayString: "highlight mode", + changeRequiresRestart: false, + }, + tapeMode: { + triggerResize: true, + changeRequiresRestart: false, + displayString: "tape mode", + overrideConfig: ({ value }) => { + if (value !== "off") { + return { + showAllLines: false, + }; + } + return {}; + }, + }, + tapeMargin: { + displayString: "tape margin", + changeRequiresRestart: false, + overrideValue: ({ value }) => { + //TODO move to migration after settings validation + if (value < 10) { + value = 10; + } + if (value > 90) { + value = 90; + } + return value; + }, + }, + smoothLineScroll: { + displayString: "smooth line scroll", + changeRequiresRestart: false, + }, + showAllLines: { + changeRequiresRestart: false, + displayString: "show all lines", + isBlocked: ({ value, currentConfig }) => { + if (value && currentConfig.tapeMode !== "off") { + Notifications.add("Show all lines doesn't support tape mode.", 0); + return true; + } + return false; + }, + }, + alwaysShowDecimalPlaces: { + displayString: "always show decimal places", + changeRequiresRestart: false, + }, + typingSpeedUnit: { + displayString: "typing speed unit", + changeRequiresRestart: false, + }, + startGraphsAtZero: { + displayString: "start graphs at zero", + changeRequiresRestart: false, + }, + maxLineWidth: { + changeRequiresRestart: false, + triggerResize: true, + displayString: "max line width", + overrideValue: ({ value }) => { + //TODO move to migration after settings validation + if (value < 20 && value !== 0) { + value = 20; + } + if (value > 1000) { + value = 1000; + } + return value; + }, + }, + fontSize: { + changeRequiresRestart: false, + triggerResize: true, + displayString: "font size", + overrideValue: ({ value }) => { + //TODO move to migration after settings validation + if (value < 0) { + value = 1; + } + return value; + }, + }, + fontFamily: { + displayString: "font family", + changeRequiresRestart: false, + }, + keymapMode: { + displayString: "keymap mode", + changeRequiresRestart: false, + }, + keymapLayout: { + displayString: "keymap layout", + changeRequiresRestart: false, + }, + keymapStyle: { + displayString: "keymap style", + changeRequiresRestart: false, + }, + keymapLegendStyle: { + displayString: "keymap legend style", + changeRequiresRestart: false, + }, + keymapShowTopRow: { + displayString: "keymap show top row", + changeRequiresRestart: false, + }, + keymapSize: { + triggerResize: true, + changeRequiresRestart: false, + displayString: "keymap size", + overrideValue: ({ value }) => { + if (value < 0.5) value = 0.5; + if (value > 3.5) value = 3.5; + return roundTo1(value); + }, + }, + + // theme + flipTestColors: { + displayString: "flip test colors", + changeRequiresRestart: false, + }, + colorfulMode: { + displayString: "colorful mode", + changeRequiresRestart: false, + }, + customBackground: { + displayString: "custom background", + changeRequiresRestart: false, + overrideValue: ({ value }) => { + return value.trim(); + }, + }, + customBackgroundSize: { + displayString: "custom background size", + changeRequiresRestart: false, + }, + customBackgroundFilter: { + displayString: "custom background filter", + changeRequiresRestart: false, + }, + autoSwitchTheme: { + displayString: "auto switch theme", + changeRequiresRestart: false, + }, + themeLight: { + displayString: "theme light", + changeRequiresRestart: false, + }, + themeDark: { + displayString: "theme dark", + changeRequiresRestart: false, + }, + randomTheme: { + changeRequiresRestart: false, + displayString: "random theme", + isBlocked: ({ value }) => { + if (value === "custom") { + const snapshot = DB.getSnapshot(); + if (!isAuthenticated()) { + Notifications.add( + "Random theme 'custom' is unavailable without an account", + 0 + ); + return true; + } + if (!snapshot) { + Notifications.add( + "Random theme 'custom' requires a snapshot to be set", + 0 + ); + return true; + } + if (snapshot?.customThemes?.length === 0) { + Notifications.add( + "Random theme 'custom' requires at least one custom theme to be saved", + 0 + ); + return true; + } + } + return false; + }, + }, + favThemes: { + displayString: "favorite themes", + changeRequiresRestart: false, + }, + theme: { + changeRequiresRestart: false, + overrideConfig: () => { + return { + customTheme: false, + }; + }, + }, + customTheme: { + displayString: "custom theme", + changeRequiresRestart: false, + }, + customThemeColors: { + displayString: "custom theme colors", + changeRequiresRestart: false, + }, + + // hide elements + showKeyTips: { + displayString: "show key tips", + changeRequiresRestart: false, + }, + showOutOfFocusWarning: { + displayString: "show out of focus warning", + changeRequiresRestart: false, + }, + capsLockWarning: { + displayString: "caps lock warning", + changeRequiresRestart: false, + }, + showAverage: { + displayString: "show average", + changeRequiresRestart: false, + }, + + // other (hidden) + accountChart: { + displayString: "account chart", + changeRequiresRestart: false, + overrideValue: ({ value, currentValue }) => { + // if both speed and accuracy are off, set opposite to on + // i dedicate this fix to AshesOfAFallen and our 2 collective brain cells + if (value[0] === "off" && value[1] === "off") { + const changedIndex = value[0] === currentValue[0] ? 0 : 1; + value[changedIndex] = "on"; + } + return value; + }, + }, + monkey: { + displayString: "monkey", + changeRequiresRestart: false, + }, + monkeyPowerLevel: { + displayString: "monkey power level", + changeRequiresRestart: false, + }, + + // ads + ads: { + changeRequiresRestart: false, + isBlocked: ({ value }) => { + if (value !== "off" && isDevEnvironment()) { + Notifications.add("Ads are disabled in development mode.", 0); + return true; + } + return false; + }, + afterSet: ({ nosave }) => { + if (!nosave && !isDevEnvironment()) { + reloadAfter(3); + Notifications.add("Ad settings changed. Refreshing...", 0); + } + }, + }, +}; diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts index 8d15c52b7..7cef16028 100644 --- a/frontend/src/ts/config.ts +++ b/frontend/src/ts/config.ts @@ -2,7 +2,6 @@ import * as DB from "./db"; import * as Notifications from "./elements/notifications"; import { isConfigValueValid } from "./config-validation"; import * as ConfigEvent from "./observables/config-event"; -import { isAuthenticated } from "./firebase"; import * as AccountButton from "./elements/account-button"; import { debounce } from "throttle-debounce"; import { @@ -11,10 +10,8 @@ import { } from "./test/funbox/funbox-validation"; import { createErrorMessage, - isDevEnvironment, isObject, promiseWithResolvers, - reloadAfter, typedKeys, } from "./utils/misc"; import * as ConfigSchemas from "@monkeytype/schemas/configs"; @@ -23,11 +20,11 @@ import { Mode } from "@monkeytype/schemas/shared"; import { Language } from "@monkeytype/schemas/languages"; import { LocalStorageWithSchema } from "./utils/local-storage-with-schema"; import { migrateConfig } from "./utils/config"; -import { roundTo1 } from "@monkeytype/util/numbers"; import { getDefaultConfig } from "./constants/default-config"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; import { ZodSchema } from "zod"; import * as TestState from "./test/test-state"; +import { ConfigMetadata, configMetadata } from "./config-metadata"; const configLS = new LocalStorageWithSchema({ key: "config", @@ -110,626 +107,6 @@ function isConfigChangeBlocked(): boolean { return false; } -// type SetBlock = { -// [K in keyof ConfigSchemas.Config]?: ConfigSchemas.Config[K][]; -// }; - -// type RequiredConfig = { -// [K in keyof ConfigSchemas.Config]?: ConfigSchemas.Config[K]; -// }; - -export type ConfigMetadata = { - [K in keyof ConfigSchemas.Config]: { - /** - * Optional display string for the config key. - */ - displayString?: string; - /** - * Should the config change trigger a resize event? handled in ui.ts:108 - */ - triggerResize?: true; - - /** - * Is a test restart required after this config change? - */ - changeRequiresRestart: boolean; - /** - * Optional function that checks if the config value is blocked from being set. - * Returns true if setting the config value should be blocked. - * @param value - The value being set for the config key. - */ - isBlocked?: (value: ConfigSchemas.Config[K]) => boolean; - /** - * Optional function to override the value before setting it. - * Returns the modified value. - * @param value - The value being set for the config key. - * @param currentValue - The current value of the config key. - */ - overrideValue?: ( - value: ConfigSchemas.Config[K], - currentValue: ConfigSchemas.Config[K] - ) => ConfigSchemas.Config[K]; - /** - * Optional function to override other config values before this one is set. - * Returns an object with the config keys and their new values. - * @param value - The value being set for the config key. - */ - overrideConfig?: ( - value: ConfigSchemas.Config[K] - ) => Partial; - /** - * Optional function that is called after the config value is set. - * It can be used to perform additional actions, like reloading the page. - * @param nosave - If true, the change is not saved to localStorage or database. - */ - afterSet?: (nosave: boolean) => void; - }; -}; - -//todo: -// maybe have generic set somehow handle test restarting -// maybe add config group to each metadata object? all though its already defined in ConfigGroupsLiteral - -const configMetadata: ConfigMetadata = { - // test - punctuation: { - changeRequiresRestart: true, - overrideValue: (value) => { - if (config.mode === "quote") { - return false; - } - return value; - }, - }, - numbers: { - changeRequiresRestart: true, - overrideValue: (value) => { - if (config.mode === "quote") { - return false; - } - return value; - }, - }, - words: { - displayString: "word count", - changeRequiresRestart: true, - }, - time: { - changeRequiresRestart: true, - displayString: "time", - }, - mode: { - changeRequiresRestart: true, - overrideConfig: (value) => { - if (value === "custom" || value === "quote" || value === "zen") { - return { - numbers: false, - punctuation: false, - }; - } - return {}; - }, - afterSet: () => { - if (config.mode === "zen" && config.paceCaret !== "off") { - Notifications.add(`Pace caret will not work with zen mode.`, 0); - } - }, - }, - quoteLength: { - displayString: "quote length", - changeRequiresRestart: true, - }, - language: { - displayString: "language", - changeRequiresRestart: true, - }, - burstHeatmap: { - displayString: "burst heatmap", - changeRequiresRestart: false, - }, - - // behavior - difficulty: { - changeRequiresRestart: true, - }, - quickRestart: { - displayString: "quick restart", - changeRequiresRestart: false, - }, - repeatQuotes: { - displayString: "repeat quotes", - changeRequiresRestart: false, - }, - blindMode: { - displayString: "blind mode", - changeRequiresRestart: false, - }, - alwaysShowWordsHistory: { - displayString: "always show words history", - changeRequiresRestart: false, - }, - singleListCommandLine: { - displayString: "single list command line", - changeRequiresRestart: false, - }, - minWpm: { - displayString: "min speed", - changeRequiresRestart: true, - }, - minWpmCustomSpeed: { - displayString: "min speed custom", - changeRequiresRestart: true, - }, - minAcc: { - displayString: "min accuracy", - changeRequiresRestart: true, - }, - minAccCustom: { - displayString: "min accuracy custom", - changeRequiresRestart: true, - }, - minBurst: { - displayString: "min burst", - changeRequiresRestart: true, - }, - minBurstCustomSpeed: { - displayString: "min burst custom speed", - changeRequiresRestart: true, - }, - britishEnglish: { - displayString: "british english", - changeRequiresRestart: true, - }, - funbox: { - changeRequiresRestart: true, - isBlocked: (value) => { - for (const funbox of config.funbox) { - if (!canSetFunboxWithConfig(funbox, config)) { - Notifications.add( - `${value}" cannot be enabled with the current config`, - 0 - ); - return true; - } - } - return false; - }, - }, - customLayoutfluid: { - displayString: "custom layoutfluid", - changeRequiresRestart: true, - overrideValue: (value) => { - return Array.from(new Set(value)); - }, - }, - customPolyglot: { - displayString: "custom polyglot", - changeRequiresRestart: false, - overrideValue: (value) => { - return Array.from(new Set(value)); - }, - }, - - // input - freedomMode: { - changeRequiresRestart: false, - displayString: "freedom mode", - overrideConfig: (value) => { - if (value) { - return { - confidenceMode: "off", - }; - } - return {}; - }, - }, - strictSpace: { - displayString: "strict space", - changeRequiresRestart: true, - }, - oppositeShiftMode: { - displayString: "opposite shift mode", - changeRequiresRestart: false, - }, - stopOnError: { - displayString: "stop on error", - changeRequiresRestart: true, - overrideConfig: (value) => { - if (value !== "off") { - return { - confidenceMode: "off", - }; - } - return {}; - }, - }, - confidenceMode: { - displayString: "confidence mode", - changeRequiresRestart: false, - overrideConfig: (value) => { - if (value !== "off") { - return { - freedomMode: false, - stopOnError: "off", - }; - } - return {}; - }, - }, - quickEnd: { - displayString: "quick end", - changeRequiresRestart: false, - }, - indicateTypos: { - displayString: "indicate typos", - changeRequiresRestart: false, - }, - hideExtraLetters: { - displayString: "hide extra letters", - changeRequiresRestart: false, - }, - lazyMode: { - displayString: "lazy mode", - changeRequiresRestart: true, - }, - layout: { - displayString: "layout", - changeRequiresRestart: true, - }, - codeUnindentOnBackspace: { - displayString: "code unindent on backspace", - changeRequiresRestart: true, - }, - - // sound - soundVolume: { - displayString: "sound volume", - changeRequiresRestart: false, - }, - playSoundOnClick: { - displayString: "play sound on click", - changeRequiresRestart: false, - }, - playSoundOnError: { - displayString: "play sound on error", - changeRequiresRestart: false, - }, - playTimeWarning: { - displayString: "play time warning", - changeRequiresRestart: false, - }, - - // caret - smoothCaret: { - displayString: "smooth caret", - changeRequiresRestart: false, - }, - caretStyle: { - displayString: "caret style", - changeRequiresRestart: false, - }, - paceCaret: { - displayString: "pace caret", - changeRequiresRestart: false, - isBlocked: (value) => { - if (document.readyState === "complete") { - if ((value === "pb" || value === "tagPb") && !isAuthenticated()) { - Notifications.add( - `Pace caret "pb" and "tag pb" are unavailable without an account`, - 0 - ); - return true; - } - } - return false; - }, - }, - paceCaretCustomSpeed: { - displayString: "pace caret custom speed", - changeRequiresRestart: false, - }, - paceCaretStyle: { - displayString: "pace caret style", - changeRequiresRestart: false, - }, - repeatedPace: { - displayString: "repeated pace", - changeRequiresRestart: false, - }, - - // appearance - timerStyle: { - displayString: "timer style", - changeRequiresRestart: false, - }, - liveSpeedStyle: { - displayString: "live speed style", - changeRequiresRestart: false, - }, - liveAccStyle: { - displayString: "live accuracy style", - changeRequiresRestart: false, - }, - liveBurstStyle: { - displayString: "live burst style", - changeRequiresRestart: false, - }, - timerColor: { - displayString: "timer color", - changeRequiresRestart: false, - }, - timerOpacity: { - displayString: "timer opacity", - changeRequiresRestart: false, - }, - highlightMode: { - displayString: "highlight mode", - changeRequiresRestart: false, - }, - tapeMode: { - triggerResize: true, - changeRequiresRestart: false, - displayString: "tape mode", - overrideConfig: (value) => { - if (value !== "off") { - return { - showAllLines: false, - }; - } - return {}; - }, - }, - tapeMargin: { - displayString: "tape margin", - changeRequiresRestart: false, - overrideValue: (value) => { - //TODO move to migration after settings validation - if (value < 10) { - value = 10; - } - if (value > 90) { - value = 90; - } - return value; - }, - }, - smoothLineScroll: { - displayString: "smooth line scroll", - changeRequiresRestart: false, - }, - showAllLines: { - changeRequiresRestart: false, - displayString: "show all lines", - isBlocked: (value) => { - if (value && config.tapeMode !== "off") { - Notifications.add("Show all lines doesn't support tape mode.", 0); - return true; - } - return false; - }, - }, - alwaysShowDecimalPlaces: { - displayString: "always show decimal places", - changeRequiresRestart: false, - }, - typingSpeedUnit: { - displayString: "typing speed unit", - changeRequiresRestart: false, - }, - startGraphsAtZero: { - displayString: "start graphs at zero", - changeRequiresRestart: false, - }, - maxLineWidth: { - changeRequiresRestart: false, - triggerResize: true, - displayString: "max line width", - overrideValue: (value) => { - //TODO move to migration after settings validation - if (value < 20 && value !== 0) { - value = 20; - } - if (value > 1000) { - value = 1000; - } - return value; - }, - }, - fontSize: { - changeRequiresRestart: false, - triggerResize: true, - displayString: "font size", - overrideValue: (value) => { - //TODO move to migration after settings validation - if (value < 0) { - value = 1; - } - return value; - }, - }, - fontFamily: { - displayString: "font family", - changeRequiresRestart: false, - }, - keymapMode: { - displayString: "keymap mode", - changeRequiresRestart: false, - }, - keymapLayout: { - displayString: "keymap layout", - changeRequiresRestart: false, - }, - keymapStyle: { - displayString: "keymap style", - changeRequiresRestart: false, - }, - keymapLegendStyle: { - displayString: "keymap legend style", - changeRequiresRestart: false, - }, - keymapShowTopRow: { - displayString: "keymap show top row", - changeRequiresRestart: false, - }, - keymapSize: { - triggerResize: true, - changeRequiresRestart: false, - displayString: "keymap size", - overrideValue: (value) => { - if (value < 0.5) value = 0.5; - if (value > 3.5) value = 3.5; - return roundTo1(value); - }, - }, - - // theme - flipTestColors: { - displayString: "flip test colors", - changeRequiresRestart: false, - }, - colorfulMode: { - displayString: "colorful mode", - changeRequiresRestart: false, - }, - customBackground: { - displayString: "custom background", - changeRequiresRestart: false, - overrideValue: (value) => { - return value.trim(); - }, - }, - customBackgroundSize: { - displayString: "custom background size", - changeRequiresRestart: false, - }, - customBackgroundFilter: { - displayString: "custom background filter", - changeRequiresRestart: false, - }, - autoSwitchTheme: { - displayString: "auto switch theme", - changeRequiresRestart: false, - }, - themeLight: { - displayString: "theme light", - changeRequiresRestart: false, - }, - themeDark: { - displayString: "theme dark", - changeRequiresRestart: false, - }, - randomTheme: { - changeRequiresRestart: false, - displayString: "random theme", - isBlocked: (value) => { - if (value === "custom") { - const snapshot = DB.getSnapshot(); - if (!isAuthenticated()) { - Notifications.add( - "Random theme 'custom' is unavailable without an account", - 0 - ); - return true; - } - if (!snapshot) { - Notifications.add( - "Random theme 'custom' requires a snapshot to be set", - 0 - ); - return true; - } - if (snapshot?.customThemes?.length === 0) { - Notifications.add( - "Random theme 'custom' requires at least one custom theme to be saved", - 0 - ); - return true; - } - } - return false; - }, - }, - favThemes: { - displayString: "favorite themes", - changeRequiresRestart: false, - }, - theme: { - changeRequiresRestart: false, - overrideConfig: () => { - return { - customTheme: false, - }; - }, - }, - customTheme: { - displayString: "custom theme", - changeRequiresRestart: false, - }, - customThemeColors: { - displayString: "custom theme colors", - changeRequiresRestart: false, - }, - - // hide elements - showKeyTips: { - displayString: "show key tips", - changeRequiresRestart: false, - }, - showOutOfFocusWarning: { - displayString: "show out of focus warning", - changeRequiresRestart: false, - }, - capsLockWarning: { - displayString: "caps lock warning", - changeRequiresRestart: false, - }, - showAverage: { - displayString: "show average", - changeRequiresRestart: false, - }, - - // other (hidden) - accountChart: { - displayString: "account chart", - changeRequiresRestart: false, - overrideValue: (value, currentValue) => { - // if both speed and accuracy are off, set opposite to on - // i dedicate this fix to AshesOfAFallen and our 2 collective brain cells - if (value[0] === "off" && value[1] === "off") { - const changedIndex = value[0] === currentValue[0] ? 0 : 1; - value[changedIndex] = "on"; - } - return value; - }, - }, - monkey: { - displayString: "monkey", - changeRequiresRestart: false, - }, - monkeyPowerLevel: { - displayString: "monkey power level", - changeRequiresRestart: false, - }, - - // ads - ads: { - changeRequiresRestart: false, - isBlocked: (value) => { - if (value !== "off" && isDevEnvironment()) { - Notifications.add("Ads are disabled in development mode.", 0); - return true; - } - return false; - }, - afterSet: (nosave) => { - if (!nosave && !isDevEnvironment()) { - reloadAfter(3); - Notifications.add("Ad settings changed. Refreshing...", 0); - } - }, - }, -}; - export function genericSet( key: T, value: ConfigSchemas.Config[T], @@ -772,12 +149,16 @@ export function genericSet( // } // } - if (metadata.isBlocked?.(value)) { + if (metadata.isBlocked?.({ value, currentConfig: config })) { return false; } if (metadata.overrideValue) { - value = metadata.overrideValue(value, config[key]); + value = metadata.overrideValue({ + value, + currentValue: config[key], + currentConfig: config, + }); } const schema = ConfigSchemas.ConfigSchema.shape[key] as ZodSchema; @@ -791,7 +172,10 @@ export function genericSet( } if (metadata.overrideConfig) { - const targetConfig = metadata.overrideConfig(value); + const targetConfig = metadata.overrideConfig({ + value, + currentConfig: config, + }); for (const targetKey of typedKeys(targetConfig)) { const targetValue = targetConfig[ @@ -819,7 +203,7 @@ export function genericSet( $(window).trigger("resize"); } - metadata.afterSet?.(nosave || false); + metadata.afterSet?.({ nosave: nosave || false, currentConfig: config }); return true; } From e357efc435cf0dbaf544c1a0efa0d65797296117 Mon Sep 17 00:00:00 2001 From: Shizuko <83967781+ShizukoV@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:25:23 -0500 Subject: [PATCH 2/3] chore(funbox): increase layout_mirror difficulty to level 3 (@ShizukoV) (#6761) ### Description I've changed the difficulty of the `layout_mirror` funbox from 1 to 3. The `layout_mirror` funbox completely mirrors the keyboard layout, significantly increasing difficulty compared to other funboxes currently labeled as difficulty level 1, (`earthquake`, `capitals`, `gibberish`), which are much less disruptive. The `layout_mirror` funbox is also similar to the `mirror` funbox (which mirrors the screen), currently rated at difficulty level 3. Since `layout_mirror` alters the entire keyboard layout itself, making even basic typing extremely challenging, it makes sense for it to also be difficulty level 3. --- packages/funbox/src/list.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/funbox/src/list.ts b/packages/funbox/src/list.ts index a6414dd86..ef0ba3992 100644 --- a/packages/funbox/src/list.ts +++ b/packages/funbox/src/list.ts @@ -133,7 +133,7 @@ const list: Record = { layout_mirror: { description: "Mirror the keyboard layout", canGetPb: true, - difficultyLevel: 1, + difficultyLevel: 3, properties: ["changesLayout"], frontendFunctions: ["applyConfig", "rememberSettings"], name: "layout_mirror", From 4f57418b6a4e4aa857549a6df60bf808c213e078 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 22 Jul 2025 21:33:18 +0200 Subject: [PATCH 3/3] refactor(config): split config and config-metadata tests (@fehmer) (#6770) --- .../__tests__/root/config-metadata.spec.ts | 278 ++++++++ frontend/__tests__/root/config.spec.ts | 631 ++++++------------ frontend/src/ts/config.ts | 3 +- frontend/src/ts/utils/misc.ts | 3 + 4 files changed, 486 insertions(+), 429 deletions(-) create mode 100644 frontend/__tests__/root/config-metadata.spec.ts diff --git a/frontend/__tests__/root/config-metadata.spec.ts b/frontend/__tests__/root/config-metadata.spec.ts new file mode 100644 index 000000000..04f98b93e --- /dev/null +++ b/frontend/__tests__/root/config-metadata.spec.ts @@ -0,0 +1,278 @@ +import { configMetadata } from "../../src/ts/config-metadata"; +import * as Config from "../../src/ts/config"; +import { ConfigKey, Config as ConfigType } from "@monkeytype/schemas/configs"; + +const { replaceConfig, getConfig } = Config.__testing; + +type TestsByConfig = Partial<{ + [K in keyof ConfigType]: (T & { value: ConfigType[K] })[]; +}>; + +describe("ConfigMeta", () => { + afterAll(() => { + replaceConfig({}); + vi.resetModules(); + }); + it("should have changeRequiresRestart defined", () => { + const configsRequiringRestarts = Object.entries(configMetadata) + .filter(([_key, value]) => value.changeRequiresRestart === true) + .map(([key]) => key) + .sort(); + + expect(configsRequiringRestarts).toEqual( + [ + "punctuation", + "numbers", + "words", + "time", + "mode", + "quoteLength", + "language", + "difficulty", + "minWpmCustomSpeed", + "minWpm", + "minAcc", + "minAccCustom", + "minBurst", + "minBurstCustomSpeed", + "britishEnglish", + "funbox", + "customLayoutfluid", + "strictSpace", + "stopOnError", + "lazyMode", + "layout", + "codeUnindentOnBackspace", + ].sort() + ); + }); + + it("should have triggerResize defined", () => { + const configsWithTriggeResize = Object.entries(configMetadata) + .filter(([_key, value]) => value.triggerResize === true) + .map(([key]) => key) + .sort(); + + expect(configsWithTriggeResize).toEqual( + ["fontSize", "keymapSize", "maxLineWidth", "tapeMode"].sort() + ); + }); + describe("overrideValue", () => { + const testCases: TestsByConfig<{ + given?: Partial; + expected: Partial; + }> = { + punctuation: [ + { value: true, expected: { punctuation: true } }, + { + value: true, + given: { mode: "quote" }, + expected: { punctuation: false }, + }, + ], + numbers: [ + { value: true, expected: { numbers: true } }, + { + value: true, + given: { mode: "quote" }, + expected: { numbers: false }, + }, + ], + customLayoutfluid: [ + { + value: ["qwerty", "qwerty", "qwertz"], + expected: { customLayoutfluid: ["qwerty", "qwertz"] }, + }, + ], + customPolyglot: [ + { + value: ["english", "polish", "english"], + expected: { customPolyglot: ["english", "polish"] }, + }, + ], + keymapSize: [ + { value: 1, expected: { keymapSize: 1 } }, + { value: 1.234, expected: { keymapSize: 1.2 } }, + { value: 0.4, expected: { keymapSize: 0.5 } }, + { value: 3.6, expected: { keymapSize: 3.5 } }, + ], + customBackground: [ + { + value: " https://example.com/test.jpg ", + expected: { customBackground: "https://example.com/test.jpg" }, + }, + ], + accountChart: [ + { + value: ["on", "off", "off", "off"], + expected: { accountChart: ["on", "off", "off", "off"] }, + }, + { + value: ["off", "off", "off", "off"], + given: { accountChart: ["on", "off", "off", "off"] }, + expected: { accountChart: ["off", "on", "off", "off"] }, + }, + { + value: ["off", "off", "on", "on"], + given: { accountChart: ["off", "on", "off", "off"] }, + expected: { accountChart: ["on", "off", "on", "on"] }, + }, + ], + }; + + it.for( + Object.entries(testCases).flatMap(([key, value]) => + value.flatMap((it) => ({ key: key as ConfigKey, ...it })) + ) + )( + `$key value=$value given=$given expect=$expected`, + ({ key, value, given, expected }) => { + //GIVEN + replaceConfig(given ?? {}); + + //WHEN + Config.genericSet(key, value as any); + + //THEN + expect(getConfig()).toMatchObject(expected); + } + ); + }); + describe("isBlocked", () => { + const testCases: TestsByConfig<{ + given?: Partial; + fail?: true; + }> = { + funbox: [ + { + value: "gibberish" as any, + given: { mode: "quote" }, + fail: true, + }, + ], + showAllLines: [ + { value: true, given: { tapeMode: "off" } }, + { value: false, given: { tapeMode: "word" } }, + { value: true, given: { tapeMode: "word" }, fail: true }, + ], + }; + + it.for( + Object.entries(testCases).flatMap(([key, value]) => + value.flatMap((it) => ({ key: key as ConfigKey, ...it })) + ) + )( + `$key value=$value given=$given fail=$fail`, + ({ key, value, given, fail }) => { + //GIVEN + replaceConfig(given ?? {}); + + //WHEN + const applied = Config.genericSet(key, value as any); + + //THEN + expect(applied).toEqual(!fail); + } + ); + }); + + describe("overrideConfig", () => { + const testCases: TestsByConfig<{ + given: Partial; + expected?: Partial; + }> = { + mode: [ + { value: "time", given: { numbers: true, punctuation: true } }, + { + value: "custom", + given: { numbers: true, punctuation: true }, + expected: { numbers: false, punctuation: false }, + }, + { + value: "quote", + given: { numbers: true, punctuation: true }, + expected: { numbers: false, punctuation: false }, + }, + { + value: "zen", + given: { numbers: true, punctuation: true }, + expected: { numbers: false, punctuation: false }, + }, + ], + numbers: [{ value: false, given: { mode: "quote" } }], + freedomMode: [ + { + value: false, + given: { confidenceMode: "on" }, + expected: { confidenceMode: "on" }, + }, + { + value: true, + given: { confidenceMode: "on" }, + expected: { confidenceMode: "off" }, + }, + ], + stopOnError: [ + { + value: "off", + given: { confidenceMode: "on" }, + expected: { confidenceMode: "on" }, + }, + { + value: "word", + given: { confidenceMode: "on" }, + expected: { confidenceMode: "off" }, + }, + ], + confidenceMode: [ + { + value: "off", + given: { freedomMode: true, stopOnError: "word" }, + expected: { freedomMode: true, stopOnError: "word" }, + }, + { + value: "on", + given: { freedomMode: true, stopOnError: "word" }, + expected: { freedomMode: false, stopOnError: "off" }, + }, + ], + tapeMode: [ + { + value: "off", + given: { showAllLines: true }, + expected: { showAllLines: true }, + }, + { + value: "letter", + given: { showAllLines: true }, + expected: { showAllLines: false }, + }, + ], + theme: [ + { + value: "8008", + given: { customTheme: true }, + expected: { customTheme: false }, + }, + ], + }; + + it.for( + Object.entries(testCases).flatMap(([key, value]) => + value.flatMap((it) => ({ key: key as ConfigKey, ...it })) + ) + )( + `$key value=$value given=$given expected=$expected`, + ({ key, value, given, expected }) => { + //GIVEN + replaceConfig(given); + + //WHEN + Config.genericSet(key, value as any); + + //THEN + expect(getConfig()).toMatchObject(expected ?? {}); + } + ); + }); +}); diff --git a/frontend/__tests__/root/config.spec.ts b/frontend/__tests__/root/config.spec.ts index 2fd5860a2..03143a852 100644 --- a/frontend/__tests__/root/config.spec.ts +++ b/frontend/__tests__/root/config.spec.ts @@ -4,7 +4,7 @@ import { CustomThemeColors, FunboxName, ConfigKey, - Config as ConfigType, + CaretStyleSchema, } from "@monkeytype/schemas/configs"; import { randomBytes } from "crypto"; import { vi } from "vitest"; @@ -15,66 +15,60 @@ import * as DB from "../../src/ts/db"; import * as AccountButton from "../../src/ts/elements/account-button"; import * as Notifications from "../../src/ts/elements/notifications"; -type TestsByConfig = Partial<{ - [K in keyof ConfigType]: (T & { value: ConfigType[K] })[]; -}>; - -const { configMetadata, replaceConfig, getConfig } = Config.__testing; +const { replaceConfig, getConfig } = Config.__testing; describe("Config", () => { - const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment"); - beforeEach(() => isDevEnvironmentMock.mockReset()); + describe("test with mocks", () => { + const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment"); + + const canSetConfigWithCurrentFunboxesMock = vi.spyOn( + FunboxValidation, + "canSetConfigWithCurrentFunboxes" + ); + const isConfigValueValidMock = vi.spyOn( + ConfigValidation, + "isConfigValueValid" + ); + const dispatchConfigEventMock = vi.spyOn(ConfigEvent, "dispatch"); + const dbSaveConfigMock = vi.spyOn(DB, "saveConfig"); + const accountButtonLoadingMock = vi.spyOn(AccountButton, "loading"); + const notificationAddMock = vi.spyOn(Notifications, "add"); + const miscReloadAfterMock = vi.spyOn(Misc, "reloadAfter"); + const miscTriggerResizeMock = vi.spyOn(Misc, "triggerResize"); + + const mocks = [ + canSetConfigWithCurrentFunboxesMock, + isConfigValueValidMock, + dispatchConfigEventMock, + dbSaveConfigMock, + accountButtonLoadingMock, + notificationAddMock, + miscReloadAfterMock, + miscTriggerResizeMock, + ]; + + beforeEach(async () => { + vi.useFakeTimers(); + mocks.forEach((it) => it.mockReset()); + + vi.mock("../../src/ts/test/test-state", () => ({ + isActive: true, + })); + + isConfigValueValidMock.mockReturnValue(true); + canSetConfigWithCurrentFunboxesMock.mockReturnValue(true); + dbSaveConfigMock.mockResolvedValue(); - describe("configMeta", () => { - afterAll(() => { replaceConfig({}); - vi.resetModules(); - }); - it("should have changeRequiresRestart defined", () => { - const configsRequiringRestarts = Object.entries(configMetadata) - .filter(([_key, value]) => value.changeRequiresRestart === true) - .map(([key]) => key) - .sort(); - - expect(configsRequiringRestarts).toEqual( - [ - "punctuation", - "numbers", - "words", - "time", - "mode", - "quoteLength", - "language", - "difficulty", - "minWpmCustomSpeed", - "minWpm", - "minAcc", - "minAccCustom", - "minBurst", - "minBurstCustomSpeed", - "britishEnglish", - "funbox", - "customLayoutfluid", - "strictSpace", - "stopOnError", - "lazyMode", - "layout", - "codeUnindentOnBackspace", - ].sort() - ); }); - it("should have triggerResize defined", () => { - const configsWithTriggeResize = Object.entries(configMetadata) - .filter(([_key, value]) => value.triggerResize === true) - .map(([key]) => key) - .sort(); - - expect(configsWithTriggeResize).toEqual( - ["fontSize", "keymapSize", "maxLineWidth", "tapeMode"].sort() - ); + afterAll(() => { + mocks.forEach((it) => it.mockRestore()); + vi.useRealTimers(); }); + beforeEach(() => isDevEnvironmentMock.mockReset()); + it("should throw if config key in not found in metadata", () => { expect(() => { Config.genericSet("nonExistentKey" as ConfigKey, true); @@ -83,417 +77,198 @@ describe("Config", () => { ); }); - describe("overrideValue", () => { - const testCases: TestsByConfig<{ - given?: Partial; - expected: Partial; - }> = { - punctuation: [ - { value: true, expected: { punctuation: true } }, - { - value: true, - given: { mode: "quote" }, - expected: { punctuation: false }, - }, - ], - numbers: [ - { value: true, expected: { numbers: true } }, - { - value: true, - given: { mode: "quote" }, - expected: { numbers: false }, - }, - ], - customLayoutfluid: [ - { - value: ["qwerty", "qwerty", "qwertz"], - expected: { customLayoutfluid: ["qwerty", "qwertz"] }, - }, - ], - customPolyglot: [ - { - value: ["english", "polish", "english"], - expected: { customPolyglot: ["english", "polish"] }, - }, - ], - keymapSize: [ - { value: 1, expected: { keymapSize: 1 } }, - { value: 1.234, expected: { keymapSize: 1.2 } }, - { value: 0.4, expected: { keymapSize: 0.5 } }, - { value: 3.6, expected: { keymapSize: 3.5 } }, - ], - customBackground: [ - { - value: " https://example.com/test.jpg ", - expected: { customBackground: "https://example.com/test.jpg" }, - }, - ], - accountChart: [ - { - value: ["on", "off", "off", "off"], - expected: { accountChart: ["on", "off", "off", "off"] }, - }, - { - value: ["off", "off", "off", "off"], - given: { accountChart: ["on", "off", "off", "off"] }, - expected: { accountChart: ["off", "on", "off", "off"] }, - }, - { - value: ["off", "off", "on", "on"], - given: { accountChart: ["off", "on", "off", "off"] }, - expected: { accountChart: ["on", "off", "on", "on"] }, - }, - ], - }; + it("fails if test is active and funbox no_quit", () => { + //GIVEN + replaceConfig({ funbox: ["no_quit"], numbers: false }); - it.for( - Object.entries(testCases).flatMap(([key, value]) => - value.flatMap((it) => ({ key: key as ConfigKey, ...it })) - ) - )( - `$key value=$value given=$given expect=$expected`, - ({ key, value, given, expected }) => { - //GIVEN - replaceConfig(given ?? {}); + //WHEN + expect(Config.genericSet("numbers", true, true)).toBe(false); - //WHEN - Config.genericSet(key, value as any); - - //THEN - expect(getConfig()).toMatchObject(expected); + //THEN + expect(notificationAddMock).toHaveBeenCalledWith( + "No quit funbox is active. Please finish the test.", + 0, + { + important: true, } ); }); - describe("isBlocked", () => { - const testCases: TestsByConfig<{ - given?: Partial; - fail?: true; - }> = { - funbox: [ - { - value: "gibberish" as any, - given: { mode: "quote" }, - fail: true, - }, - ], - showAllLines: [ - { value: true, given: { tapeMode: "off" } }, - { value: false, given: { tapeMode: "word" } }, - { value: true, given: { tapeMode: "word" }, fail: true }, - ], - }; + //TODO isBlocked + it("should fail if config is blocked", () => { + //GIVEN + replaceConfig({ tapeMode: "letter" }); - it.for( - Object.entries(testCases).flatMap(([key, value]) => - value.flatMap((it) => ({ key: key as ConfigKey, ...it })) - ) - )( - `$key value=$value given=$given fail=$fail`, - ({ key, value, given, fail }) => { - //GIVEN - replaceConfig(given ?? {}); + //WHEN / THEN + expect(Config.genericSet("showAllLines", true)).toBe(false); + }); - //WHEN - const applied = Config.genericSet(key, value as any); + it("should use overrideValue", () => { + //WHEN + Config.genericSet("customLayoutfluid", ["3l", "ABNT2", "3l"]); - //THEN - expect(applied).toEqual(!fail); - } + //THEN + expect(getConfig().customLayoutfluid).toEqual(["3l", "ABNT2"]); + }); + + it("fails if config is invalid", () => { + //GIVEN + isConfigValueValidMock.mockReturnValue(false); + + //WHEN / THEN + expect(Config.genericSet("caretStyle", "banana" as any)).toBe(false); + expect(isConfigValueValidMock).toHaveBeenCalledWith( + "caret style", + "banana", + CaretStyleSchema ); }); - describe("overrideConfig", () => { - const testCases: TestsByConfig<{ - given: Partial; - expected?: Partial; - }> = { - mode: [ - { value: "time", given: { numbers: true, punctuation: true } }, - { - value: "custom", - given: { numbers: true, punctuation: true }, - expected: { numbers: false, punctuation: false }, - }, - { - value: "quote", - given: { numbers: true, punctuation: true }, - expected: { numbers: false, punctuation: false }, - }, - { - value: "zen", - given: { numbers: true, punctuation: true }, - expected: { numbers: false, punctuation: false }, - }, - ], - numbers: [{ value: false, given: { mode: "quote" } }], - freedomMode: [ - { - value: false, - given: { confidenceMode: "on" }, - expected: { confidenceMode: "on" }, - }, - { - value: true, - given: { confidenceMode: "on" }, - expected: { confidenceMode: "off" }, - }, - ], - stopOnError: [ - { - value: "off", - given: { confidenceMode: "on" }, - expected: { confidenceMode: "on" }, - }, - { - value: "word", - given: { confidenceMode: "on" }, - expected: { confidenceMode: "off" }, - }, - ], - confidenceMode: [ - { - value: "off", - given: { freedomMode: true, stopOnError: "word" }, - expected: { freedomMode: true, stopOnError: "word" }, - }, - { - value: "on", - given: { freedomMode: true, stopOnError: "word" }, - expected: { freedomMode: false, stopOnError: "off" }, - }, - ], - tapeMode: [ - { - value: "off", - given: { showAllLines: true }, - expected: { showAllLines: true }, - }, - { - value: "letter", - given: { showAllLines: true }, - expected: { showAllLines: false }, - }, - ], - theme: [ - { - value: "8008", - given: { customTheme: true }, - expected: { customTheme: false }, - }, - ], - }; + it("cannot set if funbox disallows", () => { + //GIVEN + canSetConfigWithCurrentFunboxesMock.mockReturnValue(false); - it.for( - Object.entries(testCases).flatMap(([key, value]) => - value.flatMap((it) => ({ key: key as ConfigKey, ...it })) - ) - )( - `$key value=$value given=$given expected=$expected`, - ({ key, value, given, expected }) => { - //GIVEN - replaceConfig(given); + //WHEN / THEN + expect(Config.genericSet("numbers", true)).toBe(false); + }); - //WHEN - Config.genericSet(key, value as any); + it("sets overrideConfigs", () => { + //GIVEN + replaceConfig({ + confidenceMode: "off", + freedomMode: false, //already set correctly + stopOnError: "letter", //should get updated + }); - //THEN - expect(getConfig()).toMatchObject(expected ?? {}); - } + //WHEN + Config.genericSet("confidenceMode", "max"); + + //THEN + expect(dispatchConfigEventMock).not.toHaveBeenCalledWith( + "freedomMode", + false, + true, + true + ); + + expect(dispatchConfigEventMock).toHaveBeenCalledWith( + "stopOnError", + "off", + true, + "letter" + ); + + expect(dispatchConfigEventMock).toHaveBeenCalledWith( + "confidenceMode", + "max", + false, + "off" ); }); - describe("test with mocks", () => { - const canSetConfigWithCurrentFunboxesMock = vi.spyOn( - FunboxValidation, - "canSetConfigWithCurrentFunboxes" + it("saves to localstorage if nosave=false", async () => { + //GIVEN + replaceConfig({ numbers: false }); + + //WHEN + Config.genericSet("numbers", true); + + //THEN + //wait for debounce + await vi.advanceTimersByTimeAsync(2500); + + //show loading + expect(accountButtonLoadingMock).toHaveBeenNthCalledWith(1, true); + + //save + expect(dbSaveConfigMock).toHaveBeenCalledWith({ numbers: true }); + + //hide loading + expect(accountButtonLoadingMock).toHaveBeenNthCalledWith(2, false); + + //send event + expect(dispatchConfigEventMock).toHaveBeenCalledWith( + "saveToLocalStorage", + expect.stringContaining("numbers") ); - const isConfigValueValidMock = vi.spyOn( - ConfigValidation, - "isConfigValueValid" + }); + it("does not save to localstorage if nosave=true", async () => { + //GIVEN + + replaceConfig({ numbers: false }); + + //WHEN + Config.genericSet("numbers", true, true); + + //THEN + //wait for debounce + await vi.advanceTimersByTimeAsync(2500); + + expect(accountButtonLoadingMock).not.toHaveBeenCalled(); + expect(dbSaveConfigMock).not.toHaveBeenCalled(); + + expect(dispatchConfigEventMock).not.toHaveBeenCalledWith( + "saveToLocalStorage", + expect.any(String) ); - const dispatchConfigEventMock = vi.spyOn(ConfigEvent, "dispatch"); - const dbSaveConfigMock = vi.spyOn(DB, "saveConfig"); - const accountButtonLoadingMock = vi.spyOn(AccountButton, "loading"); - const notificationAddMock = vi.spyOn(Notifications, "add"); - const miscReloadAfterMock = vi.spyOn(Misc, "reloadAfter"); + }); - const mocks = [ - canSetConfigWithCurrentFunboxesMock, - isConfigValueValidMock, - dispatchConfigEventMock, - dbSaveConfigMock, - accountButtonLoadingMock, - notificationAddMock, - miscReloadAfterMock, - ]; + it("dispatches event on set", () => { + //GIVEN + replaceConfig({ numbers: false }); - beforeEach(async () => { - vi.useFakeTimers(); - mocks.forEach((it) => it.mockReset()); + //WHEN + Config.genericSet("numbers", true, true); - vi.mock("../../src/ts/test/test-state", () => ({ - isActive: true, - })); + //THEN - isConfigValueValidMock.mockReturnValue(true); - canSetConfigWithCurrentFunboxesMock.mockReturnValue(true); - dbSaveConfigMock.mockResolvedValue(); - }); + expect(dispatchConfigEventMock).toHaveBeenCalledWith( + "numbers", + true, + true, + false + ); + }); - afterAll(() => { - mocks.forEach((it) => it.mockRestore()); - vi.useRealTimers(); - }); + it("triggers resize if property is set", () => { + ///WHEN + Config.genericSet("maxLineWidth", 50, false); - it("cannot set if funbox disallows", () => { - //GIVEN - canSetConfigWithCurrentFunboxesMock.mockReturnValue(false); + expect(miscTriggerResizeMock).toHaveBeenCalled(); + }); - //WHEN / THEN - expect(Config.genericSet("numbers", true)).toBe(false); - }); + it("does not triggers resize if property is not set", () => { + ///WHEN + Config.genericSet("startGraphsAtZero", true, false); - it("fails if config is invalid", () => { - //GIVEN - isConfigValueValidMock.mockReturnValue(false); + expect(miscTriggerResizeMock).not.toHaveBeenCalled(); + }); - //WHEN / THEN - expect(Config.genericSet("numbers", "off" as any)).toBe(false); - }); + it("does not triggers resize if property on nosave", () => { + ///WHEN + Config.genericSet("maxLineWidth", 50, true); - it("dispatches event on set", () => { - //GIVEN - replaceConfig({ numbers: false }); + expect(miscTriggerResizeMock).not.toHaveBeenCalled(); + }); - //WHEN - Config.genericSet("numbers", true, true); + it("calls afterSet", () => { + //GIVEN + isDevEnvironmentMock.mockReturnValue(false); + replaceConfig({ ads: "off" }); - //THEN + //WHEN + Config.genericSet("ads", "sellout"); - expect(dispatchConfigEventMock).toHaveBeenCalledWith( - "numbers", - true, - true, - false - ); - }); - - it("saves to localstorage if nosave=false", async () => { - //GIVEN - replaceConfig({ numbers: false }); - - //WHEN - Config.genericSet("numbers", true); - - //THEN - //wait for debounce - await vi.advanceTimersByTimeAsync(2500); - - //show loading - expect(accountButtonLoadingMock).toHaveBeenNthCalledWith(1, true); - - //save - expect(dbSaveConfigMock).toHaveBeenCalledWith({ numbers: true }); - - //hide loading - expect(accountButtonLoadingMock).toHaveBeenNthCalledWith(2, false); - - //send event - expect(dispatchConfigEventMock).toHaveBeenCalledWith( - "saveToLocalStorage", - expect.stringContaining("numbers") - ); - }); - it("does not save to localstorage if nosave=true", async () => { - //GIVEN - - replaceConfig({ numbers: false }); - - //WHEN - Config.genericSet("numbers", true, true); - - //THEN - //wait for debounce - await vi.advanceTimersByTimeAsync(2500); - - expect(accountButtonLoadingMock).not.toHaveBeenCalled(); - expect(dbSaveConfigMock).not.toHaveBeenCalled(); - - expect(dispatchConfigEventMock).not.toHaveBeenCalledWith( - "saveToLocalStorage", - expect.any(String) - ); - }); - it("calls afterSet", () => { - //GIVEN - isDevEnvironmentMock.mockReturnValue(false); - replaceConfig({ ads: "off" }); - - //WHEN - Config.genericSet("ads", "sellout"); - - //THEN - expect(notificationAddMock).toHaveBeenCalledWith( - "Ad settings changed. Refreshing...", - 0 - ); - expect(miscReloadAfterMock).toHaveBeenCalledWith(3); - }); - - it("fails if test is active and funbox no_quit", () => { - //GIVEN - replaceConfig({ funbox: ["no_quit"], numbers: false }); - - //WHEN - expect(Config.genericSet("numbers", true, true)).toBe(false); - - //THEN - expect(notificationAddMock).toHaveBeenCalledWith( - "No quit funbox is active. Please finish the test.", - 0, - { - important: true, - } - ); - }); - - it("sends configEvents for overrideConfigs", () => { - //GIVEN - replaceConfig({ - confidenceMode: "off", - freedomMode: true, - stopOnError: "letter", - }); - - //WHEN - Config.genericSet("confidenceMode", "max"); - - //THEN - expect(dispatchConfigEventMock).toHaveBeenCalledWith( - "freedomMode", - false, - true, - true - ); - - expect(dispatchConfigEventMock).toHaveBeenCalledWith( - "stopOnError", - "off", - true, - "letter" - ); - - expect(dispatchConfigEventMock).toHaveBeenCalledWith( - "confidenceMode", - "max", - false, - "off" - ); - }); + //THEN + expect(notificationAddMock).toHaveBeenCalledWith( + "Ad settings changed. Refreshing...", + 0 + ); + expect(miscReloadAfterMock).toHaveBeenCalledWith(3); }); }); + //TODO move the rest to schema/tests or remove after removing the setX functions from Config it("setMode", () => { expect(Config.setMode("zen")).toBe(true); expect(Config.setMode("invalid" as any)).toBe(false); diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts index 7cef16028..da06fd463 100644 --- a/frontend/src/ts/config.ts +++ b/frontend/src/ts/config.ts @@ -12,6 +12,7 @@ import { createErrorMessage, isObject, promiseWithResolvers, + triggerResize, typedKeys, } from "./utils/misc"; import * as ConfigSchemas from "@monkeytype/schemas/configs"; @@ -200,7 +201,7 @@ export function genericSet( ConfigEvent.dispatch(key, value, nosave, previousValue); if (metadata.triggerResize && !nosave) { - $(window).trigger("resize"); + triggerResize(); } metadata.afterSet?.({ nosave: nosave || false, currentConfig: config }); diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index debae3866..62bdaea32 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -760,4 +760,7 @@ export function sanitize( ) as z.infer; } +export function triggerResize(): void { + $(window).trigger("resize"); +} // DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES