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 (