Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/app/features/workflows/shared/fasta.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,52 @@ import {
isValidSmiles,
parseMultiFasta,
validateDnaSequence,
validateFastaHeader,
validateMultiFastaProtein,
validateProteinSequence,
validateRnaSequence,
validateUniqueHeadersAcrossInputs,
} from "./fasta.utils";

describe("fasta.utils", () => {
describe("validateFastaHeader", () => {
it("accepts a simple name", () => {
expect(validateFastaHeader("seq1")).toEqual({ valid: true });
});

it("rejects an empty name", () => {
expect(validateFastaHeader(" ")).toEqual({
valid: false,
errorMessage: "Sequence name is required.",
});
});

it("rejects names containing spaces", () => {
expect(validateFastaHeader("seq 1")).toEqual({
valid: false,
errorMessage: "Sequence name must not contain spaces.",
});
});

it("rejects names containing underscores", () => {
expect(validateFastaHeader("seq_1")).toEqual({
valid: false,
errorMessage: "Sequence name must not contain underscores.",
});
});

it("rejects a name that duplicates an existing name", () => {
expect(validateFastaHeader("seq1", ["seq1", "seq2"])).toEqual({
valid: false,
errorMessage: "Sequence name must be unique.",
});
});

it("trims surrounding whitespace before validating", () => {
expect(validateFastaHeader(" seq1 ")).toEqual({ valid: true });
});
});

describe("validateProteinSequence", () => {
it("accepts valid protein characters", () => {
expect(validateProteinSequence("MKT AYI").valid).toBe(true);
Expand Down
32 changes: 32 additions & 0 deletions src/app/features/workflows/shared/fasta.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,38 @@ export interface MultiFastaValidationResult {
sequenceCount: number;
}

/**
* Validates a single FASTA sequence name — the identifier text that follows
* the ">" character on a header line. The same rules applied when parsing
* multi-FASTA blocks (non-empty, no surrounding whitespace, no embedded
* spaces that would split the ID from a description field).
*/
export function validateFastaHeader(
name: string,
existingNames?: string[]
): SequenceValidationResult {
const trimmed = name.trim();
if (!trimmed) {
return { valid: false, errorMessage: "Sequence name is required." };
}
if (/\s/.test(trimmed)) {
return {
valid: false,
errorMessage: "Sequence name must not contain spaces.",
};
}
if (trimmed.includes("_")) {
return {
valid: false,
errorMessage: "Sequence name must not contain underscores.",
};
}
if (existingNames?.some((n) => n.trim() === trimmed)) {
return { valid: false, errorMessage: "Sequence name must be unique." };
}
return { valid: true };
}

export interface CcdLookupResult {
valid: boolean;
name?: string;
Expand Down
110 changes: 85 additions & 25 deletions src/app/features/workflows/single-prediction/single-prediction.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
cdkDragPreviewContainer="parent"
>
<div
class="grid gap-4 lg:grid-cols-[40px_170px_110px_minmax(0,1fr)_40px] lg:items-start"
class="grid gap-4 lg:grid-cols-[40px_170px_110px_170px_minmax(0,1fr)_40px] lg:items-start"
>
<div class="flex shrink-0 cursor-grab flex-col items-center">
<span class="mb-1 block text-sm font-medium">
Expand Down Expand Up @@ -160,13 +160,50 @@
}
</div>

<div class="min-w-0">
<label
[for]="'sequence-name-' + row.id"
class="mb-1 block text-sm font-medium"
>
Sequence Name <span class="text-red-500">*</span>
</label>

<input
type="text"
[id]="'sequence-name-' + row.id"
[attr.aria-label]="'Sequence name for entity ' + (i + 1)"
[value]="row.name"
(input)="updateRowName(row.id, $any($event.target).value)"
(blur)="touchRowField(row.id, 'name')"
[placeholder]="'seq' + (i + 1)"
[class]="
shouldShowRowFieldError(row, 'name') && getRowErrors(i).name
? 'border-red-500 focus:ring-red-100'
: 'focus:ring-sky-100'
"
class="block h-10 w-full rounded-md border bg-white px-4 font-mono text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 transition-colors"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
/>

@if (shouldShowRowFieldError(row, 'name') &&
getRowErrors(i).name) {
<p class="mt-1 text-xs text-red-600" role="alert">
{{ getRowErrors(i).name }}
</p>
}
</div>

<div class="min-w-0">
<label
[for]="'sequence-' + row.id"
class="mb-1 block text-sm font-medium"
>
Sequence
Sequence <span class="text-red-500">*</span>
</label>

@if (row.moleculeType === 'ccd') {
<app-listbox-select
[inputId]="'sequence-' + row.id"
Expand All @@ -178,32 +215,55 @@
(blurred)="touchRowField(row.id, 'sequence')"
></app-listbox-select>
} @else {
<textarea
[id]="'sequence-' + row.id"
rows="1"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
[value]="row.sequence"
(input)="updateRowSequence(row.id, $any($event.target).value)"
(blur)="touchRowField(row.id, 'sequence')"
<div
class="overflow-hidden rounded-md border transition-colors focus-within:ring-2"
[class]="
shouldShowRowFieldError(row, 'sequence') && getRowErrors(i).sequence
? 'border-red-500 focus:ring-red-100'
: 'focus:ring-sky-100'
? 'border-red-500 focus-within:ring-red-100'
: 'border-gray-200 focus-within:ring-sky-100'
"
class="block min-h-10 max-h-40 w-full resize-none rounded-md border bg-white px-4 py-2 text-sm font-mono break-all text-gray-900 focus:outline-none focus:ring-2 transition-colors field-sizing-content"
[placeholder]="
row.moleculeType === 'ligand'
? 'CC(=O)OC1=CC=CC=C1C(=O)O'
: row.moleculeType === 'dna'
? 'ATGCGT'
: row.moleculeType === 'rna'
? 'AUGCGU'
: 'ACDEFGHIKLMNPQRSTVWY'
"
></textarea>
>
<div class="relative">
<div
class="pointer-events-none absolute inset-0 overflow-hidden break-all px-4 py-2 font-mono text-sm text-gray-900"
aria-hidden="true"
>
@for (cell of getSequenceCells(row.sequence); track
$index) {<span
class="inline-block w-[1ch] align-top text-center mr-0.5"
><span
class="block h-2.5 overflow-visible text-[9px] leading-2.5 whitespace-nowrap text-gray-400 select-none"
>{{ cell.label }}</span
><span class="block leading-5"
>{{ cell.char }}</span
></span
>}
</div>

<textarea
[id]="'sequence-' + row.id"
[attr.aria-label]="'Sequence for entity ' + (i + 1)"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
[value]="row.sequence"
(input)="updateRowSequence(row.id, $any($event.target).value)"
(scroll)="syncSequenceOverlayScroll($event)"
(blur)="touchRowField(row.id, 'sequence')"
class="relative block w-full resize-none bg-transparent px-4 py-2 font-mono text-sm leading-7.5 tracking-[2px] text-transparent caret-gray-900 placeholder-gray-400 focus:outline-none field-sizing-content min-h-10 max-h-40 break-all"
[placeholder]="
row.moleculeType === 'ligand'
? 'CC(=O)OC1=CC=CC=C1C(=O)O'
: row.moleculeType === 'dna'
? 'ATGCGT'
: row.moleculeType === 'rna'
? 'AUGCGU'
: 'ACDEFGHIKLMNPQRSTVWY'
"
></textarea>
</div>
</div>
} @if (shouldShowRowFieldError(row, 'sequence') &&
getRowErrors(i).sequence) {
<p class="mt-1 text-xs text-red-600" role="alert">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ describe("SinglePredictionComponent", () => {
): number {
const rowId = component.entityRows()[0].id;
component.jobName.set("test-run");
component.updateRowName(rowId, "pro");
component.updateRowSequence(rowId, sequence);
component.updateRowCopyNumber(rowId, copyNumber);
component.updateRowMoleculeType(rowId, "protein");
Expand Down Expand Up @@ -214,6 +215,7 @@ describe("SinglePredictionComponent", () => {
it("should validate DNA, RNA, and ligand formats", () => {
const rowId = component.entityRows()[0].id;
component.jobName.set("test-run");
component.updateRowName(rowId, "entity1");
component.selectTool("boltz");

component.updateRowSequence(rowId, "ACGT");
Expand Down Expand Up @@ -245,6 +247,7 @@ describe("SinglePredictionComponent", () => {
it("should mark CCD row valid when code is in the supported list", () => {
const rowId = component.entityRows()[0].id;
component.jobName.set("test-run");
component.updateRowName(rowId, "ccdrow");
component.selectTool("boltz");
component.updateRowMoleculeType(rowId, "ccd");
component.updateRowSequence(rowId, "ATP");
Expand Down Expand Up @@ -356,15 +359,35 @@ describe("SinglePredictionComponent", () => {
it("should allow Boltz with non-protein molecules and generate FASTA-like content", () => {
const rowId = component.entityRows()[0].id;
component.jobName.set("test-run");
component.updateRowName(rowId, "dna");

component.selectTool("boltz");
component.updateRowSequence(rowId, "ACGT");
component.updateRowMoleculeType(rowId, "dna");
component.updateRowCopyNumber(rowId, "2");

expect(component.isStep1Valid()).toBe(true);
expect(component.generatedFastaContent()).toContain(">dna_1");
expect(component.generatedFastaContent()).toContain(">dna_2");
expect(component.generatedFastaContent()).toContain(">dna_1|dna");
expect(component.generatedFastaContent()).toContain(">dna_2|dna");
});

it("should tag generated FASTA headers with the molecule type", () => {
const rowId = component.entityRows()[0].id;
component.jobName.set("test-run");
component.selectTool("boltz");

component.updateRowName(rowId, "seq1");
component.updateRowSequence(rowId, "ACDEFGHIK");
component.updateRowMoleculeType(rowId, "protein");
expect(component.generatedFastaContent()).toBe(">seq1|protein\nACDEFGHIK");

component.updateRowSequence(rowId, "CC(=O)O");
component.updateRowMoleculeType(rowId, "ligand");
expect(component.generatedFastaContent()).toBe(">seq1|smiles\nCC(=O)O");

component.updateRowSequence(rowId, "ATP");
component.updateRowMoleculeType(rowId, "ccd");
expect(component.generatedFastaContent()).toBe(">seq1|ccd\nATP");
});

it("should normalize protein sequence content in summary", () => {
Expand Down Expand Up @@ -619,8 +642,18 @@ describe("SinglePredictionComponent", () => {
expect(errors["copyNumber"]).toContain("greater than or equal to 1");
});

it("should reject sequence names containing underscores", () => {
const rowId = component.entityRows()[0].id;
component.updateRowSequence(rowId, "ACDEFGHIK");
component.updateRowName(rowId, "seq_1");

const errors = component.entityValidationResults()[0];
expect(errors.name).toContain("underscore");
});

it("should require jobName in step 1 validation", () => {
const rowId = component.entityRows()[0].id;
component.updateRowName(rowId, "entity1");
component.updateRowSequence(rowId, "ACDEFGHIK");

component.jobName.set("");
Expand Down Expand Up @@ -668,6 +701,7 @@ describe("SinglePredictionComponent", () => {

it("should keep input-config invalid until jobName is filled", () => {
const rowId = component.entityRows()[0].id;
component.updateRowName(rowId, "entity1");
component.updateRowSequence(rowId, "ACDEFGHIK");
component.jobName.set("");
expect(component.isSectionValid("input-config")).toBe(false);
Expand Down
Loading