← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/sequence/sequence-overview.test.ts

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

↓ Download file

import { fireEvent, render, screen, within } from "@testing-library/react";
import { createElement, type ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";

import {
  circularFeaturePath,
  coordinateFromFraction,
  normalizeSequenceAnnotationIndexState,
  SequenceOverview,
} from "./sequence-overview";
import {
  circularArrowPath,
  coordinateFromCircularPoint,
  createOverviewPlan,
  linearFeaturePath,
  localMapRanges,
  MAX_MAP_FEATURES,
  MAX_MAP_LANES,
  MAX_MAP_SEGMENTS,
  MIN_MAP_FEATURE_FRACTION,
} from "./sequence-overview-geometry";
import type { SequenceFeature, SequenceRecord } from "./types";

describe("sequence overview geometry", () => {
  it("maps overview fractions to clamped one-based coordinates", () => {
    expect(coordinateFromFraction(0, 100)).toBe(1);
    expect(coordinateFromFraction(0.49, 100)).toBe(50);
    expect(coordinateFromFraction(1, 100)).toBe(100);
    expect(coordinateFromFraction(4, 100)).toBe(100);
  });

  it("builds finite small and large circular feature arcs", () => {
    expect(circularFeaturePath(1, 10, 100, 50, 50, 30)).toMatch(/^M /u);
    const large = circularFeaturePath(1, 80, 100, 50, 50, 30);
    expect(large).toContain(" 0 1 1 ");
    expect(large).not.toContain("NaN");
  });

  it("draws an entire circular interval with two arcs and preserves strand direction", () => {
    expect(
      circularFeaturePath(1, 100, 100, 50, 50, 30).match(/ A /gu),
    ).toHaveLength(2);
    const range = { start: 1, end: 30 };
    const forward = circularArrowPath(range, 100, 50, 30, "+");
    const reverse = circularArrowPath(range, 100, 50, 30, "-");
    expect(forward).not.toEqual(reverse);
    expect(forward).toContain(" 0 0 1 ");
    expect(reverse).toContain(" 0 0 0 ");
    expect(
      circularArrowPath({ start: 1, end: 100 }, 100, 50, 30, "?"),
    ).not.toMatch(/NaN|Infinity/u);
    expect(linearFeaturePath(10, 20, 40, 10, "+")).toContain("L 50 25");
    expect(linearFeaturePath(10, 20, 40, 10, "-")).toContain("L 10 25");
    expect(linearFeaturePath(10, 20, 40, 10, "?")).not.toContain(" L ");
  });

  it("maps circular cardinal points to inclusive coordinates", () => {
    expect(coordinateFromCircularPoint(0, -1, 100)).toBe(1);
    expect(coordinateFromCircularPoint(1, 0, 100)).toBe(26);
    expect(coordinateFromCircularPoint(0, 1, 100)).toBe(51);
    expect(coordinateFromCircularPoint(-1, 0, 100)).toBe(76);
  });

  it("retains visible markers for one-base circular features, including at the origin", () => {
    const path = circularArrowPath(
      { start: 1, end: 1 },
      10_000_000,
      200,
      100,
      "+",
    );
    expect(path).toContain("198.");
    expect(path).toContain("201.");
    expect(path).not.toContain("NaN");
    const selection = circularFeaturePath(
      1,
      1,
      10_000_000,
      200,
      200,
      80,
      MIN_MAP_FEATURE_FRACTION,
    );
    expect(selection).not.toContain("M 200.000 120.000");
    const plan = createOverviewPlan(
      makeRecord({
        length: 10_000_000,
        topology: "circular",
        features: [
          makeFeature({ id: "first", start: 1, end: 1 }),
          makeFeature({ id: "last", start: 10_000_000, end: 10_000_000 }),
        ],
      }),
      "forward",
    );
    expect(plan.features.map(({ lane }) => lane)).toEqual([0, 1]);
  });

  it("mirrors actual compound intervals and strand without changing source features or qualifiers", () => {
    const feature = makeFeature({
      start: 1,
      end: 100,
      segments: [
        { start: 90, end: 100 },
        { start: 1, end: 10 },
        { start: 20, end: 30, remoteAccession: "OTHER.1" },
      ],
      qualifiers: { note: ["joined across origin", "source annotation"] },
    });
    const record = makeRecord({ features: [feature], topology: "circular" });
    const original = structuredClone(record);
    const plan = createOverviewPlan(record, "reverse-complement");
    expect(plan.features[0]).toMatchObject({
      feature,
      segments: [
        { start: 1, end: 11 },
        { start: 91, end: 100 },
      ],
      strand: "-",
    });
    expect(plan.features[0]?.feature).toBe(feature);
    expect(plan.remoteSegments).toBe(1);
    expect(record).toEqual(original);
  });

  it("splits wrapped intervals only for a circular molecule and never draws out-of-range intervals", () => {
    expect(localMapRanges({ start: 90, end: 10 }, 100, true)).toEqual([
      { start: 90, end: 100 },
      { start: 1, end: 10 },
    ]);
    expect(localMapRanges({ start: 90, end: 10 }, 100)).toEqual([]);
    expect(localMapRanges({ start: 101, end: 120 }, 100)).toEqual([]);
    expect(localMapRanges({ start: Number.NaN, end: 10 }, 100)).toEqual([]);
  });

  it("keeps overlapping features on separate lanes and gives a selected feature priority", () => {
    const features = Array.from({ length: MAX_MAP_LANES + 1 }, (_, index) =>
      makeFeature({ id: `feature-${index}` }),
    );
    const record = makeRecord({ features });
    const plan = createOverviewPlan(
      record,
      "forward",
      `feature-${MAX_MAP_LANES}`,
    );
    expect(plan.features).toHaveLength(MAX_MAP_LANES);
    expect(plan.omittedForDensity).toBe(1);
    expect(plan.features[0]?.feature.id).toBe(`feature-${MAX_MAP_LANES}`);
    expect(new Set(plan.features.map(({ lane }) => lane)).size).toBe(
      MAX_MAP_LANES,
    );
  });

  it("bounds map work while keeping annotations beyond the first map page selectable", () => {
    const record = manyFeatureRecord(MAX_MAP_FEATURES + 5);
    const selectedId = record.features.at(-1)?.id;
    const plan = createOverviewPlan(record, "forward", selectedId);
    expect(plan.features).toHaveLength(MAX_MAP_FEATURES);
    expect(plan.features[0]?.feature.id).toBe(selectedId);
    expect(plan.omittedForLimit).toBe(5);
    const segmented = createOverviewPlan(
      makeRecord({
        length: 10_000,
        features: [
          makeFeature({
            segments: Array.from(
              { length: MAX_MAP_SEGMENTS + 3 },
              (_, index) => ({ start: index * 2 + 1, end: index * 2 + 1 }),
            ),
          }),
        ],
      }),
      "forward",
    );
    expect(segmented.features[0]?.segments).toHaveLength(MAX_MAP_SEGMENTS);
    expect(segmented.omittedSegments).toBe(3);
  });
});

describe("sequence overview interaction", () => {
  it("keeps small-map feature names visible when the full index is collapsed", () => {
    const onChange = vi.fn();
    const props = renderOverview({
      layout: "circular",
      record: manyFeatureRecord(9),
      annotationIndex: { query: "", page: 0, expanded: false, onChange },
    });
    const labels = screen.getByRole("list", {
      name: "Visible map annotation labels",
    });
    expect(labels).toBeVisible();
    expect(within(labels).getAllByRole("listitem")).toHaveLength(9);
    const label = within(labels).getByRole("button", {
      name: /^Inspect annotation 8/u,
    });
    expect(label).toHaveAttribute(
      "title",
      expect.stringContaining("annotation 8"),
    );
    fireEvent.click(label);
    expect(props.onSelectFeature).toHaveBeenLastCalledWith("feature-8");
    props.rerender({
      annotationIndex: { query: "", page: 0, expanded: true, onChange },
    });
    expect(
      screen.queryByRole("list", { name: "Visible map annotation labels" }),
    ).not.toBeInTheDocument();
  });

  it("bounds compact labels to drawn features and opens the existing index for the rest", () => {
    const onChange = vi.fn();
    const record = manyFeatureRecord(13);
    record.features.push(
      makeFeature({
        id: "remote-only",
        label: "Remote only",
        segments: [{ start: 10, end: 80, remoteAccession: "OTHER.1" }],
      }),
    );
    renderOverview({
      layout: "circular",
      record,
      selectedFeatureId: "feature-12",
      annotationIndex: { query: "", page: 0, expanded: false, onChange },
    });
    const labels = screen.getByRole("list", {
      name: "Visible map annotation labels",
    });
    expect(within(labels).getAllByRole("listitem")).toHaveLength(12);
    expect(
      within(labels).getByRole("button", { name: /^Inspect annotation 12/u }),
    ).toHaveAttribute("aria-pressed", "true");
    expect(within(labels).queryByText("Remote only")).not.toBeInTheDocument();
    expect(screen.getByText("12 of 13 mapped annotations")).toBeInTheDocument();
    fireEvent.click(
      screen.getByRole("button", { name: "Browse all 14 annotations" }),
    );
    expect(onChange).toHaveBeenLastCalledWith({ expanded: true });
  });

  it.each(["linear", "circular", "split"] as const)(
    "renders the requested %s layout without an implicit second map",
    (layout) => {
      renderOverview({ layout });
      expect(screen.queryByTestId("linear-sequence-map") != null).toBe(
        layout !== "circular",
      );
      expect(screen.queryByTestId("circular-sequence-map") != null).toBe(
        layout !== "linear",
      );
    },
  );

  it("selects a map feature with pointer, Enter, and Space without also jumping to the ruler", () => {
    const props = renderOverview({ selectedFeatureId: "feature-1" });
    const feature = screen.getByRole("button", { name: /^Select test gene/u });
    expect(feature).toHaveAttribute("aria-pressed", "true");
    fireEvent.click(feature);
    fireEvent.keyDown(feature, { key: "Enter" });
    fireEvent.keyDown(feature, { key: " " });
    expect(props.onSelectFeature).toHaveBeenCalledTimes(3);
    expect(props.onSelectFeature).toHaveBeenLastCalledWith("feature-1");
    expect(props.onJump).not.toHaveBeenCalled();
  });

  it("returns source coordinates from reverse-complement map navigation and mirrors selection intervals", () => {
    const props = renderOverview({
      orientation: "reverse-complement",
      selection: {
        recordId: "record-1",
        start: 90,
        end: 10,
        segments: [
          { start: 90, end: 100 },
          { start: 1, end: 10 },
        ],
      },
    });
    const map = screen.getByRole("group", {
      name: "Linear map of Test plasmid",
    });
    vi.spyOn(map, "getBoundingClientRect").mockReturnValue(
      new DOMRect(0, 0, 1_000, 100),
    );
    fireEvent.click(map, { clientX: 24, clientY: 50 });
    expect(props.onJump).toHaveBeenLastCalledWith(100);
    fireEvent.click(map, { clientX: 976, clientY: 50 });
    expect(props.onJump).toHaveBeenLastCalledWith(1);
    expect(
      Array.from(map.querySelectorAll("[data-selection-range]")).map((node) =>
        node.getAttribute("data-selection-range"),
      ),
    ).toEqual(["1-11", "91-100"]);
    fireEvent.change(
      screen.getByRole("slider", { name: "Navigate map by coordinate" }),
      { target: { value: "26" } },
    );
    expect(props.onJump).toHaveBeenLastCalledWith(75);
  });

  it("navigates circular maps by angle without treating the molecule label as a coordinate", () => {
    const props = renderOverview({ layout: "circular" });
    const map = screen.getByRole("group", {
      name: "Circular map of Test plasmid",
    });
    vi.spyOn(map, "getBoundingClientRect").mockReturnValue(
      new DOMRect(0, 0, 400, 400),
    );
    fireEvent.click(map, { clientX: 200, clientY: 200 });
    expect(props.onJump).not.toHaveBeenCalled();
    fireEvent.click(map, { clientX: 200, clientY: 30 });
    expect(props.onJump).toHaveBeenLastCalledWith(1);
    fireEvent.click(map, { clientX: 370, clientY: 200 });
    expect(props.onJump).toHaveBeenLastCalledWith(26);
  });

  it("shares the active source coordinate between pointer navigation, both keyboard rulers, and orientation changes", () => {
    const props = renderOverview({ layout: "split" });
    const map = screen.getByRole("group", {
      name: "Linear map of Test plasmid",
    });
    vi.spyOn(map, "getBoundingClientRect").mockReturnValue(
      new DOMRect(0, 0, 1_000, 100),
    );
    fireEvent.click(map, { clientX: 872, clientY: 50 });
    expect(props.onJump).toHaveBeenLastCalledWith(90);
    props.rerender({ selection: { recordId: "record-1", start: 90, end: 90 } });
    const sliders = screen.getAllByRole("slider", {
      name: "Navigate map by coordinate",
    });
    for (const slider of sliders) expect(slider).toHaveValue("90");
    fireEvent.change(sliders[0]!, { target: { value: "91" } });
    expect(props.onJump).toHaveBeenLastCalledWith(91);
    props.rerender({
      orientation: "reverse-complement",
      selection: { recordId: "record-1", start: 91, end: 91 },
    });
    for (const slider of screen.getAllByRole("slider")) {
      expect(slider).toHaveValue("10");
      expect(slider).toHaveAttribute("aria-valuetext", "Source coordinate 91");
    }
  });

  it("accepts controlled annotation-index and origin-form state and emits the same UI patches", () => {
    const onChange = vi.fn();
    const onOriginRangeExpandedChange = vi.fn();
    const record = { ...manyFeatureRecord(65), topology: "circular" as const };
    const props = renderOverview({
      record,
      annotationIndex: { query: "", page: 1, expanded: true, onChange },
      originRangeExpanded: true,
      onOriginRangeExpandedChange,
    });
    expect(screen.getByText("31–60 of 65 annotations")).toBeInTheDocument();
    expect(
      screen.getByRole("spinbutton", {
        name: "Origin-spanning selection start",
      }),
    ).toBeVisible();
    fireEvent.click(
      screen.getByRole("button", { name: "Next annotation page" }),
    );
    expect(onChange).toHaveBeenLastCalledWith({ page: 2 });
    fireEvent.change(
      screen.getByRole("searchbox", { name: "Find annotation in map index" }),
      { target: { value: "annotation 64" } },
    );
    expect(onChange).toHaveBeenLastCalledWith({
      query: "annotation 64",
      page: 0,
    });
    props.rerender({
      annotationIndex: {
        query: "annotation 64",
        page: 0,
        expanded: true,
        onChange,
      },
      originRangeExpanded: false,
    });
    expect(screen.getByText("1–1 of 1 matches")).toBeInTheDocument();
    expect(
      screen.getByLabelText("Origin-spanning selection start"),
    ).not.toBeVisible();
    expect(
      normalizeSequenceAnnotationIndexState(record, {
        query: "annotation 64",
        page: 20,
        expanded: true,
      }),
    ).toEqual({ query: "annotation 64", page: 0, expanded: true });
    expect(
      normalizeSequenceAnnotationIndexState(record, {
        query: "",
        page: 20,
        expanded: true,
      }).page,
    ).toBe(2);
  });

  it("discloses limits and provides search and pagination for every loaded annotation", () => {
    const props = renderOverview({
      layout: "circular",
      record: manyFeatureRecord(205),
    });
    const index = screen.getByRole("list", { name: "Map annotation index" });
    expect(within(index).getAllByRole("listitem")).toHaveLength(30);
    expect(screen.getByText("1–30 of 205 annotations")).toBeInTheDocument();
    expect(screen.getByTestId("sequence-map-limits")).toHaveTextContent(
      "limited to 200 annotations",
    );
    fireEvent.click(
      screen.getByRole("button", { name: "Next annotation page" }),
    );
    expect(screen.getByText("31–60 of 205 annotations")).toBeInTheDocument();
    fireEvent.change(
      screen.getByRole("searchbox", { name: "Find annotation in map index" }),
      { target: { value: "annotation 204" } },
    );
    expect(within(index).getAllByRole("listitem")).toHaveLength(1);
    fireEvent.click(
      within(index).getByRole("button", { name: /^annotation 204/u }),
    );
    expect(props.onSelectFeature).toHaveBeenLastCalledWith("feature-204");
  });

  it("validates source-coordinate origin selections before forwarding them", () => {
    const props = renderOverview({
      orientation: "reverse-complement",
      record: makeRecord({ topology: "circular" }),
    });
    fireEvent.click(screen.getByText("Select across the origin"));
    const start = screen.getByRole("spinbutton", {
      name: "Origin-spanning selection start",
    });
    const end = screen.getByRole("spinbutton", {
      name: "Origin-spanning selection end",
    });
    fireEvent.change(start, { target: { value: "10" } });
    fireEvent.change(end, { target: { value: "20" } });
    fireEvent.click(screen.getByRole("button", { name: "Select range" }));
    expect(screen.getByRole("alert")).toHaveTextContent(
      "start greater than end",
    );
    expect(props.onSelectRange).not.toHaveBeenCalled();
    fireEvent.change(start, { target: { value: "90" } });
    fireEvent.change(end, { target: { value: "10" } });
    fireEvent.click(screen.getByRole("button", { name: "Select range" }));
    expect(props.onSelectRange).toHaveBeenLastCalledWith(90, 10, true);
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
  });
});

function renderOverview(
  overrides: Partial<ComponentProps<typeof SequenceOverview>> = {},
) {
  const props = {
    layout: "linear" as const,
    onJump: vi.fn(),
    onSelectFeature: vi.fn(),
    onSelectRange: vi.fn(),
    orientation: "forward" as const,
    record: makeRecord(),
    synchronizedViews: true,
    viewport: null,
    ...overrides,
  };
  const view = render(createElement(SequenceOverview, props));
  return {
    ...props,
    rerender: (patch: Partial<ComponentProps<typeof SequenceOverview>>) =>
      view.rerender(createElement(SequenceOverview, { ...props, ...patch })),
  };
}

function makeFeature(
  overrides: Partial<SequenceFeature> = {},
): SequenceFeature {
  return {
    id: "feature-1",
    label: "test gene",
    type: "gene",
    start: 10,
    end: 80,
    strand: "+",
    qualifiers: {},
    ...overrides,
  };
}

function makeRecord(overrides: Partial<SequenceRecord> = {}): SequenceRecord {
  // Deliberately synthetic coordinate-only fixtures, not biological evidence.
  return {
    id: "record-1",
    sourceLabel: "Test plasmid",
    features: [makeFeature()],
    length: 100,
    sequence: "A".repeat(100),
    molecule: "dna",
    metadata: {},
    ...overrides,
  };
}

function manyFeatureRecord(count: number): SequenceRecord {
  return makeRecord({
    length: 100_000,
    features: Array.from({ length: count }, (_, index) =>
      makeFeature({
        id: `feature-${index}`,
        label: `annotation ${index}`,
        start: index * 450 + 1,
        end: index * 450 + 40,
      }),
    ),
  });
}

SHA-256: 0b8c35ff786f55e22f40a7ea742c81dc23ea134627602a83ce414fcbf9f7fb3d