trails/apps/journal/app/components/SurfaceBreakdown.test.tsx
Ullrich Schäfer 3a1c34317d
route-surface-breakdown (Phase 1): Planner-path surface/waytype bars
Synchronous path + rendering for the surface/waytype breakdown:
- map-core `computeSurfaceBreakdown(coords, surfaces, highways)` → distance-
  weighted metres per surface + waytype category (unit-tested);
- `SurfaceBreakdownSchema` in @trails-cool/api;
- nullable `surfaceBreakdown` jsonb on routes + activities;
- Planner `SaveToJournalButton` computes it from the BRouter waytags already in
  routeData and sends it; `api.save-to-journal` forwards it; the journal route
  callback validates + persists (journal is the authoritative validator);
- `SurfaceBreakdown` component (stacked bars per dimension, map-core palettes,
  legend category · % · km largest-first, unknown → "other", hidden when empty)
  on route + activity detail; journal gains a @trails-cool/map-core dep;
- i18n journal.surface.* (en + de).

Phase 2 (async Overpass backfill + SSE for imports/uploads, and the e2e that
seeds a breakdown) follows in a separate PR.

Tests: computeSurfaceBreakdown unit (map-core 36); SurfaceBreakdown component
(jsdom, journal 326). typecheck + lint green; verified in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:46:49 +02:00

40 lines
1.7 KiB
TypeScript

// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx";
afterEach(cleanup);
describe("SurfaceBreakdown", () => {
it("renders nothing without a breakdown", () => {
const { container } = render(<SurfaceBreakdown breakdown={null} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when every bucket is zero", () => {
const { container } = render(<SurfaceBreakdown breakdown={{ surface: { asphalt: 0 }, highway: {} }} />);
expect(container.firstChild).toBeNull();
});
it("renders a legend item per non-zero category with its percentage", () => {
const { container, getByText } = render(
<SurfaceBreakdown breakdown={{ surface: { asphalt: 6000, gravel: 4000 }, highway: { residential: 10000 } }} />,
);
// 2 surface + 1 waytype = 3 legend entries
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(getByText(/60% · 6\.0 km/)).toBeTruthy();
expect(getByText(/40% · 4\.0 km/)).toBeTruthy();
expect(getByText(/100% · 10\.0 km/)).toBeTruthy();
});
it("sorts segments largest-first within a dimension", () => {
const { container } = render(
<SurfaceBreakdown breakdown={{ surface: { gravel: 3000, asphalt: 7000 }, highway: {} }} />,
);
// first bar's first segment is the larger (asphalt 70%)
const firstBar = container.querySelector("div.flex.h-3");
const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width);
expect(widths[0]).toBe("70%");
expect(widths[1]).toBe("30%");
});
});