← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/sequence/read-pileup.test.ts
9.84 KB · Sep 30, 2026 · 23:01 UTC
import { describe, expect, it } from "vitest";
import {
binReadCoverage,
DEFAULT_READ_PILEUP_FILTERS,
findVisibleReadMate,
getReadCigarGeometry,
mappingQualityLabel,
mappingQualityOpacity,
projectReadAlignment,
readPassesPileupFilters,
type ReadPileupEntry,
} from "./read-pileup";
import { parseSam, type TrackRead } from "./tracks";
const REFERENCE = "AGCATGTTAGATAAGATAGCTGTGCTAGTAGGCAGTCAGCGCCAT";
function samRead(line: string): TrackRead {
const read = parseSam(line)[0];
if (read == null) throw new Error("Expected a mapped fixture read.");
return read;
}
function project(read: TrackRead, start = 1, end = 45) {
return projectReadAlignment({
read,
referenceSequence: REFERENCE,
range: { end, start },
});
}
describe("browser-safe read pileup", () => {
// Exact alignment records from SAM v1.6 section 1.1:
// https://samtools.github.io/hts-specs/SAMv1.pdf
it("projects the official insertion/deletion example without advancing the reference for I", () => {
const read = samRead(
"r001\t99\tref\t7\t30\t8M2I4M1D3M\t=\t37\t39\tTTAGATAAAGGATACTG\t*",
);
const projection = project(read);
expect(projection.unavailableReason).toBeNull();
expect(projection.blocks).toEqual([
{ start: 7, end: 14, operation: "M" },
{ start: 15, end: 18, operation: "M" },
{ start: 19, end: 19, operation: "D" },
{ start: 20, end: 22, operation: "M" },
]);
expect(projection.markers).toEqual([
{ anchor: 15, kind: "insertion", length: 2, sequence: "AG" },
]);
expect(projection.bases.find((base) => base.coordinate === 15)?.base).toBe(
"G",
);
expect(projection.bases.some((base) => base.coordinate === 19)).toBe(false);
});
it("keeps official RNA-style reference skips separate from aligned blocks and deletions", () => {
const read = samRead(
"r004\t0\tref\t16\t30\t6M14N5M\t*\t0\t0\tATAGCTTCAGC\t*",
);
expect(project(read).blocks).toEqual([
{ start: 16, end: 21, operation: "M" },
{ start: 22, end: 35, operation: "N" },
{ start: 36, end: 40, operation: "M" },
]);
expect(
getReadCigarGeometry(read, { start: 20, end: 38 }).intervals,
).toEqual([
{ start1: 20, end1: 21 },
{ start1: 36, end1: 38 },
]);
expect(project(read, 25, 30).bases).toEqual([]);
});
it("places soft clipping at a boundary and does not let padding consume query bases", () => {
const read = samRead(
"r002\t0\tref\t9\t30\t3S6M1P1I4M\t*\t0\t0\tAAAAGATAAGGATA\t*",
);
const projection = project(read);
expect(projection.markers).toEqual([
{ anchor: 9, kind: "soft-clip", length: 3, sequence: "AAA" },
{ anchor: 15, kind: "insertion", length: 1, sequence: "G" },
]);
expect(projection.bases[0]).toMatchObject({ base: "A", coordinate: 9 });
expect(projection.bases).toHaveLength(10);
});
it("does not reverse-complement SAM reverse-strand SEQ or reverse its QUAL again", () => {
const read = samRead("reverse\t16\tref\t1\t60\t4M\t*\t0\t0\tAGCT\t!+5I");
const projection = projectReadAlignment({
read,
referenceSequence: "AGCA",
range: { start: 1, end: 4 },
});
expect(
projection.bases.map(({ base, quality }) => [base, quality]),
).toEqual([
["A", 0],
["G", 10],
["C", 20],
["T", 40],
]);
expect(
projection.bases.filter((base) => base.comparison === "mismatch"),
).toEqual([
{
base: "T",
comparison: "mismatch",
coordinate: 4,
quality: 40,
referenceBase: "A",
},
]);
});
it("uses the loaded reference for =/X, without calling ambiguous or unavailable bases mismatches", () => {
const read = samRead("mixed\t0\tref\t1\t60\t2=1X2M\t*\t0\t0\t=AGNC\t*");
const projection = projectReadAlignment({
read,
referenceSequence: "AACNN",
range: { start: 1, end: 5 },
});
expect(
projection.bases.map(({ base, comparison }) => [base, comparison]),
).toEqual([
["A", "match"],
["A", "match"],
["G", "mismatch"],
["N", "unknown"],
["C", "unknown"],
]);
expect(projection.bases.every((base) => base.quality === null)).toBe(true);
});
it("supports missing SEQ without fabricating bases, and reports hard clips without inventing their sequence", () => {
const missing = samRead("missing\t0\tref\t1\t60\t4M\t*\t0\t0\t*\t*");
expect(project(missing)).toMatchObject({
bases: [],
blocks: [{ start: 1, end: 4, operation: "M" }],
unavailableReason: null,
});
const clipped = samRead(
"clipped\t0\tref\t1\t60\t2H2S4M1S3H\t*\t0\t0\tTTAGCAT\tIIIIIII",
);
expect(project(clipped)).toMatchObject({
hardClippedBases: 5,
markers: [
{ anchor: 1, kind: "soft-clip", length: 2, sequence: "TT" },
{ anchor: 5, kind: "soft-clip", length: 1, sequence: "T" },
],
unavailableReason: null,
});
});
it.each([
"*",
"0M",
"3M1B",
"9007199254740992M",
"3Mgarbage",
"2M1S1M",
"1M1H3M",
])(
"does not render an approximate span for unavailable or malformed CIGAR %s",
(cigar) => {
const read = {
...samRead("read\t0\tref\t1\t60\t4M\t*\t0\t0\tAGCA\tIIII"),
cigar,
};
expect(project(read)).toMatchObject({
bases: [],
blocks: [],
markers: [],
unavailableReason: expect.any(String),
});
},
);
it("rejects a SEQ/CIGAR length discrepancy and bounds detail in wide windows", () => {
const read = samRead("read\t0\tref\t1\t60\t4M\t*\t0\t0\tAGCA\tIIII");
expect(project({ ...read, sequence: "AAA" }).unavailableReason).toMatch(
/query length/,
);
expect(project(read, 1, 1_000)).toMatchObject({
bases: [],
blocks: [{ start: 1, end: 4, operation: "M" }],
unavailableReason: null,
});
const complex = samRead(
`complex\t0\tref\t1\t60\t${"1M1D".repeat(150)}\t*\t0\t0\t${"A".repeat(150)}\t*`,
);
expect(project(complex, 1, 300)).toMatchObject({
blocks: [],
unavailableReason: expect.stringMatching(/256 CIGAR operations/),
});
expect(project(complex, 1, 20).unavailableReason).toBeNull();
});
});
describe("read filters and metadata", () => {
const read = samRead("read\t0\tref\t1\t60\t4M\t*\t0\t0\tAGCA\tIIII");
it("treats MAPQ 255 as explicitly unavailable, not as a high-confidence score", () => {
const unknown = { ...read, mappingQuality: 255 };
const filters = {
...DEFAULT_READ_PILEUP_FILTERS,
minimumMappingQuality: 30,
includeUnknownMappingQuality: false,
};
expect(readPassesPileupFilters(read, filters)).toBe(true);
expect(
readPassesPileupFilters({ ...read, mappingQuality: 20 }, filters),
).toBe(false);
expect(readPassesPileupFilters(unknown, filters)).toBe(false);
expect(
readPassesPileupFilters(unknown, {
...filters,
includeUnknownMappingQuality: true,
}),
).toBe(true);
expect(mappingQualityLabel(255)).toBe("Unavailable (255)");
expect(mappingQualityOpacity(255)).toBeLessThan(mappingQualityOpacity(60));
});
it("filters strands and SAM flags without dropping unrelated reads", () => {
expect(
readPassesPileupFilters(read, {
...DEFAULT_READ_PILEUP_FILTERS,
strand: "-",
}),
).toBe(false);
for (const [flag, key] of [
[0x400, "includeDuplicates"],
[0x200, "includeQcFailed"],
[0x100, "includeSecondary"],
[0x800, "includeSupplementary"],
] as const) {
expect(
readPassesPileupFilters(
{ ...read, flags: flag },
{ ...DEFAULT_READ_PILEUP_FILTERS, [key]: false },
),
).toBe(false);
expect(
readPassesPileupFilters(read, {
...DEFAULT_READ_PILEUP_FILTERS,
[key]: false,
}),
).toBe(true);
}
});
it("links reciprocal primary mates only within the same source and read group", () => {
const first: ReadPileupEntry = {
key: "a:0",
trackId: "a",
sourceReadIndex: 0,
trackName: "reads.sam",
read: samRead(
"r001\t99\tref\t7\t30\t8M2I4M1D3M\t=\t37\t39\tTTAGATAAAGGATACTG\t*",
),
};
const second: ReadPileupEntry = {
key: "a:1",
trackId: "a",
sourceReadIndex: 1,
trackName: "reads.sam",
read: samRead(
"r001\t147\tref\t37\t30\t9M\t=\t7\t-39\tCAGCGGCAT\t*\tNM:i:1",
),
};
expect(findVisibleReadMate(first, [first, second])).toBe(second);
expect(
findVisibleReadMate(first, [first, { ...second, trackId: "b" }]),
).toBeNull();
expect(
findVisibleReadMate(first, [
first,
{ ...second, read: { ...second.read, matePosition: 9 } },
]),
).toBeNull();
expect(
findVisibleReadMate(first, [
first,
{
...second,
read: { ...second.read, flags: second.read.flags | 0x800 },
},
]),
).toBeNull();
expect(
findVisibleReadMate(first, [
first,
{ ...second, read: { ...second.read, tags: { RG: "other" } } },
]),
).toBeNull();
expect(
findVisibleReadMate(first, [first, second, { ...second, key: "a:2" }]),
).toBeNull();
});
});
describe("coverage display bins", () => {
it("keeps a one-base peak and all source coordinates when reducing the plot", () => {
const coverage = Array.from({ length: 1_003 }, (_, index) => ({
coordinate: 101 + index,
depth: index === 7 ? 50 : 0,
}));
const bins = binReadCoverage(coverage, 100);
expect(bins).toHaveLength(100);
expect(bins[0]).toEqual({ start: 101, end: 110, maximumDepth: 50 });
expect(bins.at(-1)?.end).toBe(1_103);
expect(bins.reduce((sum, bin) => sum + bin.end - bin.start + 1, 0)).toBe(
coverage.length,
);
expect(binReadCoverage([], 10)).toEqual([]);
expect(binReadCoverage(coverage, 0)).toEqual([]);
});
});
SHA-256: a2253e29122aabc0ed2eaf393af78c17e57e2c24b376cd0fa208e45a713410b7