export class TestUtils {
static validateDNASequence(sequence: string): boolean {
return /^[ATGC]+$/i.test(sequence);
}
static validateProteinSequence(sequence: string): boolean {
return /^[ARNDCQEGHILKMFPSTWYV]+$/i.test(sequence);
}
static calculateGCContent(sequence: string): number {
const gcCount = (sequence.match(/[GC]/gi) || []).length;
return gcCount / sequence.length;
}
static calculateHydrophobicRatio(sequence: string): number {
const hydrophobic = new Set(['A', 'V', 'I', 'L', 'M', 'F', 'Y', 'W']);
const hydrophobicCount = sequence.split('').filter(aa => hydrophobic.has(aa.toUpperCase())).length;
return hydrophobicCount / sequence.length;
}
static isWithinTolerance(actual: number, expected: number, tolerance: number): boolean {
return Math.abs(actual - expected) <= tolerance;
}
static generateTestSequence(type: 'dna' | 'protein', length: number, seed?: number): string {
const random = seed ? this.seededRandom(seed) : Math.random;
if (type === 'dna') {
const bases = ['A', 'T', 'G', 'C'];
return Array.from({ length }, () => bases[Math.floor(random() * 4)]).join('');
} else {
const aminoAcids = ['A', 'R', 'N', 'D', 'C', 'Q', 'E', 'G', 'H', 'I', 'L', 'K', 'M', 'F', 'P', 'S', 'T', 'W', 'Y', 'V'];
return Array.from({ length }, () => aminoAcids[Math.floor(random() * 20)]).join('');
}
}
private static seededRandom(seed: number): () => number {
let value = seed;
return () => {
value = Math.sin(value) * 10000;
return value - Math.floor(value);
};
}
static async measureExecutionTime<T>(fn: () => Promise<T>): Promise<{ result: T; timeMs: number }> {
const start = Date.now();
const result = await fn();
const timeMs = Date.now() - start;
return { result, timeMs };
}
static validateFastaFormat(fastaString: string): boolean {
const lines = fastaString.trim().split('\n');
let inSequence = false;
for (const line of lines) {
if (line.startsWith('>')) {
inSequence = true;
} else if (inSequence && line.trim()) {
// Should be sequence data
if (!/^[A-Z]+$/i.test(line)) {
return false;
}
}
}
return true;
}
static parseFastaSequences(fastaString: string): Array<{ id: string; sequence: string }> {
const sequences: Array<{ id: string; sequence: string }> = [];
const lines = fastaString.trim().split('\n');
let currentId = '';
let currentSequence = '';
for (const line of lines) {
if (line.startsWith('>')) {
if (currentId && currentSequence) {
sequences.push({ id: currentId, sequence: currentSequence });
}
currentId = line.substring(1).trim();
currentSequence = '';
} else if (line.trim()) {
currentSequence += line.trim();
}
}
if (currentId && currentSequence) {
sequences.push({ id: currentId, sequence: currentSequence });
}
return sequences;
}
static validateNewickFormat(newick: string): boolean {
// Basic validation for Newick format
const openParens = (newick.match(/\(/g) || []).length;
const closeParens = (newick.match(/\)/g) || []).length;
// Should have balanced parentheses
if (openParens !== closeParens) {
return false;
}
// Should contain colons for branch lengths
if (!newick.includes(':')) {
return false;
}
return true;
}
static countSequenceDifferences(seq1: string, seq2: string): number {
const minLength = Math.min(seq1.length, seq2.length);
let differences = 0;
for (let i = 0; i < minLength; i++) {
if (seq1[i] !== seq2[i]) {
differences++;
}
}
// Add length difference
differences += Math.abs(seq1.length - seq2.length);
return differences;
}
static generateExpectedMutationCount(sequenceLength: number, mutationRate: number): { min: number; max: number } {
const expected = sequenceLength * mutationRate;
const standardDeviation = Math.sqrt(expected);
return {
min: Math.max(0, Math.floor(expected - 2 * standardDeviation)),
max: Math.ceil(expected + 2 * standardDeviation)
};
}
static validateStatisticalDistribution(values: number[], expectedMean: number, tolerance: number = 0.1): boolean {
const actualMean = values.reduce((sum, val) => sum + val, 0) / values.length;
return this.isWithinTolerance(actualMean, expectedMean, tolerance);
}
static validateFastqRecord(record: string): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
const lines = record.trim().split('\n');
if (lines.length !== 4) {
errors.push(`Expected 4 lines, got ${lines.length}`);
return { isValid: false, errors };
}
// Header line
if (!lines[0].startsWith('@')) {
errors.push('Header line must start with @');
}
// Sequence line
if (!/^[ATGCN]+$/i.test(lines[1])) {
errors.push('Sequence line contains invalid characters');
}
// Plus line
if (lines[2] !== '+') {
errors.push('Third line must be single +');
}
// Quality line
if (lines[3].length !== lines[1].length) {
errors.push('Quality line length must match sequence length');
}
// Quality scores validation
for (let i = 0; i < lines[3].length; i++) {
const qScore = lines[3].charCodeAt(i);
if (qScore < 33 || qScore > 126) {
errors.push(`Invalid quality score at position ${i}: ${qScore}`);
break;
}
}
return { isValid: errors.length === 0, errors };
}
static parseFastqRecords(fastqString: string): Array<{ header: string; sequence: string; quality: string }> {
const records: Array<{ header: string; sequence: string; quality: string }> = [];
const lines = fastqString.trim().split('\n');
for (let i = 0; i < lines.length; i += 4) {
if (i + 3 < lines.length) {
records.push({
header: lines[i].substring(1), // Remove @ symbol
sequence: lines[i + 1],
quality: lines[i + 3]
});
}
}
return records;
}
static calculateQualityStatistics(qualityString: string): {
meanQuality: number;
minQuality: number;
maxQuality: number;
qualityDistribution: Record<number, number>;
} {
const qualities = Array.from(qualityString).map(char => char.charCodeAt(0) - 33);
const meanQuality = qualities.reduce((sum, q) => sum + q, 0) / qualities.length;
const minQuality = Math.min(...qualities);
const maxQuality = Math.max(...qualities);
const qualityDistribution: Record<number, number> = {};
qualities.forEach(q => {
qualityDistribution[q] = (qualityDistribution[q] || 0) + 1;
});
return { meanQuality, minQuality, maxQuality, qualityDistribution };
}
static validateInsertSizeDistribution(insertSizes: number[], expectedMean: number, expectedStd: number): {
isValid: boolean;
actualMean: number;
actualStd: number;
errors: string[];
} {
const errors: string[] = [];
if (insertSizes.length === 0) {
errors.push('No insert sizes provided');
return { isValid: false, actualMean: 0, actualStd: 0, errors };
}
const actualMean = insertSizes.reduce((sum, size) => sum + size, 0) / insertSizes.length;
const variance = insertSizes.reduce((sum, size) => sum + Math.pow(size - actualMean, 2), 0) / insertSizes.length;
const actualStd = Math.sqrt(variance);
// Check if mean is within 10% of expected
if (Math.abs(actualMean - expectedMean) / expectedMean > 0.1) {
errors.push(`Mean insert size ${actualMean} differs too much from expected ${expectedMean}`);
}
// Check if standard deviation is reasonable (within 50% of expected)
if (Math.abs(actualStd - expectedStd) / expectedStd > 0.5) {
errors.push(`Standard deviation ${actualStd} differs too much from expected ${expectedStd}`);
}
return {
isValid: errors.length === 0,
actualMean,
actualStd,
errors
};
}
static generateTestReference(gcContent: number = 0.5, length: number = 1000, seed?: number): string {
const random = seed ? this.seededRandom(seed) : Math.random;
const gcProb = gcContent / 2;
const atProb = (1 - gcContent) / 2;
let sequence = '';
for (let i = 0; i < length; i++) {
const rand = random();
if (rand < atProb) {
sequence += 'A';
} else if (rand < atProb * 2) {
sequence += 'T';
} else if (rand < atProb * 2 + gcProb) {
sequence += 'G';
} else {
sequence += 'C';
}
}
return sequence;
}
static countMutationsAndErrors(originalSeq: string, simulatedSeq: string): {
totalDifferences: number;
substitutions: number;
insertions: number;
deletions: number;
} {
let substitutions = 0;
let insertions = 0;
let deletions = 0;
const minLength = Math.min(originalSeq.length, simulatedSeq.length);
// Count substitutions in overlapping region
for (let i = 0; i < minLength; i++) {
if (originalSeq[i] !== simulatedSeq[i]) {
substitutions++;
}
}
// Count insertions/deletions
if (simulatedSeq.length > originalSeq.length) {
insertions = simulatedSeq.length - originalSeq.length;
} else if (originalSeq.length > simulatedSeq.length) {
deletions = originalSeq.length - simulatedSeq.length;
}
return {
totalDifferences: substitutions + insertions + deletions,
substitutions,
insertions,
deletions
};
}
static validateCoverageCalculation(readCount: number, readLength: number, referenceLength: number, expectedCoverage: number): {
isValid: boolean;
actualCoverage: number;
error: string | null;
} {
const actualCoverage = (readCount * readLength) / referenceLength;
const tolerance = Math.max(0.2, expectedCoverage * 0.2); // 20% tolerance or 0.2x, whichever is larger
const isValid = Math.abs(actualCoverage - expectedCoverage) <= tolerance;
const error = isValid ? null : `Coverage ${actualCoverage} differs from expected ${expectedCoverage} by more than ${tolerance}`;
return { isValid, actualCoverage, error };
}
}