From cb028df08a919fffa78be67ca98a46f157a4586b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 02:19:59 -0800 Subject: [PATCH] =?UTF-8?q?Phase=204:=20=EC=9D=B8=EB=B0=95=EC=8A=A4(Smart?= =?UTF-8?q?=20Inbox)=20=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 멀티모달 캡처 컴포저(텍스트 Enter/보내기 + mic·image 스텁) - 실시간 AI 분류: POST capture(동기) → 생각중 펄스 → 결과 칩(type/sphere/proj/due/when/extra) + 이유 한 줄 + heuristic 폴백 배지("규칙 기반(오프라인)") - 확인(confirm)=실체화(federation): task → 실제 작업 생성 + 작업 페이지 등장 - 다르게 분류(reclassify) 타입 순환 task→event→idea, 낙관적 미리보기 - 분류 원칙 카드 4건(원문 그대로) + "오늘 어디로 갔나" 행선지 카드(동적 집계) - sinbox.css 픽셀 이식 + dash-base.css(공유 레이아웃/카드, 대시보드 재사용) - 서버 컴포넌트 초기 fetch + 클라 낙관적 업데이트/롤백, 전체/업무/개인 필터 검증: 백엔드 pytest 46, 프론트 vitest 42, playwright 28(shell+tasks+inbox+a11y), tsc/eslint clean, build OK. 인박스→작업 연합 e2e 통과, 4 골든 + 폴백 배지 + voice 스텁 검증. 라이브 시각 확인(컴포저/칩/이유/원칙/행선지). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/app/inbox/page.tsx | 20 +- frontend/components/inbox/CaptureComposer.tsx | 55 ++ frontend/components/inbox/CaptureRow.tsx | 73 +++ .../components/inbox/ClassifyPrinciples.tsx | 60 +++ frontend/components/inbox/InboxView.tsx | 184 +++++++ frontend/components/inbox/ReasonLine.tsx | 22 + frontend/components/inbox/RouteChips.tsx | 65 +++ frontend/components/inbox/SmartInbox.tsx | 55 ++ frontend/components/inbox/SphereFilters.tsx | 37 ++ frontend/components/inbox/TodayRouted.tsx | 83 +++ frontend/lib/inbox/api.ts | 53 ++ frontend/lib/inbox/presentation.ts | 27 + frontend/lib/types.ts | 54 ++ frontend/playwright/inbox.spec.ts | 82 +++ frontend/styles/dash-base.css | 301 +++++++++++ frontend/styles/inbox.css | 474 ++++++++++++++++++ frontend/tests/inbox/CaptureComposer.test.tsx | 37 ++ frontend/tests/inbox/RouteChips.test.tsx | 57 +++ .../tests/inbox/inbox-presentation.test.ts | 20 + 19 files changed, 1756 insertions(+), 3 deletions(-) create mode 100644 frontend/components/inbox/CaptureComposer.tsx create mode 100644 frontend/components/inbox/CaptureRow.tsx create mode 100644 frontend/components/inbox/ClassifyPrinciples.tsx create mode 100644 frontend/components/inbox/InboxView.tsx create mode 100644 frontend/components/inbox/ReasonLine.tsx create mode 100644 frontend/components/inbox/RouteChips.tsx create mode 100644 frontend/components/inbox/SmartInbox.tsx create mode 100644 frontend/components/inbox/SphereFilters.tsx create mode 100644 frontend/components/inbox/TodayRouted.tsx create mode 100644 frontend/lib/inbox/api.ts create mode 100644 frontend/lib/inbox/presentation.ts create mode 100644 frontend/playwright/inbox.spec.ts create mode 100644 frontend/styles/dash-base.css create mode 100644 frontend/styles/inbox.css create mode 100644 frontend/tests/inbox/CaptureComposer.test.tsx create mode 100644 frontend/tests/inbox/RouteChips.test.tsx create mode 100644 frontend/tests/inbox/inbox-presentation.test.ts diff --git a/frontend/app/inbox/page.tsx b/frontend/app/inbox/page.tsx index c8f5412..06a4f67 100644 --- a/frontend/app/inbox/page.tsx +++ b/frontend/app/inbox/page.tsx @@ -1,4 +1,18 @@ -// frontend/app/inbox/page.tsx (Phase 1 스텁 — Phase 4에서 교체) -export default function Page() { - return

인박스

; +// frontend/app/inbox/page.tsx +import "@/styles/inbox.css"; +import InboxView from "@/components/inbox/InboxView"; +import { getInbox } from "@/lib/inbox/api"; +import type { InboxItem } from "@/lib/types"; + +export const dynamic = "force-dynamic"; // 시드 데이터 항상 최신 + +export default async function InboxPage() { + let items: InboxItem[] = []; + let loadError = false; + try { + items = await getInbox(); + } catch { + loadError = true; + } + return ; } diff --git a/frontend/components/inbox/CaptureComposer.tsx b/frontend/components/inbox/CaptureComposer.tsx new file mode 100644 index 0000000..06122e1 --- /dev/null +++ b/frontend/components/inbox/CaptureComposer.tsx @@ -0,0 +1,55 @@ +// frontend/components/inbox/CaptureComposer.tsx +"use client"; +import { useState } from "react"; +import { Icon } from "@/components/Icon"; + +// MVP 스텁: 음성/이미지는 더미 transcript/caption 주입 +const VOICE_STUB = "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기"; +const IMAGE_STUB = "이미지 캡처 — 영수증/스크린샷 (자동 인식 결과)"; + +export default function CaptureComposer({ + onSubmit, +}: { + onSubmit: (raw: string, kind: "text" | "voice" | "image") => void; +}) { + const [input, setInput] = useState(""); + + const submitText = () => { + const t = input.trim(); + if (!t) return; + onSubmit(t, "text"); + setInput(""); + }; + + return ( +
+ + setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submitText()} + placeholder="갑자기 생각난 것 아무거나… 예) 다음 주 한국 가는 비행기 티켓 사기" + aria-label="인박스에 빠르게 캡처" + /> + + + +
+ ); +} diff --git a/frontend/components/inbox/CaptureRow.tsx b/frontend/components/inbox/CaptureRow.tsx new file mode 100644 index 0000000..d7c820e --- /dev/null +++ b/frontend/components/inbox/CaptureRow.tsx @@ -0,0 +1,73 @@ +// frontend/components/inbox/CaptureRow.tsx +"use client"; +import { Icon } from "@/components/Icon"; +import type { IconName } from "@/components/icons/paths"; +import type { UiInboxItem } from "@/lib/types"; +import ReasonLine from "./ReasonLine"; +import RouteChips from "./RouteChips"; + +export default function CaptureRow({ + item: c, + onConfirm, + onReType, +}: { + item: UiInboxItem; + onConfirm: (id: string) => void; + onReType: (id: string) => void; +}) { + const kindIcon: IconName = c.kind === "voice" ? "mic" : c.kind === "image" ? "image" : "pen"; + const canAct = c.status === "classified"; // 원본의 new(분류 완료, 액션 가능) + + return ( +
+
+ +
+
+
{c.raw}
+ + {c.status === "thinking" ? ( +
+ + + + 아리가 분류하고 있어요 +
+ ) : c.status === "error" ? ( +
+ 분류에 실패했어요.{" "} + +
+ ) : c.classification ? ( + <> + onReType(c.id)} /> + + {canAct && ( +
+ + +
+ )} + + ) : null} +
+ + {c.status === "confirmed" ? ( + + + + ) : ( + c.time + )} + +
+ ); +} diff --git a/frontend/components/inbox/ClassifyPrinciples.tsx b/frontend/components/inbox/ClassifyPrinciples.tsx new file mode 100644 index 0000000..e79006c --- /dev/null +++ b/frontend/components/inbox/ClassifyPrinciples.tsx @@ -0,0 +1,60 @@ +// frontend/components/inbox/ClassifyPrinciples.tsx +import type { CSSProperties } from "react"; +import { Icon } from "@/components/Icon"; +import type { IconName } from "@/components/icons/paths"; + +const RULES: { icon: IconName; tone: string; title: string; desc: string }[] = [ + { + icon: "check", + tone: "green", + title: "행동이 있으면 → 작업", + desc: "‘비행기 티켓 사기’도 작업이에요. 작업 트리의 ‘개인 › 여행’ 프로젝트로 들어가 다른 작업과 똑같이 보여요.", + }, + { + icon: "cal", + tone: "blue", + title: "시간이 정해지면 → 일정", + desc: "‘수요일 11시 자전거 수리’는 작업이 아니라 캘린더에 바로 등록돼요.", + }, + { + icon: "brain", + tone: "violet", + title: "막연하면 → 아이디어", + desc: "행동이 정해지지 않은 생각은 보드에 보관하고, 관련 프로젝트에 연결해둬요.", + }, + { + icon: "clock", + tone: "coral", + title: "개인은 섹션이 아니라 프로젝트", + desc: "따로 숨기거나 분리하지 않아요. 같은 작업 트리에서 필터로만 구분하고, 배치만 저녁·주말 빈 시간으로 추천해요.", + }, +]; + +export default function ClassifyPrinciples() { + return ( +
+
+
+ +
+
+

아리의 분류 원칙

+
적을 때 고민하지 마세요
+
+
+
+ {RULES.map((r, i) => ( +
+ + + + + {r.title} + {r.desc} + +
+ ))} +
+
+ ); +} diff --git a/frontend/components/inbox/InboxView.tsx b/frontend/components/inbox/InboxView.tsx new file mode 100644 index 0000000..5a79081 --- /dev/null +++ b/frontend/components/inbox/InboxView.tsx @@ -0,0 +1,184 @@ +// frontend/components/inbox/InboxView.tsx — 인박스 클라이언트 루트 +"use client"; +import { useRef, useState } from "react"; +import { Icon } from "@/components/Icon"; +import { captureInbox, confirmInbox, reclassifyInbox } from "@/lib/inbox/api"; +import { nextType } from "@/lib/inbox/presentation"; +import type { InboxItem, RouteType, Sphere, UiInboxItem } from "@/lib/types"; +import ClassifyPrinciples from "./ClassifyPrinciples"; +import SmartInbox from "./SmartInbox"; +import TodayRouted from "./TodayRouted"; + +function relTime(iso: string): string { + if (!iso) return "방금"; + const diff = Date.now() - new Date(iso).getTime(); + if (Number.isNaN(diff)) return "방금"; + const m = Math.floor(diff / 60000); + if (m < 1) return "방금"; + if (m < 60) return `${m}분 전`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}시간 전`; + return `${Math.floor(h / 24)}일 전`; +} + +function toUi(it: InboxItem): UiInboxItem { + return { ...it, status: it.status, time: relTime(it.created_at) }; +} + +export default function InboxView({ + initialItems, + initialLoadError, +}: { + initialItems: InboxItem[]; + initialLoadError: boolean; +}) { + const [items, setItems] = useState(() => initialItems.map(toUi)); + const [filter, setFilter] = useState<"all" | Sphere>("all"); + const tmpSeq = useRef(0); + + const clearFresh = (xs: UiInboxItem[]) => xs.map((x) => (x.fresh ? { ...x, fresh: false } : x)); + + /* ---- 캡처 제출 ---- */ + const submit = async (raw: string, kind: "text" | "voice" | "image") => { + const text = raw.trim(); + if (!text) return; + const tmpId = "tmp-" + ++tmpSeq.current; + setItems((xs) => [ + { + id: tmpId, + kind, + raw: text, + status: "thinking", + time: "방금", + created_at: new Date(0).toISOString(), + materialized_task_id: null, + classification: null, + fresh: true, + } as UiInboxItem, + ...clearFresh(xs), + ]); + try { + const { item, classification } = await captureInbox({ kind, raw: text }); + setItems((xs) => + xs.map((x) => + x.id === tmpId + ? { + ...item, + classification, + status: "classified", + time: "방금", + fresh: true, + fallbackUsed: classification.model === "heuristic", + } + : x, + ), + ); + } catch { + setItems((xs) => xs.map((x) => (x.id === tmpId ? { ...x, status: "error" } : x))); + } + }; + + /* ---- 다르게 분류 (타입 순환) ---- */ + const reType = async (id: string) => { + const cur = items.find((x) => x.id === id); + if (!cur?.classification) return; + const original = cur.classification.type; + const wanted = nextType(original); + // 낙관적 미리보기 + setItems((xs) => + xs.map((x) => + x.id === id && x.classification + ? { ...x, classification: { ...x.classification, type: wanted } } + : x, + ), + ); + try { + const { item, classification } = await reclassifyInbox(id, wanted as RouteType); + setItems((xs) => + xs.map((x) => + x.id === id + ? { + ...item, + classification, + status: "classified", + time: x.time, + fresh: x.fresh, + fallbackUsed: classification.model === "heuristic", + } + : x, + ), + ); + } catch { + // 롤백: 원래 타입 복구 + setItems((xs) => + xs.map((x) => + x.id === id && x.classification + ? { ...x, classification: { ...x.classification, type: original } } + : x, + ), + ); + } + }; + + /* ---- 좋아요(confirm) → 실체화 ---- */ + const confirm = async (id: string) => { + setItems((xs) => xs.map((x) => (x.id === id ? { ...x, status: "confirmed" } : x))); + try { + const res = await confirmInbox(id); + setItems((xs) => + xs.map((x) => + x.id === id + ? { ...res.item, classification: x.classification, status: "confirmed", time: x.time } + : x, + ), + ); + } catch { + setItems((xs) => xs.map((x) => (x.id === id ? { ...x, status: "classified" } : x))); + } + }; + + const todayRouted = items.filter( + (x) => x.classification && x.status !== "thinking" && x.status !== "error", + ).length; + const shown = items.filter( + (x) => filter === "all" || (x.classification && x.classification.sphere === filter), + ); + + return ( + <> +
+
+
+ 오늘 {todayRouted}건 정리 + + 분류를 고민한 시간 0초 +
+

+ 스마트 인박스 일단 적으세요 +

+
+
+ +
+
+ {initialLoadError && ( +
+ 인박스를 불러오지 못했어요. 새로고침하거나 백엔드 연결을 확인하세요. +
+ )} + + + +
+
+ + ); +} diff --git a/frontend/components/inbox/ReasonLine.tsx b/frontend/components/inbox/ReasonLine.tsx new file mode 100644 index 0000000..46073cf --- /dev/null +++ b/frontend/components/inbox/ReasonLine.tsx @@ -0,0 +1,22 @@ +// frontend/components/inbox/ReasonLine.tsx +import { Icon } from "@/components/Icon"; + +export default function ReasonLine({ + reason, + fallbackUsed, +}: { + reason: string; + fallbackUsed?: boolean; +}) { + return ( +
+ {fallbackUsed && ( + + + 규칙 기반(오프라인) + + )} + {reason} +
+ ); +} diff --git a/frontend/components/inbox/RouteChips.tsx b/frontend/components/inbox/RouteChips.tsx new file mode 100644 index 0000000..c7d2cd0 --- /dev/null +++ b/frontend/components/inbox/RouteChips.tsx @@ -0,0 +1,65 @@ +// frontend/components/inbox/RouteChips.tsx +"use client"; +import { Icon } from "@/components/Icon"; +import { SPHERE, TYPE_ICON, TYPE_LABEL } from "@/lib/inbox/presentation"; +import type { Classification } from "@/lib/types"; + +export default function RouteChips({ + r, + editable, + onReType, +}: { + r: Classification; + editable: boolean; + onReType: () => void; +}) { + return ( +
+ + + + + {/* 타입 칩: 클릭 시 다르게 분류(편집 가능할 때만) */} + + + {/* sphere 칩 (고정색) */} + + + {SPHERE[r.sphere].label} + + + {/* proj 칩 (동적 tone) */} + + + {r.proj_label} + + + {r.due_text && ( + + + {r.due_text} + + )} + {r.when_text && ( + + + {r.when_text} + + )} + {r.extra && ( + + + {r.extra} + + )} +
+ ); +} diff --git a/frontend/components/inbox/SmartInbox.tsx b/frontend/components/inbox/SmartInbox.tsx new file mode 100644 index 0000000..2ef70e2 --- /dev/null +++ b/frontend/components/inbox/SmartInbox.tsx @@ -0,0 +1,55 @@ +// frontend/components/inbox/SmartInbox.tsx +"use client"; +import { Icon } from "@/components/Icon"; +import type { Sphere, UiInboxItem } from "@/lib/types"; +import CaptureComposer from "./CaptureComposer"; +import CaptureRow from "./CaptureRow"; +import SphereFilters from "./SphereFilters"; + +export default function SmartInbox({ + items, + todayRouted, + filter, + onFilter, + onSubmit, + onConfirm, + onReType, +}: { + items: UiInboxItem[]; + todayRouted: number; + filter: "all" | Sphere; + onFilter: (f: "all" | Sphere) => void; + onSubmit: (raw: string, kind: "text" | "voice" | "image") => void; + onConfirm: (id: string) => void; + onReType: (id: string) => void; +}) { + return ( +
+
+
+ +
+
+

스마트 인박스

+
업무든 일상이든 일단 적기 — 분류는 아리가 해요
+
+ 오늘 {todayRouted}건 정리 +
+ + + + +
+ {items.length === 0 ? ( +
+ 아직 비어 있어요. 위에 아무거나 적어보세요 — 분류는 아리가 할게요. +
+ ) : ( + items.map((c) => ( + + )) + )} +
+
+ ); +} diff --git a/frontend/components/inbox/SphereFilters.tsx b/frontend/components/inbox/SphereFilters.tsx new file mode 100644 index 0000000..3ac531a --- /dev/null +++ b/frontend/components/inbox/SphereFilters.tsx @@ -0,0 +1,37 @@ +// frontend/components/inbox/SphereFilters.tsx +"use client"; +import { SPHERE } from "@/lib/inbox/presentation"; +import type { Sphere } from "@/lib/types"; + +const TABS: ["all" | Sphere, string][] = [ + ["all", "전체"], + ["work", "업무"], + ["life", "개인"], +]; + +export default function SphereFilters({ + filter, + onFilter, +}: { + filter: "all" | Sphere; + onFilter: (f: "all" | Sphere) => void; +}) { + return ( +
+ {TABS.map(([k, l]) => ( + + ))} + 업무/개인은 필터일 뿐 — 작업은 한 곳에서 다 보여요 +
+ ); +} diff --git a/frontend/components/inbox/TodayRouted.tsx b/frontend/components/inbox/TodayRouted.tsx new file mode 100644 index 0000000..f83174e --- /dev/null +++ b/frontend/components/inbox/TodayRouted.tsx @@ -0,0 +1,83 @@ +// frontend/components/inbox/TodayRouted.tsx +"use client"; +import Link from "next/link"; +import { Icon } from "@/components/Icon"; +import { TYPE_LABEL } from "@/lib/inbox/presentation"; +import type { RouteType, UiInboxItem } from "@/lib/types"; + +const DEST_META: Record = { + task: { tone: "green", href: "/tasks" }, + event: { tone: "blue", href: "/dashboard" }, + idea: { tone: "violet", href: null }, +}; +const ORDER: RouteType[] = ["task", "event", "idea"]; + +export default function TodayRouted({ + items, + todayRouted, +}: { + items: UiInboxItem[]; + todayRouted: number; +}) { + const routed = items.filter( + (x) => x.classification && (x.status === "confirmed" || x.status === "classified"), + ); + const byType = (t: RouteType) => routed.filter((x) => x.classification!.type === t); + + const subFor = (t: RouteType) => { + const g = byType(t); + const life = g.filter((x) => x.classification!.sphere === "life").length; + const work = g.filter((x) => x.classification!.sphere === "work").length; + if (t === "event") return "캘린더 등록"; + if (t === "idea") return "보드 보관"; + return `개인 ${life} · 업무 ${work}`; + }; + + return ( +
+
+
+ +
+
+

오늘 어디로 갔나

+
{todayRouted}건의 행선지
+
+
+ +
+ {ORDER.map((t) => { + const meta = DEST_META[t]; + const n = byType(t).length; + const inner = ( + <> + + {TYPE_LABEL[t]} + {subFor(t)} + {n} + {meta.href && ( + + + + )} + + ); + return meta.href ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ); + })} +
+ +
+ + 분류가 마음에 안 들면 칩을 눌러 바꾸세요 — 아리가 다음부터 기억해요. +
+
+ ); +} diff --git a/frontend/lib/inbox/api.ts b/frontend/lib/inbox/api.ts new file mode 100644 index 0000000..bca88fa --- /dev/null +++ b/frontend/lib/inbox/api.ts @@ -0,0 +1,53 @@ +// frontend/lib/inbox/api.ts +import type { CaptureResult, ConfirmResult, InboxItem, RouteType } from "@/lib/types"; + +const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000"; + +export class ApiError extends Error { + constructor( + public status: number, + public body: string, + ) { + super(`API ${status}`); + } +} + +async function j(res: Response): Promise { + if (!res.ok) throw new ApiError(res.status, await res.text()); + return res.json() as Promise; +} + +export async function getInbox(): Promise { + return j(await fetch(`${BASE}/api/inbox`, { cache: "no-store" })); +} + +export async function captureInbox(input: { + kind: "text" | "voice" | "image"; + raw: string; +}): Promise { + return j( + await fetch(`${BASE}/api/inbox/capture`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + ); +} + +export async function reclassifyInbox(id: string, type?: RouteType): Promise { + return j( + await fetch(`${BASE}/api/inbox/${id}/reclassify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(type ? { type } : {}), + }), + ); +} + +export async function confirmInbox(id: string): Promise { + return j(await fetch(`${BASE}/api/inbox/${id}/confirm`, { method: "POST" })); +} + +export async function dismissInbox(id: string): Promise { + return j(await fetch(`${BASE}/api/inbox/${id}/dismiss`, { method: "POST" })); +} diff --git a/frontend/lib/inbox/presentation.ts b/frontend/lib/inbox/presentation.ts new file mode 100644 index 0000000..af2f6a4 --- /dev/null +++ b/frontend/lib/inbox/presentation.ts @@ -0,0 +1,27 @@ +// frontend/lib/inbox/presentation.ts — 분류 결과 → 화면 표현 매핑 +import type { IconName } from "@/components/icons/paths"; +import type { RouteType, Sphere } from "@/lib/types"; + +/** 원본 sinbox.jsx SPHERE */ +export const SPHERE: Record = { + work: { label: "업무", tone: "var(--blue)" }, + life: { label: "개인", tone: "var(--green)" }, +}; + +/** 원본 sinbox.jsx TYPE_ICON */ +export const TYPE_ICON: Record = { + task: "check", + event: "cal", + idea: "brain", +}; + +export const TYPE_LABEL: Record = { + task: "작업", + event: "일정", + idea: "아이디어", +}; + +/** 원본 cycleType — 다르게 분류 시 다음 타입 */ +export function nextType(t: RouteType): RouteType { + return ({ task: "event", event: "idea", idea: "task" } as const)[t]; +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 758f459..bbd7b7f 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -80,3 +80,57 @@ export interface ScaffoldResult { created: boolean; created_task_ids: string[]; } + +// ---------- 인박스 (Phase 4) ---------- +export type InboxKind = "text" | "voice" | "image"; +export type InboxStatus = "new" | "classified" | "confirmed" | "dismissed"; +export type RouteType = "task" | "event" | "idea"; +export type Sphere = "work" | "life"; + +/** 백엔드 inbox_classification 1:1 */ +export interface Classification { + id: string; + inbox_item_id: string; + type: RouteType; + sphere: Sphere; + project_id: string | null; + proj_label: string; + tone: Tone; + due_text: string | null; + when_text: string | null; + extra: string | null; + reason: string; + confidence: number; + model: string; + created_at: string; +} + +/** 백엔드 inbox_item (+ 최신 classification) */ +export interface InboxItem { + id: string; + kind: InboxKind; + raw: string; + status: InboxStatus; + created_at: string; + materialized_task_id: string | null; + classification: Classification | null; +} + +export interface CaptureResult { + item: InboxItem; + classification: Classification; +} + +export interface ConfirmResult { + item: InboxItem; + task?: Task; +} + +/** UI 전용: thinking/error 추가 */ +export type UiInboxStatus = InboxStatus | "thinking" | "error"; +export interface UiInboxItem extends Omit { + status: UiInboxStatus; + time: string; + fresh?: boolean; + fallbackUsed?: boolean; +} diff --git a/frontend/playwright/inbox.spec.ts b/frontend/playwright/inbox.spec.ts new file mode 100644 index 0000000..24f0e4a --- /dev/null +++ b/frontend/playwright/inbox.spec.ts @@ -0,0 +1,82 @@ +// frontend/playwright/inbox.spec.ts +// 백엔드를 LLM_PROVIDER=heuristic 로 기동한 상태를 가정(결정성·속도·폴백 배지). +// 캡처는 인박스 시드를 변경하므로 단일 워커 직렬. +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +test("캡처 → 분류 → 좋아요 → 작업 페이지에 등장(연합)", async ({ page }) => { + await page.goto("/inbox"); + const input = page.getByLabel("인박스에 빠르게 캡처"); + await input.fill("다음 주에 한국 놀러가는 비행기 티켓 사기"); + await input.press("Enter"); + const fresh = page.locator(".sb-cap.fresh").first(); + await expect(fresh.getByText("작업", { exact: true })).toBeVisible(); + await expect(fresh.locator(".r-chip.proj")).toContainText("개인 › 여행 — 한국"); + await expect(fresh.locator(".r-chip.auto")).toContainText("가격 추적 알림 켜둠"); + await fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click(); + await expect(fresh.locator(".sb-done")).toBeVisible(); + + // 작업 페이지(개인 스코프)에서 확인 — 연합 + await page.goto("/tasks"); + await page.locator(".tree-row.folder", { hasText: "개인" }).click(); + await expect( + page.locator(".kcard-title", { hasText: "다음 주에 한국 놀러가는 비행기 티켓 사기" }), + ).toBeVisible(); +}); + +test("다르게 분류 — 타입 순환 task→event", async ({ page }) => { + await page.goto("/inbox"); + const input = page.getByLabel("인박스에 빠르게 캡처"); + await input.fill("리포트 회신 보내기"); + await input.press("Enter"); + const fresh = page.locator(".sb-cap.fresh").first(); + await expect(fresh.getByText("작업", { exact: true })).toBeVisible(); + await fresh.getByRole("button", { name: /다르게 분류/ }).click(); + await expect(fresh.getByText("일정", { exact: true })).toBeVisible(); +}); + +const GOLDEN = [ + { raw: "다음 주에 한국 놀러가는 비행기 티켓 사기", type: "작업", proj: "개인 › 여행 — 한국" }, + { raw: "수요일 11시 자전거 수리 맡기기", type: "일정", proj: "개인 캘린더" }, + { raw: "엄마 생신 선물 미리 알아보기", type: "작업", proj: "가족" }, + { raw: "온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까", type: "아이디어", proj: "아이디어 보드" }, +]; +for (const g of GOLDEN) { + test(`골든: "${g.raw}" → ${g.type}`, async ({ page }) => { + await page.goto("/inbox"); + const input = page.getByLabel("인박스에 빠르게 캡처"); + await input.fill(g.raw); + await input.press("Enter"); + const fresh = page.locator(".sb-cap.fresh").first(); + await expect(fresh.getByText(g.type, { exact: true })).toBeVisible(); + await expect(fresh.locator(".r-chip.proj")).toContainText(g.proj); + }); +} + +test("LLM 폴백 — 규칙 기반(오프라인) 배지", async ({ page }) => { + await page.goto("/inbox"); + const input = page.getByLabel("인박스에 빠르게 캡처"); + await input.fill("엄마 생신 선물 미리 알아보기"); + await input.press("Enter"); + const fresh = page.locator(".sb-cap.fresh").first(); + await expect(fresh.getByText("작업", { exact: true })).toBeVisible(); + await expect(fresh.getByText("규칙 기반(오프라인)")).toBeVisible(); +}); + +test("음성 스텁 — voice 아이콘 행", async ({ page }) => { + await page.goto("/inbox"); + await page.getByLabel(/음성으로 캡처/).click(); + await expect(page.locator(".cap-k.voice").first()).toBeVisible(); +}); + +test("a11y — axe 위반 없음(color-contrast 제외)", async ({ page }) => { + await page.goto("/inbox"); + await expect(page.getByRole("heading", { name: "스마트 인박스", exact: true })).toBeVisible(); + const r = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa"]) + .disableRules(["color-contrast"]) + .analyze(); + expect(r.violations).toEqual([]); +}); diff --git a/frontend/styles/dash-base.css b/frontend/styles/dash-base.css new file mode 100644 index 0000000..9400531 --- /dev/null +++ b/frontend/styles/dash-base.css @@ -0,0 +1,301 @@ +/* frontend/styles/dash-base.css — 원본 dash.css 의 공유 레이아웃/카드 기본 클래스 + (인박스·대시보드 공통). 전역/토큰/상단바는 tokens.css·globals.css 가 제공. + .ph-title 은 globals.css 에 정의됨(항상 로드). */ + +/* 페이지 헤더 */ +.pagehead { + display: flex; + align-items: flex-end; + gap: 18px; + padding: 14px 4px 22px; +} +.ph-back { + width: 46px; + height: 46px; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--glass); + border: 1px solid var(--glass-brd); + -webkit-backdrop-filter: var(--blur); + backdrop-filter: var(--blur); + color: var(--ink); + box-shadow: var(--shadow-sm); + margin-bottom: 4px; + transition: transform 0.12s; +} +.ph-back .ic { + width: 19px; + height: 19px; +} +.ph-back:hover { + transform: translateX(-2px); +} +.ph-eyebrow { + font-size: 13.5px; + color: var(--muted); + font-weight: 500; + margin-bottom: 7px; + display: flex; + align-items: center; + gap: 9px; +} +.ph-eyebrow .wx { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--ink-2); +} +.ph-eyebrow .wx .ic { + width: 15px; + height: 15px; + color: var(--amber); +} +.ph-eyebrow .sep { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--faint); +} +.ph-title em { + font-style: normal; + color: var(--muted); +} +.ph-search { + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; + width: 250px; + padding: 12px 18px; + border-radius: 999px; + background: var(--glass); + border: 1px solid var(--glass-brd); + -webkit-backdrop-filter: var(--blur); + backdrop-filter: var(--blur); + box-shadow: var(--shadow-sm); + margin-bottom: 4px; + transition: box-shadow 0.18s, border-color 0.18s; +} +.ph-search:focus-within { + border-color: var(--line-2); + box-shadow: 0 0 0 4px rgba(79, 114, 224, 0.12); +} +.ph-search .ic { + width: 17px; + height: 17px; + color: var(--muted); +} +.ph-search input { + flex: 1; + min-width: 0; + border: none; + background: none; + outline: none; + font-size: 14px; + color: var(--ink); +} +.ph-search input::placeholder { + color: var(--faint); +} + +/* 작업 영역 + 보드 */ +.work { + display: flex; + gap: 18px; + align-items: flex-start; +} +.board { + flex: 1; + min-width: 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} +.card { + background: var(--glass); + -webkit-backdrop-filter: var(--blur); + backdrop-filter: var(--blur); + border: 1px solid var(--glass-brd); + border-radius: var(--radius); + box-shadow: var(--shadow), var(--glass-hi); + padding: 20px; + display: flex; + flex-direction: column; + min-width: 0; +} +.card.sp2 { + grid-column: span 2; +} +.card.r2 { + grid-row: span 2; +} + +/* 카드 헤더 */ +.ch { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} +.ch .ico { + width: 34px; + height: 34px; + border-radius: 10px; + display: grid; + place-items: center; + background: var(--glass-2); + color: var(--ink); + border: 1px solid var(--glass-brd); + flex-shrink: 0; +} +.ch .ico .ic { + width: 17px; + height: 17px; +} +.ch .htext { + min-width: 0; +} +.ch h3 { + font-size: 15.5px; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0; + white-space: nowrap; +} +.ch .sub { + font-size: 12px; + color: var(--muted); + margin-top: 1px; +} +.ch .tool { + margin-left: auto; + width: 30px; + height: 30px; + border-radius: 9px; + display: grid; + place-items: center; + color: var(--muted); + transition: background 0.15s, color 0.15s; +} +.ch .tool .ic { + width: 16px; + height: 16px; +} +.ch .tool:hover { + background: var(--card-2); + color: var(--ink); +} +.ch .count { + margin-left: auto; + font-size: 12px; + font-weight: 700; + color: var(--ink-2); + background: var(--glass-2); + border: 1px solid var(--glass-brd); + border-radius: 999px; + padding: 3px 10px; +} + +/* 캡처 종류 아이콘 / 분류 칩 / 시간 (인박스·대시보드 공유) */ +.cap-k { + width: 32px; + height: 32px; + border-radius: 9px; + flex-shrink: 0; + display: grid; + place-items: center; + background: var(--glass-2); + border: 1px solid var(--glass-brd); + color: var(--ink-2); +} +.cap-k .ic { + width: 15px; + height: 15px; +} +.cap-k.voice { + color: var(--violet); +} +.cap-k.image { + color: var(--blue); +} +.cap-k.text { + color: var(--ink-2); +} +.r-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + color: var(--ink-2); + background: var(--glass-2); + border: 1px solid var(--glass-brd); + padding: 3px 9px; + border-radius: 999px; +} +.r-chip .ic { + width: 12px; + height: 12px; + color: var(--muted); +} +.r-chip .pdot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.r-chip.proj { + color: var(--ink); + font-weight: 700; +} +.cap-time { + font-size: 11px; + color: var(--faint); + white-space: nowrap; + flex-shrink: 0; + padding-top: 2px; +} + +/* 자율성 노트 (원본 approve.css) */ +.auton-note { + margin-top: 13px; + display: flex; + gap: 8px; + align-items: flex-start; + font-size: 11.5px; + color: var(--ink-2); + line-height: 1.5; + background: var(--glass-2); + border: 1px solid var(--glass-brd); + border-radius: var(--radius-sm); + padding: 10px 12px; +} +.auton-note .ic { + width: 13px; + height: 13px; + flex-shrink: 0; + margin-top: 2px; + color: var(--coral); +} +.auton-note.dim { + color: var(--muted); +} +.auton-note.dim .ic { + color: var(--muted); +} + +/* 보드 반응형 (원본 786~795) */ +@media (max-width: 1200px) { + .board { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 700px) { + .board { + grid-template-columns: 1fr; + } + .card.sp2 { + grid-column: span 1; + } +} diff --git a/frontend/styles/inbox.css b/frontend/styles/inbox.css new file mode 100644 index 0000000..615e251 --- /dev/null +++ b/frontend/styles/inbox.css @@ -0,0 +1,474 @@ +/* frontend/styles/inbox.css — 원본 sinbox.css 이식 + 신설 클래스 + (공유 dash 기본 클래스는 dash-base.css) */ +@import "./dash-base.css"; + +/* ---------- 입력 ---------- */ +.sb-cmd { + display: flex; + align-items: center; + gap: 9px; + background: var(--glass-2); + border: 1px solid var(--glass-brd); + border-radius: 15px; + padding: 6px 6px 6px 15px; + margin-bottom: 11px; +} +.sb-cmd > svg:first-child { + width: 17px; + height: 17px; + color: var(--coral); + flex-shrink: 0; +} +.sb-cmd input { + flex: 1; + min-width: 0; + border: none; + background: none; + outline: none; + color: var(--ink); + font-size: 14px; +} +.sb-cmd input::placeholder { + color: var(--faint); +} +.sb-mode { + width: 34px; + height: 34px; + border-radius: 10px; + flex-shrink: 0; + display: grid; + place-items: center; + color: var(--muted); + transition: background 0.13s, color 0.13s; +} +.sb-mode .ic { + width: 16px; + height: 16px; +} +.sb-mode:hover { + background: var(--card-2); + color: var(--ink); +} +.sb-send { + width: 36px; + height: 36px; + border-radius: 11px; + flex-shrink: 0; + background: var(--fill); + color: var(--on-fill); + display: grid; + place-items: center; + transition: filter 0.14s; +} +.sb-send .ic { + width: 16px; + height: 16px; +} +.sb-send:hover { + filter: brightness(1.1); +} + +/* ---------- 컨텍스트 필터 ---------- */ +.sb-filters { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.sb-f { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--muted); + padding: 6px 12px; + border-radius: 999px; + border: 1px solid var(--glass-brd); + background: var(--glass-2); + transition: background 0.13s, color 0.13s; +} +.sb-f .fdot { + width: 7px; + height: 7px; + border-radius: 50%; +} +.sb-f:hover { + color: var(--ink); +} +.sb-f.on { + background: var(--fill); + color: var(--on-fill); + border-color: transparent; +} +.sb-hint { + font-size: 11px; + color: var(--faint); + margin-left: auto; + text-align: right; + min-width: 140px; + flex: 1; +} + +/* ---------- 목록 ---------- */ +.sb-list { + display: flex; + flex-direction: column; +} +.sb-cap { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 13px 0; + border-top: 1px solid var(--line); +} +.sb-cap:first-child { + border-top: none; + padding-top: 2px; +} +.sb-cap.fresh { + margin: 0 -10px; + padding: 13px 10px; + border-radius: var(--radius-sm); + border-top: none; + background: linear-gradient( + 100deg, + color-mix(in oklab, var(--coral) 9%, transparent), + transparent 65% + ), + var(--glass-2); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--coral) 26%, var(--glass-brd)); +} +.sb-cap.fresh + .sb-cap { + border-top: none; +} +.sb-body { + flex: 1; + min-width: 0; +} +.sb-raw { + font-size: 13.5px; + font-weight: 600; + line-height: 1.4; + letter-spacing: -0.01em; +} + +.sb-route { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 8px; +} +.sb-route .r-arrow { + color: var(--coral); + display: inline-flex; +} +.sb-route .r-arrow .ic { + width: 14px; + height: 14px; +} +.r-chip.type { + font-weight: 700; + color: var(--ink); + cursor: pointer; +} +.r-chip.type .ic { + color: var(--ink-2); +} +.r-chip.type.event { + color: var(--blue); + border-color: color-mix(in oklab, var(--blue) 30%, var(--glass-brd)); +} +.r-chip.type.event .ic { + color: var(--blue); +} +.r-chip.type.idea { + color: var(--violet); + border-color: color-mix(in oklab, var(--violet) 30%, var(--glass-brd)); +} +.r-chip.type.idea .ic { + color: var(--violet); +} +.r-chip.type:disabled { + cursor: default; +} +.r-chip.sphere { + font-weight: 700; +} +.r-chip.auto { + color: var(--amber); + font-weight: 700; + border-color: color-mix(in oklab, var(--amber) 36%, var(--glass-brd)); + background: color-mix(in oklab, var(--amber) 9%, transparent); +} +.r-chip.auto .ic { + color: var(--amber); +} + +.sb-reason { + font-size: 11.5px; + color: var(--muted); + line-height: 1.5; + margin-top: 7px; + max-width: 62ch; +} +.sb-fallback { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10.5px; + font-weight: 700; + color: var(--amber); + margin-right: 6px; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--amber) 36%, var(--glass-brd)); + background: color-mix(in oklab, var(--amber) 9%, transparent); +} +.sb-fallback .ic { + width: 10px; + height: 10px; +} + +/* ---------- 확인 액션 ---------- */ +.sb-acts { + display: flex; + align-items: center; + gap: 7px; + margin-top: 10px; +} +.sb-ok { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: var(--lime-ink); + background: var(--lime); + padding: 7px 13px; + border-radius: 999px; + box-shadow: 0 4px 12px -6px color-mix(in oklab, var(--lime-ink) 50%, transparent); + transition: background 0.13s, transform 0.12s; +} +.sb-ok .ic { + width: 12px; + height: 12px; +} +.sb-ok:hover { + background: var(--lime-hi); + transform: translateY(-1px); +} +.sb-alt { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--muted); + padding: 7px 11px; + border-radius: 999px; + border: 1px solid var(--line); + transition: color 0.13s, border-color 0.13s; +} +.sb-alt .ic { + width: 12px; + height: 12px; +} +.sb-alt:hover { + color: var(--ink); + border-color: var(--line-2); +} + +/* ---------- 생각 중 ---------- */ +.sb-think { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--muted); + margin-top: 9px; +} +.sb-think .tdot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--coral); + animation: sb-pulse 1s ease-in-out infinite; +} +.sb-think .tdot:nth-child(2) { + animation-delay: 0.15s; +} +.sb-think .tdot:nth-child(3) { + animation-delay: 0.3s; + margin-right: 4px; +} +@keyframes sb-pulse { + 0%, + 100% { + opacity: 0.25; + transform: scale(0.8); + } + 50% { + opacity: 1; + transform: scale(1); + } +} + +.sb-done { + width: 20px; + height: 20px; + border-radius: 50%; + display: grid; + place-items: center; + background: color-mix(in oklab, var(--green) 16%, transparent); + color: var(--green); +} +.sb-done .ic { + width: 11px; + height: 11px; +} + +/* 빈 상태 / 에러 (신설) */ +.sb-empty { + font-size: 12.5px; + color: var(--muted); + padding: 16px 2px; +} +.sb-err { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--coral); + margin-top: 9px; +} +.sb-retry { + font-weight: 700; + color: var(--ink); + text-decoration: underline; +} +.inbox-loaderr { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-radius: 14px; + background: color-mix(in oklab, var(--coral) 8%, var(--card)); + border: 1px solid color-mix(in oklab, var(--coral) 26%, transparent); + color: var(--ink-2); + font-size: 13px; + margin-bottom: 14px; +} + +/* ===== 분류 원칙 + 행선지 ===== */ +.prin-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.prin { + display: flex; + gap: 11px; + align-items: flex-start; + padding: 11px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--glass-brd); + background: var(--glass-2); +} +.prin-ic { + width: 28px; + height: 28px; + border-radius: 9px; + flex-shrink: 0; + display: grid; + place-items: center; + background: color-mix(in oklab, var(--tone) 14%, transparent); + color: var(--tone); +} +.prin-ic .ic { + width: 14px; + height: 14px; +} +.prin-body { + min-width: 0; +} +.prin-title { + display: block; + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; +} +.prin-desc { + display: block; + font-size: 11.5px; + color: var(--muted); + margin-top: 3px; + line-height: 1.5; +} + +.dest-list { + display: flex; + flex-direction: column; + margin-bottom: 4px; +} +.dest { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 2px; + border-top: 1px solid var(--line); + text-decoration: none; + color: inherit; +} +.dest:first-child { + border-top: none; + padding-top: 2px; +} +.dest-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; +} +.dest-label { + font-size: 13.5px; + font-weight: 700; + letter-spacing: -0.01em; +} +.dest-sub { + font-size: 11.5px; + color: var(--muted); + flex: 1; + min-width: 0; +} +.dest-n { + font-family: var(--font-mono); + font-size: 15px; + font-weight: 500; +} +.dest-go { + width: 24px; + height: 24px; + border-radius: 8px; + display: grid; + place-items: center; + color: var(--muted); + transition: background 0.13s, color 0.13s, transform 0.13s; +} +.dest-go .ic { + width: 13px; + height: 13px; +} +.dest.go:hover .dest-go { + background: var(--card-2); + color: var(--ink); + transform: translateX(2px); +} + +@media (prefers-reduced-motion: reduce) { + .sb-think .tdot { + animation: none !important; + } +} diff --git a/frontend/tests/inbox/CaptureComposer.test.tsx b/frontend/tests/inbox/CaptureComposer.test.tsx new file mode 100644 index 0000000..87bba54 --- /dev/null +++ b/frontend/tests/inbox/CaptureComposer.test.tsx @@ -0,0 +1,37 @@ +// frontend/tests/inbox/CaptureComposer.test.tsx +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CaptureComposer from "@/components/inbox/CaptureComposer"; + +describe("CaptureComposer", () => { + it("Enter로 text 제출 후 입력 비움", () => { + const onSubmit = vi.fn(); + render(); + const input = screen.getByLabelText("인박스에 빠르게 캡처") as HTMLInputElement; + fireEvent.change(input, { target: { value: "엄마 생신 선물 알아보기" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onSubmit).toHaveBeenCalledWith("엄마 생신 선물 알아보기", "text"); + expect(input.value).toBe(""); + }); + + it("mic 버튼: voice kind + 더미 transcript", () => { + const onSubmit = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText(/음성으로 캡처/)); + expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining("음성 메모"), "voice"); + }); + + it("image 버튼: image kind", () => { + const onSubmit = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText(/이미지로 캡처/)); + expect(onSubmit).toHaveBeenCalledWith(expect.any(String), "image"); + }); + + it("빈 입력은 제출 무시", () => { + const onSubmit = vi.fn(); + render(); + fireEvent.keyDown(screen.getByLabelText("인박스에 빠르게 캡처"), { key: "Enter" }); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/inbox/RouteChips.test.tsx b/frontend/tests/inbox/RouteChips.test.tsx new file mode 100644 index 0000000..213c607 --- /dev/null +++ b/frontend/tests/inbox/RouteChips.test.tsx @@ -0,0 +1,57 @@ +// frontend/tests/inbox/RouteChips.test.tsx +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import RouteChips from "@/components/inbox/RouteChips"; +import type { Classification } from "@/lib/types"; + +const base: Classification = { + id: "c1", + inbox_item_id: "s1", + type: "task", + sphere: "life", + project_id: "p", + proj_label: "개인 › 여행 — 한국", + tone: "coral", + due_text: "출발 전", + when_text: "오늘 21:00", + extra: "가격 추적 알림 켜둠", + reason: "r", + confidence: 0.9, + model: "heuristic", + created_at: "", +}; + +describe("RouteChips", () => { + it("task 칩: 작업 + sphere(개인) + proj + extra", () => { + render( {}} />); + expect(screen.getByText("작업")).toBeInTheDocument(); + expect(screen.getByText("개인")).toBeInTheDocument(); + expect(screen.getByText("개인 › 여행 — 한국")).toBeInTheDocument(); + expect(screen.getByText("가격 추적 알림 켜둠")).toBeInTheDocument(); + }); + + it("event 칩: type 클래스에 event 포함", () => { + const { container } = render( + {}} />, + ); + expect(container.querySelector(".r-chip.type.event")).toBeTruthy(); + expect(screen.getByText("일정")).toBeInTheDocument(); + }); + + it("due/when/extra 없으면 칩 미표시", () => { + render( + {}} + />, + ); + expect(screen.queryByText("출발 전")).toBeNull(); + expect(screen.getByText("아이디어")).toBeInTheDocument(); + }); + + it("editable=false면 타입 칩 disabled", () => { + render( {}} />); + expect(screen.getByRole("button", { name: /타입: 작업/ })).toBeDisabled(); + }); +}); diff --git a/frontend/tests/inbox/inbox-presentation.test.ts b/frontend/tests/inbox/inbox-presentation.test.ts new file mode 100644 index 0000000..e1f3efc --- /dev/null +++ b/frontend/tests/inbox/inbox-presentation.test.ts @@ -0,0 +1,20 @@ +// frontend/tests/inbox/inbox-presentation.test.ts +import { describe, expect, it } from "vitest"; +import { SPHERE, TYPE_ICON, TYPE_LABEL, nextType } from "@/lib/inbox/presentation"; + +describe("inbox-presentation", () => { + it("nextType 순환 task→event→idea→task", () => { + expect(nextType("task")).toBe("event"); + expect(nextType("event")).toBe("idea"); + expect(nextType("idea")).toBe("task"); + }); + it("라벨/아이콘/sphere 맵", () => { + expect(TYPE_LABEL.task).toBe("작업"); + expect(TYPE_LABEL.event).toBe("일정"); + expect(TYPE_LABEL.idea).toBe("아이디어"); + expect(TYPE_ICON.idea).toBe("brain"); + expect(TYPE_ICON.task).toBe("check"); + expect(SPHERE.life.label).toBe("개인"); + expect(SPHERE.work.label).toBe("업무"); + }); +});