← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/sequence/formats/chromatogram.test.ts
8.46 KB · Sep 30, 2026 · 23:01 UTC
import { describe, expect, it } from "vitest";
import {
makeSyntheticAbif,
makeSyntheticScf,
SYNTHETIC_TRACE_CHANNELS,
} from "../__fixtures__/chromatogram";
import {
CHROMATOGRAM_LIMITS,
parseAbifRecord,
parseScfRecord,
} from "./chromatogram";
describe("ABIF Sanger trace decoding", () => {
it("decodes true processed signals in declared dye order, base calls, peak offsets, and Phred bytes", () => {
const bytes = makeSyntheticAbif();
const original = bytes.slice();
const { record, warnings } = parseAbifRecord({
bytes,
fileName: "C:\\private\\sample.ab1",
});
expect(record).toMatchObject({
id: "trace-1",
sequence: "ACGTN",
length: 5,
sourceLabel: "sample.ab1",
topology: "linear",
});
expect(record.chromatogram).toEqual({
channels: SYNTHETIC_TRACE_CHANNELS,
format: "abif",
peakLocations: [2, 5, 8, 11, 14],
quality: [31, 32, 33, 34, 0],
qualityEncoding: "phred",
sampleCount: 16,
});
expect(record.quality).toBeUndefined();
expect(warnings.some(({ code }) => code === "abif-undisplayed-tags")).toBe(
true,
);
expect(bytes).toEqual(original);
});
it("supports inline directory payloads, edited-only revisions, and signed signal baselines", () => {
const channels = {
...SYNTHETIC_TRACE_CHANNELS,
A: [-3, -1, ...SYNTHETIC_TRACE_CHANNELS.A.slice(2)],
};
const { record, warnings } = parseAbifRecord({
bytes: makeSyntheticAbif({
channels,
editedOnly: true,
peaks: [2, 5, 8, 11],
quality: [41, 42, 43, 44],
sequence: "ACGT",
}),
});
expect(record.chromatogram?.channels.A.slice(0, 3)).toEqual([-3, -1, 40]);
expect(record.chromatogram?.quality).toEqual([41, 42, 43, 44]);
expect(record.sequence).toBe("ACGT");
expect(warnings.map(({ code }) => code)).toContain("abif-edited-calls");
});
it("does not invent quality values when PCON is absent", () => {
const parsed = parseAbifRecord({
bytes: makeSyntheticAbif({ quality: null }),
});
expect(parsed.record.chromatogram?.quality).toBeUndefined();
expect(parsed.warnings.map(({ code }) => code)).toContain(
"chromatogram-quality-absent",
);
});
it("accepts reserved unused root-directory slots present in real ABI 310 and 3730 files", () => {
const parsed = parseAbifRecord({
bytes: makeSyntheticAbif({ directoryPaddingEntries: 5 }),
});
expect(parsed.record.sequence).toBe("ACGTN");
expect(parsed.record.chromatogram?.channels).toEqual(
SYNTHETIC_TRACE_CHANNELS,
);
});
it("bounds opaque user-defined tags by their byte size without imposing undefined element metadata", () => {
const bytes = makeSyntheticAbif();
const view = new DataView(bytes.buffer);
const offset = 128 + 8 * 28; // Replace the generated S/N% metadata, not a decoded trace tag.
bytes.set(new TextEncoder().encode("USER"), offset);
view.setUint16(offset + 8, 1024, false);
view.setUint16(offset + 10, 1, false);
view.setUint32(offset + 12, 1, false);
const parsed = parseAbifRecord({ bytes });
expect(parsed.record.chromatogram?.channels).toEqual(
SYNTHETIC_TRACE_CHANNELS,
);
expect(parsed.warnings.map(({ code }) => code)).toContain(
"abif-undisplayed-tags",
);
view.setUint32(offset + 20, bytes.length - 1, false);
expect(() => parseAbifRecord({ bytes })).toThrow(/truncated|outside/u);
});
it.each([
["unsupported version", () => makeSyntheticAbif({ version: 102 })],
["incorrect dye order", () => makeSyntheticAbif({ order: "AAAA" })],
["quality length mismatch", () => makeSyntheticAbif({ quality: [31] })],
[
"peak outside trace",
() => makeSyntheticAbif({ peaks: [2, 5, 8, 11, 16] }),
],
["decreasing peaks", () => makeSyntheticAbif({ peaks: [2, 5, 4, 11, 14] })],
["non-DNA calls", () => makeSyntheticAbif({ sequence: "ACGTX" })],
])("rejects %s", (_, build) => {
expect(() => parseAbifRecord({ bytes: build() })).toThrow();
});
it("rejects truncated payloads, directory count bombs, and overlapping external data", () => {
const bytes = makeSyntheticAbif();
expect(() =>
parseAbifRecord({ bytes: bytes.subarray(0, bytes.length - 1) }),
).toThrow(/truncated|outside/u);
const oversized = bytes.slice();
new DataView(oversized.buffer).setUint32(18, 0xffffffff, false);
expect(() => parseAbifRecord({ bytes: oversized })).toThrow(
/count|directory/u,
);
const overlapping = bytes.slice();
new DataView(overlapping.buffer).setUint32(128 + 28 + 20, 128, false);
expect(() => parseAbifRecord({ bytes: overlapping })).toThrow(/overlaps/u);
});
it("rejects unsupported reverse-complemented source flags", () => {
const bytes = makeSyntheticAbif();
const view = new DataView(bytes.buffer);
const entries = view.getUint32(18, false);
view.setInt16(128 + (entries - 1) * 28 + 20, 1, false);
expect(() => parseAbifRecord({ bytes })).toThrow(/original-orientation/u);
});
});
describe("SCF Sanger trace decoding", () => {
it.each([
["2.00", 1],
["2.00", 2],
["3.00", 1],
["3.00", 2],
] as const)(
"decodes SCF %s with %i-byte samples and correctly associates confidences",
(version, sampleSize) => {
const bytes = makeSyntheticScf({ sampleSize, version });
const original = bytes.slice();
const { record } = parseScfRecord({ bytes, fileName: "sample.scf" });
expect(record.sequence).toBe("ACGTN");
expect(record.chromatogram?.channels).toEqual(SYNTHETIC_TRACE_CHANNELS);
expect(record.chromatogram?.peakLocations).toEqual([2, 5, 8, 11, 14]);
expect(record.chromatogram?.quality).toEqual([31, 32, 33, 34, null]);
expect(record.chromatogram?.qualityEncoding).toBe("source-confidence");
expect(record.chromatogram?.baseConfidences).toEqual({
A: [31, 1, 1, 1, 1],
C: [2, 32, 2, 2, 2],
G: [3, 3, 33, 3, 3],
T: [4, 4, 4, 34, 4],
});
expect(bytes).toEqual(original);
},
);
it.each([1, 2] as const)(
"reconstructs wrapped double-delta arithmetic at %i-byte precision",
(sampleSize) => {
const mask = sampleSize === 1 ? 255 : 65535;
const samples = [mask, 2, mask - 1, 0, 1];
const channels = { A: samples, C: samples, G: samples, T: samples };
const parsed = parseScfRecord({
bytes: makeSyntheticScf({
channels,
peaks: [0, 1, 2, 3, 4],
sampleSize,
}),
});
expect(parsed.record.chromatogram?.channels).toEqual(channels);
},
);
it("reports preserved but uninterpreted comments, private data, and historical clipping", () => {
const bytes = makeSyntheticScf({
comments: "Generated test comment\n",
privateData: Uint8Array.of(1, 2, 3),
sequence: "ACGT-",
});
new DataView(bytes.buffer).setUint32(16, 1, false);
const { record, warnings } = parseScfRecord({ bytes });
expect(record.sequence).toBe("ACGTN");
expect(record.metadata.scf_left_clip).toBe("1");
expect(warnings.map(({ code }) => code)).toEqual(
expect.arrayContaining([
"scf-comments-not-interpreted",
"scf-private-data-not-interpreted",
"scf-clipping-not-applied",
"scf-ambiguous-calls",
]),
);
});
it("rejects truncated, overlapping, oversized, incompatible, or unmappable SCF data", () => {
const source = makeSyntheticScf();
expect(() =>
parseScfRecord({ bytes: source.subarray(0, source.length - 1) }),
).toThrow();
for (const [offset, value] of [
[8, 16],
[4, CHROMATOGRAM_LIMITS.maxSamples + 1],
[12, 0],
[40, 4],
[44, 9],
] as const) {
const bytes = source.slice();
new DataView(bytes.buffer).setUint32(offset, value, false);
expect(() => parseScfRecord({ bytes })).toThrow();
}
const future = source.slice();
future.set(new TextEncoder().encode("3.01"), 36);
expect(() => parseScfRecord({ bytes: future })).toThrow(
/Unsupported SCF version/u,
);
expect(() =>
parseScfRecord({
bytes: makeSyntheticScf({ peaks: [2, 5, 8, 11, 100] }),
}),
).toThrow(/peak/u);
});
it("honors Uint8Array byteOffset for embedded file buffers", () => {
const source = makeSyntheticScf();
const padded = new Uint8Array(source.length + 16);
padded.set(source, 7);
expect(
parseScfRecord({ bytes: padded.subarray(7, 7 + source.length) }).record
.sequence,
).toBe("ACGTN");
});
});
SHA-256: a26733a7731454ba4af4d909588907255a6c32101d9d8c8cc68eadf925001e61