From 1bbb28ae0a9356e4aa3a3ac92aaaa884589f0512 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 28 Aug 2026 20:16:59 +0100 Subject: [PATCH] Make every setting reachable, add consumable toggles, and expose Evoker mastery effectiveness Settings are grouped by a category field, but the panel rendered a hardcoded list of categories. A setting whose category was missing from that list was stored in Redux, fully wired to the engine, and never drawn, with no error anywhere. The list now lives in one exported place and SettingsCategories.test.js fails if any category with settings is missing from it, if a listed category is empty, or if a Retail setting has no title and tooltip. That last check caught a pre-existing empty tooltip on enchantItems, which is now filled. Categories with nothing in them for the current game type no longer draw a bare heading, and one with no settings at all can't crash the panel. Food, weapon oil and Vantus Rune were applied unconditionally with no way to turn them off. They're toggles now. Only options with real modelled values are offered - there is no food data beyond a single hardcoded entry, so that dropdown is deliberately limited rather than invented. Mastery effectiveness for Preservation Evoker was hardcoded at 0.9 inside scoreEvokerSet, overwriting whatever was passed in. Preservation mastery scales with how injured the target is so its real effectiveness varies a lot by content, and Resto Shaman already exposed this as a setting. Absent settings keep 0.9 so existing results are unchanged. The value is read with Number() rather than a typeof check because the settings panel writes number inputs back as strings - a strict typeof === "number" check worked on the reducer's initial value and then silently fell back to the default the moment the player edited the box, which looked exactly like the setting doing nothing. 42 suites / 200 tests passing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J --- .../MasteryEffectiveness.test.js | 44 +++++++++++++++ .../PreservationEvokerProfile.ts | 11 +++- .../Settings/SettingsCategories.test.js | 54 +++++++++++++++++++ .../Modules/Settings/SettingsComponent.js | 14 ++++- .../Modules/TopGear/Engine/TopGearEngine.ts | 19 ++++--- src/Redux/Reducers/RootReducer.ts | 6 +++ src/locale/en/translate.json | 43 +++++++-------- 7 files changed, 161 insertions(+), 30 deletions(-) create mode 100644 src/General/Modules/Player/ClassDefaults/PreservationEvoker/MasteryEffectiveness.test.js create mode 100644 src/General/Modules/Settings/SettingsCategories.test.js diff --git a/src/General/Modules/Player/ClassDefaults/PreservationEvoker/MasteryEffectiveness.test.js b/src/General/Modules/Player/ClassDefaults/PreservationEvoker/MasteryEffectiveness.test.js new file mode 100644 index 0000000000..3942ddbd30 --- /dev/null +++ b/src/General/Modules/Player/ClassDefaults/PreservationEvoker/MasteryEffectiveness.test.js @@ -0,0 +1,44 @@ +import { scoreEvokerSet } from "General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile"; + +/* + Mastery effectiveness was hardcoded inside scoreEvokerSet. Preservation mastery scales with how injured the + target is, so its real effectiveness varies by content - Resto Shaman already exposed this as a setting. + The settings panel writes number inputs back as strings, which is what the string test below guards. +*/ + +describe("Evoker mastery effectiveness is configurable", () => { + const stats = { intellect: 60000, haste: 900, crit: 900, mastery: 1300, versatility: 200, leech: 0 }; + const run = (pct) => { + const s = pct === null ? {} : { masteryEffectivenessEvoker: { value: pct, options: [], category: "specSpecific", type: "Entry", gameType: "Retail" } }; + return scoreEvokerSet(stats, { spec: "Preservation Evoker", heroTree: "Chronowarden", settings: s, stats, tierSets: [], effectList: [] }, s).healing; + }; + + test("the setting changes modelled throughput", () => { + expect(run(100)).toBeGreaterThan(run(70)); + }); + + test("higher effectiveness is monotonically better", () => { + const values = [70, 80, 90, 100].map(run); + expect(values).toEqual([...values].sort((a, b) => a - b)); + }); + + test("an absent setting keeps the previous hardcoded 0.9", () => { + expect(Math.round(run(null))).toEqual(Math.round(run(90))); + }); + + test("a STRING value works - the settings panel writes numbers back as strings", () => { + // Regression: a strict typeof === "number" check here meant editing the box silently did nothing. + const asString = (v) => ({ masteryEffectivenessEvoker: { value: v, options: [], category: "specSpecific", type: "Entry", gameType: "Retail" } }); + const runStr = (v) => scoreEvokerSet(stats, { spec: "Preservation Evoker", heroTree: "Chronowarden", settings: asString(v), stats, tierSets: [], effectList: [] }, asString(v)).healing; + + expect(Math.round(runStr("100"))).toEqual(Math.round(run(100))); + expect(Math.round(runStr("70"))).toEqual(Math.round(run(70))); + expect(runStr("100")).toBeGreaterThan(runStr("70")); + }); + + test("a malformed setting falls back rather than zeroing mastery", () => { + const s = { masteryEffectivenessEvoker: { value: "nonsense", options: [], category: "specSpecific", type: "Entry", gameType: "Retail" } }; + const result = scoreEvokerSet(stats, { spec: "Preservation Evoker", heroTree: "Chronowarden", settings: s, stats, tierSets: [], effectList: [] }, s).healing; + expect(Math.round(result)).toEqual(Math.round(run(90))); + }); +}); diff --git a/src/General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile.ts b/src/General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile.ts index 11beeb671e..f4217e8151 100644 --- a/src/General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile.ts +++ b/src/General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile.ts @@ -80,7 +80,16 @@ export function scoreEvokerSet(stats: Stats, playerData: any, settings: PlayerSe const healingBreakdown: Record = {}; const castBreakdown: Record = {}; - playerData.masteryEffectiveness = 0.9; + // Preservation mastery scales with how injured your targets are, so its real effectiveness varies a lot by + // content. Resto Shaman already exposes this as a setting; Evoker now does too. Falls back to the previous + // hardcoded 0.9 when the setting is absent so existing results are unchanged. + // The settings panel writes number inputs back as strings (e.target.value), so this has to coerce rather than + // type-check. A strict typeof check here silently fell back to the default the moment the player edited the box. + const masteryEffectivenessRaw = settings && settings.masteryEffectivenessEvoker ? settings.masteryEffectivenessEvoker.value : null; + const masteryEffectivenessPct = Number(masteryEffectivenessRaw); + playerData.masteryEffectiveness = Number.isFinite(masteryEffectivenessPct) && masteryEffectivenessPct > 0 + ? masteryEffectivenessPct / 100 + : 0.9; // Apply Talents const talents = initialState.talents; diff --git a/src/General/Modules/Settings/SettingsCategories.test.js b/src/General/Modules/Settings/SettingsCategories.test.js new file mode 100644 index 0000000000..4c4f4b14a6 --- /dev/null +++ b/src/General/Modules/Settings/SettingsCategories.test.js @@ -0,0 +1,54 @@ +import { SETTINGS_CATEGORIES } from "./SettingsComponent"; +import rootReducer from "Redux/Reducers/RootReducer"; +import translations from "locale/en/translate.json"; + +/* + Settings are grouped by a `category` field, but the panel renders a hardcoded list of categories. A setting whose + category is missing from that list is stored in Redux, is fully wired to the engine, and never appears in the UI - + with no error anywhere. That's exactly how the Omnium Folio dropdowns shipped invisible. +*/ + +const playerSettings = rootReducer(undefined, { type: "@@INIT" }).playerSettings; +const settingsFor = (gameType) => Object.entries(playerSettings).filter(([, v]) => v.gameType === gameType); + +describe("Every setting is reachable in the UI", () => { + ["Retail", "Classic"].forEach((gameType) => { + test(`${gameType}: every category that has settings is rendered`, () => { + const used = [...new Set(settingsFor(gameType).map(([, v]) => v.category))]; + const rendered = SETTINGS_CATEGORIES[gameType]; + + const orphaned = used.filter((c) => !rendered.includes(c)); + expect(orphaned).toEqual([]); + }); + + test(`${gameType}: no rendered category is empty`, () => { + const used = new Set(settingsFor(gameType).map(([, v]) => v.category)); + // A listed category with nothing in it would draw a bare heading. + const empty = SETTINGS_CATEGORIES[gameType].filter((c) => !used.has(c)); + expect(empty).toEqual([]); + }); + }); + + test("every setting declares a category and a gameType", () => { + const malformed = Object.entries(playerSettings) + .filter(([, v]) => !v.category || !v.gameType) + .map(([k]) => k); + expect(malformed).toEqual([]); + }); +}); + +describe("Every setting is labelled", () => { + const retailStrings = translations.translations.Settings.Retail; + + test("each category has a heading string", () => { + const missing = SETTINGS_CATEGORIES.Retail.filter((c) => !retailStrings[c]); + expect(missing).toEqual([]); + }); + + test("each Retail setting has a title and tooltip", () => { + const missing = settingsFor("Retail") + .map(([k]) => k) + .filter((k) => !retailStrings[k] || !retailStrings[k].title || !retailStrings[k].tooltip); + expect(missing).toEqual([]); + }); +}); diff --git a/src/General/Modules/Settings/SettingsComponent.js b/src/General/Modules/Settings/SettingsComponent.js index c30484fb3f..fc37c1bd5b 100644 --- a/src/General/Modules/Settings/SettingsComponent.js +++ b/src/General/Modules/Settings/SettingsComponent.js @@ -6,6 +6,13 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { togglePlayerSettings } from "Redux/Actions"; +// Any category listed here is rendered. A setting whose category is missing from this list exists in the store +// but is never drawn, which is silent - SettingsCategories.test.js guards against that. +export const SETTINGS_CATEGORIES = { + Retail: ["trinkets", "embellishments", "topGear", "consumables", "upgradeFinder", "specSpecific"], + Classic: ["topGear", "enchants", "specSpecific"], +}; + const useStyles = makeStyles((theme) => ({ root: { width: "100%", @@ -31,7 +38,7 @@ export default function SettingsComponent(props) { const dispatch = useDispatch(); - const categories = gameType === "Retail" ? ["trinkets", "embellishments", "topGear", "upgradeFinder", "specSpecific"] : ["topGear", "enchants", "specSpecific"]; + const categories = SETTINGS_CATEGORIES[gameType] || SETTINGS_CATEGORIES.Retail; //const settingsCategories = [...new Set(playerSettings.map(o => o.category))]; /* ---------------------------------------------------------------------------------------------- */ @@ -73,6 +80,9 @@ export default function SettingsComponent(props) { return ( {categories.map((category) => { + const categoryKeys = mappedKeys[category] || []; + if (categoryKeys.length === 0) return null; + return ( @@ -81,7 +91,7 @@ export default function SettingsComponent(props) { {t("Settings.Retail." + category)} - {mappedKeys[category].map((key, i) => { + {categoryKeys.map((key, i) => { return (