diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d3034dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: ci +on: [push, pull_request] +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync + working-directory: backend + - run: uv run ruff check . + working-directory: backend + - run: uv run pytest -q # federation/nplus1/golden 포함 + working-directory: backend + + frontend-unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm, cache-dependency-path: frontend/pnpm-lock.yaml } + - run: pnpm install --frozen-lockfile + working-directory: frontend + - run: pnpm exec tsc --noEmit + working-directory: frontend + - run: pnpm lint + working-directory: frontend + - run: pnpm test + working-directory: frontend + + e2e: + runs-on: ubuntu-latest + needs: [backend, frontend-unit] + env: + LLM_PROVIDER: heuristic # CI는 Ollama 없이 결정론적 + ARI_ALLOW_TEST_RESET: "1" + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync && uv run alembic upgrade head && uv run python -m app.seed + working-directory: backend + - run: ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic uv run uvicorn app.main:app --port 8000 & + working-directory: backend + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm, cache-dependency-path: frontend/pnpm-lock.yaml } + - run: pnpm install --frozen-lockfile + working-directory: frontend + - run: pnpm exec playwright install --with-deps chromium + working-directory: frontend + - run: pnpm exec playwright test --workers=1 + working-directory: frontend + - uses: actions/upload-artifact@v4 + if: failure() + with: { name: playwright-report, path: frontend/playwright-report } diff --git a/README.md b/README.md index 417ad83..2ed87b3 100644 --- a/README.md +++ b/README.md @@ -2,39 +2,110 @@ 일·삶을 한곳에서 관리하는 한국어 AI 개인비서 **아리**의 MVP. 핵심 철학: **"적을 때는 분류하지 않는다 — 분류·배치·자동화는 아리가."** +사용자는 *읽고 탭 한 번*, 나머지는 아리가 합니다. -모노레포: `frontend/`(Next.js App Router + TS) · `backend/`(FastAPI + SQLite + Ollama). -개발 문서는 `dev/`(진입: `dev/overview.md`), 디자인 기준은 `design-reference/`. +MVP 3페이지 — **작업(/tasks) · 인박스(/inbox) · 대시보드(/dashboard)**. +나머지 10개 내비 항목은 "준비 중" 플레이스홀더(내비 일관성 유지). -## 빠른 시작 (Phase 0) +``` +workspace/ +├─ frontend/ Next.js 16 (App Router, TypeScript) · next-themes · SWR · Vitest · Playwright +├─ backend/ FastAPI · SQLite(SQLModel·Alembic) · Ollama 추상화(모델 비종속, heuristic 폴백) +├─ dev/ 개발 문서 (overview.md + phase-0~6) +└─ design-reference/ 원본 프로토타입 (픽셀 충실 재현 기준) +``` + +## 스택 + +| 레이어 | 선택 | +|---|---| +| 프론트엔드 | Next.js(App Router) + React 19 + TypeScript, 순수 CSS 변수 토큰, next-themes 테마 | +| 백엔드 | Python + FastAPI + SQLite(SQLModel + Alembic) | +| LLM | 로컬 Ollama + `LLMProvider` 추상화(모델 비종속, `OLLAMA_MODEL` 주입). 미가용 시 `HeuristicProvider` 폴백 | +| 테스트 | pytest(백엔드) / Vitest·Playwright·axe(프론트) | + +## 빠른 시작 + +### 0) 사전 설치 +Node ≥ 20 · pnpm · Python ≥ 3.11 · uv · (선택) Ollama + +### 1) 의존성 + 환경변수 + 시드 ```bash -# 0) 사전 설치: Node20+/pnpm, Python3.11+/uv, Ollama -make install # 프론트+백 의존성 +make install # frontend(pnpm) + backend(uv) 의존성 + +cp backend/.env.example backend/.env # 필요 시 OLLAMA_MODEL 등 수정 +cp frontend/.env.local.example frontend/.env.local + +cd backend && uv run alembic upgrade head && uv run python -m app.seed && cd .. +``` -# 1) 환경변수 -cp backend/.env.example backend/.env # 필요 시 OLLAMA_MODEL 등 수정 -echo 'NEXT_PUBLIC_API_BASE=http://localhost:8000' > frontend/.env.local +### 2) (선택) Ollama — 모델 비종속 -# 2) (선택) Ollama +```bash ollama serve & -ollama pull <설치할_모델> # .env 의 OLLAMA_MODEL 과 맞춤 (특정 모델 강제 아님) +ollama pull <설치할_모델> # backend/.env 의 OLLAMA_MODEL 과 일치시킬 것 (특정 모델 강제 아님) +``` -# 3) 실행 -make dev # 프론트 :3000 + 백 :8000 +> Ollama가 없거나 느리면 자동으로 규칙 기반(HeuristicProvider)으로 분류가 계속됩니다. +> `LLM_PROVIDER=heuristic` 로 강제할 수도 있습니다(테스트·오프라인). -# 4) 스모크 -make health # /api/health, /api/llm/health -make test # pytest + vitest -make lint # ruff + eslint +### 3) 실행 + +```bash +make dev # backend :8000 + frontend :3000 (또는 scripts/dev.sh) +# http://localhost:3000 → / 는 /dashboard 로 리다이렉트 ``` -## 구조 +### 4) 스모크 / 테스트 +```bash +make health # /api/health, /api/llm/health +make test # pytest + vitest +make lint # ruff + eslint + +# E2E (백엔드는 리셋 허용 + heuristic 으로 기동해야 federation/a11y/responsive 전부 통과) +ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic uv run --directory backend uvicorn app.main:app --port 8000 & +cd frontend && pnpm exec playwright test ``` -workspace/ -├─ frontend/ Next.js (App Router, TypeScript) -├─ backend/ FastAPI (SQLite, SQLModel, Alembic, Ollama) -├─ dev/ 개발 문서 (overview.md + phase-*.md) -└─ design-reference/ 원본 프로토타입 (픽셀 충실 재현 기준) + +## 시드 초기화(데모 리셋) + +```bash +cd backend && rm -f ari.db && uv run alembic upgrade head && uv run python -m app.seed +# 또는 E2E용(가드): ARI_ALLOW_TEST_RESET=1 로 기동 후 +curl -X POST http://localhost:8000/api/_test/reset ``` + +## 환경변수 + +| 변수 | 위치 | 기본값 | 설명 | +|---|---|---|---| +| `OLLAMA_HOST` | backend `.env` | `http://localhost:11434` | Ollama HTTP 엔드포인트 | +| `OLLAMA_MODEL` | backend `.env` | (설치 모델 주입) | 분류용 모델명 — **비종속**(env로만 지정) | +| `LLM_PROVIDER` | backend env | `auto` | `auto`\|`ollama`\|`heuristic` (테스트/E2E는 `heuristic`) | +| `LLM_TIMEOUT` | backend env | `30` | 분류 1건 타임아웃(초). 초과 시 heuristic 폴백 | +| `DATABASE_URL` | backend `.env` | `sqlite:///./ari.db` | DB 경로 | +| `FRONTEND_ORIGIN` | backend `.env` | `http://localhost:3000` | CORS 허용 출처(콤마구분) | +| `ARI_ALLOW_TEST_RESET` | backend env | (미설정) | `1`이면 `/api/_test/reset` 허용(E2E 전용, 운영 403) | +| `NEXT_PUBLIC_API_BASE` | frontend `.env.local` | `http://localhost:8000` | 프론트가 호출할 백엔드 베이스 | + +## 연합(federation) 흐름 — MVP의 핵심 + +1. **인박스**에 한 줄 적기 → 아리가 즉시 **작업/일정/아이디어 + 업무/개인 + 행선지 프로젝트**로 분류. +2. **확인(탭 한 번)** → 실제 `task`로 실체화(`materialized_task_id` 연결). +3. **작업 페이지**의 `개인 › 여행 — 한국`에 등장 — 개인 일도 숨기지 않고 업무 작업과 *같은 트리에서 필터로만* 구분. +4. 작업 데이터로 **리스크 레이더**(지연·쏠림·의존성)가 자동 재계산, **대시보드**가 작업/인박스/결재함을 집계. + +## 데모 스크립트(약 3분) + +1. 대시보드 진입 — 아침 브리핑/일정/할 일/목표/결재함·인박스 요약. +2. 인박스에서 `다음 주에 한국 놀러가는 비행기 티켓 사기` 캡처 → 자동 분류(작업/개인 › 여행 — 한국 + 이유). +3. "좋아요, 그렇게 해줘" → 실체화. +4. 작업 → 개인 필터 → 여행 — 한국에 등장(개인도 숨기지 않음). +5. 업무 필터 → 리스크 레이더 "분기 리포트가 오늘 마감인데 진행 중". +6. 대시보드 복귀 → 요약 반영. + +## 문서 + +개발 문서 진입점은 `dev/overview.md`. 빌드 순서: `phase-0` → `phase-1` → `phase-2` → (`phase-3` 작업 → `phase-4` 인박스 → `phase-5` 대시보드) → `phase-6` 통합. diff --git a/backend/app/main.py b/backend/app/main.py index 8eb4b93..5f760a9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from .config import get_settings from .db import init_db -from .routers import dashboard, inbox, llm, people, tasks, tree +from .routers import _test, dashboard, inbox, llm, people, tasks, tree settings = get_settings() health_router = APIRouter() # 내부 prefix 없음. /health → /api/health @@ -42,3 +42,5 @@ app.include_router(tasks.router, prefix="/api", tags=["tasks"]) app.include_router(inbox.router, prefix="/api", tags=["inbox"]) app.include_router(dashboard.router, prefix="/api", tags=["dashboard"]) app.include_router(llm.router, prefix="/api", tags=["llm"]) +# 테스트 전용 리셋(ARI_ALLOW_TEST_RESET=1 가드, 운영 403) +app.include_router(_test.router, prefix="/api", tags=["test"]) diff --git a/backend/app/routers/_test.py b/backend/app/routers/_test.py new file mode 100644 index 0000000..bec05c3 --- /dev/null +++ b/backend/app/routers/_test.py @@ -0,0 +1,22 @@ +# backend/app/routers/_test.py (테스트 전용 — 운영 빌드에서 가드) +import os + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, SQLModel + +from app.db import engine, get_session +from app.seed import run_seed + +router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록. + + +@router.post("/_test/reset") +def reset_seed(session: Session = Depends(get_session)): + """시드를 초기 상태로 되돌린다. ARI_ALLOW_TEST_RESET=1 일 때만 동작(운영 403).""" + if os.getenv("ARI_ALLOW_TEST_RESET") != "1": + raise HTTPException(403, "test reset disabled") + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + run_seed(session=session, reset=True) + session.commit() + return {"status": "reset"} diff --git a/backend/app/routers/inbox.py b/backend/app/routers/inbox.py index 57123fc..69a982e 100644 --- a/backend/app/routers/inbox.py +++ b/backend/app/routers/inbox.py @@ -92,6 +92,9 @@ def confirm(iid: str, s: Session = Depends(get_session)): item = s.get(InboxItem, iid) if not item: raise HTTPException(404, "inbox item not found") + # 멱등: 이미 confirmed 면 중복 실체화하지 않고 현재 상태만 반환 + if item.status == InboxStatus.confirmed: + return ConfirmResponse(item=item_out(s, item), task=None) c = latest_cls(s, iid) if not c: raise HTTPException(400, "no classification to confirm") diff --git a/backend/tests/test_federation.py b/backend/tests/test_federation.py new file mode 100644 index 0000000..b1e42cb --- /dev/null +++ b/backend/tests/test_federation.py @@ -0,0 +1,106 @@ +"""연합 시나리오 통합 테스트 — 캡처→분류→확인→tasks 등장→risks→dashboard 집계. +LLM은 HeuristicProvider로 고정(conftest, 결정론적). golden case 기준. +""" + +GOLDEN_RAW = "다음 주에 한국 놀러가는 비행기 티켓 사기" + + +def _flatten(nodes): + out = [] + for n in nodes: + out.append(n) + out.extend(_flatten(n.get("children", []) or [])) + return out + + +def test_health(client): + assert client.get("/api/health").json() == {"status": "ok"} + + +def test_capture_classifies_to_task_life_travel(client): + """① 캡처 → 분류: 비행기 티켓 = 작업 / life / 개인 › 여행 — 한국""" + r = client.post("/api/inbox/capture", json={"kind": "text", "raw": GOLDEN_RAW}) + assert r.status_code == 200 + c = r.json()["classification"] + assert c["type"] == "task" + assert c["sphere"] == "life" + assert "여행" in c["proj_label"] and "한국" in c["proj_label"] + assert "작업" in c["reason"] + assert 0.0 <= c["confidence"] <= 1.0 + assert r.json()["item"]["status"] == "classified" + + +def test_confirm_materializes_into_task_tree(client): + """① 확인(실체화): confirm 시 task 생성 + tasks 트리·life 필터에 등장""" + cap = client.post("/api/inbox/capture", json={"kind": "text", "raw": GOLDEN_RAW}).json() + item_id = cap["item"]["id"] + confirmed = client.post(f"/api/inbox/{item_id}/confirm").json() + new_task_id = confirmed["task"]["id"] + assert new_task_id + + inbox = client.get("/api/inbox").json() + target = next(i for i in inbox if i["id"] == item_id) + assert target["status"] == "confirmed" + assert target["materialized_task_id"] == new_task_id + + life = client.get("/api/tasks", params={"area": "life"}).json() + flat = _flatten(life) + created = next(t for t in flat if t["id"] == new_task_id) + assert created["project_id"] == "life-trip" + assert "비행기 티켓" in created["title"] + assert created["status"] == "todo" + assert "가격 추적" in created["notes"] + + +def test_confirm_idempotent(client): + """confirm 두 번 호출 — 동일 materialized_task_id, 중복 task 생성 금지""" + cap = client.post("/api/inbox/capture", json={"kind": "text", "raw": GOLDEN_RAW}).json() + iid = cap["item"]["id"] + first = client.post(f"/api/inbox/{iid}/confirm").json() + first_tid = first["task"]["id"] + # 두 번째 confirm: 이미 confirmed → task 미생성(없음), materialized_task_id 유지 + second = client.post(f"/api/inbox/{iid}/confirm").json() + assert second["task"] is None + inbox = {i["id"]: i for i in client.get("/api/inbox").json()} + assert inbox[iid]["materialized_task_id"] == first_tid + + +def test_risk_recompute_after_task_change(client): + """② 작업 데이터 → 리스크 레이더 자동 계산(최대 3건, TODAY=8)""" + risks = client.get("/api/risks", params={"area": "work"}).json() + assert len(risks) <= 3 + kinds = [r["kind"] for r in risks] + assert "지연 위험" in kinds + delay = next(r for r in risks if r["kind"] == "지연 위험") + assert delay["tone"] == "coral" and delay["icon"] == "clock" + if "의존성" in kinds: + dep = next(r for r in risks if r["kind"] == "의존성") + assert dep["tone"] == "violet" and dep["icon"] == "link" + + +def test_risk_disappears_when_done(client): + """k1(분기 리포트)을 done 으로 PATCH 후 → 지연 위험에서 k1 사라짐(재계산)""" + before = client.get("/api/risks", params={"area": "work"}).json() + delay_before = next((r for r in before if r["kind"] == "지연 위험"), None) + assert delay_before and delay_before["task_id"] == "k1" + client.patch("/api/tasks/k1", json={"status": "done"}) + after = client.get("/api/risks", params={"area": "work"}).json() + delay_after = next((r for r in after if r["kind"] == "지연 위험"), None) + # k1 이 done → 지연 위험이 없거나 다른 작업으로 바뀜(k1 아님) + assert delay_after is None or delay_after["task_id"] != "k1" + + +def test_dashboard_aggregates_tasks_inbox_approvals(client): + """③ 대시보드 집계 — 작업/인박스/결재함 요약 + 배지""" + cap = client.post("/api/inbox/capture", json={"kind": "text", "raw": GOLDEN_RAW}).json() + client.post(f"/api/inbox/{cap['item']['id']}/confirm") + + d = client.get("/api/dashboard").json() + assert "user" in d and d["user"]["name"] + assert "briefing" in d and "schedule" in d + assert "task_summary" in d and "goals" in d + assert "approvals_summary" in d and "inbox_recent" in d + assert "open_count" in d["task_summary"] and "items" in d["task_summary"] + assert len(d["approvals_summary"]) <= 3 + assert set(d["badges"]) == {"appr", "task", "noti"} + assert isinstance(d["inbox_recent"], list) diff --git a/backend/tests/test_nplus1.py b/backend/tests/test_nplus1.py new file mode 100644 index 0000000..0d7149b --- /dev/null +++ b/backend/tests/test_nplus1.py @@ -0,0 +1,34 @@ +"""N+1 쿼리 회귀 — 트리/대시보드의 실제 발행 쿼리 수를 센다.""" +from contextlib import contextmanager + +from sqlalchemy import event + + +@contextmanager +def count_queries(engine): + counter = {"n": 0} + + def _before(conn, cursor, statement, *a, **k): + counter["n"] += 1 + + event.listen(engine, "before_cursor_execute", _before) + try: + yield counter + finally: + event.remove(engine, "before_cursor_execute", _before) + + +def test_tree_no_nplus1(client, session): + _s, engine = session + with count_queries(engine) as c: + r = client.get("/api/tree") + assert r.status_code == 200 + assert c["n"] <= 6, f"트리 쿼리 {c['n']}개 — N+1 의심" + + +def test_dashboard_query_budget(client, session): + _s, engine = session + with count_queries(engine) as c: + r = client.get("/api/dashboard") + assert r.status_code == 200 + assert c["n"] <= 12, f"대시보드 쿼리 {c['n']}개 — 집계 합치기 필요" diff --git a/frontend/playwright/a11y.spec.ts b/frontend/playwright/a11y.spec.ts index da757bd..b6a9778 100644 --- a/frontend/playwright/a11y.spec.ts +++ b/frontend/playwright/a11y.spec.ts @@ -1,15 +1,40 @@ -// frontend/playwright/a11y.spec.ts -import { test, expect } from "@playwright/test"; +// frontend/playwright/a11y.spec.ts — 3페이지 접근성 감사(라이트/다크) + 키보드 +// color-contrast 제외(레퍼런스 액센트 팔레트가 디자인 정본) — 구조/시맨틱 WCAG 는 엄격. import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; -for (const theme of ["light", "dark"] as const) { - test(`axe — ${theme} 테마 대시보드 위반 0건`, async ({ page }) => { - await page.goto("/dashboard"); - if (theme === "dark") { - await page.getByLabel("테마 전환").click(); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - } - const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze(); - expect(results.violations).toEqual([]); +const AXE = (page: import("@playwright/test").Page) => + new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).disableRules(["color-contrast"]); + +const PAGES: [string, string][] = [ + ["/dashboard", "아리 브리핑"], + ["/inbox", "스마트 인박스"], + ["/tasks", "리스크 레이더"], +]; + +for (const [path, marker] of PAGES) { + test(`a11y(axe): ${path} — 위반 0건`, async ({ page }) => { + await page.goto(path); + await page.getByText(marker).first().waitFor(); + const r = await AXE(page).analyze(); + expect(r.violations, JSON.stringify(r.violations, null, 2)).toEqual([]); }); } + +test("다크 테마 대시보드 — 위반 0건 + 테마 영속", async ({ page }) => { + await page.goto("/dashboard"); + await page.getByText("아리 브리핑").waitFor(); + await page.getByLabel("테마 전환").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + const r = await AXE(page).analyze(); + expect(r.violations).toEqual([]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); // localStorage 영속 +}); + +test("키보드 내비: Tab 으로 포커스 이동 가능", async ({ page }) => { + await page.goto("/dashboard"); + await page.keyboard.press("Tab"); + const focused = await page.evaluate(() => document.activeElement?.tagName); + expect(focused).toBeTruthy(); +}); diff --git a/frontend/playwright/dashboard.a11y.spec.ts b/frontend/playwright/dashboard.a11y.spec.ts deleted file mode 100644 index 84e872a..0000000 --- a/frontend/playwright/dashboard.a11y.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -// frontend/playwright/dashboard.a11y.spec.ts -import AxeBuilder from "@axe-core/playwright"; -import { expect, test } from "@playwright/test"; - -// color-contrast 제외(레퍼런스 액센트 팔레트) — 구조/시맨틱 WCAG 는 엄격. -const AXE = (page: import("@playwright/test").Page) => - new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).disableRules(["color-contrast"]); - -test("대시보드 a11y — 라이트", async ({ page }) => { - await page.goto("/dashboard"); - await page.getByText("아리 브리핑").waitFor(); - expect((await AXE(page).analyze()).violations).toEqual([]); -}); - -test("대시보드 a11y — 다크", async ({ page }) => { - await page.goto("/dashboard"); - await page.getByText("아리 브리핑").waitFor(); - await page.getByLabel("테마 전환").click(); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - expect((await AXE(page).analyze()).violations).toEqual([]); -}); diff --git a/frontend/playwright/federation.spec.ts b/frontend/playwright/federation.spec.ts new file mode 100644 index 0000000..330635f --- /dev/null +++ b/frontend/playwright/federation.spec.ts @@ -0,0 +1,58 @@ +// frontend/playwright/federation.spec.ts +// 연합 E2E 사용자 여정: 대시보드 → 인박스 캡처 → 분류 좋아요(확인) → +// 작업 페이지(개인 › 여행 — 한국) 등장 → 리스크 레이더 → 대시보드 복귀. +// 백엔드는 ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic 로 기동 가정. +import { expect, test } from "@playwright/test"; +import { resetSeed } from "./fixtures/seed-reset"; + +test.describe.configure({ mode: "serial" }); + +const RAW = "다음 주에 한국 놀러가는 비행기 티켓 사기"; + +test.beforeEach(async () => { + await resetSeed(); +}); + +test("연합: 캡처 → 분류 → 확인 → 작업 트리 등장 → 리스크 → 대시보드 집계", async ({ page }) => { + // 0) 루트 → 대시보드 리다이렉트, 상단 내비 13항목 + await page.goto("/"); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole("navigation").getByText("작업")).toBeVisible(); + await expect(page.getByRole("navigation").getByText("인박스")).toBeVisible(); + + // 1) 인박스로 이동 후 캡처 (내비로 스코프 — 카드 CTA 와 구분) + const nav = page.getByRole("navigation"); + await nav.getByRole("link", { name: "인박스" }).click(); + await expect(page).toHaveURL(/\/inbox$/); + const composer = page.getByLabel("인박스에 빠르게 캡처"); + await composer.fill(RAW); + await composer.press("Enter"); + + // 2) 분류 결과 — 작업 / 개인 › 여행 — 한국 / 구매(reason) + const card = page.locator(".sb-cap.fresh").first(); + await expect(card.getByText("작업", { exact: true })).toBeVisible(); + await expect(card.locator(".r-chip.proj")).toContainText("개인 › 여행 — 한국"); + await expect(card.locator(".sb-reason")).toContainText("작업"); + + // 3) 좋아요(확인=실체화) + await card.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click(); + await expect(card.locator(".sb-done")).toBeVisible(); + + // 4) 작업 페이지 → 개인 필터 → 여행 — 한국에 새 task 등장(개인도 숨기지 않음) + await nav.getByRole("link", { name: "작업" }).click(); + await expect(page).toHaveURL(/\/tasks/); + await page.locator(".tree-row.folder", { hasText: "개인" }).click(); + await expect(page.locator(".kcard-title", { hasText: "비행기 티켓" }).first()).toBeVisible(); + + // 5) 업무 필터 → 리스크 레이더 지연 위험 + await page.locator(".tree-row.folder", { hasText: "업무" }).click(); + const radar = page.locator(".rradar"); + await expect(radar).toContainText("리스크 레이더"); + await expect(radar).toContainText("지연 위험"); + + // 6) 대시보드 복귀 → 요약 갱신 + await nav.getByRole("link", { name: "대시보드" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByText("아리 브리핑")).toBeVisible(); + await expect(page.getByRole("heading", { name: "할 일" })).toBeVisible(); +}); diff --git a/frontend/playwright/fixtures/seed-reset.ts b/frontend/playwright/fixtures/seed-reset.ts new file mode 100644 index 0000000..2c09907 --- /dev/null +++ b/frontend/playwright/fixtures/seed-reset.ts @@ -0,0 +1,16 @@ +// frontend/playwright/fixtures/seed-reset.ts +import { request } from "@playwright/test"; + +const API = process.env.API_BASE ?? "http://localhost:8000"; + +/** 백엔드 시드를 초기 상태로 되돌린다. ARI_ALLOW_TEST_RESET=1 일 때만 동작. */ +export async function resetSeed(): Promise { + const ctx = await request.newContext(); + const res = await ctx.post(`${API}/api/_test/reset`); + if (!res.ok()) { + throw new Error( + `시드 리셋 실패(${res.status()}). 백엔드를 ARI_ALLOW_TEST_RESET=1 로 띄웠는지 확인하세요.`, + ); + } + await ctx.dispose(); +} diff --git a/frontend/playwright/inbox.spec.ts b/frontend/playwright/inbox.spec.ts index 24f0e4a..634f4f4 100644 --- a/frontend/playwright/inbox.spec.ts +++ b/frontend/playwright/inbox.spec.ts @@ -18,11 +18,11 @@ test("캡처 → 분류 → 좋아요 → 작업 페이지에 등장(연합)", a await fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click(); await expect(fresh.locator(".sb-done")).toBeVisible(); - // 작업 페이지(개인 스코프)에서 확인 — 연합 + // 작업 페이지(개인 스코프)에서 확인 — 연합 (.first(): 전체 스위트에서 중복 캡처 대비) await page.goto("/tasks"); await page.locator(".tree-row.folder", { hasText: "개인" }).click(); await expect( - page.locator(".kcard-title", { hasText: "다음 주에 한국 놀러가는 비행기 티켓 사기" }), + page.locator(".kcard-title", { hasText: "다음 주에 한국 놀러가는 비행기 티켓 사기" }).first(), ).toBeVisible(); }); diff --git a/frontend/playwright/responsive.spec.ts b/frontend/playwright/responsive.spec.ts new file mode 100644 index 0000000..c0ce35b --- /dev/null +++ b/frontend/playwright/responsive.spec.ts @@ -0,0 +1,21 @@ +// frontend/playwright/responsive.spec.ts +import { expect, test } from "@playwright/test"; + +const viewports = [ + { name: "mobile", w: 390, h: 844 }, + { name: "tablet", w: 834, h: 1112 }, + { name: "desktop", w: 1440, h: 900 }, +]; + +for (const v of viewports) { + test(`반응형(${v.name}): 가로 스크롤 없음 + 내비 접근 가능`, async ({ page }) => { + await page.setViewportSize({ width: v.w, height: v.h }); + await page.goto("/tasks"); + await expect(page.locator(".kboard")).toBeVisible(); + const scrollW = await page.evaluate(() => document.documentElement.scrollWidth); + const clientW = await page.evaluate(() => document.documentElement.clientWidth); + expect(scrollW).toBeLessThanOrEqual(clientW + 1); + // 모바일에서도 13항목 내비에 도달 가능(가로 스크롤 내비 유지) + await expect(page.getByRole("navigation").getByRole("link", { name: /작업/ })).toBeVisible(); + }); +} diff --git a/frontend/playwright/tasks.a11y.spec.ts b/frontend/playwright/tasks.a11y.spec.ts deleted file mode 100644 index 761298e..0000000 --- a/frontend/playwright/tasks.a11y.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -// frontend/playwright/tasks.a11y.spec.ts -import AxeBuilder from "@axe-core/playwright"; -import { expect, test } from "@playwright/test"; - -// color-contrast 는 비활성화: 원본 디자인 레퍼런스의 액센트 팔레트(코랄/앰버/바이올렛 -// 소형 라벨)가 WCAG AA 대비 미달이나, 픽셀 충실 재현이 디자인 정본이므로 제외한다. -// 구조/시맨틱(라벨/role/이름 등) WCAG 검사는 그대로 엄격 적용. -const AXE = (page: import("@playwright/test").Page) => - new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).disableRules(["color-contrast"]); - -test("작업 페이지 a11y (칸반)", async ({ page }) => { - await page.goto("/tasks"); - await expect(page.locator(".kboard")).toBeVisible(); - const r = await AXE(page).analyze(); - expect(r.violations).toEqual([]); -}); - -test("드로어 a11y (role=dialog, Esc 닫힘)", async ({ page }) => { - await page.goto("/tasks"); - await page.locator(".kcard").first().click(); - await expect(page.locator('[role=dialog][aria-label="작업 상세"]')).toBeVisible(); - const r = await AXE(page).include(".dpanel").analyze(); - expect(r.violations).toEqual([]); - await page.keyboard.press("Escape"); - await expect(page.locator(".dpanel")).toHaveCount(0); -}); diff --git a/frontend/styles/dash-base.css b/frontend/styles/dash-base.css index 9400531..e5fe334 100644 --- a/frontend/styles/dash-base.css +++ b/frontend/styles/dash-base.css @@ -291,6 +291,16 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 760px) { + /* 페이지 헤더: 좁은 화면에서 줄바꿈 + 검색창 가변폭 (가로 스크롤 방지) */ + .pagehead { + flex-wrap: wrap; + } + .ph-search { + margin-left: 0; + width: 100%; + } +} @media (max-width: 700px) { .board { grid-template-columns: 1fr; diff --git a/frontend/styles/tasks.css b/frontend/styles/tasks.css index 2568afd..1884f6a 100644 --- a/frontend/styles/tasks.css +++ b/frontend/styles/tasks.css @@ -2173,6 +2173,15 @@ .lrow { grid-template-columns: minmax(0, 2.2fr) 70px 60px 36px; } + /* 툴바: 좁은 화면에서 줄바꿈 + 검색창 가변폭 (가로 스크롤 방지) */ + .ttoolbar { + flex-wrap: wrap; + } + .t-search { + width: auto; + flex: 1 1 140px; + margin-left: 0; + } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/tests/tokens.test.ts b/frontend/tests/tokens.test.ts new file mode 100644 index 0000000..c9f70f4 --- /dev/null +++ b/frontend/tests/tokens.test.ts @@ -0,0 +1,45 @@ +// frontend/tests/tokens.test.ts — 디자인 토큰 값이 원본 dash.css :root 와 일치하는지 회귀 +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const css = fs.readFileSync(path.resolve(__dirname, "../styles/tokens.css"), "utf8"); + +const cases: [string, string][] = [ + ["--bg-top", "#f6efe7"], + ["--bg-mid", "#eef0f1"], + ["--bg-bot", "#e9ebed"], + ["--card", "#ffffff"], + ["--ink", "#211f1c"], + ["--ink-2", "#514d47"], + ["--muted", "#8d8a85"], + ["--faint", "#b6b3ad"], + ["--fill", "#29241f"], + ["--on-fill", "#f4efe6"], + ["--blue", "#4f72e0"], + ["--coral", "#df7256"], + ["--green", "#4e9b66"], + ["--violet", "#8b6fd4"], + ["--amber", "#e0a23c"], + ["--lime", "#c2f24a"], + ["--lime-ink", "#233006"], + ["--radius", "22px"], + ["--radius-sm", "14px"], +]; + +describe("tokens.css 충실도", () => { + for (const [name, val] of cases) { + it(`${name} = ${val}`, () => { + const re = new RegExp( + `${name}\\s*:\\s*${val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, + "i", + ); + expect(css).toMatch(re); + }); + } + + it("다크 오버라이드 존재 (--card #2c2925)", () => { + expect(css).toMatch(/\[data-theme="dark"\]/); + expect(css).toMatch(/--card:\s*#2c2925/i); + }); +}); diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..786f1a3 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# 아리 — backend(:8000) + frontend(:3000) 동시 기동. Ctrl-C 로 둘 다 정리. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +trap 'kill 0' INT TERM EXIT +( cd "$ROOT/backend" && uv run uvicorn app.main:app --reload --port 8000 ) & +( cd "$ROOT/frontend" && pnpm dev ) & +wait