← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/sequence/chromatogram-panel.test.tsx

21.2 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";

import { ChromatogramPanel } from "./chromatogram-panel";
import type {
  ChromatogramBase,
  SequenceChromatogram,
  SequenceRecord,
} from "./types";

afterEach(() => {
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});

describe("ChromatogramPanel", () => {
  it("renders genuine channel samples with called bases and Phred quality", () => {
    const record = traceRecord({ sequence: "ACGT" });
    const { container } = render(
      <ChromatogramPanel onSelectRange={vi.fn()} record={record} />,
    );

    expect(screen.getByText(/Original read orientation/)).toBeInTheDocument();
    expect(screen.getByRole("figure")).toHaveAccessibleName(
      /bases 1 to 4, four signal channels in original read orientation/,
    );
    expect(screen.getByText("Phred quality below calls")).toBeInTheDocument();
    expect(
      screen.getByRole("button", { name: "Base 3, G, Phred quality 32" }),
    ).toBeInTheDocument();
    const traces = container.querySelectorAll("path[data-channel]");
    expect(traces).toHaveLength(4);
    expect(
      new Set(Array.from(traces, (trace) => trace.getAttribute("d"))).size,
    ).toBe(4);
    // The A channel's observed peak is 100; a common 0..100 scale maps it to y=10.
    expect(
      container.querySelector('path[data-channel="A"]')?.getAttribute("d"),
    ).toContain(`L${(12 + (8 / 47) * 776).toFixed(2)},10.00`);
    expect(container.querySelector("svg")).toHaveAttribute(
      "aria-hidden",
      "true",
    );
  });

  it("selects 1-based source coordinates, supports Shift ranges, and marks selection", async () => {
    const record = traceRecord();
    const onSelectRange = vi.fn();
    const user = userEvent.setup();
    const { rerender } = render(
      <ChromatogramPanel onSelectRange={onSelectRange} record={record} />,
    );
    await user.click(screen.getByRole("button", { name: /^Base 3,/ }));
    expect(onSelectRange).toHaveBeenLastCalledWith(3, 3);
    await user.keyboard("{Shift>}");
    await user.click(screen.getByRole("button", { name: /^Base 6,/ }));
    await user.keyboard("{/Shift}");
    expect(onSelectRange).toHaveBeenLastCalledWith(3, 6);

    rerender(
      <ChromatogramPanel
        onSelectRange={onSelectRange}
        record={record}
        selection={{ end: 6, recordId: record.id, start: 3 }}
      />,
    );
    expect(screen.getByRole("button", { name: /^Base 3,/ })).toHaveAttribute(
      "aria-pressed",
      "true",
    );
    expect(screen.getByRole("button", { name: /^Base 6,/ })).toHaveAttribute(
      "aria-pressed",
      "true",
    );
    expect(screen.getByRole("button", { name: /^Base 7,/ })).toHaveAttribute(
      "aria-pressed",
      "false",
    );
  });

  it("selects the nearest called peak when the signal is clicked", () => {
    const record = traceRecord();
    const onSelectRange = vi.fn();
    const { container } = render(
      <ChromatogramPanel onSelectRange={onSelectRange} record={record} />,
    );
    const svg = container.querySelector("svg");
    if (svg == null) throw new Error("Expected a trace SVG.");
    vi.spyOn(svg, "getBoundingClientRect").mockReturnValue(
      new DOMRect(20, 0, 800, 152),
    );
    // Fourth called base is at source sample 32 of 80, with 12px chart padding.
    fireEvent.click(svg, { clientX: 20 + 12 + (32 / 79) * 776 });
    expect(onSelectRange).toHaveBeenCalledWith(4, 4);
  });

  it("moves keyboard focus across windows and selects with native Enter/Space", async () => {
    const record = traceRecord({ sequence: "ACGT".repeat(30) });
    const onSelectRange = vi.fn();
    render(<ChromatogramPanel onSelectRange={onSelectRange} record={record} />);

    screen.getByRole("button", { name: /^Base 1,/ }).focus();
    await userEvent.keyboard("{ArrowRight}{Enter}");
    expect(screen.getByRole("button", { name: /^Base 2,/ })).toHaveFocus();
    expect(onSelectRange).toHaveBeenLastCalledWith(2, 2);
    await userEvent.keyboard("{End} ");
    expect(screen.getByRole("button", { name: /^Base 120,/ })).toHaveFocus();
    expect(onSelectRange).toHaveBeenLastCalledWith(120, 120);
    await userEvent.keyboard("{Shift>}{ArrowLeft}{/Shift}");
    expect(onSelectRange).toHaveBeenLastCalledWith(119, 120);
  });

  it("pans and zooms a bounded base window without changing the source selection", async () => {
    const record = traceRecord({ sequence: "ACGT".repeat(50) });
    const onSelectRange = vi.fn();
    render(<ChromatogramPanel onSelectRange={onSelectRange} record={record} />);

    expect(screen.getByText("Bases 1–36 of 200")).toBeInTheDocument();
    await userEvent.click(
      screen.getByRole("button", { name: "Next chromatogram window" }),
    );
    expect(screen.getByText("Bases 37–72 of 200")).toBeInTheDocument();
    await userEvent.click(
      screen.getByRole("button", { name: "Zoom in chromatogram" }),
    );
    expect(screen.getByText("Bases 37–54 of 200")).toBeInTheDocument();
    await userEvent.click(
      screen.getByRole("button", { name: "Previous chromatogram window" }),
    );
    expect(screen.getByText("Bases 19–36 of 200")).toBeInTheDocument();
    fireEvent.change(
      screen.getByRole("slider", { name: "Chromatogram window start" }),
      {
        target: { value: "170" },
      },
    );
    expect(screen.getByText("Bases 170–187 of 200")).toBeInTheDocument();
    expect(onSelectRange).not.toHaveBeenCalled();
  });

  it("keeps confidence absent when the source has no quality values", () => {
    const record = traceRecord({ quality: null });
    render(<ChromatogramPanel onSelectRange={vi.fn()} record={record} />);

    expect(
      screen.getByText("No called-base confidence provided"),
    ).toBeInTheDocument();
    expect(
      screen.getByRole("button", { name: "Base 1, A" }),
    ).toBeInTheDocument();
    expect(screen.queryByText(/Phred/)).not.toBeInTheDocument();
    expect(
      screen.queryByRole("button", { name: /quality 0/ }),
    ).not.toBeInTheDocument();
  });

  it("labels SCF source confidence honestly and leaves an ambiguous call unscored", () => {
    const record = traceRecord({
      format: "scf",
      quality: [90, null, 65, 0],
      qualityEncoding: "source-confidence",
      sequence: "ANCT",
    });
    render(<ChromatogramPanel onSelectRange={vi.fn()} record={record} />);

    expect(
      screen.getByText("Source confidence below calls"),
    ).toBeInTheDocument();
    expect(
      screen.getByRole("button", { name: "Base 1, A, Source confidence 90" }),
    ).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Base 2, N" })).toHaveTextContent(
      "—",
    );
    expect(
      screen.getByRole("button", { name: "Base 4, T, Source confidence 0" }),
    ).toBeInTheDocument();
    expect(screen.queryByText(/Phred/)).not.toBeInTheDocument();
  });

  it("retains signed baseline and narrow spikes while bounding long paths on a narrow viewport", () => {
    setObservedWidth(220);
    const record = traceRecord({ sequence: "ACGT", samplesPerBase: 2_000 });
    const trace = record.chromatogram;
    if (trace == null) throw new Error("Expected test trace samples.");
    trace.channels.A[1_777] = 1_000;
    trace.channels.C[777] = -300;
    const { container } = render(
      <ChromatogramPanel onSelectRange={vi.fn()} record={record} />,
    );

    expect(
      screen.getByText("Trace display uses a min/max envelope"),
    ).toBeInTheDocument();
    const traces = container.querySelectorAll("path[data-channel]");
    for (const path of Array.from(traces)) {
      expect(
        path.getAttribute("d")?.match(/[ML]/g)?.length,
      ).toBeLessThanOrEqual(440);
      expect(path.getAttribute("d")).not.toMatch(/NaN|Infinity/);
    }
    // Preserve both actual extremes at their sample positions, not averaged bins.
    expect(
      container.querySelector('path[data-channel="A"]')?.getAttribute("d"),
    ).toContain(`${(12 + (1_777 / 11_999) * 196).toFixed(2)},10.00`);
    expect(
      container.querySelector('path[data-channel="C"]')?.getAttribute("d"),
    ).toContain(`${(12 + (777 / 11_999) * 196).toFixed(2)},142.00`);
    expect(
      Number(container.querySelector("line")?.getAttribute("y1")),
    ).toBeLessThan(142);
  });

  it("caps labels at 100 even on a wide viewport and reduces the window on narrow screens", async () => {
    setObservedWidth(4_000);
    const record = traceRecord({ sequence: "ACGT".repeat(200) });
    const { container, unmount } = render(
      <ChromatogramPanel onSelectRange={vi.fn()} record={record} />,
    );
    await userEvent.click(
      screen.getByRole("button", { name: "Zoom out chromatogram" }),
    );
    await userEvent.click(
      screen.getByRole("button", { name: "Zoom out chromatogram" }),
    );
    expect(container.querySelectorAll("button[data-coordinate]")).toHaveLength(
      100,
    );
    expect(
      screen.getByRole("button", { name: "Zoom out chromatogram" }),
    ).toBeDisabled();
    unmount();
    setObservedWidth(220);
    const narrow = render(
      <ChromatogramPanel onSelectRange={vi.fn()} record={record} />,
    );
    expect(
      narrow.container.querySelectorAll("button[data-coordinate]"),
    ).toHaveLength(10);
  });

  it("follows external selections in this record but ignores another record's coordinates", () => {
    const record = traceRecord({ sequence: "ACGT".repeat(50) });
    const onSelectRange = vi.fn();
    const { rerender } = render(
      <ChromatogramPanel onSelectRange={onSelectRange} record={record} />,
    );
    rerender(
      <ChromatogramPanel
        onSelectRange={onSelectRange}
        record={record}
        selection={{ end: 151, recordId: record.id, start: 150 }}
      />,
    );
    expect(screen.getByRole("button", { name: /^Base 150,/ })).toHaveAttribute(
      "aria-pressed",
      "true",
    );
    expect(screen.getByRole("button", { name: /^Base 151,/ })).toHaveAttribute(
      "aria-pressed",
      "true",
    );
    rerender(
      <ChromatogramPanel
        onSelectRange={onSelectRange}
        record={record}
        selection={{ end: 2, recordId: "different-record", start: 1 }}
      />,
    );
    expect(
      screen.queryByRole("button", { name: /^Base 1,/ }),
    ).not.toBeInTheDocument();
    expect(screen.getByRole("button", { name: /^Base 150,/ })).toHaveAttribute(
      "aria-pressed",
      "false",
    );
  });

  it("resets pan and zoom when the record or underlying trace changes", async () => {
    const record = traceRecord({ sequence: "ACGT".repeat(50) });
    const onSelectRange = vi.fn();
    const { rerender } = render(
      <ChromatogramPanel onSelectRange={onSelectRange} record={record} />,
    );
    await userEvent.click(
      screen.getByRole("button", { name: "Next chromatogram window" }),
    );
    await userEvent.click(
      screen.getByRole("button", { name: "Zoom in chromatogram" }),
    );
    const otherRecord = traceRecord({
      id: "other-read",
      sequence: "TGCA".repeat(50),
    });
    rerender(
      <ChromatogramPanel onSelectRange={onSelectRange} record={otherRecord} />,
    );
    expect(screen.getByText("Bases 1–36 of 200")).toBeInTheDocument();
    expect(
      screen.getByRole("button", { name: /^Base 1, T,/ }),
    ).toBeInTheDocument();

    await userEvent.click(
      screen.getByRole("button", { name: "Next chromatogram window" }),
    );
    rerender(
      <ChromatogramPanel
        onSelectRange={onSelectRange}
        record={traceRecord({ id: "other-read", sequence: "ACGT".repeat(50) })}
        selection={{ end: 172, recordId: "other-read", start: 172 }}
      />,
    );
    expect(screen.getByRole("button", { name: /^Base 172,/ })).toHaveAttribute(
      "aria-pressed",
      "true",
    );
    expect(screen.getAllByRole("button", { name: /^Base \d+,/ })).toHaveLength(
      36,
    );
  });

  it("does not create a chromatogram for a record without trace data", () => {
    const { chromatogram: _trace, ...record } = traceRecord();
    const { container } = render(
      <ChromatogramPanel onSelectRange={vi.fn()} record={record} />,
    );
    expect(container).toBeEmptyDOMElement();
  });

  it("routes all pan, zoom, and slider changes through the controlled view callback", async () => {
    const record = traceRecord({ sequence: "ACGT".repeat(50) });
    const onViewChange = vi.fn();
    const props = { onSelectRange: vi.fn(), onViewChange, record };
    const { rerender } = render(
      <ChromatogramPanel
        {...props}
        view={{ basesPerWindow: 20, firstBase: 41 }}
      />,
    );
    expect(screen.getByText("Bases 41–60 of 200")).toBeInTheDocument();
    expect(onViewChange).not.toHaveBeenCalled();
    await userEvent.click(
      screen.getByRole("button", { name: "Next chromatogram window" }),
    );
    expect(onViewChange).toHaveBeenLastCalledWith({
      basesPerWindow: 20,
      firstBase: 61,
    });
    // Controlled state changes only after the parent applies the requested update.
    expect(screen.getByText("Bases 41–60 of 200")).toBeInTheDocument();
    rerender(
      <ChromatogramPanel
        {...props}
        view={{ basesPerWindow: 20, firstBase: 61 }}
      />,
    );
    await userEvent.click(
      screen.getByRole("button", { name: "Zoom in chromatogram" }),
    );
    expect(onViewChange).toHaveBeenLastCalledWith({
      basesPerWindow: 10,
      firstBase: 61,
    });
    rerender(
      <ChromatogramPanel
        {...props}
        view={{ basesPerWindow: 10, firstBase: 61 }}
      />,
    );
    await userEvent.click(
      screen.getByRole("button", { name: "Zoom out chromatogram" }),
    );
    expect(onViewChange).toHaveBeenLastCalledWith({
      basesPerWindow: 20,
      firstBase: 61,
    });
    await userEvent.click(
      screen.getByRole("button", { name: "Previous chromatogram window" }),
    );
    expect(onViewChange).toHaveBeenLastCalledWith({
      basesPerWindow: 10,
      firstBase: 51,
    });
    fireEvent.change(
      screen.getByRole("slider", { name: "Chromatogram window start" }),
      {
        target: { value: "140" },
      },
    );
    expect(onViewChange).toHaveBeenLastCalledWith({
      basesPerWindow: 10,
      firstBase: 140,
    });
  });

  it("does not override explicit controlled windows but follows a changed external selection", () => {
    const record = traceRecord({ sequence: "ACGT".repeat(50) });
    const onViewChange = vi.fn();
    const props = { onSelectRange: vi.fn(), onViewChange, record };
    const firstSelection = { end: 1, recordId: record.id, start: 1 };
    const { rerender } = render(
      <ChromatogramPanel
        {...props}
        selection={firstSelection}
        view={{ basesPerWindow: 20, firstBase: 1 }}
      />,
    );
    rerender(
      <ChromatogramPanel
        {...props}
        selection={firstSelection}
        view={{ basesPerWindow: 20, firstBase: 101 }}
      />,
    );
    expect(screen.getByText("Bases 101–120 of 200")).toBeInTheDocument();
    expect(onViewChange).not.toHaveBeenCalled();
    const nextSelection = { end: 172, recordId: record.id, start: 170 };
    rerender(
      <ChromatogramPanel
        {...props}
        selection={nextSelection}
        view={{ basesPerWindow: 20, firstBase: 101 }}
      />,
    );
    expect(onViewChange).toHaveBeenCalledExactlyOnceWith({
      basesPerWindow: 20,
      firstBase: 160,
    });
    rerender(
      <ChromatogramPanel
        {...props}
        selection={nextSelection}
        view={{ basesPerWindow: 20, firstBase: 160 }}
      />,
    );
    expect(screen.getByRole("button", { name: /^Base 170,/ })).toHaveAttribute(
      "aria-pressed",
      "true",
    );
    expect(onViewChange).toHaveBeenCalledTimes(1);
  });

  it("keeps controlled first-base coordinates literal at the end of the read", () => {
    const record = traceRecord({ sequence: "ACGT".repeat(50) });
    render(
      <ChromatogramPanel
        onSelectRange={vi.fn()}
        onViewChange={vi.fn()}
        record={record}
        view={{ basesPerWindow: 40, firstBase: 199 }}
      />,
    );
    expect(screen.getByText("Bases 199–200 of 200")).toBeInTheDocument();
    expect(screen.getAllByRole("button", { name: /^Base \d+,/ })).toHaveLength(
      2,
    );
    expect(
      screen.getByRole("slider", { name: "Chromatogram window start" }),
    ).toHaveValue("199");
  });

  it("preserves an explicit restored window when selection and view change together", () => {
    const record = traceRecord({ sequence: "ACGT".repeat(25) });
    const onViewChange = vi.fn();
    const props = { onSelectRange: vi.fn(), onViewChange, record };
    const { rerender } = render(
      <ChromatogramPanel
        {...props}
        selection={{ end: 25, recordId: record.id, start: 25 }}
        view={{ basesPerWindow: 20, firstBase: 20 }}
      />,
    );
    rerender(
      <ChromatogramPanel
        {...props}
        selection={{ end: 1, recordId: record.id, start: 1 }}
        view={{ basesPerWindow: 20, firstBase: 60 }}
      />,
    );

    expect(screen.getByText("Bases 60–79 of 100")).toBeInTheDocument();
    expect(onViewChange).not.toHaveBeenCalled();
  });

  it("honors a restore epoch when the restored window is unchanged, then follows later selection-only jumps", () => {
    const record = traceRecord({ sequence: "ACGT".repeat(25) });
    const onViewChange = vi.fn();
    const props = { onSelectRange: vi.fn(), onViewChange, record };
    const view = { basesPerWindow: 20, firstBase: 60 };
    const { rerender } = render(
      <ChromatogramPanel
        {...props}
        restorationEpoch={0}
        selection={{ end: 65, recordId: record.id, start: 65 }}
        view={view}
      />,
    );
    rerender(
      <ChromatogramPanel
        {...props}
        restorationEpoch={1}
        selection={{ end: 1, recordId: record.id, start: 1 }}
        view={view}
      />,
    );
    expect(screen.getByText("Bases 60–79 of 100")).toBeInTheDocument();
    expect(onViewChange).not.toHaveBeenCalled();

    rerender(
      <ChromatogramPanel
        {...props}
        restorationEpoch={1}
        selection={{ end: 5, recordId: record.id, start: 5 }}
        view={view}
      />,
    );
    expect(onViewChange).toHaveBeenCalledExactlyOnceWith({
      basesPerWindow: 20,
      firstBase: 1,
    });
  });

  it("uses a changed external selection as the new Shift-selection anchor", async () => {
    const record = traceRecord();
    const onSelectRange = vi.fn();
    const { rerender } = render(
      <ChromatogramPanel onSelectRange={onSelectRange} record={record} />,
    );
    await userEvent.click(screen.getByRole("button", { name: /^Base 2,/ }));
    rerender(
      <ChromatogramPanel
        onSelectRange={onSelectRange}
        record={record}
        selection={{ end: 5, recordId: record.id, start: 5 }}
      />,
    );
    fireEvent.click(screen.getByRole("button", { name: /^Base 8,/ }), {
      shiftKey: true,
    });
    expect(onSelectRange).toHaveBeenLastCalledWith(5, 8);
  });
});

/** Synthetic trace fixture only; no instrument data or binary fixture is implied. */
function traceRecord({
  format = "abif",
  id = "synthetic-trace",
  quality,
  qualityEncoding = "phred",
  samplesPerBase = 8,
  sequence = "ACGTACGT",
}: {
  format?: SequenceChromatogram["format"];
  id?: string;
  quality?: SequenceChromatogram["quality"] | null;
  qualityEncoding?: SequenceChromatogram["qualityEncoding"];
  samplesPerBase?: number;
  sequence?: string;
} = {}): SequenceRecord {
  const sampleCount = (sequence.length + 2) * samplesPerBase;
  const channels: Record<ChromatogramBase, Array<number>> = {
    A: Array<number>(sampleCount).fill(0),
    C: Array<number>(sampleCount).fill(0),
    G: Array<number>(sampleCount).fill(0),
    T: Array<number>(sampleCount).fill(0),
  };
  const peakLocations = Array.from(
    { length: sequence.length },
    (_, index) => (index + 1) * samplesPerBase,
  );
  for (let index = 0; index < sequence.length; index += 1) {
    const base = sequence[index];
    if (base !== "A" && base !== "C" && base !== "G" && base !== "T") continue;
    const peak = peakLocations[index];
    const amplitude = { A: 100, C: 90, G: 80, T: 70 }[base];
    channels[base][peak - 1] = amplitude / 3;
    channels[base][peak] = amplitude;
    channels[base][peak + 1] = amplitude / 3;
  }
  return {
    chromatogram: {
      channels,
      format,
      peakLocations,
      quality:
        quality === null
          ? undefined
          : (quality ??
            Array.from(
              { length: sequence.length },
              (_, index) => 30 + (index % 10),
            )),
      qualityEncoding: quality === null ? undefined : qualityEncoding,
      sampleCount,
    },
    features: [],
    id,
    length: sequence.length,
    metadata: {},
    molecule: "dna",
    sequence,
    sourceLabel: "QA synthetic trace",
    topology: "linear",
  };
}

function setObservedWidth(width: number): void {
  class FixedResizeObserver implements ResizeObserver {
    constructor(private readonly callback: ResizeObserverCallback) {}
    disconnect(): void {}
    unobserve(): void {}
    observe(target: Element): void {
      this.callback(
        [
          {
            borderBoxSize: [],
            contentBoxSize: [],
            contentRect: new DOMRect(0, 0, width, 220),
            devicePixelContentBoxSize: [],
            target,
          },
        ],
        this,
      );
    }
  }
  vi.stubGlobal("ResizeObserver", FixedResizeObserver);
}

SHA-256: 3d9a2ba0cc2974c921056a1cfe7bc9368e68d972185acfe01ded457c5c618236