mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Rework fluentslider component and write tests (#2682)
## Description: After 2 months of vacancy(my bad sorry), i have returned to end this mess of a PR stain that i left to the codebase. The issue is fixed, my written tests are passing, and i hand-tested and it worked out. Also I had to transforms Lit's ES modules into commonJS format so jest can execute for my test, which will indirectly enable other Lit components to be able for testing(only my test for now) refer to #2148 for UI stuff, nothing changed there. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: jackochess
This commit is contained in:
+1
-1
@@ -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: {
|
||||
|
||||
Generated
+3
-3
@@ -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": {
|
||||
|
||||
@@ -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")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
<label for="bots-count" class="option-card">
|
||||
<input
|
||||
type="range"
|
||||
id="bots-count"
|
||||
<div class="option-card">
|
||||
<fluent-slider
|
||||
min="0"
|
||||
max="400"
|
||||
step="1"
|
||||
@input=${this.handleBotsChange}
|
||||
@change=${this.handleBotsChange}
|
||||
.value="${String(this.bots)}"
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
<span>${translateText("host_modal.bots")}</span>${
|
||||
this.bots === 0
|
||||
? translateText("host_modal.bots_disabled")
|
||||
: this.bots
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
.value=${this.bots}
|
||||
labelKey="host_modal.bots"
|
||||
disabledKey="host_modal.bots_disabled"
|
||||
@value-changed=${this.handleBotsChange}
|
||||
></fluent-slider>
|
||||
</div>
|
||||
|
||||
${
|
||||
!(
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
<label for="bots-count" class="option-card">
|
||||
<input
|
||||
type="range"
|
||||
id="bots-count"
|
||||
<div class="option-card">
|
||||
<fluent-slider
|
||||
min="0"
|
||||
max="400"
|
||||
step="1"
|
||||
@input=${this.handleBotsChange}
|
||||
@change=${this.handleBotsChange}
|
||||
.value="${String(this.bots)}"
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
<span>${translateText("single_modal.bots")}</span>${this
|
||||
.bots === 0
|
||||
? translateText("single_modal.bots_disabled")
|
||||
: this.bots}
|
||||
</div>
|
||||
</label>
|
||||
.value=${this.bots}
|
||||
labelKey="single_modal.bots"
|
||||
disabledKey="single_modal.bots_disabled"
|
||||
@value-changed=${this.handleBotsChange}
|
||||
></fluent-slider>
|
||||
</div>
|
||||
|
||||
${!(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<div class="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
.min=${this.min}
|
||||
.max=${this.max}
|
||||
.step=${this.step}
|
||||
.valueAsNumber=${this.value}
|
||||
@input=${this.handleSliderInput}
|
||||
@change=${this.handleSliderChange}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
<span>${this.labelKey ? translateText(this.labelKey) : ""}</span>
|
||||
${this.isEditing
|
||||
? html`<input
|
||||
type="number"
|
||||
.min=${this.min}
|
||||
.max=${this.max}
|
||||
.valueAsNumber=${this.value}
|
||||
@input=${this.handleNumberChange}
|
||||
@blur=${() => (this.isEditing = false)}
|
||||
@keydown=${this.handleNumberKeyDown}
|
||||
/>`
|
||||
: html`<span
|
||||
class="editable"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${this.enableEditing}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
this.enableEditing();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${this.value === 0 && this.disabledKey
|
||||
? translateText(this.disabledKey)
|
||||
: this.value}
|
||||
</span>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user