Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
45 changes: 43 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
import { demoGenome } from "@/lib/mocks/demoGenome";
import type { GenomeData, PrimerDesignResponseUI } from "@/types";
import { useViewStore } from "@/store/useViewStore";
import {
getInvalidStep1TemplateSequenceChar,
normalizeStep1TemplateSequence,
} from "../src/lib/parsers/step1TemplateSequence";
import Step1TemplateEssential from "@/components/steps/Step1TemplateEssential";
import Step2PrimerProperties from "@/components/steps/Step2PrimerProperties";
import Step3BindingLocation from "@/components/steps/Step3BindingLocation";
Expand Down Expand Up @@ -56,13 +60,37 @@ export default function Home() {
const [isLoading, setIsLoading] = useState(false);
const [apiResult, setApiResult] = useState<PrimerDesignResponseUI | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [step1WarningMessage, setStep1WarningMessage] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const totalSteps = steps.length;
const handleStepChange = (next: number) => {
const clamped = Math.min(Math.max(next, 1), totalSteps) as 1 | 2 | 3 | 4;
setStep(clamped);
};
const handleNext = () => handleStepChange(step + 1);
const clearStep1Warning = () => {
if (step1WarningMessage) {
setStep1WarningMessage(null);
}
};
const validateStep1Sequence = () => {
const invalidChar = getInvalidStep1TemplateSequenceChar(sequenceInputRef.current);
if (!invalidChar) {
setStep1WarningMessage(null);
return true;
}

const warningMessage = `대소문자 구분 없이 A, T, G, C만 입력 가능합니다. 잘못된 문자를 제거해 주세요.`;
setStep1WarningMessage(warningMessage);
alert(warningMessage);
return false;
};
const handleNext = () => {
if (step === 1 && !validateStep1Sequence()) {
return;
}

handleStepChange(step + 1);
};
const handleBack = () => handleStepChange(step - 1);
const isLastStep = step === totalSteps;

Expand All @@ -74,11 +102,22 @@ export default function Home() {
);

const handleGenerate = async () => {
const inputSequence = sequenceInputRef.current;
const rawInputSequence = sequenceInputRef.current;
const inputSequence = normalizeStep1TemplateSequence(rawInputSequence);
if (rawInputSequence.trim().length > 0 && inputSequence.length === 0) {
const warningMessage =
"전송할 수 있는 유효한 염기서열이 없습니다. A, T, G, C 문자만 입력해 주세요.";
setStep1WarningMessage(warningMessage);
setErrorMessage(warningMessage);
alert(warningMessage);
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동일한 경고 메시지를 여러 상태에 설정하고 있습니다 (110-112번 줄). setStep1WarningMessage, setErrorMessage, 그리고 alert로 세 번 표시되는데, 이는 사용자에게 혼란을 줄 수 있습니다. 각 메커니즘의 역할을 명확히 하거나, 불필요한 중복을 제거해 주세요.

Suggested change
setErrorMessage(warningMessage);
alert(warningMessage);

Copilot uses AI. Check for mistakes.
return;
Comment on lines 111 to +131
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자가 Step 1에서 다음 단계로 이동할 때와 Generate 버튼을 클릭할 때 중복된 검증 로직이 실행됩니다. handleNext (88번 줄)와 handleGenerate (105-113번 줄) 모두에서 유효성 검증을 수행하는데, 이는 일관성이 부족하고 유지보수를 어렵게 만듭니다. 검증 로직을 한 곳에서 수행하거나, 각각의 검증 목적이 다르다면 주석으로 명확히 구분해 주세요.

Copilot uses AI. Check for mistakes.
}

const targetSeq =
inputSequence && inputSequence.trim().length > 0
? inputSequence.trim()
: "ATGCGTACGTAGCTAGCTAGCTAGCTAATGCGTACGTAGCTAGCTAGCTAGCTA";

const payload: AnalyzeRequestInput = {
target_sequence: targetSeq,
species: "Homo sapiens",
Expand Down Expand Up @@ -149,6 +188,8 @@ export default function Home() {
{step === 1 && (
<Step1TemplateEssential
sequenceRef={sequenceInputRef}
validationMessage={step1WarningMessage}
onSequenceChange={clearStep1Warning}
/>
)}

Expand Down
110 changes: 90 additions & 20 deletions components/steps/Step1TemplateEssential.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@
import {
type ClipboardEvent,
type ChangeEvent,
type FormEvent,
type MutableRefObject,
useEffect,
useRef,
useState,
} from "react";
import { SlidersHorizontal } from "lucide-react";
import {
getInvalidStep1TemplateSequenceChars,
sanitizeStep1TemplateSequenceInput,
} from "../../src/lib/parsers/step1TemplateSequence";

type Step1TemplateEssentialProps = {
sequenceRef: MutableRefObject<string>;
validationMessage?: string | null;
onSequenceChange?: (value: string) => void;
};

const countAlphabeticChars = (value: string) => {
Expand Down Expand Up @@ -47,6 +54,8 @@ const getPreviewValue = (value: string) => {

export default function Step1TemplateEssential({
sequenceRef,
validationMessage,
onSequenceChange,
}: Step1TemplateEssentialProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
Expand Down Expand Up @@ -77,6 +86,7 @@ export default function Step1TemplateEssential({

const updateSequence = (value: string, countMode: "defer" | "immediate" = "defer") => {
sequenceRef.current = value;
onSequenceChange?.(value);
const nextIsLargeSequence = value.length > MAX_EDITOR_CHARS;
setIsLargeSequenceMode(nextIsLargeSequence);

Expand Down Expand Up @@ -104,21 +114,63 @@ export default function Step1TemplateEssential({

const focusTextarea = () => textareaRef.current?.focus();

const appendSequence = (next: string) => {
const sanitized = next.trim();
const confirmInvalidRemoval = (rawSequence: string) => {
const invalidChars = getInvalidStep1TemplateSequenceChars(rawSequence);
if (invalidChars.length === 0) return true;

const previewInvalidChars = invalidChars.slice(0, 8).join(", ");
const extraKinds = invalidChars.length > 8 ? ` 외 ${invalidChars.length - 8}종` : "";
return window.confirm(
`붙여넣으려는 데이터에 A, T, G, C 이외 문자가 포함되어 있습니다 (${previewInvalidChars}${extraKinds}). 해당 문자를 제거하고 계속할까요?`,
);
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자 경험 개선을 위해 경고 메시지를 window.alertwindow.confirm 대신 더 현대적인 UI 컴포넌트(예: toast, modal, inline alert)를 사용하는 것을 고려해 주세요. 현재 alertconfirm은 브라우저 네이티브 대화상자를 사용하여 UX가 일관되지 않고, 스타일링이 불가능하며, 모바일에서 사용성이 떨어집니다.

Copilot uses AI. Check for mistakes.
};

const appendSequence = (
next: string,
options: { requireInvalidRemovalConsent?: boolean } = {},
) => {
if (options.requireInvalidRemovalConsent && !confirmInvalidRemoval(next)) {
focusTextarea();
return;
}

if (!sanitized) {
const sanitizedChunk = sanitizeStep1TemplateSequenceInput(next);
if (!sanitizedChunk) {
focusTextarea();
return;
}

const currentValue = sequenceRef.current;
const appendedValue = currentValue ? `${currentValue}\n${sanitized}` : sanitized;
const appendedValue = `${currentValue}${sanitizedChunk}`;
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 로직이 변경되었습니다. 이전에는 currentValue와 새로운 내용 사이에 개행 문자(\n)를 추가했지만, 현재는 바로 연결합니다. 이 변경이 의도된 것인지 확인하세요. 여러 파일을 연속으로 업로드하거나 붙여넣을 때 시퀀스가 구분 없이 합쳐질 수 있습니다.

Suggested change
const appendedValue = `${currentValue}${sanitizedChunk}`;
const needsSeparator =
currentValue.length > 0 &&
!currentValue.endsWith("\n") &&
!sanitizedChunk.startsWith("\n");
const appendedValue = needsSeparator
? `${currentValue}\n${sanitizedChunk}`
: `${currentValue}${sanitizedChunk}`;

Copilot uses AI. Check for mistakes.

updateSequence(appendedValue, "immediate");
focusTextarea();
};

const insertSanitizedChunkAtSelection = (
textarea: HTMLTextAreaElement,
rawChunk: string,
) => {
const sanitizedChunk = sanitizeStep1TemplateSequenceInput(rawChunk);
const currentValue = sequenceRef.current;
const selectionStart = textarea.selectionStart ?? currentValue.length;
const selectionEnd = textarea.selectionEnd ?? currentValue.length;
const nextValue =
currentValue.slice(0, selectionStart) +
sanitizedChunk +
currentValue.slice(selectionEnd);

if (nextValue !== currentValue) {
updateSequence(nextValue, "immediate");
}

if (textareaRef.current && nextValue.length <= MAX_EDITOR_CHARS) {
const cursor = selectionStart + sanitizedChunk.length;
textareaRef.current.selectionStart = cursor;
textareaRef.current.selectionEnd = cursor;
}
};

const handleUploadClick = () => fileInputRef.current?.click();

const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
Expand All @@ -128,7 +180,7 @@ export default function Step1TemplateEssential({

try {
const text = await file.text();
appendSequence(text);
appendSequence(text, { requireInvalidRemovalConsent: true });
} catch (error) {
console.error("Failed to read FASTA file", error);
} finally {
Expand All @@ -144,7 +196,7 @@ export default function Step1TemplateEssential({

try {
const text = await navigator.clipboard.readText();
appendSequence(text);
appendSequence(text, { requireInvalidRemovalConsent: true });
} catch (error) {
console.error("Failed to read from clipboard", error);
}
Expand All @@ -160,27 +212,37 @@ export default function Step1TemplateEssential({

const pastedText = event.clipboardData.getData("text");
if (!pastedText) return;
if (!confirmInvalidRemoval(pastedText)) {
focusTextarea();
return;
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event.preventDefault()가 사용자 확인(confirm) 전에 호출되어 잘못된 동작이 발생합니다. 현재 211번 줄에서 기본 paste 동작을 차단한 후, 215번 줄에서 사용자에게 확인을 요청합니다. 사용자가 "취소"를 클릭하면 아무것도 붙여넣어지지 않습니다 (기본 동작도, 정제된 데이터도 아님). event.preventDefault()를 215번 줄 확인 이후로 이동하여, 사용자가 확인한 경우에만 기본 동작을 차단하고 정제된 데이터를 삽입하도록 수정해 주세요.

Copilot uses AI. Check for mistakes.
}
insertSanitizedChunkAtSelection(event.currentTarget, pastedText);
};

const currentValue = sequenceRef.current;
const selectionStart = event.currentTarget.selectionStart ?? currentValue.length;
const selectionEnd = event.currentTarget.selectionEnd ?? currentValue.length;
const nextValue =
currentValue.slice(0, selectionStart) +
pastedText +
currentValue.slice(selectionEnd);
const handleTextareaBeforeInput = (event: FormEvent<HTMLTextAreaElement>) => {
if (isLargeSequenceMode || event.currentTarget.readOnly) {
event.preventDefault();
return;
}

updateSequence(nextValue, "immediate");
const nativeEvent = event.nativeEvent as InputEvent;
const inputType = nativeEvent.inputType ?? "";
if (inputType === "insertFromPaste") return;
if (!inputType.startsWith("insert")) return;

if (textareaRef.current && nextValue.length <= MAX_EDITOR_CHARS) {
const cursor = selectionStart + pastedText.length;
textareaRef.current.selectionStart = cursor;
textareaRef.current.selectionEnd = cursor;
}
const rawChunk = nativeEvent.data ?? "";
if (!rawChunk) return;

const sanitizedChunk = sanitizeStep1TemplateSequenceInput(rawChunk);
if (sanitizedChunk === rawChunk) return;

event.preventDefault();
insertSanitizedChunkAtSelection(event.currentTarget, rawChunk);
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 210에서 sanitizedChunk === rawChunk 조건이 역설적입니다. 이 조건이 true일 때 함수가 early return하므로, 실제로는 입력이 이미 유효할 때만 반환하고 무효한 입력만 처리합니다. 그러나 line 209에서 sanitizeStep1TemplateSequenceInput이 FASTA 헤더와 공백도 제거하므로, 유효한 ATGC 입력이더라도 공백이 포함되어 있으면 불필요하게 처리됩니다. 로직을 재검토하세요: 유효한 입력은 그대로 통과시키고, 무효한 입력만 sanitize해야 합니다.

Suggested change
insertSanitizedChunkAtSelection(event.currentTarget, rawChunk);
insertSanitizedChunkAtSelection(event.currentTarget, sanitizedChunk);

Copilot uses AI. Check for mistakes.
};

const handleTextareaChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
if (isLargeSequenceMode || event.currentTarget.readOnly) return;
updateSequence(event.currentTarget.value);
updateSequence(sanitizeStep1TemplateSequenceInput(event.currentTarget.value));
};

const handleCleanClick = () => {
Expand Down Expand Up @@ -221,6 +283,7 @@ export default function Step1TemplateEssential({
readOnly={isLargeSequenceMode}
defaultValue={getPreviewValue(sequenceRef.current)}
onChange={handleTextareaChange}
onBeforeInput={handleTextareaBeforeInput}
onPaste={handleTextareaPaste}
/>
</div>
Expand All @@ -231,6 +294,13 @@ export default function Step1TemplateEssential({
shows only a preview.
</p>
)}
<p className="mt-2 text-[11px] text-slate-500">
A, T, G, C 이외 문자는 입력 시 자동으로 제거됩니다. Paste 버튼,
Ctrl+V, Upload FASTA에서는 제거 전에 확인을 요청합니다.
</p>
{validationMessage && (
<p className="mt-2 text-xs text-red-300">{validationMessage}</p>
)}
</label>
<div className="flex flex-wrap gap-2 justify-end">
<input
Expand Down
Empty file removed src/lib/parsers/.gitkeep
Empty file.
40 changes: 40 additions & 0 deletions src/lib/parsers/step1TemplateSequence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const FASTA_HEADER_PREFIX = ">";
const ATGC_ONLY_PATTERN = /^[ATGC]*$/i;
const UPPERCASE_ATGC_ONLY_PATTERN = /^[ATGC]+$/;
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정규식 패턴이 빈 문자열을 거부합니다. + 수량자는 최소 1개 이상의 문자를 요구하지만, 빈 시퀀스도 유효한 입력으로 처리되어야 합니다. *로 변경하여 빈 문자열도 허용하도록 수정하세요.

Suggested change
const UPPERCASE_ATGC_ONLY_PATTERN = /^[ATGC]+$/;
const UPPERCASE_ATGC_ONLY_PATTERN = /^[ATGC]*$/;

Copilot uses AI. Check for mistakes.
const NON_ATGC_PATTERN = /[^ATGCatgc]/;
const NON_ATGC_GLOBAL_PATTERN = /[^ATGCatgc]/g;

const stripFastaHeadersAndWhitespace = (rawSequence: string) =>
rawSequence
.split(/\r?\n/)
.filter((line) => !line.trim().startsWith(FASTA_HEADER_PREFIX))
.join("")
.replace(/\s+/g, "");

export const toUpperCaseAtgcOnly = (value: string) =>
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 함수가 export 되어 있지만 코드베이스 어디에서도 사용되지 않습니다. 불필요한 export는 제거하거나, 실제로 사용할 계획이 있다면 해당 위치에서 사용하세요.

Suggested change
export const toUpperCaseAtgcOnly = (value: string) =>
const toUpperCaseAtgcOnly = (value: string) =>

Copilot uses AI. Check for mistakes.
ATGC_ONLY_PATTERN.test(value) ? value.toUpperCase() : value;
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toUpperCaseAtgcOnly 함수가 정의되어 있지만 코드베이스에서 실제로 사용되지 않고 있습니다. 사용되지 않는 export를 제거하거나, 실제로 사용할 계획이 있다면 해당 위치에서 사용하도록 수정해 주세요.

Copilot uses AI. Check for mistakes.

export const sanitizeStep1TemplateSequenceInput = (rawSequence: string) =>
stripFastaHeadersAndWhitespace(rawSequence)
.replace(NON_ATGC_GLOBAL_PATTERN, "")
.toUpperCase();

Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 함수는 단순히 sanitizeStep1TemplateSequenceInput을 호출하는 wrapper입니다. 중복 레이어를 제거하고 직접 sanitizeStep1TemplateSequenceInput을 사용하거나, 이 함수가 향후 다른 로직을 추가할 계획이 있다면 주석으로 명시하세요.

Suggested change
// NOTE: 이 함수는 step1 템플릿 시퀀스에 대한 "정규화(normalize)" 공개 API입니다.
// 현재 구현은 sanitize 로직에만 위임하지만, 향후 추가적인 정규화/검증 단계가
// 필요해질 수 있으므로 별도 함수로 유지합니다.

Copilot uses AI. Check for mistakes.
export const normalizeStep1TemplateSequence = (rawSequence: string) =>
sanitizeStep1TemplateSequenceInput(rawSequence);

Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isUppercaseAtgcOnlySequence 함수가 정의되어 있지만 코드베이스에서 사용되지 않고 있습니다. 사용하지 않는 export를 제거하거나, 향후 사용할 계획이 있다면 그 의도를 주석으로 명확히 해 주세요.

Suggested change
// NOTE: 현재 코드베이스에서는 사용되지 않지만, 시퀀스 입력 검증/밸리데이션 로직에서
// 재사용하기 위해 의도적으로 export를 유지합니다.

Copilot uses AI. Check for mistakes.
export const isUppercaseAtgcOnlySequence = (value: string) =>
UPPERCASE_ATGC_ONLY_PATTERN.test(value);

export const getInvalidStep1TemplateSequenceChars = (rawSequence: string) => {
const matches = stripFastaHeadersAndWhitespace(rawSequence).match(NON_ATGC_GLOBAL_PATTERN);
if (!matches) return [];

const uniqueChars = new Set<string>();
for (const char of matches) {
uniqueChars.add(char);
}
return [...uniqueChars];
};

export const getInvalidStep1TemplateSequenceChar = (rawSequence: string) =>
stripFastaHeadersAndWhitespace(rawSequence).match(NON_ATGC_PATTERN)?.[0] ?? null;
Comment on lines +1 to +32
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

새로 추가된 파서 함수들에 대한 단위 테스트가 없습니다. 코드베이스에 vitest를 사용한 테스트 인프라가 있으므로(tests/visibleRange.test.ts 참조), 다음과 같은 케이스를 테스트하는 파일을 추가하세요: 1) 소문자 입력의 대문자 변환, 2) 잘못된 문자 제거, 3) FASTA 헤더 제거, 4) 빈 문자열 처리, 5) 유효한 시퀀스 검증.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +32
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트 커버리지가 부족합니다. 새로 추가된 step1TemplateSequence.ts 파일의 함수들(sanitizeStep1TemplateSequenceInput, getInvalidStep1TemplateSequenceChars, getInvalidStep1TemplateSequenceChar 등)에 대한 단위 테스트를 추가해 주세요. 특히 FASTA 헤더 제거, 공백 처리, 잘못된 문자 감지, 대소문자 변환 등 핵심 기능에 대한 테스트가 필요합니다.

Suggested change
export const getInvalidStep1TemplateSequenceChar = (rawSequence: string) =>
stripFastaHeadersAndWhitespace(rawSequence).match(NON_ATGC_PATTERN)?.[0] ?? null;
export const getInvalidStep1TemplateSequenceChar = (rawSequence: string) => {
const invalidChars = getInvalidStep1TemplateSequenceChars(rawSequence);
return invalidChars.length > 0 ? invalidChars[0] : null;
};

Copilot uses AI. Check for mistakes.