feat(snippets): 이름 규칙(영문 대문자·숫자·_, 3~50자)·실시간 중복/저장될 이름 표시, 설명 필수, 검색에 본문 ★머리줄·주석 포함(한글 검색), 메타 일괄 반영 명령 apply_snippet_meta

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-23 13:42:32 +09:00
co-authored by Claude Opus 5.5
parent aa7481ffe9
commit 7a1498a791
10 changed files with 355 additions and 27 deletions
@@ -22,3 +22,60 @@ it("팔레트 재소환으로 편집기를 닫으면 삭제 확인도 사라짐"
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument())
expect(props.onDelete).not.toHaveBeenCalled()
})
it("생성: 타이핑하면 저장될 이름·중복을 바로 보여주고, 정규화된 이름으로 저장", async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<EditDialog
open
mode="create"
onClose={vi.fn()}
onSave={onSave}
isSaving={false}
existingNames={["ALV_FILTER"]}
/>
)
const name = screen.getByLabelText(/이름/)
await user.type(name, "alv-filter")
expect(screen.getByText("이미 있는 이름임: ALV_FILTER")).toBeInTheDocument()
await user.clear(name)
await user.type(name, "alv filter rows")
expect(screen.getByText("저장될 이름: ALV_FILTER_ROWS")).toBeInTheDocument()
await user.type(screen.getByLabelText(/내용/), "WRITE 1.")
await user.click(screen.getByRole("button", { name: "생성" }))
expect(await screen.findByText(/설명을 입력해야 함/)).toBeInTheDocument() // 설명 필수
expect(onSave).not.toHaveBeenCalled()
await user.type(screen.getByLabelText(/설명/), "ALV 행 필터")
await user.click(screen.getByRole("button", { name: "생성" }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ name: "ALV_FILTER_ROWS", desc: "ALV 행 필터" })
})
it("편집: 옛 규칙 이름(IF_SUBRC-ABAP)도 이름 검사 없이 저장 가능", async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<EditDialog
open
mode="edit"
snippet={{
name: "IF_SUBRC-ABAP",
desc: "sy-subrc 분기",
body: "IF sy-subrc = 0.",
category: "코드",
usageCount: 0,
lastUsed: 0,
}}
onClose={vi.fn()}
onSave={onSave}
isSaving={false}
existingNames={["IF_SUBRC-ABAP"]}
/>
)
await user.click(screen.getByRole("button", { name: "저장" }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0].name).toBe("IF_SUBRC-ABAP")
})
@@ -1,6 +1,6 @@
// 생성/편집 공용 다이얼로그(T030). react-hook-form+zod를 사용하되
// shadcn Dialog(중앙 모달)로 — 팔레트 위에 뜨는 Raycast 결 유지.
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
@@ -27,17 +27,27 @@ import { Input } from "@/shared/ui/input"
import { Textarea } from "@/shared/ui/textarea"
import { Label } from "@/shared/ui/label"
import type { Snippet, SnippetInput } from "../types"
import { nameProblem, normalizeName } from "../core/name"
const DEFAULT_CATEGORY = "코드"
const schema = z.object({
name: z.string().min(1, "이름을 입력해야 함"),
desc: z.string(),
body: z.string().min(1, "내용을 입력해야 함"),
category: z.string(),
})
// 이름 규칙·중복은 생성 때만(편집은 이름 읽기 전용 — 옛 이름도 그대로 편집 가능). 설명은 필수(한글 검색이 여기로 걸림).
function makeSchema(mode: "create" | "edit", existing: string[]) {
return z
.object({
name: z.string(),
desc: z.string().trim().min(1, "설명을 입력해야 함 — 한글로 무엇을 하는 코드인지"),
body: z.string().min(1, "내용을 입력해야 함"),
category: z.string(),
})
.superRefine((v, ctx) => {
if (mode !== "create") return
const problem = nameProblem(v.name, existing)
if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["name"], message: problem })
})
}
type FormValues = z.infer<typeof schema>
type FormValues = z.infer<ReturnType<typeof makeSchema>>
interface EditDialogProps {
open: boolean
@@ -48,6 +58,8 @@ interface EditDialogProps {
onDelete?: (name: string) => void
isSaving: boolean
isDeleting?: boolean
/** 이미 있는 이름들 — 생성 때 타이핑하는 즉시 중복 표시 */
existingNames?: string[]
}
export function EditDialog({
@@ -59,12 +71,15 @@ export function EditDialog({
onDelete,
isSaving,
isDeleting,
existingNames = [],
}: EditDialogProps) {
const schema = useMemo(() => makeSchema(mode, existingNames), [mode, existingNames])
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -95,9 +110,14 @@ export function EditDialog({
})
}, [open, mode, snippet, reset, setValue])
// 생성 중 실시간 안내 — 저장될 이름 + 규칙/중복 문제(제출 전에 보여줌)
const typedName = watch("name")
const liveName = mode === "create" ? normalizeName(typedName ?? "") : ""
const liveProblem = mode === "create" && typedName ? nameProblem(typedName, existingNames) : null
function onSubmit(data: FormValues) {
onSave({
name: data.name,
name: mode === "create" ? normalizeName(data.name) : data.name,
desc: data.desc,
body: data.body,
category: data.category || DEFAULT_CATEGORY,
@@ -122,17 +142,33 @@ export function EditDialog({
</Label>
<Input
id="snippet-name"
placeholder="예: GIT_COMMIT"
placeholder="예: ALV_FILTER_ROWS (영역_대상_동작, 영문)"
readOnly={mode === "edit"}
className={mode === "edit" ? "bg-muted text-muted-foreground" : undefined}
{...register("name")}
/>
{errors.name && <p className="text-destructive text-xs">{errors.name.message}</p>}
{mode === "create" && typedName && (
<p
className={`text-xs ${liveProblem ? "text-destructive" : "text-muted-foreground"}`}
>
{liveProblem ?? `저장될 이름: ${liveName}`}
</p>
)}
{errors.name && !typedName && (
<p className="text-destructive text-xs">{errors.name.message}</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-desc"></Label>
<Input id="snippet-desc" placeholder="짧은 설명" {...register("desc")} />
<Label htmlFor="snippet-desc">
<span className="text-destructive">*</span>
</Label>
<Input
id="snippet-desc"
placeholder="예: ALV 에서 조건에 맞는 행만 필터링 (한글 검색이 여기로 걸림)"
{...register("desc")}
/>
{errors.desc && <p className="text-destructive text-xs">{errors.desc.message}</p>}
</div>
<div className="flex flex-col gap-1.5">
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest"
import { normalizeName, nameProblem } from "./name"
describe("normalizeName", () => {
it("소문자·공백·하이픈·점을 대문자 _ 로, 기호·한글은 버리고 _ 정리", () => {
expect(normalizeName(" alv filter-rows.v2 ")).toBe("ALV_FILTER_ROWS_V2")
expect(normalizeName("IF_SUBRC-ABAP")).toBe("IF_SUBRC_ABAP")
expect(normalizeName("__a__b__")).toBe("A_B")
expect(normalizeName("필터 ALV (신규)")).toBe("ALV")
})
})
describe("nameProblem", () => {
it("통과", () => {
expect(nameProblem("alv filter")).toBeNull()
expect(nameProblem("ITAB_COUNT_ROWS_LINES")).toBeNull()
})
it("한글만 쓰면 설명으로 안내, 빈 값·숫자 시작·길이", () => {
expect(nameProblem("필터")).toMatch(/설명/)
expect(nameProblem(" ")).toMatch(/입력/)
expect(nameProblem("1ALV")).toMatch(/영문자로 시작/)
expect(nameProblem("AB")).toMatch(/3자 이상/)
expect(nameProblem("A".repeat(51))).toMatch(/50자 이하/)
})
it("정규화 기준으로 중복 검사 — 하이픈/소문자로 바꿔 넣어도 막힘", () => {
expect(nameProblem("alv-filter", ["ALV_FILTER"])).toMatch(/이미 있는 이름임: ALV_FILTER/)
expect(nameProblem("alv filter2", ["ALV_FILTER"])).toBeNull()
})
})
@@ -0,0 +1,31 @@
// 스니펫 이름 규칙 — 서버 apps/snippets/views.py 의 normalize_name/NAME_RE 와 같은 규칙(바꾸면 양쪽 같이).
// 모양: 영문 대문자·숫자·_ 만, 문자로 시작, 3~50자, _ 연속·앞뒤 금지. 예: ALV_FILTER_ROWS
// 권장 형식은 "영역_대상_동작"(ALV·ITAB·OPEN_SQL·SELECTION_SCREEN·STRING·DYNPRO·DDIC·DATE·BDC·FILE …) — 안내만, 강제 안 함.
// 한글은 이름 말고 설명에.
export const NAME_MIN = 3
export const NAME_MAX = 50
export const NAME_RE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/
/** 입력값 → 저장될 이름. 소문자→대문자, 공백·하이픈·점→_, 그 외 기호·한글은 버림, _ 연속·앞뒤 정리. */
export function normalizeName(raw: string): string {
return raw
.toUpperCase()
.replace(/[\s\-.]+/g, "_")
.replace(/[^A-Z0-9_]/g, "")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
}
/** 규칙 위반이면 이유(한글), 통과면 null. 입력 원문을 받아 정규화 결과로 판단. */
export function nameProblem(raw: string, existing: Iterable<string> = []): string | null {
const name = normalizeName(raw)
if (!name)
return /[가-힣]/.test(raw) ? "이름은 영문으로 — 한글은 설명에 적어" : "이름을 입력해야 함"
if (!/^[A-Z]/.test(name)) return "이름은 영문자로 시작해야 함"
if (name.length < NAME_MIN) return `이름은 ${NAME_MIN}자 이상`
if (name.length > NAME_MAX) return `이름은 ${NAME_MAX}자 이하`
if (!NAME_RE.test(name)) return "영문 대문자·숫자·_ 만 쓸 수 있음"
for (const e of existing) if (e === name) return `이미 있는 이름임: ${name}`
return null
}
@@ -42,11 +42,24 @@ describe("searchSnippets", () => {
expect(result.map((i) => i.name)).toEqual(["DOCKER_RUN"])
})
it("body 검색 대상 아님 — body에만 있는 키워드는 매칭 안 됨", () => {
it("body 코드 줄은 검색 대상 아님 — 코드에만 있는 키워드는 매칭 안 됨", () => {
const result = searchSnippets(items, "XYZKEYWORD")
expect(result).toHaveLength(0)
})
it('body 의 ★ 머리줄·주석줄(* / ")은 검색됨 — 한글 키워드가 여기 있음', () => {
const withHeader = [
s({
name: "ALV_FILTER_ROWS",
body: '"★★ALV_FILTER-FILTER 필터\nDATA lt_x TYPE TABLE OF mara.',
}),
s({ name: "POPUP_CONFIRM", body: "* 확인 팝업 띄우기\nCALL FUNCTION 'POPUP_TO_CONFIRM'." }),
s({ name: "PLAIN", body: "DATA 필터 TYPE c." }), // 코드 줄의 한글은 안 걸림
]
expect(searchSnippets(withHeader, "필터").map((i) => i.name)).toEqual(["ALV_FILTER_ROWS"])
expect(searchSnippets(withHeader, "팝업").map((i) => i.name)).toEqual(["POPUP_CONFIRM"])
})
it("category 지정 시 그 분류로 먼저 제한", () => {
const result = searchSnippets(items, "", "기타")
expect(result.map((i) => i.name)).toEqual(["MEMO"])
@@ -1,9 +1,20 @@
// 검색 순수함수 — data-model.md §검색. FR-005/006 그대로.
// 검색 순수함수 — data-model.md §검색. FR-005/006.
import type { Snippet } from "../types"
/** 본문 중 검색에 넣을 줄 — 원작성자가 달아둔 ★ 머리줄과 ABAP 주석(* 로 시작, " 로 시작).
* 본문 전체는 안 넣음: 코드에 TABLE·DATA 같은 흔한 말이 많아 결과가 흐려짐.
* 2026-09-23 실측: 143개 중 한글이 이름 0·설명 0, 본문 72 — 한글 키워드가 여기에만 있었음. */
export function keywordLines(body: string): string {
return body
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.startsWith("*") || l.startsWith('"') || l.includes("★"))
.join(" ")
}
/**
* category 로 먼저 제한(없거나 "전체"면 무제한) 후, 쿼리를 공백으로 나눈 키워드 전부(AND)가
* name+desc 합친 문자열(대소문자 무시)에 포함되는 것만 남긴다. body 는 검색 대상 아님. 퍼지 없음.
* 이름 + 설명 + 본문 키워드줄(keywordLines) 합친 문자열(대소문자 무시)에 포함되는 것만 남긴다. 퍼지 없음.
* 빈 쿼리 → (category 제한된) 전체.
*/
export function searchSnippets(snippets: Snippet[], query: string, category?: string): Snippet[] {
@@ -14,7 +25,7 @@ export function searchSnippets(snippets: Snippet[], query: string, category?: st
if (keywords.length === 0) return inCategory
return inCategory.filter((s) => {
const haystack = `${s.name} ${s.desc}`.toUpperCase()
const haystack = `${s.name} ${s.desc} ${keywordLines(s.body)}`.toUpperCase()
return keywords.every((k) => haystack.includes(k))
})
}
@@ -76,6 +76,7 @@ export default function SnippetPalettePage() {
return [ALL_CATEGORY, ...found]
}, [snippets])
const [category, setCategory] = useState(ALL_CATEGORY)
const existingNames = useMemo(() => snippets.map((s) => s.name), [snippets])
// 선택 중이던 category의 스니펫이 다 사라지면(삭제 등) "전체"로 복귀.
useEffect(() => {
if (!categories.includes(category)) setCategory(ALL_CATEGORY)
@@ -413,6 +414,7 @@ export default function SnippetPalettePage() {
onDelete={removeSnippet}
isSaving={createSnippet.isPending || updateSnippet.isPending}
isDeleting={deleteSnippet.isPending}
existingNames={existingNames}
/>
</div>
)