Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions web/src/app/community/p/[id]/PatternDetailClient.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// The pattern page's knob sliders kept the range input's own arrow step,
// 0.001. Ten presses of ArrowRight took Knob 1 from 0.5 to 0.51, and the
// whole of Knob 2 (0.1..10) was 9,900 presses. The Lab's Knobs panel turns
// one detent a press through knobArrowValue, 48 across any range, and the
// page now does the same.

import { fireEvent, render, screen } from "@testing-library/react";
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { DETENTS_PER_TURN, TURNS_PER_FULL_RANGE } from "@/lib/shared/constants";
import { knobDetentStep } from "@/lib/pattern/controls";
import PatternDetailClient, { type PatternView } from "./PatternDetailClient";

vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
vi.mock("@/lib/community/auth-client", () => ({
authClient: {
useSession: () => ({ data: null, isPending: false }),
},
}));
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; prefetch?: boolean; children: ReactNode }) => {
delete (rest as { prefetch?: boolean }).prefetch;
return (
<a href={href} {...rest}>
{children}
</a>
);
},
}));
// The preview runs pattern code in an iframe and the editor is Monaco. Neither
// is what these tests look at.
vi.mock("@/components/community/SandboxPreview", () => ({ default: () => null }));
vi.mock("@monaco-editor/react", () => ({ default: () => null }));

const PRESSES_ACROSS = DETENTS_PER_TURN * TURNS_PER_FULL_RANGE;

const PATTERN: PatternView = {
id: "p1",
title: "Tide",
description: null,
code: "// @knobs Hue=0..1, Speed=0.1..10, Mode=0..4.9, Offset=0..1\nfunction draw() {}",
codeCpp: null,
ownCpp: null,
ownCppModeratedAt: null,
portedBy: null,
ports: [],
performances: [],
hasAuthorPerformance: false,
license: "CC-BY-SA-4.0",
madeOn: null,
madeHow: null,
visibility: "public",
hiddenAt: null,
provenance: { madeHow: null, madeHowLabel: null, madeHowBlurb: null, signals: [] },
createdAt: "2026-10-01T00:00:00.000Z",
username: "dora",
displayUsername: "dora",
parent: null,
likeCount: 0,
forkCount: 0,
};

const RANGES = [
[0, 1],
[0.1, 10],
[0, 4.9],
[0, 1],
] as const;
const LABELS = ["Hue", "Speed", "Mode", "Offset"];

function slider(label: string) {
return screen.getByRole("slider", { name: `${label} value` }) as HTMLInputElement;
}

describe("pattern page knobs", () => {
it("turn one detent on an arrow key, up on Right and Up, down on Left and Down", () => {
render(<PatternDetailClient pattern={PATTERN} comments={[]} />);
const hue = slider("Hue");
expect(Number(hue.value)).toBe(0.5);
// fireEvent returns false when the handler called preventDefault, which
// keeps the input from adding its own 0.001 on top.
expect(fireEvent.keyDown(hue, { key: "ArrowRight" })).toBe(false);
expect(Number(hue.value)).toBeCloseTo(0.5 + knobDetentStep(RANGES[0]), 12);
expect(screen.getByText("0.521")).toBeTruthy();
fireEvent.keyDown(hue, { key: "ArrowUp" });
fireEvent.keyDown(hue, { key: "ArrowLeft" });
fireEvent.keyDown(hue, { key: "ArrowDown" });
expect(Number(hue.value)).toBeCloseTo(0.5, 12);

const speed = slider("Speed");
fireEvent.keyDown(speed, { key: "ArrowLeft" });
expect(Number(speed.value)).toBeCloseTo(5.05 - knobDetentStep(RANGES[1]), 12);
});

it("cross each knob's whole range in 48 presses and stop at the top", () => {
render(<PatternDetailClient pattern={PATTERN} comments={[]} />);
LABELS.forEach((label, index) => {
const [min, max] = RANGES[index];
const input = slider(label);
fireEvent.change(input, { target: { value: String(min) } });
expect(Number(input.value)).toBe(min);
for (let press = 1; press < PRESSES_ACROSS; press++) fireEvent.keyDown(input, { key: "ArrowRight" });
expect([label, Number(input.value) < max]).toEqual([label, true]);
fireEvent.keyDown(input, { key: "ArrowRight" });
expect([label, Number(input.value)]).toEqual([label, max]);
fireEvent.keyDown(input, { key: "ArrowRight" });
expect([label, Number(input.value)]).toEqual([label, max]);
});
});

it("leave Home, End, Page keys and modified arrows to the input and the browser", () => {
render(<PatternDetailClient pattern={PATTERN} comments={[]} />);
const hue = slider("Hue");
for (const event of [
{ key: "Home" },
{ key: "End" },
{ key: "PageUp" },
{ key: "ArrowRight", altKey: true },
{ key: "ArrowRight", ctrlKey: true },
{ key: "ArrowRight", metaKey: true },
]) {
expect([event, fireEvent.keyDown(hue, event)]).toEqual([event, true]);
}
expect(Number(hue.value)).toBe(0.5);
});
});
17 changes: 12 additions & 5 deletions web/src/app/community/p/[id]/PatternDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ZipProgress } from "@/components/community/ZipDownload";
import { buildsConfigured } from "@/lib/community/apiBase";
import { COLLECTION_EVENT, deckAdd, deckHas, deckRemove } from "@/lib/community/deck";
import { knobSetupFromCode } from "@/lib/community/knobs";
import { knobArrowValue } from "@/lib/pattern/controls";
import { describeMatrixShape, matrixFromCode } from "@/lib/pattern/matrix";
import { writeLabHandoff } from "@/lib/community/handoff";
import { downloadPatternHeader, downloadPatternJs } from "@/lib/community/download";
Expand Down Expand Up @@ -206,6 +207,9 @@ export default function PatternDetailClient({
return knobSetup.values;
});

const setKnob = (index: number, next: number) =>
setKnobValues((current) => current.map((item, itemIndex) => (itemIndex === index ? next : item)));

const edited = code !== pattern.code;

// Whether the .h on show was last written by a moderator rather than by the
Expand Down Expand Up @@ -336,11 +340,14 @@ export default function PatternDetailClient({
step="0.001"
value={value}
aria-label={`${knobSetup.labels[index]} value`}
onChange={(event) => {
const next = Number(event.target.value);
setKnobValues((current) =>
current.map((item, itemIndex) => (itemIndex === index ? next : item)),
);
onChange={(event) => setKnob(index, Number(event.target.value))}
// An arrow key turns one detent, as in the Lab's Knobs panel.
// The input's own step, 0.001, took 1,000 presses across a 0..1 knob.
onKeyDown={(event) => {
const next = knobArrowValue(event, value, knobSetup.ranges[index]);
if (next === null) return;
event.preventDefault();
setKnob(index, next);
}}
/>
<span className={styles.knobValue}>{value.toFixed(3)}</span>
Expand Down
Loading