← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/sequence/evidence-track-panel.test.tsx

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

↓ Download file

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

import { createOriginSpanningSelection } from "./selection";
import { parseSequenceDocument } from "./parser";
import { parseSequenceTrack } from "./tracks";
import { EvidenceTrackPanel } from "./evidence-track-panel";
import { DEFAULT_READ_PILEUP_STATE, type ReadPileupState } from "./read-pileup";
import { getSequenceResidueStyle } from "./sequence-palette";

afterEach(() => cleanup());

describe("EvidenceTrackPanel", () => {
  it.each([
    ["dna", "ACGTRYSWKMBDHVN"],
    ["rna", "ACGURYSWKMBDHVN"],
    ["nucleic-acid-ambiguous", "ACGTURYSWKMBDHVN"],
  ] as const)(
    "uses the shared soft palette for %s reference and read glyphs, keeping ambiguity neutral",
    (molecule, sequence) => {
      const record = { ...referenceRecord(sequence), molecule };
      const track = samTrack(
        `all-bases\t0\tref\t1\t60\t${sequence.length}M\t*\t0\t0\t${sequence}\t${"I".repeat(sequence.length)}`,
      );
      const { container } = render(
        <EvidenceTrackPanel
          record={record}
          readPileupState={{
            ...DEFAULT_READ_PILEUP_STATE,
            options: {
              ...DEFAULT_READ_PILEUP_STATE.options,
              showAllBases: true,
            },
          }}
          tracks={[track]}
          viewport={null}
        />,
      );
      const referenceGlyphs = screen
        .getByRole("img", { name: "Selected reference bases" })
        .querySelectorAll("g");
      const readGlyphs = container.querySelectorAll("[data-base-comparison]");
      expect(referenceGlyphs).toHaveLength(sequence.length);
      expect(readGlyphs).toHaveLength(sequence.length);
      for (const [index, residue] of [...sequence].entries()) {
        const token =
          residue === "U"
            ? "t"
            : "ACGT".includes(residue)
              ? residue.toLowerCase()
              : "neutral";
        for (const glyph of [referenceGlyphs[index], readGlyphs[index]]) {
          expect(glyph?.querySelector("rect")).toHaveAttribute(
            "fill",
            `var(--bio-sequence-${token}-surface)`,
          );
          const lettering = glyph?.querySelector("text");
          expect(lettering).toHaveAttribute(
            "fill",
            `var(--bio-sequence-${token}-foreground)`,
          );
          expect(lettering).toHaveTextContent(residue);
          expect(lettering).not.toHaveAttribute("stroke");
        }
      }
    },
  );

  it.each([
    ["ncbi-nucleic-acid", "#008000"],
    ["jalview-nucleotide", "#3c88ee"],
  ] as const)(
    "keeps low-quality %s read lettering outlined without changing palette fills or quality opacity",
    (paletteId, uracilColor) => {
      const record = referenceRecord("GUNA");
      const track = samTrack(
        "rna-read\t0\tref\t1\t60\t4M\t*\t0\t0\tGUNA\t!+5I",
      );
      const props = {
        record,
        readPileupState: {
          ...DEFAULT_READ_PILEUP_STATE,
          options: { ...DEFAULT_READ_PILEUP_STATE.options, showAllBases: true },
        },
        tracks: [track],
        viewport: null,
      };
      const { container, rerender } = render(<EvidenceTrackPanel {...props} />);
      expect(
        screen
          .getByRole("img", { name: "Selected reference bases" })
          .querySelectorAll("rect")[1],
      ).toHaveAttribute("fill", "var(--bio-sequence-t-surface)");
      const opacityBefore = Array.from(
        container.querySelectorAll("[data-base-comparison] rect"),
        (rect) => rect.getAttribute("fill-opacity"),
      );
      expect(opacityBefore).toEqual(["0.25", "0.45", "0.65", "1"]);
      const softLettering = container.querySelector(
        "[data-base-comparison] text",
      );
      expect(softLettering).toHaveAttribute(
        "fill",
        "var(--bio-sequence-g-foreground)",
      );
      expect(softLettering).not.toHaveAttribute("stroke");
      expect(softLettering).not.toHaveAttribute("paint-order");

      rerender(<EvidenceTrackPanel {...props} paletteId={paletteId} />);

      const referenceGlyphs = screen
        .getByRole("img", { name: "Selected reference bases" })
        .querySelectorAll("g");
      const readGlyphs = container.querySelectorAll("[data-base-comparison]");
      for (const [index, residue] of [...record.sequence].entries()) {
        const style = getSequenceResidueStyle({
          molecule: record.molecule,
          paletteId,
          residue,
        });
        for (const glyph of [referenceGlyphs[index], readGlyphs[index]]) {
          expect(glyph?.querySelector("rect")).toHaveAttribute(
            "fill",
            style.backgroundColor,
          );
        }
        expect(referenceGlyphs[index]?.querySelector("text")).toHaveAttribute(
          "fill",
          style.color,
        );
        const lettering = readGlyphs[index]?.querySelector("text");
        expect(lettering).toHaveAttribute("fill", "white");
        expect(lettering).toHaveAttribute("stroke", "#1f2937");
        expect(lettering).toHaveAttribute("stroke-width", "0.6");
        expect(lettering).toHaveAttribute("paint-order", "stroke");
      }
      expect(referenceGlyphs[1]?.querySelector("rect")).toHaveAttribute(
        "fill",
        uracilColor,
      );
      expect(readGlyphs[1]?.querySelector("rect")).toHaveAttribute(
        "fill",
        uracilColor,
      );
      expect(
        Array.from(
          container.querySelectorAll("[data-base-comparison] rect"),
          (rect) => rect.getAttribute("fill-opacity"),
        ),
      ).toEqual(opacityBefore);

      rerender(<EvidenceTrackPanel {...props} paletteId="neutral" />);
      const monochromeLettering = container.querySelector(
        "[data-base-comparison] text",
      );
      expect(monochromeLettering).toHaveAttribute(
        "fill",
        "var(--bio-token-text-primary)",
      );
      expect(monochromeLettering).not.toHaveAttribute("stroke");
      expect(monochromeLettering).not.toHaveAttribute("paint-order");
    },
  );

  it("uses a valid segment for an origin-spanning selection", () => {
    const document = parseSequenceDocument({
      contents: `LOCUS       demo        12 bp    DNA     circular
ACCESSION   demo
ORIGIN
        1 acgtacgtacgt
//`,
      fileName: "demo.gb",
    });
    const record = document.records[0];
    if (record == null) throw new Error("Expected a sequence record.");
    const track = parseSequenceTrack({
      content: "demo\t11\tv1\tG\tA\t50\tPASS\tDP=2\n",
      displayName: "variants.vcf",
      format: "vcf",
      id: "variants",
      requestedReference: "demo",
    });

    render(
      <EvidenceTrackPanel
        record={record}
        selection={createOriginSpanningSelection({
          end: 3,
          record,
          start: 10,
        })}
        tracks={[track]}
        viewport={null}
      />,
    );

    expect(
      screen.getByText(/10–12 · 0 loaded reads · 1 variants/),
    ).toBeInTheDocument();
    // A variant-only track is not evidence of zero read coverage.
    expect(
      screen.queryByLabelText("Coverage from 10 to 12"),
    ).not.toBeInTheDocument();
  });

  it("renders CIGAR events, read details and filters without counting missing MAPQ as high confidence", async () => {
    const user = userEvent.setup();
    const record = referenceRecord("ACGTACGTACGT");
    const track = samTrack(
      [
        "high\t0\tref\t1\t60\t2M1I1M1D2M2N1M1S\t*\t0\t0\tATGTCGAA\tIIIIIIII",
        "low\t16\tref\t1\t5\t4M\t*\t0\t0\tACGT\t!!!!",
        "unknown\t0\tref\t1\t255\t4M\t*\t0\t0\tAAAA\t*",
      ].join("\n"),
    );
    const { container, rerender } = render(
      <EvidenceTrackPanel record={record} tracks={[track]} viewport={null} />,
    );
    for (const operation of ["I", "D", "N", "S"]) {
      expect(
        container.querySelector(`[data-cigar-operation="${operation}"]`),
      ).toBeInTheDocument();
    }
    expect(
      container.querySelector('[data-base-comparison="mismatch"]'),
    ).toBeInTheDocument();
    const cigarGraphics = Array.from(
      container.querySelectorAll("[data-cigar-operation]"),
      (graphic) => graphic.outerHTML,
    );
    rerender(
      <EvidenceTrackPanel
        record={record}
        paletteId="jalview-nucleotide"
        tracks={[track]}
        viewport={null}
      />,
    );
    expect(
      Array.from(
        container.querySelectorAll("[data-cigar-operation]"),
        (graphic) => graphic.outerHTML,
      ),
    ).toEqual(cigarGraphics);
    await user.click(
      screen.getByRole("button", { name: /^Inspect read high,/ }),
    );
    expect(
      screen.getByRole("complementary", { name: "Selected read details" }),
    ).toHaveTextContent("2M1I1M1D2M2N1M1S");
    expect(
      screen.getByRole("complementary", { name: "Selected read details" }),
    ).toHaveTextContent("Q40.0");
    await user.selectOptions(screen.getByLabelText("Minimum MAPQ"), "30");
    expect(
      screen.queryByRole("button", { name: /^Inspect read low,/ }),
    ).not.toBeInTheDocument();
    expect(
      screen.getByRole("button", { name: /^Inspect read unknown,/ }),
    ).toBeInTheDocument();
    await user.click(screen.getByText("Display & filters"));
    await user.click(screen.getByLabelText("Include unavailable MAPQ (255)"));
    expect(
      screen.queryByRole("button", { name: /^Inspect read unknown,/ }),
    ).not.toBeInTheDocument();
    expect(
      screen.getByText(/Coverage uses all 1 filtered, loaded alignments/),
    ).toBeInTheDocument();
    await user.click(screen.getByLabelText("Show soft clips"));
    expect(
      container.querySelector('[data-cigar-operation="S"]'),
    ).not.toBeInTheDocument();
    rerender(
      <EvidenceTrackPanel
        record={record}
        tracks={[track]}
        viewport={{ start: 10, end: 12 }}
      />,
    );
    expect(
      screen.queryByRole("complementary", { name: "Selected read details" }),
    ).not.toBeInTheDocument();
    expect(
      screen.getByText(/No loaded reads match this region/),
    ).toBeInTheDocument();
  });

  it("renders a bounded sample but uses every loaded read for coverage and discloses partial sources", () => {
    const track = samTrack(
      Array.from(
        { length: 110 },
        (_, index) => `r${index}\t0\tref\t1\t60\t4M\t*\t0\t0\tACGT\tIIII`,
      ).join("\n"),
    );
    track.summary = { ...track.summary, itemCount: 1_000, truncated: true };
    render(
      <EvidenceTrackPanel
        record={referenceRecord("ACGT")}
        tracks={[track]}
        viewport={null}
      />,
    );
    expect(
      screen.getAllByRole("button", { name: /^Inspect read / }),
    ).toHaveLength(100);
    expect(
      screen.getByText(
        /Showing 100 of 110 reads passing filters · deterministic display sample/,
      ),
    ).toBeInTheDocument();
    expect(screen.getByText("max observed 110×")).toBeInTheDocument();
    expect(
      screen.getByText(/Partial source · coverage is not extrapolated/),
    ).toBeInTheDocument();
    expect(
      screen.getByText(
        /1,000 source-reported reads; only loaded reads contribute/,
      ),
    ).toBeInTheDocument();
  });

  it("navigates only an unambiguous displayed mate without reusing the same read name as identity", async () => {
    const user = userEvent.setup();
    const track = samTrack(
      [
        "r001\t99\tref\t7\t30\t8M2I4M1D3M\t=\t37\t39\tTTAGATAAAGGATACTG\t*",
        "r001\t147\tref\t37\t30\t9M\t=\t7\t-39\tCAGCGGCAT\t*\tNM:i:1",
      ].join("\n"),
    );
    render(
      <EvidenceTrackPanel
        record={referenceRecord(
          "AGCATGTTAGATAAGATAGCTGTGCTAGTAGGCAGTCAGCGCCAT",
        )}
        tracks={[track]}
        viewport={null}
      />,
    );
    await user.click(
      screen.getByRole("button", { name: /^Inspect read r001, 7–22/ }),
    );
    await user.click(screen.getByRole("button", { name: "Inspect mate" }));
    expect(
      screen.getByRole("complementary", { name: "Selected read details" }),
    ).toHaveTextContent("ref:37–45");
    expect(
      screen.getByRole("button", { name: /^Inspect read r001, 37–45/ }),
    ).toHaveAttribute("aria-pressed", "true");
  });

  it("discloses unavailable CIGAR and does not draw fabricated span coverage", async () => {
    const user = userEvent.setup();
    render(
      <EvidenceTrackPanel
        record={referenceRecord("ACGT")}
        tracks={[samTrack("unknown\t0\tref\t1\t60\t*\t*\t0\t0\tACGT\tIIII")]}
        viewport={null}
      />,
    );
    expect(screen.getByText("max observed 0×")).toBeInTheDocument();
    expect(
      screen.getByText(/1 reads excluded from coverage/),
    ).toBeInTheDocument();
    await user.click(
      screen.getByRole("button", { name: /^Inspect read unknown,/ }),
    );
    expect(screen.getByText(/Read rendering unavailable:/)).toBeInTheDocument();
  });

  it("shares controlled read options and exact source selection between agent and human actions", async () => {
    const user = userEvent.setup();
    const record = referenceRecord("ACGT");
    const tracks = [
      samTrack("selected\t0\tref\t1\t60\t4M\t*\t0\t0\tACGT\tIIII"),
    ];
    const state: ReadPileupState = {
      options: {
        ...DEFAULT_READ_PILEUP_STATE.options,
        minimumMappingQuality: 15,
      },
      selectedRead: { trackId: "reads", sourceReadIndex: 0 },
    };
    const onChange = vi.fn<(next: ReadPileupState) => void>();
    const { rerender } = render(
      <EvidenceTrackPanel
        record={record}
        tracks={tracks}
        viewport={null}
        readPileupState={state}
        onReadPileupStateChange={onChange}
      />,
    );
    expect(screen.getByLabelText("Minimum MAPQ")).toHaveValue("15");
    expect(
      screen.getByRole("button", { name: /^Inspect read selected,/ }),
    ).toHaveAttribute("aria-pressed", "true");
    await user.selectOptions(screen.getByLabelText("Minimum MAPQ"), "30");
    const next = onChange.mock.calls[0]?.[0];
    expect(next).toEqual({
      ...state,
      options: { ...state.options, minimumMappingQuality: 30 },
    });
    expect(screen.getByLabelText("Minimum MAPQ")).toHaveValue("15");
    rerender(
      <EvidenceTrackPanel
        record={record}
        tracks={tracks}
        viewport={null}
        readPileupState={next}
        onReadPileupStateChange={onChange}
      />,
    );
    expect(screen.getByLabelText("Minimum MAPQ")).toHaveValue("30");
    await user.click(
      screen.getByRole("button", { name: "Close read details" }),
    );
    expect(onChange.mock.calls.at(-1)?.[0].selectedRead).toBeNull();
  });

  it("withholds read and variant placement on an edited reference while retaining source tracks", () => {
    const record = {
      ...referenceRecord("TACGT"),
      evidenceCoordinatesStale: true,
    };
    const reads = samTrack("original\t0\tref\t1\t60\t4M\t*\t0\t0\tACGT\tIIII");
    const variants = parseSequenceTrack({
      content: "ref\t2\tv1\tC\tT\t50\tPASS\t.\n",
      displayName: "variants.vcf",
      format: "vcf",
      id: "variants",
      requestedReference: "ref",
    });
    render(
      <EvidenceTrackPanel
        record={record}
        tracks={[reads, variants]}
        viewport={null}
      />,
    );
    expect(screen.getByRole("status")).toHaveTextContent(
      "Imported tracks are retained in their original source coordinates",
    );
    expect(screen.queryByLabelText(/Coverage from/)).not.toBeInTheDocument();
    expect(screen.queryByLabelText("Variants in view")).not.toBeInTheDocument();
    expect(
      screen.queryByRole("button", { name: /^Inspect read / }),
    ).not.toBeInTheDocument();
    expect(reads.reads?.[0]).toMatchObject({
      position: 1,
      sequence: "ACGT",
      cigar: "4M",
    });
  });
});

function referenceRecord(sequence: string) {
  const record = parseSequenceDocument({
    contents: `>ref\n${sequence}\n`,
    fileName: "reference.fasta",
  }).records[0];
  if (record == null) throw new Error("Expected a reference record.");
  return record;
}

function samTrack(content: string) {
  return parseSequenceTrack({
    content,
    displayName: "reads.sam",
    format: "sam",
    id: "reads",
    requestedReference: "ref",
  });
}

SHA-256: e5d79f0ce0c761ae9c9c359c89e03eb1f0fc1ab81bb402a5502655cacb77cedd