Files
OpenFrontIO/tests/client/components/FluentSlider.test.ts
T
Evan Pelle a7f992e9b0 refactor: convert to npm-workspaces monorepo (engine/core-public/shared/client/server)
Restructure the single src/ tree into an npm-workspaces monorepo under
packages/, rename core -> engine, extract a types-only core-public layer,
and break the pre-existing engine -> client dependency cycle.

Structure (packages/):
  core-public  public API/wire schemas + shared enums (clean leaf)
  shared       framework-agnostic helpers (clean leaf)
  engine       deterministic simulation (was src/core)
  client       rendering/UI (was src/client)
  server       coordination (was src/server)

Dependency DAG: engine -> {core-public, shared}; client -> {core-public,
shared, engine}; server -> {core-public, engine}.

- npm workspaces: root package.json workspaces + per-package package.json;
  tsconfig.base.json holds shared options + path aliases
  (core-public/* shared/* engine/* client/* server/*) resolved uniformly by
  tsc, Vite (resolve.tsconfigPaths), Vitest, and tsx. Lockfile regenerated.
- core-public: moved Schemas/ApiSchemas/CosmeticSchemas/StatsSchemas/
  ClanApiSchemas/WorkerSchemas/Base64/PatternDecoder; extracted the enums
  (GameTypes), GameEvent type, emoji table, and GraphicsOverrides schema.
  Engine re-exports the moved enums/types so existing imports keep working.
- Broke engine -> client cycle:
  - renderNumber/renderTroops -> shared/format
  - NameBoxCalculator moved into engine
  - username validation returns translation key + params; client translates
  - applyStateUpdate moved to client (operates on the render-only PlayerState)
  - Config/UnitGrid/execution-Util/GameImpl now use structural read
    interfaces (engine/game/ReadViews: PlayerLike/UnitLike/GameLike) instead
    of importing client view classes; client imports view classes from a new
    client/view barrel; deleted the engine/game/GameView re-export shim.
- Build/deploy updated: vite.config, index.html, eslint, Dockerfile
  (copies packages/ + tsconfig.base.json before npm ci), .vscode, tests.

Verified: tsc --noEmit clean; 1364 + 65 tests pass; production vite build
succeeds; engine has zero client/server imports; core-public and shared are
dependency leaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:51:03 +00:00

296 lines
9.4 KiB
TypeScript

import { FluentSlider } from "client/components/FluentSlider";
// Mock the translateText function
vi.mock("client/Utils", () => ({
translateText: vi.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.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.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 = vi.fn();
slider.addEventListener("value-changed", eventSpy);
const rangeInput = slider.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 = vi.fn();
slider.addEventListener("value-changed", eventSpy);
const rangeInput = slider.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 = vi.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.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 = vi.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.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.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.querySelector(
'input[type="range"]',
) as HTMLInputElement;
expect(rangeInput.min).toBe("0");
expect(rangeInput.max).toBe("400");
expect(rangeInput.step).toBe("1");
});
it("should render a span for the value display with role button", () => {
const valueSpan = slider.querySelector('span[role="button"]');
expect(valueSpan).toBeTruthy();
expect(valueSpan?.getAttribute("role")).toBe("button");
expect(valueSpan?.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.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);
});
});
describe("Edge Cases", () => {
it("should handle min equal to max without NaN in style", async () => {
slider.min = 100;
slider.max = 100;
slider.value = 100;
await slider.updateComplete;
const rangeInput = slider.querySelector('input[type="range"]');
const style = rangeInput?.getAttribute("style");
expect(style).not.toContain("NaN");
expect(style).toContain("0%");
});
});
});