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
3 changes: 2 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig, devices } from "@playwright/test";

const port = 5179;
// Parallel worktrees each take their own dev server: PW_PORT=5183 npm run test:e2e
const port = Number(process.env.PW_PORT ?? 5179);

export default defineConfig({
testDir: "tests/e2e",
Expand Down
42 changes: 42 additions & 0 deletions src/components/uplot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";

export type ChartOptions = Omit<uPlot.Options, "width" | "height">;

/** One uPlot filling its host; recreated when `options` changes, resized with the host. */
export function Chart({
options,
data,
className,
}: {
options: ChartOptions;
data: uPlot.AlignedData;
className?: string;
}) {
const host = useRef<HTMLDivElement>(null);
const chart = useRef<uPlot | null>(null);

useEffect(() => {
const el = host.current;
if (el === null) return;
const blank: uPlot.AlignedData = [[], ...options.series.slice(1).map(() => [])];
const u = new uPlot({ ...options, width: el.clientWidth, height: el.clientHeight }, blank, el);
chart.current = u;
const observer = new ResizeObserver(() => {
u.setSize({ width: el.clientWidth, height: el.clientHeight });
});
observer.observe(el);
return () => {
observer.disconnect();
u.destroy();
chart.current = null;
};
}, [options]);

useEffect(() => {
chart.current?.setData(data);
}, [data, options]);

return <div ref={host} className={className} />;
}
27 changes: 25 additions & 2 deletions src/lib/backend.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import init, { OscClient, pid, requestDevice, vid } from "@openservocore/client";
import init, { OscClient, pid, requestDevice, vid, type Track } from "@openservocore/client";

const ID_MIN = 1;
const ID_MAX = 0xf9;
Expand Down Expand Up @@ -36,7 +36,30 @@ export async function openClient(): Promise<OscClient> {
wasmReady ??= init();
await wasmReady;
const ids = parseSimIds(window.location.search);
if (ids !== undefined) return OscClient.fake(ids);
if (ids !== undefined) {
const track = await simTrack();
return OscClient.fakeWithTracks(ids.map((id) => ({ id, track })));
}
const device = permittedAdapter(await navigator.usb.getDevices(), vid(), pid());
return OscClient.connect(device ?? (await requestDevice()));
}

// Loaded on demand: the recording is for the simulated fleet only and stays
// out of the bundle a real adapter needs.
async function simTrack(): Promise<Track> {
const { track } = (await import("../../tests/fixtures/stall-24mhz.json")).default;
return {
pos: Uint16Array.from(track.pos),
current: Int16Array.from(track.current),
currentTrough: Uint16Array.from(track.currentTrough),
dutyQ15: Int16Array.from(track.dutyQ15),
vdiff: Int16Array.from(track.vdiff),
vbus: Uint16Array.from(track.vbus),
currentRaw: Uint16Array.from(track.currentRaw),
vmotorA: Uint16Array.from(track.vmotorA),
vmotorB: Uint16Array.from(track.vmotorB),
vbusRaw: Uint16Array.from(track.vbusRaw),
ntcRaw: Uint16Array.from(track.ntcRaw),
windowValid: Uint8Array.from(track.windowValid),
};
}
82 changes: 82 additions & 0 deletions src/lib/chart-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useSyncExternalStore } from "react";

export interface ChartTokens {
series1: string;
series2: string;
series3: string;
ctx: string;
grid: string;
axis: string;
label: string;
}

const TOKENS: Record<keyof ChartTokens, string> = {
series1: "--color-series-1",
series2: "--color-series-2",
series3: "--color-series-3",
ctx: "--color-series-ctx",
grid: "--color-grid",
axis: "--color-axis",
label: "--color-text-3",
};

export function readChartTokens(style: { getPropertyValue(name: string): string }): ChartTokens {
const out = {} as ChartTokens;
for (const key of Object.keys(TOKENS) as (keyof ChartTokens)[]) {
out[key] = style.getPropertyValue(TOKENS[key]).trim();
}
return out;
}

const empty = readChartTokens({ getPropertyValue: () => "" });
const DARK = "(prefers-color-scheme: dark)";
const listeners = new Set<() => void>();
let cached = empty;
let stale = true;
let observer: MutationObserver | undefined;

function invalidate(): void {
stale = true;
for (const listener of listeners) listener();
}

// The theme lands as a data-theme attribute (or the OS setting), so both are
// watched; the tokens are re-read from computed style only after a change.
function subscribe(listener: () => void): () => void {
listeners.add(listener);
if (listeners.size === 1) {
observer = new MutationObserver(invalidate);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
matchMedia(DARK).addEventListener("change", invalidate);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
observer?.disconnect();
observer = undefined;
matchMedia(DARK).removeEventListener("change", invalidate);
}
};
}

function snapshot(): ChartTokens {
if (stale) {
stale = false;
const next = readChartTokens(getComputedStyle(document.documentElement));
if (
Object.keys(TOKENS).some(
(k) => next[k as keyof ChartTokens] !== cached[k as keyof ChartTokens],
)
) {
cached = next;
}
}
return cached;
}

export function useChartTokens(): ChartTokens {
return useSyncExternalStore(subscribe, snapshot, () => empty);
}
30 changes: 30 additions & 0 deletions src/lib/in-flight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import { inFlight } from "./in-flight";

test("a call while one is pending is skipped, the next one after it runs", async () => {
let resolve: (v: number) => void = () => undefined;
let calls = 0;
const guarded = inFlight(
() =>
new Promise<number>((r) => {
calls++;
resolve = r;
}),
);
const first = guarded();
const skipped = guarded();
expect(calls).toBe(1);
await expect(skipped).resolves.toBeUndefined();
resolve(7);
await expect(first).resolves.toBe(7);
const third = guarded();
expect(calls).toBe(2);
resolve(9);
await expect(third).resolves.toBe(9);
});

test("a rejection releases the flag", async () => {
const guarded = inFlight(() => Promise.reject(new Error("boom")));
await expect(guarded()).rejects.toThrow("boom");
await expect(guarded()).rejects.toThrow("boom");
});
19 changes: 19 additions & 0 deletions src/lib/in-flight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Until the session serializes commands itself: the adapter answers "busy" to
// a second in-flight command, so a call made while one is pending is skipped
// (resolves undefined) instead of reaching the adapter. One flag for the whole
// app, so a read the previous selection left pending still counts.
let pending = false;

export function inFlight<A extends unknown[], T>(
fn: (...args: A) => Promise<T>,
): (...args: A) => Promise<T | undefined> {
return async (...args) => {
if (pending) return undefined;
pending = true;
try {
return await fn(...args);
} finally {
pending = false;
}
};
}
16 changes: 16 additions & 0 deletions src/lib/prefs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import {
PANE_KEY,
readPane,
readTheme,
readUnits,
THEME_KEY,
UNITS_KEY,
writePane,
writeTheme,
writeUnits,
type StorageLike,
} from "./prefs";

Expand Down Expand Up @@ -58,3 +61,16 @@ test("applyTheme sets data-theme for light and dark and removes it for system",
applyTheme(root, "system");
expect(attrs.has("data-theme")).toBe(false);
});

test("units default to real and reject unknown values", () => {
expect(readUnits(stub())).toBe("real");
expect(readUnits(stub({ [UNITS_KEY]: "raw" }))).toBe("raw");
expect(readUnits(stub({ [UNITS_KEY]: "si" }))).toBe("real");
});

test("writeUnits round-trips through the storage", () => {
const s = stub();
writeUnits(s, "raw");
expect(s.map.get(UNITS_KEY)).toBe("raw");
expect(readUnits(s)).toBe("raw");
});
16 changes: 16 additions & 0 deletions src/lib/prefs.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export type Theme = "light" | "dark" | "system";
export type Pane = "open" | "collapsed";
export type Units = "real" | "raw";

export const THEME_KEY = "osc-theme";
export const PANE_KEY = "osc-pane";
export const UNITS_KEY = "osc-units";

export interface StorageLike {
getItem(key: string): string | null;
Expand All @@ -16,6 +18,7 @@ export interface ThemeRoot {

const themes: readonly Theme[] = ["light", "dark", "system"];
const panes: readonly Pane[] = ["open", "collapsed"];
const units: readonly Units[] = ["real", "raw"];

export function isTheme(value: string): value is Theme {
return themes.some((t) => t === value);
Expand All @@ -39,6 +42,19 @@ export function writePane(storage: StorageLike, pane: Pane): void {
storage.setItem(PANE_KEY, pane);
}

export function isUnits(value: string): value is Units {
return units.some((u) => u === value);
}

export function readUnits(storage: StorageLike): Units {
const value = storage.getItem(UNITS_KEY);
return value !== null && isUnits(value) ? value : "real";
}

export function writeUnits(storage: StorageLike, value: Units): void {
storage.setItem(UNITS_KEY, value);
}

export function applyTheme(root: ThemeRoot, theme: Theme): void {
if (theme === "system") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", theme);
Expand Down
Loading
Loading