diff --git a/jest.config.ts b/jest.config.ts index b7f770614..d546dfb96 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -15,7 +15,7 @@ export default { "^.+\\.js$": ["@swc/jest"], }, transformIgnorePatterns: [ - "node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js|jose)/)", + "node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js|jose|lit.*|@lit)/)", ], collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"], coverageThreshold: { diff --git a/package-lock.json b/package-lock.json index 1f6cd8a0f..b9417e1e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3825,9 +3825,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 48ce400c1..09d840048 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -25,6 +25,7 @@ import { import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import "./components/Difficulties"; +import "./components/FluentSlider"; import "./components/LobbyTeamView"; import "./components/Maps"; import { JoinLobbyEvent } from "./Main"; @@ -331,25 +332,17 @@ export class HostLobbyModal extends LitElement { ${translateText("host_modal.options_title")}
- + .value=${this.bots} + labelKey="host_modal.bots" + disabledKey="host_modal.bots_disabled" + @value-changed=${this.handleBotsChange} + > +
${ !( @@ -661,7 +654,8 @@ export class HostLobbyModal extends LitElement { // Modified to include debouncing private handleBotsChange(e: Event) { - const value = parseInt((e.target as HTMLInputElement).value); + const customEvent = e as CustomEvent<{ value: number }>; + const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 8b937ae30..f65c955cf 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -21,6 +21,7 @@ import { generateID } from "../core/Util"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import "./components/Difficulties"; +import "./components/FluentSlider"; import "./components/Maps"; import { fetchCosmetics } from "./Cosmetics"; import { FlagInput } from "./FlagInput"; @@ -236,24 +237,17 @@ export class SinglePlayerModal extends LitElement { ${translateText("single_modal.options_title")}
- + .value=${this.bots} + labelKey="single_modal.bots" + disabledKey="single_modal.bots_disabled" + @value-changed=${this.handleBotsChange} + > +
${!( this.gameMode === GameMode.Team && @@ -448,7 +442,8 @@ export class SinglePlayerModal extends LitElement { } private handleBotsChange(e: Event) { - const value = parseInt((e.target as HTMLInputElement).value); + const customEvent = e as CustomEvent<{ value: number }>; + const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } diff --git a/src/client/components/FluentSlider.ts b/src/client/components/FluentSlider.ts new file mode 100644 index 000000000..56957d49c --- /dev/null +++ b/src/client/components/FluentSlider.ts @@ -0,0 +1,152 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { translateText } from "../Utils"; + +@customElement("fluent-slider") +export class FluentSlider extends LitElement { + static styles = css` + :host { + display: block; + width: 100%; + font-family: inherit; + } + .slider-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + width: 100%; + text-align: center; + } + .option-card-title { + font-size: 14px; /* match other cards */ + color: #aaa; /* light gray text */ + text-align: center; + margin: 0 0 4px 0; + font-weight: normal; + } + input[type="range"] { + width: 100%; + max-width: 100%; + background-color: transparent; + } + input[type="number"] { + width: 60px; + background-color: #2d3748; + color: #aaa; /* match label color */ + border: 1px solid #4a5568; + text-align: center; + border-radius: 4px; + font-weight: normal; + font-family: inherit; + } + span.editable { + cursor: pointer; + min-width: 60px; + display: inline-block; + text-align: center; + color: #aaa; /* match label color */ + font-weight: normal; + user-select: none; + } + `; + + @property({ type: Number }) value = 0; + @property({ type: Number }) min = 0; + @property({ type: Number }) max = 400; + @property({ type: Number }) step = 1; + @property({ type: String }) labelKey = ""; + @property({ type: String }) disabledKey = ""; + + @state() private isEditing = false; + + @query("input[type='number']") private numberInput!: HTMLInputElement; + + private dispatchValueChange() { + this.dispatchEvent( + new CustomEvent("value-changed", { + detail: { value: this.value }, + bubbles: true, + composed: true, + }), + ); + } + + private handleSliderInput(e: Event) { + const target = e.target as HTMLInputElement; + this.value = target.valueAsNumber; + } + + private handleSliderChange(e: Event) { + const target = e.target as HTMLInputElement; + this.value = target.valueAsNumber; + this.dispatchValueChange(); + } + + private handleNumberChange(e: Event) { + const target = e.target as HTMLInputElement; + let val = target.valueAsNumber; + if (isNaN(val)) { + val = this.min; + } + if (val < this.min) val = this.min; + if (val > this.max) val = this.max; + this.value = val; + this.dispatchValueChange(); + } + + private handleNumberKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") this.isEditing = false; + } + + private enableEditing() { + this.isEditing = true; + this.updateComplete.then(() => this.numberInput?.focus()); + } + + render() { + return html` +
+ +
+ ${this.labelKey ? translateText(this.labelKey) : ""} + ${this.isEditing + ? html` (this.isEditing = false)} + @keydown=${this.handleNumberKeyDown} + />` + : html` { + if (e.key === "Enter" || e.key === " ") { + this.enableEditing(); + e.preventDefault(); + } + }} + > + ${this.value === 0 && this.disabledKey + ? translateText(this.disabledKey) + : this.value} + `} +
+
+ `; + } +} diff --git a/tests/client/components/FluentSlider.test.ts b/tests/client/components/FluentSlider.test.ts new file mode 100644 index 000000000..44856f918 --- /dev/null +++ b/tests/client/components/FluentSlider.test.ts @@ -0,0 +1,285 @@ +/** + * @jest-environment jsdom + */ +import { FluentSlider } from "../../../src/client/components/FluentSlider"; + +// Mock the translateText function +jest.mock("../../../src/client/Utils", () => ({ + translateText: jest.fn((key: string) => key), +})); + +describe("FluentSlider", () => { + let slider: FluentSlider; + + beforeEach(async () => { + // Define the custom element if not already defined + if (!customElements.get("fluent-slider")) { + customElements.define("fluent-slider", FluentSlider); + } + slider = document.createElement("fluent-slider") as FluentSlider; + document.body.appendChild(slider); + await slider.updateComplete; + }); + + afterEach(() => { + document.body.removeChild(slider); + }); + + describe("Initialization", () => { + it("should initialize with default values", () => { + expect(slider.value).toBe(0); + expect(slider.min).toBe(0); + expect(slider.max).toBe(400); + expect(slider.step).toBe(1); + expect(slider.labelKey).toBe(""); + expect(slider.disabledKey).toBe(""); + }); + + it("should accept custom property values", async () => { + slider.value = 150; + slider.min = 10; + slider.max = 300; + slider.step = 5; + slider.labelKey = "host_modal.bots"; + slider.disabledKey = "host_modal.bots_disabled"; + + await slider.updateComplete; + + expect(slider.value).toBe(150); + expect(slider.min).toBe(10); + expect(slider.max).toBe(300); + expect(slider.step).toBe(5); + expect(slider.labelKey).toBe("host_modal.bots"); + expect(slider.disabledKey).toBe("host_modal.bots_disabled"); + }); + }); + + describe("Value Updates from Range Slider", () => { + it("should update value when slider input changes", async () => { + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + expect(rangeInput).toBeTruthy(); + + // Simulate slider input + rangeInput.valueAsNumber = 250; + rangeInput.dispatchEvent(new Event("input", { bubbles: true })); + await slider.updateComplete; + + expect(slider.value).toBe(250); + }); + + it("should update value when slider change event fires", async () => { + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + + rangeInput.valueAsNumber = 100; + rangeInput.dispatchEvent(new Event("change", { bubbles: true })); + await slider.updateComplete; + + expect(slider.value).toBe(100); + }); + }); + + describe("Value-Changed Event - CRITICAL FOR BUG FIX", () => { + it("should dispatch CustomEvent with detail.value (not event.target.value)", async () => { + const eventSpy = jest.fn(); + slider.addEventListener("value-changed", eventSpy); + + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + rangeInput.valueAsNumber = 200; + rangeInput.dispatchEvent(new Event("change", { bubbles: true })); + await slider.updateComplete; + + expect(eventSpy).toHaveBeenCalled(); + const event = eventSpy.mock.calls[0][0] as CustomEvent<{ + value: number; + }>; + + // CRITICAL: Event must have detail.value, not target.value + expect(event.detail).toBeDefined(); + expect(event.detail.value).toBe(200); + expect(event.bubbles).toBe(true); + expect(event.composed).toBe(true); + }); + + it("should not dispatch event on input, only on change", async () => { + const eventSpy = jest.fn(); + slider.addEventListener("value-changed", eventSpy); + + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + + // Input event should NOT trigger value-changed + rangeInput.valueAsNumber = 150; + rangeInput.dispatchEvent(new Event("input", { bubbles: true })); + await slider.updateComplete; + expect(eventSpy).not.toHaveBeenCalled(); + + // Change event SHOULD trigger value-changed + rangeInput.dispatchEvent(new Event("change", { bubbles: true })); + await slider.updateComplete; + expect(eventSpy).toHaveBeenCalledTimes(1); + }); + + it("should work with the handler pattern used in HostLobbyModal", async () => { + // This simulates the actual handler code in HostLobbyModal.ts:656-660 + const mockHandler = jest.fn((e: Event) => { + const customEvent = e as CustomEvent<{ value: number }>; + const value = customEvent.detail.value; + if (isNaN(value) || value < 0 || value > 400) { + return; + } + // If we get here, the event structure is correct! + expect(value).toBeDefined(); + expect(typeof value).toBe("number"); + }); + + slider.addEventListener("value-changed", mockHandler); + + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + rangeInput.valueAsNumber = 250; + rangeInput.dispatchEvent(new Event("change", { bubbles: true })); + await slider.updateComplete; + + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(slider.value).toBe(250); + }); + + it("should work with the handler pattern used in SinglePlayerModal", async () => { + // This simulates the actual handler code in SinglePlayerModal.ts:444-451 + const mockHandler = jest.fn((e: Event) => { + const customEvent = e as CustomEvent<{ value: number }>; + const value = customEvent.detail.value; + if (isNaN(value) || value < 0 || value > 400) { + return; + } + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(400); + }); + + slider.addEventListener("value-changed", mockHandler); + + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + rangeInput.valueAsNumber = 350; + rangeInput.dispatchEvent(new Event("change", { bubbles: true })); + await slider.updateComplete; + + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(slider.value).toBe(350); + }); + }); + + describe("Value Validation via Number Input", () => { + it("should clamp values to min", () => { + slider.min = 10; + slider.max = 100; + + // Simulate the handleNumberChange logic + let testValue = 5; // Below min + if (isNaN(testValue)) { + testValue = slider.min; + } + if (testValue < slider.min) testValue = slider.min; + if (testValue > slider.max) testValue = slider.max; + + expect(testValue).toBe(10); + }); + + it("should clamp values to max", () => { + slider.min = 10; + slider.max = 100; + + let testValue = 150; // Above max + if (isNaN(testValue)) { + testValue = slider.min; + } + if (testValue < slider.min) testValue = slider.min; + if (testValue > slider.max) testValue = slider.max; + + expect(testValue).toBe(100); + }); + + it("should default to min when NaN", () => { + slider.min = 5; + + let testValue = NaN; + if (isNaN(testValue)) { + testValue = slider.min; + } + if (testValue < slider.min) testValue = slider.min; + if (testValue > slider.max) testValue = slider.max; + + expect(testValue).toBe(5); + }); + }); + + describe("Component Structure", () => { + it("should render a range input", () => { + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ); + expect(rangeInput).toBeTruthy(); + }); + + it("should have correct range input properties", () => { + slider.value = 150; + slider.min = 0; + slider.max = 400; + slider.step = 1; + + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + + expect(rangeInput.min).toBe("0"); + expect(rangeInput.max).toBe("400"); + expect(rangeInput.step).toBe("1"); + }); + + it("should render an editable span for the value display", () => { + const editableSpan = slider.shadowRoot?.querySelector("span.editable"); + expect(editableSpan).toBeTruthy(); + expect(editableSpan?.getAttribute("role")).toBe("button"); + expect(editableSpan?.getAttribute("tabindex")).toBe("0"); + }); + }); + + describe("Bot Count Scenario - Regression Test", () => { + it("should correctly update bot count from 0 to 400 and dispatch proper events", async () => { + const capturedValues: number[] = []; + + slider.addEventListener("value-changed", (e) => { + const customEvent = e as CustomEvent<{ value: number }>; + capturedValues.push(customEvent.detail.value); + }); + + slider.value = 0; + await slider.updateComplete; + + const rangeInput = slider.shadowRoot?.querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + + // Simulate dragging the slider from 0 to 400 + for (const val of [0, 100, 200, 300, 400]) { + rangeInput.valueAsNumber = val; + rangeInput.dispatchEvent(new Event("input", { bubbles: true })); // Updates display + rangeInput.dispatchEvent(new Event("change", { bubbles: true })); // Triggers event + await slider.updateComplete; + } + + // Should have captured all change events (not input events) + expect(capturedValues).toEqual([0, 100, 200, 300, 400]); + expect(slider.value).toBe(400); + }); + }); +});