diff --git a/app/features/settings/components/MatchProfileTab.tsx b/app/features/settings/components/MatchProfileTab.tsx index b8ae2d1fe..594b6a913 100644 --- a/app/features/settings/components/MatchProfileTab.tsx +++ b/app/features/settings/components/MatchProfileTab.tsx @@ -89,11 +89,7 @@ function MapModePreferencesField({ if (preference !== "NEUTRAL") { newModePreferences.push({ mode, preference }); } - const newPool = - preference === "AVOID" - ? value.pool.filter((p) => p.mode !== mode) - : value.pool; - onChange({ modes: newModePreferences, pool: newPool }); + onChange({ modes: newModePreferences, pool: value.pool }); }; const handlePoolChange = (mode: ModeShort, stages: StageId[]) => { diff --git a/app/features/settings/match-profile-schemas.test.ts b/app/features/settings/match-profile-schemas.test.ts new file mode 100644 index 000000000..5a6e0adec --- /dev/null +++ b/app/features/settings/match-profile-schemas.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { mapModePreferencesValueSchema } from "./match-profile-schemas"; + +describe("mapModePreferencesValueSchema", () => { + it("strips pools for avoided modes", () => { + const result = mapModePreferencesValueSchema.parse({ + modes: [ + { mode: "SZ", preference: "PREFER" }, + { mode: "TC", preference: "AVOID" }, + ], + pool: [ + { mode: "SZ", stages: [1, 2] }, + { mode: "TC", stages: [3, 4] }, + ], + }); + + expect(result.pool).toEqual([{ mode: "SZ", stages: [1, 2] }]); + }); + + it("keeps pools for preferred and neutral modes", () => { + const result = mapModePreferencesValueSchema.parse({ + modes: [{ mode: "SZ", preference: "PREFER" }], + pool: [ + { mode: "SZ", stages: [1] }, + { mode: "TC", stages: [2] }, + ], + }); + + expect(result.pool).toEqual([ + { mode: "SZ", stages: [1] }, + { mode: "TC", stages: [2] }, + ]); + }); + + it("does not mutate the modes selection", () => { + const result = mapModePreferencesValueSchema.parse({ + modes: [{ mode: "TC", preference: "AVOID" }], + pool: [{ mode: "TC", stages: [1] }], + }); + + expect(result.modes).toEqual([{ mode: "TC", preference: "AVOID" }]); + expect(result.pool).toEqual([]); + }); +}); diff --git a/app/features/settings/match-profile-schemas.ts b/app/features/settings/match-profile-schemas.ts index 381a327f6..c49c2dfcb 100644 --- a/app/features/settings/match-profile-schemas.ts +++ b/app/features/settings/match-profile-schemas.ts @@ -22,7 +22,7 @@ export const LANGUAGE_OPTIONS = languagesUnified.map((lang) => ({ const preferenceSchema = z.enum(["AVOID", "PREFER"]).optional(); -const mapModePreferencesValueSchema = z +export const mapModePreferencesValueSchema = z .object({ modes: z.array(z.object({ mode: modeShort, preference: preferenceSchema })), pool: z.array( @@ -32,14 +32,16 @@ const mapModePreferencesValueSchema = z }), ), }) - .refine( - (val) => - val.pool.every((pool) => { - const mp = val.modes.find((m) => m.mode === pool.mode); - return mp?.preference !== "AVOID"; - }), - "Can't have map pool for a mode that was avoided", - ); + // Pools for avoided modes are kept in the client form state so they can be + // restored if the user later un-avoids the mode, but they must not be + // persisted as active pools. Strip them out before the value reaches the action. + .transform((val) => ({ + ...val, + pool: val.pool.filter((pool) => { + const mp = val.modes.find((m) => m.mode === pool.mode); + return mp?.preference !== "AVOID"; + }), + })); export const updateMatchProfileSchema = z.object({ _action: stringConstant("UPDATE_MATCH_PROFILE"), diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index ba6a413a5..fdccc0d0a 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -16,6 +16,7 @@ import { isNotVisible, navigate, seed, + submit, test, waitForPOSTResponse, } from "./helpers/playwright"; @@ -94,6 +95,96 @@ test.describe("Settings", () => { }); }); +const AVOIDED_MODE_POOL_ERROR = + "Can't have map pool for a mode that was avoided"; + +const setModePreference = ( + page: Page, + mode: string, + preference: "Avoid" | "Neutral" | "Prefer", +) => { + const name = + preference === "Neutral" + ? "Neutral towards the mode" + : `${preference} the mode`; + return page + .getByRole("radiogroup", { name: `Select preference towards ${mode}` }) + .getByRole("radio", { name }) + .click({ force: true }); +}; + +const mapButton = (page: Page, mode: string, stageId: number) => + page.getByTestId(`map-pool-${mode}-${stageId}`); + +const SELECTED_MAP_CLASS = /mapButtonGreyedOut/; + +// The seeded user already has random map pools, so empty the mode's pool to get +// a known starting state. Only currently selected (greyed, non-banned) stages +// are clickable to deselect. +const clearMapPool = async (page: Page, mode: string) => { + const picked = page.locator( + `[data-testid^="map-pool-${mode}-"][class*="mapButtonGreyedOut"]:not([disabled])`, + ); + for (let count = await picked.count(); count > 0; count--) { + // the selected-state check icon overlays the button and intercepts clicks + await picked.first().click({ force: true }); + await expect(picked).toHaveCount(count - 1); + } +}; + +test.describe("Match profile map preferences", () => { + test("retains map selection when toggling a mode prefer -> avoid -> prefer", async ({ + page, + }) => { + await seed(page); + await impersonate(page); + await navigate({ page, url: SETTINGS_PAGE }); + + await setModePreference(page, "SZ", "Prefer"); + await clearMapPool(page, "SZ"); + await mapButton(page, "SZ", 1).click(); + await expect(mapButton(page, "SZ", 1)).toHaveClass(SELECTED_MAP_CLASS); + + // avoiding hides the picker, but the selection should be remembered + await setModePreference(page, "SZ", "Avoid"); + await isNotVisible(mapButton(page, "SZ", 1)); + + await setModePreference(page, "SZ", "Prefer"); + await expect(mapButton(page, "SZ", 1)).toHaveClass(SELECTED_MAP_CLASS); + }); + + test("can save 'zones only' after a now-avoided mode previously had a map pool", async ({ + page, + }) => { + await seed(page); + await impersonate(page); + await navigate({ page, url: SETTINGS_PAGE }); + + // Save a map pool for both SZ and TC (stage 2 is not banned in TC). + await setModePreference(page, "SZ", "Prefer"); + await clearMapPool(page, "SZ"); + await mapButton(page, "SZ", 1).click(); + await setModePreference(page, "TC", "Prefer"); + await clearMapPool(page, "TC"); + await mapButton(page, "TC", 2).click(); + await submit(page); + + // Switch to "zones only" by avoiding every mode except SZ, then save. + await setModePreference(page, "TW", "Avoid"); + await setModePreference(page, "TC", "Avoid"); + await setModePreference(page, "RM", "Avoid"); + await setModePreference(page, "CB", "Avoid"); + await submit(page); + + // Reload so the form loads the persisted preferences. The previously saved + // TC pool must not resurface as an invalid "pool for an avoided mode". + await navigate({ page, url: SETTINGS_PAGE }); + + await submit(page); + await isNotVisible(page.getByText(AVOIDED_MODE_POOL_ERROR)); + }); +}); + const enableSpoilerFreeMode = async (page: Page) => { await navigate({ page, url: `${SETTINGS_PAGE}?tab=preferences` }); const form = createFormHelpers(page, spoilerFreeModeSchema);