Phase 6: 통합 · 연합 흐름 · QA & MVP 수용 기준

- 백엔드: test_federation(캡처→분류→확인→tasks 등장→risks 재계산→dashboard 집계,
  confirm 멱등, k1 done 후 지연위험 소멸) + test_nplus1(tree≤6/dashboard≤12 쿼리 예산)
  + /api/_test/reset(ARI_ALLOW_TEST_RESET 가드) + confirm 멱등 가드
- 프론트 E2E: federation.spec(7단계 사용자 여정) + a11y.spec(3페이지 라이트/다크/키보드,
  color-contrast 제외) + responsive.spec(모바일/태블릿/데스크톱 가로스크롤 0) + seed-reset fixture
- tokens.test(토큰 HEX/px 회귀)
- 반응형 보정: 모바일에서 .ttoolbar/.pagehead 줄바꿈 + 검색창 가변폭(가로 스크롤 제거)
- README 한 장 실행법/데모 스크립트, scripts/dev.sh, frontend/.env.local.example, CI 워크플로

최종 검증(전 phase 누적):
- 백엔드 pytest 63 passed, ruff clean
- 프론트 vitest 69 passed, tsc/eslint clean, build OK
- Playwright E2E 39 passed(연합/a11y/반응형/shell/tasks/inbox/dashboard) — axe 위반 0
- 라이브: Ollama 분류 동작(모델 비종속) + heuristic 폴백, 전 엔드포인트 200, 콘솔 에러 0
- 소스 TODO/FIXME 0

MVP 완료: 작업·인박스·대시보드 3페이지 + 연합 end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Claude 2 months ago
parent 09ed8497f3
commit c4e86e06ec

@ -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 }

@ -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) 의존성
# 1) 환경변수
cp backend/.env.example backend/.env # 필요 시 OLLAMA_MODEL 등 수정
echo 'NEXT_PUBLIC_API_BASE=http://localhost:8000' > frontend/.env.local
cp frontend/.env.local.example frontend/.env.local
# 2) (선택) Ollama
cd backend && uv run alembic upgrade head && uv run python -m app.seed && cd ..
```
### 2) (선택) Ollama — 모델 비종속
```bash
ollama serve &
ollama pull <설치할_모델> # .env 의 OLLAMA_MODEL 과 맞춤 (특정 모델 강제 아님)
ollama pull <설치할_모델> # backend/.env 의 OLLAMA_MODEL 과 일치시킬 것 (특정 모델 강제 아님)
```
> Ollama가 없거나 느리면 자동으로 규칙 기반(HeuristicProvider)으로 분류가 계속됩니다.
> `LLM_PROVIDER=heuristic` 로 강제할 수도 있습니다(테스트·오프라인).
# 3) 실행
make dev # 프론트 :3000 + 백 :8000
### 3) 실행
# 4) 스모크
```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
```
## 구조
## 시드 초기화(데모 리셋)
```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
```
workspace/
├─ frontend/ Next.js (App Router, TypeScript)
├─ backend/ FastAPI (SQLite, SQLModel, Alembic, Ollama)
├─ dev/ 개발 문서 (overview.md + phase-*.md)
└─ design-reference/ 원본 프로토타입 (픽셀 충실 재현 기준)
```
## 환경변수
| 변수 | 위치 | 기본값 | 설명 |
|---|---|---|---|
| `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` 통합.

@ -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"])

@ -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"}

@ -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")

@ -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)

@ -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']}개 — 집계 합치기 필요"

@ -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 }) => {
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");
if (theme === "dark") {
await page.getByText("아리 브리핑").waitFor();
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 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();
});

@ -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([]);
});

@ -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();
});

@ -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<void> {
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();
}

@ -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();
});

@ -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();
});
}

@ -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);
});

@ -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;

@ -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) {

@ -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);
});
});

@ -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
Loading…
Cancel
Save