← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/sequence/editing.test.ts
21.5 KB · Sep 30, 2026 · 23:01 UTC
import { describe, expect, it } from "vitest";
import {
addSequenceFeature,
deleteSequenceFeature,
deleteSequenceRange,
insertSequence,
replaceSequenceRange,
reverseComplementSequenceRange,
rotateCircularSequence,
updateSequenceFeature,
} from "./editing";
import { buildFastqExport } from "./exports";
import { parseSequenceDocument } from "./parser";
// Public RNAcentral accession URS0000D6941A, SHA-256
// f78fe5ffca0051bc8b8b62cbf753dc5b9465a24e51a9dadea0bfaadb454840e7:
// https://raw.githubusercontent.com/r2dt-bio/R2DT/a1ca674f245e4dc13838e346b8e03295c2130d0c/examples/RF02976.fasta
const PUBLIC_RNACENTRAL_FASTA = `>URS0000D6941A
CCGAGAUAGGGGUACAAAUCCUGCCAAUGGCGUUUCCCUGGACCUUAGCGAUGAAGGCAUUUCAAAGCCUGAGCAACGAC
UCUCGGCGUCAGAUGGGGAGAGUGCCAGACGUGGUGUCCCCGCUGAC`;
// Genuine public Biopython sequencing reads and measured Phred+33 qualities,
// SHA-256 10bc5b39327a363b0019193c9823bc424a6d5706197688fdbdd45023a1481a0c:
// https://raw.githubusercontent.com/biopython/biopython/c9489604d1d9607602ca9199a3852c1219ed330f/Tests/Quality/example.fastq
const PUBLIC_SANGER_FASTQ = `@EAS54_6_R1_2_1_413_324
CCCTTCTTGTCTTCAGCGTTTCTCC
+
;;3;;;;;;;;;;;;7;;;;;;;88
@EAS54_6_R1_2_1_540_792
TTGGCAGGCCAAGGCCGATGGATCA
+
;;;;;;;;;;;7;;;;;-;;;3;83
@EAS54_6_R1_2_1_443_348
GTTGCTTCTGGCGTGGGTGGGGGGG
+
;;;;;;;;;;;9;7;;.7;393333
`;
describe("safe sequence edit copies", () => {
it.each([
"insert",
"delete",
"replace",
"reverse-complement",
"rotate",
] as const)(
"drops source-bound chromatograms from %s copies without altering the source trace",
(operation) => {
const document = chromatogramFixture();
const record = document.records[0]!;
const source = structuredClone(document);
const edits = {
delete: () => deleteSequenceRange(document, record.id, 2, 3),
insert: () => insertSequence(document, record.id, 2, "GA"),
replace: () => replaceSequenceRange(document, record.id, 2, 3, "GA"),
"reverse-complement": () =>
reverseComplementSequenceRange(document, record.id, 1, record.length),
rotate: () => rotateCircularSequence(document, record.id, 3),
};
const result = edits[operation]();
expect(Object.hasOwn(result.record, "chromatogram")).toBe(false);
expect(result.record.quality).toBeUndefined();
expect(result.document.records[0]).toBe(result.record);
expect(result.document.warnings).toContainEqual(
expect.objectContaining({ code: "source-trace-not-carried" }),
);
expect(document).toEqual(source);
},
);
it("retains source traces through annotation-only edits", () => {
const document = chromatogramFixture();
const record = document.records[0]!;
const feature = {
end: 5,
id: "trace-annotation",
qualifiers: {},
start: 2,
strand: "+" as const,
type: "misc_feature",
};
const added = addSequenceFeature(document, record.id, feature);
const updated = updateSequenceFeature(added, record.id, feature.id, {
label: "reviewed base calls",
});
const removed = deleteSequenceFeature(updated, record.id, feature.id);
expect(added.records[0]?.chromatogram).toBe(record.chromatogram);
expect(updated.records[0]?.chromatogram).toBe(record.chromatogram);
expect(removed.records[0]?.chromatogram).toBe(record.chromatogram);
expect(removed.records[0]?.sequence).toBe(record.sequence);
});
it.each([
["reverse-complement", "ACGT"],
["rotate", "ACAC"],
] as const)(
"invalidates original evidence after a symmetric %s even when the residue text is unchanged",
(operation, sequence) => {
const parsed = parseSequenceDocument({
contents: `>ref\n${sequence}\n`,
fileName: "symmetric-reference.fasta",
});
const document = {
...parsed,
records: parsed.records.map((record) => ({
...record,
topology: "circular" as const,
})),
};
const record = document.records[0]!;
const result =
operation === "rotate"
? rotateCircularSequence(document, record.id, 3)
: reverseComplementSequenceRange(
document,
record.id,
1,
record.length,
);
expect(result.record.sequence).toBe(record.sequence);
expect(result.record.evidenceCoordinatesStale).toBe(true);
expect(record.evidenceCoordinatesStale).not.toBe(true);
},
);
it("keeps source-coordinate evidence valid for an exact no-op residue replacement", () => {
const document = parseSequenceDocument({
contents: ">ref\nACGT\n",
fileName: "no-op-reference.fasta",
});
const record = document.records[0]!;
const result = replaceSequenceRange(
document,
record.id,
1,
4,
record.sequence,
);
expect(result.record.sequence).toBe(record.sequence);
expect(result.record.evidenceCoordinatesStale).not.toBe(true);
});
it("inserts residues and shifts downstream feature coordinates exactly", () => {
const document = fixture();
const record = document.records[0];
const result = insertSequence(document, record?.id ?? "", 10, "AAA");
expect(result.record.sequence.slice(9, 12)).toBe("AAA");
expect(result.record.features.find(({ id }) => id.includes("gene"))).toMatchObject({
start: 13,
end: 21,
});
expect(result.record.quality).toBeUndefined();
expect(Object.hasOwn(result.record, "quality")).toBe(false);
expect(result.document.warnings.at(-1)?.code).toBe("edited-copy");
});
it("deletes ranges while preserving exact remaining compound segments", () => {
const document = fixture();
const record = document.records[0];
const result = deleteSequenceRange(document, record?.id ?? "", 4, 6);
const compound = result.record.features.find(
({ type }) => type === "misc_feature",
);
expect(result.record.length).toBe((record?.length ?? 0) - 3);
expect(compound).toMatchObject({
segments: [
{ start: 1, end: 3 },
{ start: 6, end: 9 },
],
translationTrackReliable: false,
});
});
it("refuses partial feature replacement instead of fabricating remapped coordinates", () => {
const document = fixture();
const record = document.records[0];
expect(() =>
replaceSequenceRange(document, record?.id ?? "", 8, 12, "AAAAA"),
).toThrow("partially overlaps");
});
it("reverse-complements a bounded feature and flips strand/segments", () => {
const document = fixture();
const record = document.records[0];
const withoutCompound = deleteSequenceFeature(
document,
record?.id ?? "",
record?.features.find(({ type }) => type === "misc_feature")?.id ?? "",
);
const result = reverseComplementSequenceRange(
withoutCompound,
record?.id ?? "",
10,
18,
);
expect(result.record.features.find(({ id }) => id.includes("gene"))).toMatchObject({
start: 10,
end: 18,
strand: "-",
translationTrackReliable: false,
});
});
it("rotates circular records and represents origin-spanning features with exact segments", () => {
const document = fixture(true);
const record = document.records[0];
const result = rotateCircularSequence(document, record?.id ?? "", 15);
const gene = result.record.features.find(({ id }) => id.includes("gene"));
expect(result.record.sequence).toBe(
`${record?.sequence.slice(14)}${record?.sequence.slice(0, 14)}`,
);
expect(gene?.segments).toEqual([
expect.objectContaining({ start: 14, end: 18 }),
expect.objectContaining({ start: 1, end: 4 }),
]);
});
it("adds, updates, and deletes annotations without changing sequence quality", () => {
const document = fixture();
const record = document.records[0];
const feature = {
end: 5,
id: "user-note",
label: "candidate motif",
qualifiers: { note: "created in workbench" },
start: 2,
strand: "+" as const,
type: "misc_feature",
};
const added = addSequenceFeature(document, record?.id ?? "", feature);
const updated = updateSequenceFeature(
added,
record?.id ?? "",
feature.id,
{ label: "confirmed motif" },
);
const deleted = deleteSequenceFeature(updated, record?.id ?? "", feature.id);
expect(updated.records[0]?.features.find(({ id }) => id === feature.id)?.label).toBe(
"confirmed motif",
);
expect(deleted.records[0]?.features.some(({ id }) => id === feature.id)).toBe(
false,
);
expect(updated.records[0]?.sequence).toBe(record?.sequence);
expect(Object.hasOwn(added.records[0]!, "quality")).toBe(false);
expect(Object.hasOwn(updated.records[0]!, "quality")).toBe(false);
expect(Object.hasOwn(deleted.records[0]!, "quality")).toBe(false);
});
it("reverse-complements genuine RNAcentral RNA without introducing DNA thymine", () => {
const document = parseSequenceDocument({
contents: PUBLIC_RNACENTRAL_FASTA,
fileName: "RF02976.fasta",
});
const record = document.records[0]!;
expect(record.molecule).toBe("rna");
const edited = reverseComplementSequenceRange(document, record.id, 1, 12);
expect(edited.record.sequence.slice(0, 12)).toBe("CCCCUAUCUCGG");
expect(edited.record.sequence).not.toContain("T");
expect(edited.record.molecule).toBe("rna");
expect(record.sequence.slice(0, 12)).toBe("CCGAGAUAGGGG");
});
it("preserves RNA IUPAC ambiguity symbols and molecule-aware complements", () => {
const document = parseSequenceDocument({
contents: ">RNA-AMBIGUOUS\nAUGCRYN\n",
fileName: "rna.fasta",
});
const record = document.records[0]!;
expect(record.molecule).toBe("rna");
expect(
reverseComplementSequenceRange(document, record.id, 1, record.length)
.record.sequence,
).toBe("NRYGCAU");
});
it.each([
"U",
"😈",
"é",
"ı",
"ſ",
"A",
"A\nC",
"A\tC",
"A\u0000C",
"A\u007fC",
"A0",
"A;",
"A-",
])(
"rejects invalid DNA edit residues %j without modifying the source",
(sequence) => {
const document = fixture();
const record = document.records[0]!;
const source = structuredClone(document);
expect(() => insertSequence(document, record.id, 1, sequence)).toThrow(
/not valid for the dna molecular alphabet/,
);
expect(document).toEqual(source);
},
);
it("accepts DNA IUPAC ambiguity residues but rejects RNA uracil", () => {
const document = fixture();
const record = document.records[0]!;
expect(insertSequence(document, record.id, 1, "ryswkmbdhvn").record.sequence)
.toMatch(/^RYSWKMBDHVN/);
expect(() => replaceSequenceRange(document, record.id, 1, 1, "U")).toThrow(
/not valid for the dna molecular alphabet/,
);
});
it("accepts RNA IUPAC residues and rejects DNA thymine", () => {
const document = parseSequenceDocument({
contents: PUBLIC_RNACENTRAL_FASTA,
fileName: "RF02976.fasta",
});
const record = document.records[0]!;
expect(insertSequence(document, record.id, 1, "u r y n").record.sequence)
.toMatch(/^URYN/);
expect(() => insertSequence(document, record.id, 1, "T")).toThrow(
/not valid for the rna molecular alphabet/,
);
expect(() => replaceSequenceRange(document, record.id, 1, 1, "T")).toThrow(
/not valid for the rna molecular alphabet/,
);
});
it("validates protein symbols without accepting punctuation, digits, or Unicode", () => {
const document = parseSequenceDocument({
contents: ">KRAS-PROTEIN\nMTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYR\n",
fileName: "kras.faa",
});
const record = document.records[0]!;
expect(record.molecule).toBe("protein");
expect(insertSequence(document, record.id, 1, "bxzjuo*").record.sequence)
.toMatch(/^BXZJUO\*/);
for (const invalid of ["1", ".", "-", "🧬", "ß", "ı", "ſ"]) {
expect(() => insertSequence(document, record.id, 1, invalid)).toThrow(
/not valid for the protein molecular alphabet/,
);
}
});
it("rejects unknown alphabets and mixed thymine/uracil ambiguity safely", () => {
const ambiguous = parseSequenceDocument({
contents: ">QA-AMBIGUOUS-NUCLEOTIDE-CONTROL\nACGN\n",
fileName: "ambiguous.fasta",
});
const record = ambiguous.records[0]!;
expect(record.molecule).toBe("nucleic-acid-ambiguous");
expect(() => insertSequence(ambiguous, record.id, 1, "TU")).toThrow(
/cannot mix DNA thymine and RNA uracil/,
);
const unknown = {
...ambiguous,
records: [{ ...record, molecule: "unknown" as const }],
};
expect(() => insertSequence(unknown, record.id, 1, "A")).toThrow(
/molecular alphabet is unknown/,
);
});
it("retains actual FASTQ qualities for equal-length edits and refreshes real QC", () => {
const document = fastqFixture();
const record = document.records[0]!;
const edited = replaceSequenceRange(document, record.id, 1, 5, "GGGGG");
expect(edited.record.sequence.slice(0, 5)).toBe("GGGGG");
expect(edited.record.quality).toEqual(record.quality);
expect(edited.record.quality?.ascii).toBe(";;3;;;;;;;;;;;;7;;;;;;;88");
expect(edited.record.quality?.phred).toHaveLength(edited.record.length);
expect(buildFastqExport(edited.record)).toBe(
`@EAS54_6_R1_2_1_413_324\n${edited.record.sequence}\n+\n;;3;;;;;;;;;;;;7;;;;;;;88`,
);
expect(edited.document.fastqSummary?.readCount).toBe(3);
expect(edited.document.fastqSummary?.totalBases).toBe(
document.fastqSummary?.totalBases,
);
expect(edited.document.fastqSummary?.meanQuality).toBe(
document.fastqSummary?.meanQuality,
);
expect(edited.document.fastqSummary?.gcFraction).toBeGreaterThan(
document.fastqSummary?.gcFraction ?? 0,
);
expect(record.sequence).toBe("CCCTTCTTGTCTTCAGCGTTTCTCC");
});
it("deletes measured FASTQ qualities at exactly the deleted base positions", () => {
const document = fastqFixture();
const record = document.records[0]!;
const originalQuality = record.quality!;
const edited = deleteSequenceRange(document, record.id, 2, 4);
expect(edited.record.quality?.ascii).toBe(
`${originalQuality.ascii.slice(0, 1)}${originalQuality.ascii.slice(4)}`,
);
expect(edited.record.quality?.phred).toEqual([
...originalQuality.phred.slice(0, 1),
...originalQuality.phred.slice(4),
]);
expect(edited.record.quality?.ascii).toHaveLength(edited.record.length);
expect(edited.document.fastqSummary?.totalBases).toBe(
(document.fastqSummary?.totalBases ?? 0) - 3,
);
expect(record.quality).toEqual(originalQuality);
});
it("reverses only the quality scores of reverse-complemented FASTQ bases", () => {
const document = fastqFixture();
const record = document.records[0]!;
const originalQuality = record.quality!;
const edited = reverseComplementSequenceRange(document, record.id, 2, 9);
expect(edited.record.quality?.ascii).toBe(
`${originalQuality.ascii.slice(0, 1)}${[...originalQuality.ascii.slice(1, 9)].reverse().join("")}${originalQuality.ascii.slice(9)}`,
);
expect(edited.record.quality?.phred).toEqual([
...originalQuality.phred.slice(0, 1),
...originalQuality.phred.slice(1, 9).reverse(),
...originalQuality.phred.slice(9),
]);
expect(edited.document.fastqSummary?.meanQuality).toBe(
document.fastqSummary?.meanQuality,
);
});
it("rejects FASTQ operations that would invent or discard measured qualities", () => {
const document = fastqFixture();
const record = document.records[0]!;
const source = structuredClone(document);
expect(() => insertSequence(document, record.id, 2, "A")).toThrow(
/without measured quality scores/,
);
expect(() => replaceSequenceRange(document, record.id, 2, 3, "A")).toThrow(
/without measured quality scores/,
);
expect(() => replaceSequenceRange(document, record.id, 2, 3, "AAA")).toThrow(
/without measured quality scores/,
);
expect(document).toEqual(source);
});
it("rejects edits of oversized FASTQ reads without retained base qualities", () => {
const document = fastqFixture();
const { quality: _quality, ...unscored } = document.records[0]!;
const oversized = {
...document,
records: [unscored, ...document.records.slice(1)],
};
expect(() => replaceSequenceRange(oversized, unscored.id, 1, 1, "A"))
.toThrow(/quality scores were not retained/);
});
it("rejects mismatched sequence and quality positions before changing a FASTQ read", () => {
const document = fastqFixture();
const record = document.records[0]!;
const corrupted = {
...document,
records: [
{
...record,
quality: {
ascii: record.quality!.ascii.slice(1),
phred: record.quality!.phred.slice(1),
},
},
...document.records.slice(1),
],
};
expect(() => deleteSequenceRange(corrupted, record.id, 1, 1)).toThrow(
/quality scores do not align/,
);
});
it("preserves exact whole-file QC when editing a partially retained FASTQ collection", () => {
const document = fastqFixture();
const record = document.records[0]!;
const partial = {
...document,
recordInventory: {
materializedCount: 1,
totalCount: 3,
truncated: true,
},
records: [record],
};
const edited = replaceSequenceRange(partial, record.id, 1, 5, "GGGGG");
const complete = replaceSequenceRange(document, record.id, 1, 5, "GGGGG");
expect(edited.document.fastqSummary).toEqual(complete.document.fastqSummary);
expect(() => deleteSequenceRange(partial, record.id, 2, 3)).toThrow(
/partially materialized FASTQ collection/,
);
});
it("rotates actual quality positions with a quality-scored circular sequence", () => {
const source = fastqFixture();
const record = { ...source.records[0]!, topology: "circular" as const };
const document = {
...source,
records: [record, ...source.records.slice(1)],
};
const edited = rotateCircularSequence(document, record.id, 4);
expect(edited.record.quality?.ascii).toBe(
`${record.quality!.ascii.slice(3)}${record.quality!.ascii.slice(0, 3)}`,
);
expect(edited.record.quality?.phred).toEqual([
...record.quality!.phred.slice(3),
...record.quality!.phred.slice(0, 3),
]);
expect(edited.document.fastqSummary).toEqual(source.fastqSummary);
});
it("omits absent feature and quality fields from durable annotation edits", () => {
const document = fixture();
const record = document.records[0]!;
const annotated = addSequenceFeature(document, record.id, {
end: 5,
id: "undefined-optional-fields",
label: undefined,
qualifiers: {},
segments: undefined,
start: 2,
strand: "+",
translationCoordinateMap: undefined,
type: "misc_feature",
});
const feature = annotated.records[0]!.features.find(
({ id }) => id === "undefined-optional-fields",
)!;
expect(Object.hasOwn(annotated.records[0]!, "quality")).toBe(false);
expect(Object.hasOwn(feature, "label")).toBe(false);
expect(Object.hasOwn(feature, "segments")).toBe(false);
expect(Object.hasOwn(feature, "translationCoordinateMap")).toBe(false);
expect(JSON.parse(JSON.stringify(annotated.records[0]!))).toEqual(
annotated.records[0],
);
});
it("keeps measured qualities when only annotations are added, updated, or deleted", () => {
const document = fastqFixture();
const record = document.records[0]!;
const feature = {
end: 5,
id: "fastq-annotation",
qualifiers: {},
start: 2,
strand: "+" as const,
type: "misc_feature",
};
const added = addSequenceFeature(document, record.id, feature);
const updated = updateSequenceFeature(added, record.id, feature.id, {
label: "validated",
});
const removed = deleteSequenceFeature(updated, record.id, feature.id);
expect(added.records[0]?.quality).toEqual(record.quality);
expect(updated.records[0]?.quality).toEqual(record.quality);
expect(removed.records[0]?.quality).toEqual(record.quality);
expect(removed.fastqSummary).toEqual(document.fastqSummary);
});
});
function fastqFixture() {
return parseSequenceDocument({
contents: PUBLIC_SANGER_FASTQ,
fileName: "example.fastq",
});
}
function chromatogramFixture() {
// Synthetic trace control for coordinate integrity, not an experimental read.
const document = parseSequenceDocument({
contents: ">QA-SYNTHETIC-TRACE\nATGCATGA\n",
fileName: "synthetic-trace.fasta",
});
const record = document.records[0]!;
document.format = "abif";
document.kind = "chromatogram";
record.topology = "circular";
record.chromatogram = {
channels: {
A: [0, 10, 0, 0, 0, 20, 0, 0],
C: [0, 0, 0, 30, 0, 0, 0, 0],
G: [0, 0, 40, 0, 0, 0, 50, 0],
T: [0, 0, 0, 0, 60, 0, 0, 70],
},
format: "abif",
peakLocations: [0, 1, 2, 3, 4, 5, 6, 7],
quality: [20, 21, 22, 23, 24, 25, 26, 27],
qualityEncoding: "phred",
sampleCount: 8,
};
return document;
}
function fixture(circular = false) {
return parseSequenceDocument({
contents: `LOCUS EDIT 18 bp DNA ${
circular ? "circular" : "linear"
}
ACCESSION EDIT1
FEATURES Location/Qualifiers
misc_feature join(1..6,9..12)
/label="compound"
gene 10..18
/gene="example"
ORIGIN
1 atgcgtacgttagcctaa
//`,
fileName: "edit.gb",
});
}
SHA-256: c45efbe2ccb399d67b34dcd802958f62718a407252d3ac02d16adb8c50be5c12