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
24 changes: 13 additions & 11 deletions server/installer/data-downloader/backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class AnalysisConfigCreate(BaseModel):
start: datetime
end: datetime
plots: List[PlotGroupModel]
colors: Dict[str, str] | None = None


class AnalysisConfigPatch(BaseModel):
Expand Down Expand Up @@ -332,17 +333,18 @@ def create_analysis_config(payload: AnalysisConfigCreate) -> dict:
if not set(group.rightAxis).issubset(set(group.signals)):
raise HTTPException(status_code=400, detail="rightAxis must be a subset of signals")
normalized_plots.append({"signals": group.signals, "rightAxis": group.rightAxis})
config = service.create_analysis_config(
{
"name": name,
"note": payload.note.strip(),
"author": payload.author.strip(),
"season": payload.season,
"start": series_queries.normalize_utc(payload.start).isoformat(),
"end": series_queries.normalize_utc(payload.end).isoformat(),
"plots": normalized_plots,
}
)
fields = {
"name": name,
"note": payload.note.strip(),
"author": payload.author.strip(),
"season": payload.season,
"start": series_queries.normalize_utc(payload.start).isoformat(),
"end": series_queries.normalize_utc(payload.end).isoformat(),
"plots": normalized_plots,
}
if payload.colors:
fields["colors"] = payload.colors
config = service.create_analysis_config(fields)
return config


Expand Down
2 changes: 2 additions & 0 deletions server/installer/data-downloader/backend/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ def create_config(self, fields: dict) -> dict:
"created_at": now,
"updated_at": now,
}
if fields.get("colors"):
config["colors"] = fields["colors"]
# Newest first so the UI list needs no re-sort.
payload.setdefault("configs", []).insert(0, config)
payload["updated_at"] = now
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ def test_create_rejects_rightaxis_not_subset():
assert "subset" in r.json()["detail"].lower()


def test_create_persists_color_overrides():
colors = {"Brake_Pressure": "#ff0000"}
created = client.post(
"/api/analysis-configs",
json=create_payload(colors=colors),
)
assert created.status_code == 201
body = created.json()
assert body["colors"] == colors

listed = client.get("/api/analysis-configs")
saved = next(c for c in listed.json()["configs"] if c["id"] == body["id"])
assert saved["colors"] == colors

client.delete(f"/api/analysis-configs/{body['id']}")


def test_patch_rejects_blank_name():
created = client.post("/api/analysis-configs", json=create_payload())
config_id = created.json()["id"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ def test_create_assigns_id_and_timestamps(tmp_path: Path):
assert config["created_at"] == config["updated_at"]
assert config["name"] == "Brake event"
assert config["plots"] == [{"signals": ["Brake_Pressure"], "rightAxis": []}]
assert "colors" not in config


def test_create_persists_colors_when_provided(tmp_path: Path):
repo = AnalysisConfigsRepository(tmp_path)
colors = {"Brake_Pressure": "#ff0000"}
config = repo.create_config(_fields(colors=colors))
assert config["colors"] == colors
assert repo.list_configs()["configs"][0]["colors"] == colors


def test_list_returns_newest_first(tmp_path: Path):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
NEW_PLOT,
type PlotLayout,
assignSignals,
clearRightAxisForSignals,
flattenSignals,
parseLayout,
pruneUnknown,
Expand Down Expand Up @@ -82,6 +83,24 @@ describe("assignSignals", () => {
);
expect(next).toEqual([group("b", ["S2", "S1"], ["S1"])]);
});

it("clears rightAxis when a moved signal is assigned to the left axis", () => {
const moved = assignSignals(
[group("a", ["S1"], ["S1"]), group("b", ["S2"])],
["S1"],
"b",
);
const next = clearRightAxisForSignals(moved, ["S1"]);
expect(next).toEqual([group("b", ["S2", "S1"])]);
});
});

describe("clearRightAxisForSignals", () => {
it("removes listed signals from rightAxis and is a no-op when none match", () => {
const layout = [group("a", ["S1", "S2"], ["S2"])];
expect(clearRightAxisForSignals(layout, ["S2"])).toEqual([group("a", ["S1", "S2"])]);
expect(clearRightAxisForSignals(layout, ["S9"])).toBe(layout);
});
});

describe("toggleRightAxis", () => {
Expand Down Expand Up @@ -121,12 +140,31 @@ describe("serializeLayout and parseLayout", () => {
const layout = [group("a", ["S1", "S2"], ["S2"])];
const parsed = parseLayout(serializeLayout(layout));
expect(parsed).not.toBeNull();
expect(parsed![0].signals).toEqual(["S1", "S2"]);
expect(parsed![0].rightAxis).toEqual(["S2"]);
expect(parsed![0].id).toBeTruthy();
expect(parsed!.layout[0].signals).toEqual(["S1", "S2"]);
expect(parsed!.layout[0].rightAxis).toEqual(["S2"]);
expect(parsed!.layout[0].id).toBeTruthy();
expect(parsed!.colorOverrides).toEqual({});
});

it("round-trips v2 with color overrides", () => {
const layout = [group("a", ["S1"])];
const colors = { S1: "#ff0000" };
const serialized = serializeLayout(layout, colors);
const parsed = parseLayout(serialized);
expect(parsed).not.toBeNull();
expect(parsed!.layout[0].signals).toEqual(["S1"]);
expect(parsed!.colorOverrides).toEqual({ S1: "#ff0000" });
});

it("parses legacy v1 layout without errors", () => {
const raw = '{"v":1,"plots":[{"signals":["S1"],"rightAxis":[]}]}';
const parsed = parseLayout(raw);
expect(parsed).not.toBeNull();
expect(parsed!.layout[0].signals).toEqual(["S1"]);
expect(parsed!.colorOverrides).toEqual({});
});

it.each([null, "", "not json", '{"v":2,"plots":[]}', '{"v":1,"plots":"x"}', '{"v":1,"plots":[{"signals":"x"}]}'])(
it.each([null, "", "not json", '{"v":3,"plots":[]}', '{"v":1,"plots":"x"}', '{"v":1,"plots":[{"signals":"x"}]}'])(
"returns null for corrupt or wrong-version input %#",
(raw) => {
expect(parseLayout(raw as string | null)).toBeNull();
Expand All @@ -135,7 +173,7 @@ describe("serializeLayout and parseLayout", () => {

it("drops non-string entries and keeps rightAxis a subset on parse", () => {
const parsed = parseLayout('{"v":1,"plots":[{"signals":["S1",5],"rightAxis":["S1","GHOST"]}]}');
expect(parsed).toEqual([
expect(parsed!.layout).toEqual([
{ id: expect.any(String), signals: ["S1"], rightAxis: ["S1"] },
]);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ export function assignSignals(
);
}

export function clearRightAxisForSignals(
layout: PlotLayout,
signals: string[],
): PlotLayout {
const remove = new Set(signals);
const hasAny = layout.some((g) => g.rightAxis.some((s) => remove.has(s)));
if (!hasAny) return layout;
return layout.map((g) => ({
...g,
rightAxis: g.rightAxis.filter((s) => !remove.has(s)),
}));
}

export function toggleRightAxis(
layout: PlotLayout,
groupId: string,
Expand Down Expand Up @@ -116,18 +129,27 @@ export function pruneUnknown(
);
}

export function serializeLayout(layout: PlotLayout): string {
export function serializeLayout(
layout: PlotLayout,
colorOverrides?: Record<string, string>,
): string {
return JSON.stringify({
v: 1,
v: 2,
plots: layout.map((g) => ({ signals: g.signals, rightAxis: g.rightAxis })),
colors: colorOverrides && Object.keys(colorOverrides).length > 0 ? colorOverrides : undefined,
});
}

export function parseLayout(raw: string | null): PlotLayout | null {
export interface ParsedLayout {
layout: PlotLayout;
colorOverrides: Record<string, string>;
}

export function parseLayout(raw: string | null): ParsedLayout | null {
if (!raw) return null;
try {
const data = JSON.parse(raw) as { v?: unknown; plots?: unknown };
if (data.v !== 1 || !Array.isArray(data.plots)) return null;
const data = JSON.parse(raw) as { v?: unknown; plots?: unknown; colors?: unknown };
if ((data.v !== 1 && data.v !== 2) || !Array.isArray(data.plots)) return null;
const layout: PlotLayout = [];
for (const entry of data.plots) {
if (typeof entry !== "object" || entry === null) return null;
Expand All @@ -144,8 +166,35 @@ export function parseLayout(raw: string | null): PlotLayout | null {
rightAxis: right.filter((s) => signals.includes(s)),
});
}
return layout;
// v2 added color overrides; v1 layouts have none.
const colorOverrides: Record<string, string> =
data.v === 2 && data.colors && typeof data.colors === "object" && !Array.isArray(data.colors)
? Object.fromEntries(
Object.entries(data.colors as Record<string, unknown>).filter(
([, v]) => typeof v === "string",
),
)
: {};
return { layout, colorOverrides };
} catch {
return null;
}
}

export function setSignalColor(
overrides: Record<string, string>,
signal: string,
color: string,
): Record<string, string> {
return { ...overrides, [signal]: color };
}

export function clearSignalColor(
overrides: Record<string, string>,
signal: string,
): Record<string, string> {
const next = { ...overrides };
delete next[signal];
return next;
}

Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,57 @@ describe("AnalysisPlotStack groups", () => {
expect(plot).toHaveStyle({ width: "100%", height: "180px" });
expect(plot.style.height).not.toBe("100%");
});

it("dispatches onAssignSignalsToAxis when dropped on left or right axis overlay zones", () => {
const onAssignSignalsToAxis = vi.fn();
render(
<AnalysisPlotStack
{...baseProps}
onAssignSignalsToAxis={onAssignSignalsToAxis}
layout={[{ id: "g1", signals: ["S1"], rightAxis: [] }]}
/>,
);
const card = screen.getByTestId("analysis-plot-card");
const dt = makeDataTransfer({ signals: ["S2"] });

// Drag enter to reveal zones
fireEvent.dragEnter(card, { dataTransfer: dt });

const leftZone = screen.getByText(/left axis/i);

fireEvent.drop(leftZone, { dataTransfer: dt });
expect(onAssignSignalsToAxis).toHaveBeenCalledWith(["S2"], "g1", "left");

onAssignSignalsToAxis.mockClear();
fireEvent.dragEnter(card, { dataTransfer: dt });
const rightZoneAfter = screen.getByText(/right axis/i);
fireEvent.drop(rightZoneAfter, { dataTransfer: dt });
expect(onAssignSignalsToAxis).toHaveBeenCalledWith(["S2"], "g1", "right");
});

it("opens color picker on swatch click and dispatches color handlers", () => {
const onSetSignalColor = vi.fn();
const onClearSignalColor = vi.fn();
render(
<AnalysisPlotStack
{...baseProps}
colorOverrides={{ S1: "#ff0000" }}
onSetSignalColor={onSetSignalColor}
onClearSignalColor={onClearSignalColor}
layout={[{ id: "g1", signals: ["S1"], rightAxis: [] }]}
/>,
);

const swatch = screen.getByRole("button", { name: /change color for S1/i });
fireEvent.click(swatch);

expect(screen.getByText(/custom color/i)).toBeInTheDocument();
const resetButton = screen.getByRole("button", { name: /reset/i });
expect(resetButton).toBeInTheDocument();

fireEvent.click(resetButton);
expect(onClearSignalColor).toHaveBeenCalledWith("S1");
});
});

describe("readSignalsPayload", () => {
Expand Down
Loading
Loading