전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영

- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리
  (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가.
  RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset
  루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉
  마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤
  스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등.
- 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규
  학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual),
  저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence +
  결정론 파생 폴백).
- 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의
  CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect.
- 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest
  421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과,
  layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드
  142~144차 노트.
This commit is contained in:
Yun Chan 2026-07-27 14:25:24 +09:00
parent 0ae94499f2
commit 4511383cd9
55 changed files with 9333 additions and 573 deletions

View file

@ -291,11 +291,18 @@ def _row_value(row, key: str, default=None):
def _cohort_ids(cohort: str | None) -> list[str]:
return [cohort] if cohort else []
"""단일 TEXT 컬럼(콤마 직렬화)을 cohort id 리스트로 복원한다."""
if not cohort:
return []
return [part for part in (piece.strip() for piece in cohort.split(",")) if part]
def _cohort_value(cohort_ids: list[str] | None) -> str | None:
return (cohort_ids or [None])[0]
"""cohort id 리스트를 콤마 직렬화해 저장한다. 첫 항목만 남기던 유실 버그 수정."""
cleaned = [part for part in ((item or "").strip() for item in cohort_ids or []) if part]
if not cleaned:
return None
return ",".join(cleaned)
async def _runtime_tables_ready(conn) -> bool:

View file

@ -36,6 +36,12 @@ _REVIEW_COLUMNS = """
source_provenance, is_synthetic, created_at, approved_at
"""
# UPDATE ... FROM target 처럼 다른 테이블이 스코프에 있는 RETURNING에서는
# 무한정 `code`가 target.code와 모호해진다(AmbiguousColumnError → 보관 503).
_REVIEW_COLUMNS_CARD_QUALIFIED = ", ".join(
f"card.{column.strip()}" for column in _REVIEW_COLUMNS.split(",")
)
_PERSONA_STATUSES = {"draft", "review", "approved", "archived"}
_REVIEW_QUEUE_STATUSES = ("draft", "review")
PersonaReviewAction = Literal["approve", "reject"]
@ -818,7 +824,7 @@ async def archive_persona_family(
FROM target
WHERE upper(card.code) = upper(target.code)
AND card.status <> 'archived'
RETURNING {_REVIEW_COLUMNS}
RETURNING {_REVIEW_COLUMNS_CARD_QUALIFIED}
)
SELECT {_REVIEW_COLUMNS}
FROM archived

View file

@ -7,8 +7,10 @@ import time
from pathlib import Path
from typing import Literal
import re
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from ..auth_types import RoleName
from ..auth_sessions import (
@ -128,6 +130,20 @@ class UserProfileResponse(BaseModel):
onboarding_required: bool = True
# 연락처: 선행 + 허용, 숫자·하이픈·공백·괄호·점만, 숫자 9~15자리.
_PHONE_ALLOWED_RE = re.compile(r"^\+?[0-9()\-\s.]+$")
def _validate_phone_format(value: str) -> str:
trimmed = value.strip()
digits = re.sub(r"\D", "", trimmed)
if not _PHONE_ALLOWED_RE.fullmatch(trimmed) or not 9 <= len(digits) <= 15:
raise ValueError(
"연락처는 숫자 9~15자리의 전화번호 형식이어야 합니다 (예: 010-1234-5678)."
)
return trimmed
class UserProfilePatch(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=80)
affiliation: str | None = Field(default=None, max_length=120)
@ -140,6 +156,13 @@ class UserProfilePatch(BaseModel):
self_introduction: str | None = Field(default=None, max_length=600)
avatar_url: str | None = Field(default=None, max_length=500)
@field_validator("phone")
@classmethod
def _check_phone(cls, value: str | None) -> str | None:
if value is None:
return value
return _validate_phone_format(value)
class OnboardingRequest(BaseModel):
legal_name: str = Field(..., min_length=1, max_length=80)
@ -154,6 +177,11 @@ class OnboardingRequest(BaseModel):
terms_accepted: bool
privacy_accepted: bool
@field_validator("phone")
@classmethod
def _check_phone(cls, value: str) -> str:
return _validate_phone_format(value)
class AvatarUploadResponse(BaseModel):
avatar_url: str

View file

@ -264,6 +264,13 @@ class TechniqueDistribution(BaseModel):
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
class TurnValencePoint(BaseModel):
"""deep-loop 턴별 내담자 정서가 — 축어록 seq(1-based) 지목 + v(1~+1)."""
seq: int # 마스킹 축어록의 1-based 턴 번호
v: float # 정서가(1 매우 부정 ~ +1 매우 긍정)
class SessionEvaluation(BaseModel):
"""deep-loop 회기말/단계전환 정밀 평가 결과.
@ -282,6 +289,9 @@ class SessionEvaluation(BaseModel):
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
turn_valence: list[TurnValencePoint] = Field(
default_factory=list
) # 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천)
theory_mode: Optional[str] = None
error: Optional[str] = None
@ -378,6 +388,19 @@ def _deep_schema() -> dict[str, Any]:
"supervisor_rationale": {"type": "string"},
"supervisor_critique": {"type": "string"},
"alternative_utterances": {"type": "array", "items": {"type": "string"}},
# 선택 필드 — 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천).
"turn_valence": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"seq": {"type": "integer", "minimum": 1},
"v": {"type": "number", "minimum": -1, "maximum": 1},
},
"required": ["seq", "v"],
},
},
},
"required": ["strengths", "improvements"],
}
@ -583,7 +606,10 @@ def build_deep_messages(
"- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n"
"- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n"
"- supervisor_critique: 과도/부족/평가적 시각 등 주의점(critique) 종합.\n"
"- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개."
"- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개.\n"
"- turn_valence: *내담자* 발화 각각의 정서가를 {seq, v}로 산출. seq 는 축어록의 "
"1-based 턴 번호, v 는 1(매우 부정)~+1(매우 긍정). 근거 없는 극단값을 피하고 "
"불확실하면 0 근처로."
),
]
)
@ -854,6 +880,18 @@ async def evaluate_session(
dev = _parse_intent_deviation(d)
if dev is not None:
base.intent_deviations.append(dev)
for item in payload.get("turn_valence") or []:
if not isinstance(item, dict):
continue
seq = item.get("seq")
v = item.get("v")
if not isinstance(seq, int) or isinstance(seq, bool) or seq < 1:
continue
if not isinstance(v, (int, float)) or isinstance(v, bool):
continue
base.turn_valence.append(
TurnValencePoint(seq=seq, v=max(-1.0, min(1.0, float(v))))
)
_evaluator_cache_put(cache_key, base.model_dump())
return base
@ -888,6 +926,7 @@ __all__ = [
"ClientStateRead",
"TurnEvaluation",
"TechniqueDistribution",
"TurnValencePoint",
"SessionEvaluation",
"aggregate_distribution",
"evaluate_turn",

View file

@ -46,6 +46,10 @@ _KOREAN_SURNAME_CHARS = (
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?"
_KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}"
# 인명 마지막 글자로 사실상 쓰이지 않는 용언 활용/명사화 꼬리 글자. 문맥 단서가
# 없는 인명 패턴이 "유의점은·방치되는·마무리했을·연결감과·표현함을·어지러움은"
# 같은 일반 단어를 이름으로 오탐해 평가 문장을 훼손하는 것을 막는다(정밀도 가드).
_KOREAN_NAME_TAIL_GUARD = "(?<![했됐되된될것듯점음움됨함감])"
_KOREAN_NAME_STOPWORDS = {
"연락",
"연락처",
@ -109,7 +113,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
"NAME",
re.compile(
r"(?P<prefix>(?:(?:저는|나는|제가|내가)\s*)?)"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX}){_KOREAN_NAME_TAIL_GUARD}"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요))"
r"(?=$|[\s,.;!?。])"
),
@ -128,7 +132,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME})"
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME}){_KOREAN_NAME_TAIL_GUARD}"
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
),
),

View file

@ -431,7 +431,11 @@ async def _session_review_payload_and_recipients(
OR lower(u.email) = ANY($1::text[])
OR (
u.role = 'instructor'
AND ($2 = '' OR u.cohort = $2)
AND (
$2 = ''
OR string_to_array(COALESCE(u.cohort, ''), ',')
&& string_to_array($2, ',')
)
)
)
AND COALESCE((p.notifications->>'learner_progress')::boolean, true)

View file

@ -750,6 +750,24 @@ def _client_name(raw: str) -> str:
return name or raw.strip() or "내담자"
_DIFFICULTY_KO = {"easy": "기초", "moderate": "중간", "hard": "고난도"}
def _persona_label(code: str, difficulty: str) -> str:
return f"{code} · {_DIFFICULTY_KO.get(difficulty, difficulty)}"
def _josa_wa_gwa(name: str) -> str:
"""이름 마지막 글자의 받침 유무로 와/과를 고른다. 한글이 아니면 병기한다."""
tail = name[-1] if name else ""
if "" <= tail <= "":
has_final = (ord(tail) - 0xAC00) % 28 != 0
if has_final:
return ""
return ""
return "와(과)"
def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str:
if not turns:
return (
@ -760,7 +778,7 @@ def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[
client_count = sum(1 for turn in turns if turn.speaker == "client")
return (
f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. "
f"{client_name}의 회기는 {reached_phase} 단계까지 진행되었고, "
f"{client_name}{_josa_wa_gwa(client_name)}의 회기는 {reached_phase} 단계까지 진행되었고, "
f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. "
"평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다."
)
@ -1405,6 +1423,185 @@ def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
return events
# ─ 감정 밸런스 타임라인(valence) 파생 — 순수 함수 ──────────────────────────────
_VALENCE_MAX_POINTS = 10
# client_state_read 코드 → 정서가 극성(taxonomy.ClientState 코드 기준 휴리스틱 맵).
_CLIENT_STATE_VALENCE: dict[str, float] = {
# 음의 극성 — 방어·위축·위기 신호
"involuntary": -0.5,
"defensive": -0.7,
"suicidal_ideation_admit": -0.9,
"negative_self_perception": -0.7,
"conflicted": -0.4,
"lack_of_confidence": -0.4,
"compliant_surface": -0.2,
"externalizing": -0.3,
"apparent_competence": -0.2,
"active_passivity": -0.3,
"self_harm_disclosure": -0.9,
"somatic_complaint": -0.4,
"affect_masking": -0.3,
"focus_drift_fusion": -0.3,
# 양의 극성 — 개방·접촉·진전 신호
"affect_contact": 0.6,
"thought_organizing": 0.5,
"responds_to_exploration": 0.5,
"expresses_plan": 0.7,
"defense_loosening": 0.6,
"seeks_guidance": 0.2,
}
def _clamp_valence(value: float) -> float:
return max(-1.0, min(1.0, value))
def _valence_t(
created_at: float, first_turn_ts: float, duration_seconds: float
) -> float:
"""턴 시각 → 회기 진행률(0~1 클램프)."""
if duration_seconds <= 0:
return 0.0
return max(0.0, min(1.0, (created_at - first_turn_ts) / duration_seconds))
def _finalize_valence_points(
points: list[ReviewValencePoint],
) -> list[ReviewValencePoint]:
"""2개 미만이면 빈 배열(차트 빈 상태), 10개 초과면 균등 리샘플(양 끝점 유지)."""
if len(points) < 2:
return []
if len(points) <= _VALENCE_MAX_POINTS:
return points
last = len(points) - 1
indices: list[int] = []
for i in range(_VALENCE_MAX_POINTS):
idx = round(i * last / (_VALENCE_MAX_POINTS - 1))
if not indices or idx != indices[-1]:
indices.append(idx)
return [points[i] for i in indices]
def counselor_baseline_points(
turns: list[TurnRecord],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""학습자 턴 rapport_signal 누적 이동평균 → 상담자 기준선 궤적."""
points: list[ReviewValencePoint] = []
total = 0.0
count = 0
for turn in turns:
if turn.speaker != "counselor":
continue
ev = session_metrics.turn_eval(turn)
if ev is None or str(ev.get("error") or "").strip():
continue
rapport = session_metrics.turn_rapport(ev)
if rapport is None:
continue
count += 1
total += rapport
points.append(
ReviewValencePoint(
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
v=_clamp_valence(total / count),
)
)
return _finalize_valence_points(points)
def _fallback_client_valence_points(
turns: list[TurnRecord],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""폴백 — 상담자 턴 평가의 client_state_read 극성과 appropriateness(0~1) 결합."""
points: list[ReviewValencePoint] = []
for turn in turns:
if turn.speaker != "counselor":
continue
ev = session_metrics.turn_eval(turn)
if ev is None or str(ev.get("error") or "").strip():
continue
polarities: list[float] = []
for state in ev.get("client_state_read") or []:
code = state.get("code") if isinstance(state, dict) else state
mapped = _CLIENT_STATE_VALENCE.get(str(code or "").strip())
if mapped is not None:
polarities.append(mapped)
score01 = session_metrics.turn_score(ev)
parts: list[float] = []
if polarities:
parts.append(0.7 * (sum(polarities) / len(polarities)))
if score01 is not None:
parts.append(0.3 * (score01 * 2.0 - 1.0))
if not parts:
continue
points.append(
ReviewValencePoint(
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
v=_clamp_valence(sum(parts)),
)
)
return points
def client_valence_points(
turns: list[TurnRecord],
evaluation_payload: dict[str, object],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""내담자 정서가 궤적 — deep 평가 turn_valence 우선, 없으면 턴 평가 기반 폴백."""
raw = (
evaluation_payload.get("turn_valence")
if isinstance(evaluation_payload, dict)
else None
)
points: list[ReviewValencePoint] = []
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
continue
seq = item.get("seq")
v = item.get("v")
if isinstance(seq, bool) or not isinstance(seq, int):
continue
if isinstance(v, bool) or not isinstance(v, (int, float)):
continue
index = seq - 1
if index < 0 or index >= len(turns):
continue
points.append(
ReviewValencePoint(
t=_valence_t(
turns[index].created_at, first_turn_ts, duration_seconds
),
v=_clamp_valence(float(v)),
)
)
points.sort(key=lambda point: point.t)
if not points:
points = _fallback_client_valence_points(
turns, first_turn_ts=first_turn_ts, duration_seconds=duration_seconds
)
return _finalize_valence_points(points)
def _valence_axis(
duration_seconds: int, *, has_points: bool, fallback: list[str]
) -> list[str]:
"""포인트가 있으면 시간 라벨 4개(0~회기말 균등), 없으면 기존 axis 유지."""
if not has_points or duration_seconds <= 0:
return fallback
return [_offset_label(duration_seconds * i / 3) for i in range(4)]
def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse:
sess = read_input.session
visible_turns = learner_visible_turns(sess)
@ -1455,6 +1652,27 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
)
)
# 감정 밸런스 타임라인 — 학습자 기준선 + 내담자 정서가(비공개 턴 존재 시 비산출)
counselor_baseline: list[ReviewValencePoint] = []
client_valence: list[ReviewValencePoint] = []
if not hidden_turns:
counselor_baseline = counselor_baseline_points(
visible_turns,
first_turn_ts=first_turn_ts,
duration_seconds=duration_seconds,
)
client_valence = client_valence_points(
visible_turns,
evaluation_payload,
first_turn_ts=first_turn_ts,
duration_seconds=duration_seconds,
)
valence_axis = _valence_axis(
duration_seconds,
has_points=bool(counselor_baseline or client_valence),
fallback=axis,
)
if not turns:
session_signal = "기록 없음"
elif sess.ended:
@ -1533,7 +1751,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
client=ReviewClient(
name=client_name,
initial=client_initial,
persona=f"{sess.persona_code} · {sess.persona.difficulty}",
persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)),
),
date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
durationLabel=_duration_label(duration_seconds),
@ -1545,9 +1763,9 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
summary=summary,
phases=_phase_segments(stage_labels),
phaseAxis=axis,
valenceAxis=axis,
clientValence=[],
counselorBaseline=[],
valenceAxis=valence_axis,
clientValence=client_valence,
counselorBaseline=counselor_baseline,
turns=turns,
rubric=rubric,
goodMoments=good_moments,

View file

@ -21,6 +21,26 @@ class _Acquire:
return None
class CohortSerializationTest(unittest.TestCase):
"""2026-07-27 전수 순회 결함 #20: 서버가 cohort_ids 첫 항목만 저장하던 유실 회귀 가드."""
def test_multi_cohort_round_trip_preserves_every_id(self) -> None:
from .auth_sessions import _cohort_ids, _cohort_value
self.assertEqual(_cohort_value(["co-a", "co-b"]), "co-a,co-b")
self.assertEqual(_cohort_ids("co-a,co-b"), ["co-a", "co-b"])
self.assertEqual(_cohort_ids(_cohort_value(["x-1", "x-2", "x-3"])), ["x-1", "x-2", "x-3"])
def test_cohort_value_trims_and_drops_empty_entries(self) -> None:
from .auth_sessions import _cohort_ids, _cohort_value
self.assertEqual(_cohort_value([" co-a ", "", " ", "co-b"]), "co-a,co-b")
self.assertIsNone(_cohort_value([]))
self.assertIsNone(_cohort_value(None))
self.assertEqual(_cohort_ids(None), [])
self.assertEqual(_cohort_ids(" co-a , ,co-b "), ["co-a", "co-b"])
class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
async def test_usage_from_database_orders_by_aggregated_token_sum(self) -> None:
case = self

View file

@ -214,6 +214,21 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
)
self.assertNotIn("NAME", masked.entities)
def test_mask_pii_does_not_mask_evaluator_abstract_words_as_names(self) -> None:
# 실제 교수자 리뷰 화면에서 관측된 오탐 문형: 성씨 문자로 시작하는 일반
# 명사/용언(유의점·방치되는·마무리했을·연결감·표현함·어지러움)이
# 조사와 결합하면 문맥-없는 인명 패턴에 걸려 평가 문장이 훼손된다.
text = (
"유의점은 세 가지다. 정서 신호가 방치되는 것을 피하고, 회기를 "
"마무리했을 때 연결감과 작업동맹을 지키며, 감정을 표현함을 존중한다. "
"어지러움은 신체 신호로 다룬다."
)
masked = guardrail.mask_pii(text)
self.assertEqual(masked.text_masked, text)
self.assertNotIn("NAME", masked.entities)
def test_prepare_turn_masks_korean_pii_from_engine_messages(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",

View file

@ -0,0 +1,50 @@
"""리뷰 요약 문장의 한국어 조사(와/과) 선택 회귀 테스트.
2026-07-27 전수 순회에서 발견: 받침 있는 내담자 이름(하늘) 뒤에도 "와의"
붙어 "하늘와의 회기는"으로 표기됐다. 마지막 글자 받침 유무에 따라 /과를
골라야 한다.
"""
from __future__ import annotations
import unittest
from .session_read_model import ReviewTurn, _review_summary
def _turns() -> list[ReviewTurn]:
return [
ReviewTurn(id="t1", ts="0:00", speaker="learner", who="", text="안녕하세요."),
ReviewTurn(id="t2", ts="0:01", speaker="client", who="하늘", text="...네."),
]
class ReviewPersonaLabelTest(unittest.TestCase):
def test_difficulty_is_localized_to_korean(self) -> None:
from .session_read_model import _persona_label
self.assertEqual(_persona_label("P4", "easy"), "P4 · 기초")
self.assertEqual(_persona_label("P2", "moderate"), "P2 · 중간")
self.assertEqual(_persona_label("P1", "hard"), "P1 · 고난도")
# 알 수 없는 값은 원문 유지(빈 라벨로 숨기지 않는다)
self.assertEqual(_persona_label("P9", "custom"), "P9 · custom")
class ReviewSummaryJosaTest(unittest.TestCase):
def test_name_with_final_consonant_uses_gwa(self) -> None:
summary = _review_summary(client_name="하늘", reached_phase="라포", turns=_turns())
self.assertIn("하늘과의 회기는", summary)
self.assertNotIn("하늘와의", summary)
def test_name_without_final_consonant_uses_wa(self) -> None:
summary = _review_summary(client_name="지우", reached_phase="라포", turns=_turns())
self.assertIn("지우와의 회기는", summary)
self.assertNotIn("지우과의", summary)
def test_non_hangul_tail_falls_back_to_gwa_slash_wa(self) -> None:
summary = _review_summary(client_name="P4", reached_phase="라포", turns=_turns())
self.assertIn("P4와(과)의 회기는", summary)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,287 @@
"""감정 밸런스 타임라인(리뷰 valence 차트) 파생 로직 회귀 테스트."""
from __future__ import annotations
import unittest
from typing import Any
from . import session_read_model
from .engine_client import GenerateResponse
from .services import evaluator, persona as persona_service, state_machine
from .store import InProcSession, TurnRecord
def _session(*, created_at: float = 1_000.0) -> InProcSession:
card = persona_service.P1
return InProcSession(
session_id="review-valence-session",
case_id="review-valence-case",
learner_id="00000000-0000-0000-0000-000000000201",
persona_code=card.code,
theory_mode="humanistic",
persona=card,
state=state_machine.SessionState(
resistance=card.base_resistance(),
ideation_stage=card.ideation_baseline(),
),
created_at=created_at,
)
def _turn(
seq: int,
speaker: str,
created_at: float,
evaluation: dict[str, Any] | None = None,
) -> TurnRecord:
return TurnRecord(
turn_seq=seq,
speaker=speaker,
stage="rapport",
text=f"발화 {seq}",
text_masked=f"발화 {seq}",
created_at=created_at,
evaluation=evaluation,
)
class CounselorBaselineTest(unittest.TestCase):
def test_cumulative_average_and_t_normalization(self) -> None:
base = 1_000.0
turns = [
_turn(1, "counselor", base, {"rapport_signal": 0.5}),
_turn(2, "client", base + 10.0),
_turn(3, "counselor", base + 30.0, {"rapport_signal": -0.5}),
_turn(4, "client", base + 40.0),
_turn(5, "counselor", base + 60.0, {"rapport_signal": 0.5}),
]
points = session_read_model.counselor_baseline_points(
turns, first_turn_ts=base, duration_seconds=120
)
self.assertEqual(len(points), 3)
self.assertAlmostEqual(points[0].v, 0.5)
self.assertAlmostEqual(points[1].v, 0.0)
self.assertAlmostEqual(points[2].v, 0.5 / 3.0)
self.assertAlmostEqual(points[0].t, 0.0)
self.assertAlmostEqual(points[1].t, 0.25)
self.assertAlmostEqual(points[2].t, 0.5)
def test_resamples_to_at_most_ten_points(self) -> None:
base = 1_000.0
turns = [
_turn(i + 1, "counselor", base + i * 10.0, {"rapport_signal": 0.1})
for i in range(12)
]
points = session_read_model.counselor_baseline_points(
turns, first_turn_ts=base, duration_seconds=110
)
self.assertEqual(len(points), 10)
# 균등 리샘플이어도 양 끝점은 유지된다.
self.assertAlmostEqual(points[0].t, 0.0)
self.assertAlmostEqual(points[-1].t, 1.0)
# t 는 단조 증가·클램프 범위 내.
for prev, cur in zip(points, points[1:]):
self.assertLessEqual(prev.t, cur.t)
for point in points:
self.assertGreaterEqual(point.v, -1.0)
self.assertLessEqual(point.v, 1.0)
def test_fewer_than_two_points_returns_empty(self) -> None:
base = 1_000.0
turns = [
_turn(1, "counselor", base, {"rapport_signal": 0.4}),
_turn(2, "client", base + 10.0),
]
self.assertEqual(
session_read_model.counselor_baseline_points(
turns, first_turn_ts=base, duration_seconds=60
),
[],
)
class ClientValenceTest(unittest.TestCase):
def test_prefers_payload_turn_valence(self) -> None:
base = 1_000.0
turns = [
_turn(1, "counselor", base),
_turn(2, "client", base + 30.0),
_turn(3, "counselor", base + 60.0),
_turn(4, "client", base + 90.0),
]
payload = {
"turn_valence": [
{"seq": 2, "v": 0.4},
{"seq": 4, "v": -0.6},
]
}
points = session_read_model.client_valence_points(
turns, payload, first_turn_ts=base, duration_seconds=120
)
self.assertEqual(len(points), 2)
self.assertAlmostEqual(points[0].t, 0.25)
self.assertAlmostEqual(points[0].v, 0.4)
self.assertAlmostEqual(points[1].t, 0.75)
self.assertAlmostEqual(points[1].v, -0.6)
def test_fallback_derives_from_turn_evaluations(self) -> None:
base = 1_000.0
negative_eval = {
"client_state_read": [{"code": "defensive", "label_ko": "방어"}],
"appropriateness": "warn",
}
positive_eval = {
"client_state_read": [{"code": "defense_loosening", "label_ko": "방어 완화"}],
"appropriateness": "pos",
}
turns = [
_turn(1, "counselor", base, negative_eval),
_turn(2, "client", base + 30.0),
_turn(3, "counselor", base + 60.0, positive_eval),
_turn(4, "client", base + 90.0),
]
points = session_read_model.client_valence_points(
turns, {}, first_turn_ts=base, duration_seconds=120
)
self.assertEqual(len(points), 2)
self.assertLess(points[0].v, 0.0)
self.assertGreater(points[1].v, 0.0)
for point in points:
self.assertGreaterEqual(point.v, -1.0)
self.assertLessEqual(point.v, 1.0)
class BuildSessionReviewValenceTest(unittest.TestCase):
def test_review_fills_valence_arrays_and_axis(self) -> None:
base = 1_000.0
sess = _session(created_at=base)
sess.ended = True
sess.ended_at = base + 120.0
for i in range(4):
counselor_eval = {
"rapport_signal": 0.2 + i * 0.1,
"client_state_read": [{"code": "affect_contact", "label_ko": "정서 접촉/표현"}],
"appropriateness": "pos",
}
sess.turns.append(
_turn(i * 2 + 1, "counselor", base + i * 30.0, counselor_eval)
)
sess.turns.append(_turn(i * 2 + 2, "client", base + i * 30.0 + 15.0))
evaluation_record = {
"status": "ready",
"payload": {
"strengths": ["감정 반영이 좋았습니다."],
"improvements": [],
"turn_valence": [
{"seq": 2, "v": -0.3},
{"seq": 4, "v": -0.1},
{"seq": 6, "v": 0.2},
{"seq": 8, "v": 0.5},
],
},
}
review = session_read_model.build_session_review(
session_read_model.SessionReviewReadInput(
session=sess,
evaluation_record=evaluation_record,
evaluation_durable=True,
now_ts=base + 200.0,
)
)
self.assertEqual(len(review.clientValence), 4)
self.assertEqual(len(review.counselorBaseline), 4)
# payload turn_valence 를 그대로 쓴다.
self.assertAlmostEqual(review.clientValence[0].v, -0.3)
self.assertAlmostEqual(review.clientValence[-1].v, 0.5)
# x축 라벨은 3~5개.
self.assertGreaterEqual(len(review.valenceAxis), 3)
self.assertLessEqual(len(review.valenceAxis), 5)
self.assertEqual(review.valenceAxis[0], "0:00")
def test_review_without_signals_keeps_empty_arrays(self) -> None:
base = 1_000.0
sess = _session(created_at=base)
sess.ended = True
sess.ended_at = base + 60.0
sess.turns.append(_turn(1, "counselor", base))
sess.turns.append(_turn(2, "client", base + 10.0))
review = session_read_model.build_session_review(
session_read_model.SessionReviewReadInput(
session=sess,
evaluation_record={"status": "ready", "payload": {}},
evaluation_durable=True,
now_ts=base + 100.0,
)
)
self.assertEqual(review.clientValence, [])
self.assertEqual(review.counselorBaseline, [])
# 포인트가 없으면 기존 2원소 axis 를 유지한다.
self.assertEqual(len(review.valenceAxis), 2)
class _DeepValenceEngine:
"""deep-loop 구조화 응답에 turn_valence 를 포함하는 가짜 엔진."""
def __init__(self) -> None:
self.requests: list[Any] = []
async def generate(self, req: Any) -> GenerateResponse:
self.requests.append(req)
return GenerateResponse(
text="",
model="fake-deep",
provider="fake-provider",
structured={
"strengths": ["공감 표현"],
"improvements": [],
"turn_valence": [
{"seq": 2, "v": -0.4},
{"seq": 4, "v": 1.7}, # 범위 초과 → 클램프
{"seq": 0, "v": 0.2}, # 잘못된 seq → 버림
],
},
)
class DeepSchemaTurnValenceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
evaluator.clear_evaluator_semantic_cache()
async def asyncTearDown(self) -> None:
evaluator.clear_evaluator_semantic_cache()
def test_deep_schema_declares_turn_valence(self) -> None:
schema = evaluator._deep_schema()
turn_valence = schema["properties"].get("turn_valence")
self.assertIsNotNone(turn_valence)
self.assertEqual(turn_valence["type"], "array")
item_props = turn_valence["items"]["properties"]
self.assertIn("seq", item_props)
self.assertIn("v", item_props)
# 선택 필드라 required 에는 없어야 한다.
self.assertNotIn("turn_valence", schema["required"])
async def test_evaluate_session_parses_turn_valence(self) -> None:
engine = _DeepValenceEngine()
result = await evaluator.evaluate_session(
session_id="review-valence-session",
stage="라포",
masked_turns=[
{"seq": 1, "speaker": "counselor", "text": "요즘 어떠세요?"},
{"seq": 2, "speaker": "client", "text": "잘 모르겠어요."},
],
engine=engine, # type: ignore[arg-type]
)
self.assertIsNone(result.error)
self.assertEqual(len(result.turn_valence), 2)
self.assertEqual(result.turn_valence[0].seq, 2)
self.assertAlmostEqual(result.turn_valence[0].v, -0.4)
self.assertAlmostEqual(result.turn_valence[1].v, 1.0) # 클램프
# 영속 payload 경로(to_dict)에 turn_valence 가 실린다.
payload = result.to_dict()
self.assertIn("turn_valence", payload)
self.assertEqual(payload["turn_valence"][0]["seq"], 2)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,68 @@
"""온보딩/프로필 연락처(phone) 형식 검증 회귀 테스트.
2026-07-27 전수 순회에서 발견: 온보딩 연락처에 "전화번호아님텍스트" 같은
임의 문자열을 넣어도 클라이언트·서버 모두 통과되어 DB에 저장됐다.
OnboardingRequest / UserProfilePatch 숫자 9~15자리(하이픈·공백·괄호··
선행 + 허용) 받아야 한다.
"""
from __future__ import annotations
import unittest
from pydantic import ValidationError
from .routes.users import OnboardingRequest, UserProfilePatch
def _onboarding_kwargs(**overrides: object) -> dict:
base: dict = {
"legal_name": "검증 사용자",
"affiliation": "한신대학교",
"department": "상담심리학과",
"grade_level": "3학년",
"phone": "010-1234-5678",
"contact_address": "경기도 오산시 한신대길 137",
"nickname": "검증",
"self_introduction": "연락처 검증 테스트 사용자입니다.",
"avatar_url": "",
"terms_accepted": True,
"privacy_accepted": True,
}
base.update(overrides)
return base
class OnboardingPhoneValidationTest(unittest.TestCase):
def test_onboarding_rejects_non_numeric_phone_text(self) -> None:
with self.assertRaises(ValidationError):
OnboardingRequest(**_onboarding_kwargs(phone="전화번호아님텍스트"))
def test_onboarding_rejects_phone_with_too_few_digits(self) -> None:
with self.assertRaises(ValidationError):
OnboardingRequest(**_onboarding_kwargs(phone="010-12"))
def test_onboarding_accepts_common_korean_phone_formats(self) -> None:
for phone in (
"010-1234-5678",
"01012345678",
"010 1234 5678",
"02-123-4567",
"031-8000-9000",
"+82-10-1234-5678",
):
with self.subTest(phone=phone):
req = OnboardingRequest(**_onboarding_kwargs(phone=phone))
self.assertEqual(req.phone, phone.strip())
def test_profile_patch_rejects_non_numeric_phone_text(self) -> None:
with self.assertRaises(ValidationError):
UserProfilePatch(phone="전화번호아님텍스트")
def test_profile_patch_allows_omitted_phone(self) -> None:
patch = UserProfilePatch()
self.assertIsNone(patch.phone)
if __name__ == "__main__":
unittest.main()

View file

@ -781,6 +781,28 @@ test.describe("admin route guards", () => {
await expectNoHorizontalOverflow(page);
});
test("keeps the boot diagnostic overlay off a rendered AI operations page", async ({ page }) => {
await mockAdminSession(page, {
user_id: "ai-watchdog-admin",
email: "ai-watchdog-admin@twentyoz.kr",
display_name: "AI Watchdog Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
});
await page.goto("/admin/ai");
await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible();
// index.html watchdog은 로드 후 3.5초/8초에 부트 진단을 판정한다.
// 정상 렌더된 화면에서는 두 판정 창이 지난 뒤에도 진단 오버레이가 없어야 한다.
await page.waitForTimeout(8_600);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible();
});
test("resets scroll when moving from long admin pages to access policy", async ({ page }) => {
await mockAdminSession(page, {
user_id: "scroll-admin",

View file

@ -0,0 +1,501 @@
// 2026-07-27 E2E 전수 순회 — 관리자 AI 설정(/admin/ai) 영역의 "신규 spec 필요" 항목 검증.
// docs/ops/e2e-full-sweep-2026-07-27.md §8 (admin-ai) 체크리스트 중 기존 spec이 없는 항목을
// route fixture로 상태를 고정해 검증한다. 실제 AI 엔진 턴 생성은 하지 않는다.
import { expect, test, type Page } from "@playwright/test";
interface AdminUsageBreakdown {
provider: string;
model: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
interface AdminUsageDailyCost {
day: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
interface AdminUsageEvaluatorCache {
enabled: boolean;
entries: number;
hits: number;
misses: number;
stores: number;
evictions: number;
requests: number;
hit_rate: number;
}
interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
generated_at: number;
window_days: number;
total_turns: number;
metered_turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
budget: {
limit_usd: number;
used_ratio: number;
remaining_usd: number | null;
status: "disabled" | "ok" | "warn" | "exceeded";
};
evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[];
daily_cost?: AdminUsageDailyCost[];
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
source: "database" | "runtime_cache" | "runtime_default";
durable: boolean;
updated_by: string | null;
updated_at: number | null;
}
function usageFixture(
days: number,
overrides: Partial<AdminUsageResponse> = {},
): AdminUsageResponse {
return {
source: "database",
durable: true,
generated_at: 1_785_142_800,
window_days: days,
total_turns: 0,
metered_turns: 0,
tokens_in: 0,
tokens_out: 0,
cost_usd: 0,
budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled" },
evaluator_cache: {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
},
by_provider: [],
daily_cost: [],
...overrides,
};
}
function engineConfigFixture(
overrides: Partial<AdminEngineConfigResponse> = {},
): AdminEngineConfigResponse {
return {
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
source: "database",
durable: true,
updated_by: "admin@twentyoz.kr",
updated_at: 1_785_142_800,
...overrides,
};
}
interface AdminAiMockState {
counts: { usage: number; health: number; engine: number };
authUser: {
account_status?: "pending" | "approved" | "suspended";
onboarding_completed_at?: number | null;
};
usageForWindow: (days: number) => AdminUsageResponse;
/** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */
usageGate?: (days: number) => Promise<void> | undefined;
engineConfig: AdminEngineConfigResponse;
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
engineConfigStatus?: number;
}
function createMockState(overrides: Partial<AdminAiMockState> = {}): AdminAiMockState {
return {
counts: { usage: 0, health: 0, engine: 0 },
authUser: {},
usageForWindow: (days) => usageFixture(days),
engineConfig: engineConfigFixture(),
...overrides,
};
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
const path = url.pathname;
const fulfillJson = (body: unknown, status = 200) =>
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
if (method === "GET" && path.endsWith("/auth/me")) {
await fulfillJson({
user_id: "sweep-ai-admin",
email: "sweep-ai-admin@twentyoz.kr",
display_name: "Sweep AI Admin",
role: "admin",
admin_access: true,
super_admin: true,
account_status: state.authUser.account_status ?? "approved",
approval_required: false,
cohort_ids: [],
consent_at: null,
onboarding_completed_at: state.authUser.onboarding_completed_at ?? 1_782_900_000,
nickname: "",
self_introduction: "",
avatar_url: "",
});
return;
}
if (method === "GET" && path.endsWith("/admin/usage")) {
state.counts.usage += 1;
const days = Number(url.searchParams.get("window_days") ?? 30);
const gate = state.usageGate?.(days);
if (gate) await gate;
await fulfillJson(state.usageForWindow(days));
return;
}
if (method === "GET" && path.endsWith("/admin/health")) {
state.counts.health += 1;
await fulfillJson({
status: "ok",
environment: "dev",
engine_mode: "openai",
services: [
{
key: "engine",
name: "응답 엔진",
status: "ok",
detail: "엔진 응답 정상",
metric: "120ms",
load: 0.2,
},
],
});
return;
}
if (method === "GET" && path.endsWith("/admin/engine-config")) {
state.counts.engine += 1;
if (state.engineConfigStatus) {
await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus);
return;
}
await fulfillJson(state.engineConfig);
return;
}
if (method === "PATCH" && path.endsWith("/admin/engine-config")) {
const body = request.postDataJSON() as Partial<AdminEngineConfigResponse>;
await fulfillJson({ ...state.engineConfig, ...body });
return;
}
await fulfillJson({ detail: `unmocked API request: ${method} ${path}` }, 404);
});
}
test.describe("full sweep: admin ai operations", () => {
// checklist: admin-ai-guard-pending-approval
test("redirects a pending-approval admin from /admin/ai to /pending", async ({ page }) => {
const state = createMockState({
authUser: { account_status: "pending", onboarding_completed_at: 1_782_900_000 },
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.getByText("승인 대기")).toBeVisible();
await expect(
page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }),
).toBeVisible();
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveCount(0);
// 승인 대기 계정은 관리자 데이터 API를 호출하지 않아야 한다.
expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0 });
});
// checklist: admin-ai-aria-busy, admin-ai-refresh-button
test("marks the page aria-busy while loading and reloads all three data sets on refresh", async ({
page,
}) => {
let gate: ReturnType<typeof deferred> | null = deferred();
const state = createMockState({
usageGate: () => gate?.promise,
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const container = page.locator('[data-testid="admin-ai-page"]');
const refreshButton = page.getByRole("button", { name: "새로고침" });
// usage 응답이 보류된 동안 aria-busy=true, 새로고침 버튼 비활성.
await expect(container).toHaveAttribute("aria-busy", "true");
await expect(refreshButton).toBeDisabled();
gate.resolve();
gate = null;
await expect(container).toHaveAttribute("aria-busy", "false");
await expect(refreshButton).toBeEnabled();
// React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다.
const base = { ...state.counts };
expect(base.health).toBe(1);
expect(base.engine).toBe(1);
// 새로고침 클릭 → 세 엔드포인트 모두 재조회, 로딩 동안 다시 비활성.
gate = deferred();
await refreshButton.click();
await expect(container).toHaveAttribute("aria-busy", "true");
await expect(refreshButton).toBeDisabled();
gate.resolve();
gate = null;
await expect(container).toHaveAttribute("aria-busy", "false");
await expect(refreshButton).toBeEnabled();
await expect
.poll(() => state.counts)
.toEqual({ usage: base.usage + 1, health: base.health + 1, engine: base.engine + 1 });
});
// checklist: admin-ai-error-alert, admin-ai-engine-loading-empty
test("shows a role=alert banner with the engine panel placeholder on load failure and clears it on refresh", async ({
page,
}) => {
const state = createMockState({ engineConfigStatus: 500 });
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
// engine-config 로드 실패 → role=alert 배너에 오류 메시지 노출.
const alert = page.locator(".aic-alert[role='alert']");
await expect(alert).toBeVisible();
await expect(alert).toContainText("engine config unavailable");
// 엔진 패널은 폼 대신 로딩/빈 상태 문구를 표시한다.
await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toBeVisible();
await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0);
// 다음 요청이 시작되면 오류 배너가 초기화된다: 응답을 보류한 채 새로고침.
state.engineConfigStatus = undefined;
let gate: ReturnType<typeof deferred> | null = deferred();
state.usageGate = () => gate?.promise;
await page.getByRole("button", { name: "새로고침" }).click();
await expect(alert).toHaveCount(0);
gate.resolve();
gate = null;
// 성공 응답 이후에도 배너는 없고 엔진 설정 폼이 렌더된다.
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(alert).toHaveCount(0);
});
// checklist: admin-ai-window-toggle
test("refetches only usage on window change and ignores a stale late response", async ({
page,
}) => {
const slowWindow = deferred();
const state = createMockState({
usageForWindow: (days) =>
usageFixture(days, {
total_turns: 10,
metered_turns: 10,
cost_usd: days === 90 ? 9.99 : days === 7 ? 1.11 : 3.33,
}),
usageGate: (days) => (days === 90 ? slowWindow.promise : undefined),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const ledger = page.locator(".aic-ledger");
await expect(ledger).toContainText("$3.3300");
// React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다.
const base = { ...state.counts };
expect(base.health).toBe(1);
expect(base.engine).toBe(1);
// 90일 클릭(응답 보류) 후 곧바로 7일 클릭 → 7일 데이터가 반영된다.
const staleResponse = page.waitForResponse((response) =>
response.url().includes("/api/admin/usage?window_days=90"),
);
await page.getByRole("button", { name: "90일" }).click();
await expect(page.getByRole("button", { name: "90일" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "7일" }).click();
await expect(page.getByRole("button", { name: "7일" })).toHaveAttribute("aria-pressed", "true");
await expect(ledger).toContainText("$1.1100");
await expect(ledger).toContainText("7일 DB 집계");
// 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다.
slowWindow.resolve();
await staleResponse;
await expect(ledger).toContainText("$1.1100");
await expect(ledger).not.toContainText("$9.9900");
// 기간 변경은 usage만 재조회한다(health·engine-config는 초기 1회 그대로).
await expect.poll(() => state.counts.usage).toBe(base.usage + 2);
expect(state.counts.health).toBe(1);
expect(state.counts.engine).toBe(1);
});
// checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel
test("renders the coverage track, daily cost chart, and evaluator cache panel from metered usage", async ({
page,
}) => {
const state = createMockState({
usageForWindow: (days) =>
usageFixture(days, {
total_turns: 40,
metered_turns: 30,
tokens_in: 125_000,
tokens_out: 18_500,
cost_usd: 6.6212,
budget: { limit_usd: 20, used_ratio: 0.33106, remaining_usd: 13.3788, status: "ok" },
evaluator_cache: {
enabled: true,
entries: 12,
hits: 8,
misses: 2,
stores: 2,
evictions: 1,
requests: 10,
hit_rate: 0.8,
},
by_provider: [
{
provider: "openai",
model: "gpt-5-mini",
turns: 30,
tokens_in: 125_000,
tokens_out: 18_500,
cost_usd: 6.6212,
},
],
daily_cost: [
{ day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
{ day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
{ day: "2026-07-15", turns: 12, tokens_in: 52_000, tokens_out: 7_600, cost_usd: 3.1212 },
],
}),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
// DB 계량 커버리지 트랙: metered/total 비율과 미계량 턴 안내.
const budgetPanel = page.locator(".aic-budget");
await expect(budgetPanel).toContainText("DB 계량 커버리지");
await expect(budgetPanel).toContainText("75%");
await expect(budgetPanel.locator('[aria-label="DB 계량 커버리지 75%"]')).toHaveCount(1);
await expect(budgetPanel).toContainText(
"10개 턴은 provider·model·token·cost 계량 필드가 없습니다.",
);
// 일별 비용 차트: role=img aria-label, 날짜·비용 툴팁, 막대 개수.
const chart = page.locator(".aic-chart[role='img']");
await expect(chart).toBeVisible();
await expect(chart).toHaveAttribute("aria-label", "30일 일별 AI 비용 막대 차트");
await expect(chart.locator(".aic-chart__day")).toHaveCount(3);
await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute(
"title",
"2026-07-13 $1.4200",
);
await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴");
// 평가 캐시 패널: 사용 중 배지, hit-rate, 6개 카운트, 원문 미저장 안내.
const cachePanel = page.locator(".aic-panel").filter({ hasText: "평가 캐시 효율" });
await expect(cachePanel).toContainText("사용 중");
await expect(cachePanel.locator(".aic-cache-score")).toContainText("80%");
await expect(cachePanel.locator(".aic-cache-score")).toContainText("hit-rate");
const miniGrid = cachePanel.locator(".aic-mini-grid");
await expect(miniGrid).toContainText("요청");
await expect(miniGrid.locator("div").filter({ hasText: "요청" }).locator("dd")).toHaveText("10");
await expect(miniGrid.locator("div").filter({ hasText: "적중" }).locator("dd")).toHaveText("8");
await expect(miniGrid.locator("div").filter({ hasText: "미스" }).locator("dd")).toHaveText("2");
await expect(miniGrid.locator("div").filter({ hasText: "저장" }).locator("dd")).toHaveText("2");
await expect(
miniGrid.locator("div").filter({ hasText: "현재 엔트리" }).locator("dd"),
).toHaveText("12");
await expect(miniGrid.locator("div").filter({ hasText: "축출" }).locator("dd")).toHaveText("1");
await expect(cachePanel).toContainText(
"캐시는 성공적으로 파싱된 평가 결과만 보관하며 원문 프롬프트와 응답은 저장하지 않습니다.",
);
});
// checklist: admin-ai-daily-chart-empty, admin-ai-provider-table-empty
test("shows empty states for the daily chart and the provider ledger", async ({ page }) => {
const state = createMockState({
usageForWindow: (days) => usageFixture(days, { by_provider: [], daily_cost: [] }),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
await expect(page.getByText("선택한 기간에 비용이 계량된 AI 호출이 없습니다.")).toBeVisible();
await expect(page.getByText("모델별 계량 행이 아직 없습니다.")).toBeVisible();
await expect(page.locator(".aic-chart")).toHaveCount(0);
await expect(page.locator(".aic-table")).toHaveCount(0);
});
// checklist: admin-ai-engine-config-meta
test("shows engine config metadata for durable DB and runtime-only sources", async ({ page }) => {
const state = createMockState({
engineConfig: engineConfigFixture({
source: "database",
durable: true,
updated_by: "admin@twentyoz.kr",
updated_at: 1_785_142_800,
}),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const meta = page.locator(".aic-config-meta");
await expect(meta).toBeVisible();
const rowValue = (label: string) =>
meta.locator("div").filter({ hasText: label }).locator("dd");
await expect(rowValue("저장 원천")).toHaveText("DB 영구 저장");
await expect(rowValue("현재 소스")).toHaveText("database");
await expect(rowValue("최근 변경")).not.toHaveText("기록 없음");
await expect(rowValue("변경자")).toHaveText("admin@twentyoz.kr");
// 런타임 적용 변형: durable=false·기록 없음 표시.
state.engineConfig = engineConfigFixture({
source: "runtime_default",
durable: false,
updated_by: null,
updated_at: null,
});
await page.getByRole("button", { name: "새로고침" }).click();
await expect(rowValue("저장 원천")).toHaveText("런타임 적용");
await expect(rowValue("현재 소스")).toHaveText("runtime_default");
await expect(rowValue("최근 변경")).toHaveText("기록 없음");
await expect(rowValue("변경자")).toHaveText("기록 없음");
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,433 @@
/* =====================================================================
full-sweep-auth.spec.ts E2E (2026-07-27) auth .
docs/ops/e2e-full-sweep-2026-07-27.md 1 "검증: 신규 spec 필요"
:
- auth-login-generic-google-button
- auth-login-brand-domain-badges
- auth-onboarding-avatar-upload
- auth-onboarding-legal-doc-details
- auth-onboarding-error-notice
- auth-pending-status-panel
- auth-pending-refresh-button
- auth-pending-signout-button
- auth-guard-pending-approval-gate
원칙: API(dev-login··legal-docs· ) ,
(OAuth redirect , ,
5xx) page.route fixture로 . AI .
===================================================================== */
import { expect, test, type Page } from "@playwright/test";
import { completeOnboarding, useRealApi } from "./support";
const CONFIG_PATTERN = "**/api/auth/config";
const ME_PATTERN = "**/api/auth/me";
/** 로컬 redirect_uri라서 두 OAuth 버튼이 모두 활성화되는 구성 fixture. */
const READY_AUTH_CONFIG = {
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
{
provider: "saml",
configured: false,
enabled: false,
login_path: "/auth/login?provider=saml",
},
],
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: true,
};
let sweepEmailCounter = 0;
function uniqueEmail(prefix: string) {
sweepEmailCounter += 1;
return `${prefix}.${Date.now()}.${sweepEmailCounter}@hs.ac.kr`;
}
/** 온보딩 미완료 learner 계정을 만들고 세션 쿠키를 심는다. */
async function loginFreshLearner(page: Page, prefix: string): Promise<string> {
const email = uniqueEmail(prefix);
const res = await page.request.post("/api/auth/dev-login", {
data: { email, role: "learner", display_name: "Full Sweep Auth" },
});
expect(res.ok(), await res.text()).toBeTruthy();
return email;
}
/** 온보딩까지 끝낸 learner 계정을 만든다(승인 대기 화면 fixture의 원본 계정). */
async function loginOnboardedLearner(page: Page, prefix: string): Promise<string> {
const email = await loginFreshLearner(page, prefix);
await completeOnboarding(page, {
legal_name: "전수 순회 학습자",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-0000-0000",
contact_address: "경기도 오산시 한신대학교",
});
return email;
}
/** 실 세션의 /auth/me 응답 원본을 가져온다(page.request는 route fixture를 우회한다). */
async function fetchRealMe(page: Page): Promise<Record<string, unknown>> {
const res = await page.request.get("/api/auth/me");
expect(res.ok(), await res.text()).toBeTruthy();
return (await res.json()) as Record<string, unknown>;
}
/** GET /auth/me만 지정한 본문으로 고정한다. 그 외 메서드/요청은 실 API로 보낸다. */
async function mockAuthMe(page: Page, body: () => Record<string, unknown>) {
await page.route(ME_PATTERN, async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body()),
});
});
}
async function fulfillConfig(page: Page, body: unknown) {
await page.route(CONFIG_PATTERN, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
});
}
test.describe("full sweep auth", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
// 검증 checklist: auth-login-generic-google-button
test("secondary Google button shows the second allowed domain and starts the same google OAuth flow", async ({
page,
}) => {
await fulfillConfig(page, READY_AUTH_CONFIG);
await page.route("**/api/auth/login**", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/html; charset=utf-8",
body: "<html><body>oauth-start-stub</body></html>",
});
});
await page.goto("/login");
const secondary = page.locator(".lg-obtn.secondary");
await expect(secondary).toBeEnabled();
await expect(secondary).toContainText("Google 계정으로 계속");
// 서브라벨은 두 번째 허용 도메인을 표시한다.
await expect(secondary.locator(".sub")).toHaveText("@twentyoz.kr");
// primary 버튼과 동일하게 첫 번째 도메인은 primary 서브라벨에 있다.
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr");
// 클릭 시 primary와 동일한 provider=google 시작 URL로 이동한다.
await Promise.all([
page.waitForURL(/\/api\/auth\/login\?provider=google&next=%2F/),
secondary.click(),
]);
});
// 검증 checklist: auth-login-brand-domain-badges
test("brand panel domain badges distinguish checking, unset, and loaded states", async ({
page,
}) => {
// 1) 설정 조회가 끝나지 않은 동안: "확인 중" 배지.
await page.route(CONFIG_PATTERN, () => {
/* 응답을 보류해 조회 중 상태를 고정한다 */
});
await page.goto("/login");
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
await expect(page.locator(".lg-policy b")).toHaveText("확인 중");
// 2) 설정 조회 실패: "설정 필요" 배지.
await page.unroute(CONFIG_PATTERN);
await page.route(CONFIG_PATTERN, async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "config unavailable" }),
});
});
await page.goto("/login");
await expect(page.locator(".lg-policy b")).toHaveText("설정 필요");
// 3) 정상 로드: allowed_email_domains를 배지로 나열.
await page.unroute(CONFIG_PATTERN);
await fulfillConfig(page, READY_AUTH_CONFIG);
await page.goto("/login");
const badges = page.locator(".lg-policy b");
await expect(badges).toHaveCount(2);
await expect(badges.nth(0)).toHaveText("hs.ac.kr");
await expect(badges.nth(1)).toHaveText("twentyoz.kr");
});
// 검증 checklist: auth-onboarding-avatar-upload
test("avatar upload stores avatar_url immediately and shows the 3MB notice on failure", async ({
page,
}) => {
await loginFreshLearner(page, "sweep-avatar");
await page.goto("/onboarding");
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
// 성공 경로(실 API): 파일 선택 즉시 업로드되어 avatar_url이 폼과 미리보기에 반영된다.
await page.getByLabel("아바타 이미지").setInputFiles({
name: "sweep-avatar.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
"base64",
),
});
await expect(page.getByText("다른 이미지 선택")).toBeVisible();
await expect(page.locator(".ob-avatar__preview img")).toHaveAttribute(
"src",
/\/uploads\/profile-avatars\//,
);
// 실패 경로(fixture): 서버가 거절하면 3MB·형식 안내를 role=alert로 띄운다.
await page.route("**/api/users/me/avatar", async (route) => {
await route.fulfill({
status: 413,
contentType: "application/json",
body: JSON.stringify({ detail: "file too large" }),
});
});
await page.getByLabel("아바타 이미지").setInputFiles({
name: "sweep-avatar-too-big.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
"base64",
),
});
const avatarAlert = page.locator(".ob-avatar .ob-error[role='alert']");
await expect(avatarAlert).toBeVisible();
await expect(avatarAlert).toContainText("아바타 업로드에 실패했습니다");
await expect(avatarAlert).toContainText("3MB");
});
// 검증 checklist: auth-onboarding-legal-doc-details
test("legal document details expand real docs and fall back when docs fail to load", async ({
page,
}) => {
await loginFreshLearner(page, "sweep-legal");
const docsRes = await page.request.get("/api/users/legal-docs");
expect(docsRes.ok(), await docsRes.text()).toBeTruthy();
const docs = (await docsRes.json()) as {
terms: { title: string; version: string; body: string };
privacy: { title: string; version: string; body: string };
};
const termsNeedle = docs.terms.body.trim().split(/\s+/).slice(0, 3).join(" ");
const privacyNeedle = docs.privacy.body.trim().split(/\s+/).slice(0, 3).join(" ");
await page.goto("/onboarding");
const details = page.locator(".ob-legal details");
await expect(details).toHaveCount(2);
// 제목·버전이 실 legal-docs 응답과 일치한다.
await expect(details.nth(0).locator("summary")).toContainText(docs.terms.title);
await expect(details.nth(0).locator("summary b")).toHaveText(docs.terms.version);
await expect(details.nth(1).locator("summary")).toContainText(docs.privacy.title);
await expect(details.nth(1).locator("summary b")).toHaveText(docs.privacy.version);
// 펼치면 본문이 보인다.
await details.nth(0).locator("summary").click();
await expect(details.nth(0).locator(".ob-doc-text")).toBeVisible();
await expect(details.nth(0).locator(".ob-doc-text")).toContainText(termsNeedle);
await details.nth(1).locator("summary").click();
await expect(details.nth(1).locator(".ob-doc-text")).toContainText(privacyNeedle);
// 문서 로드 실패 시 제목·버전·본문이 폴백 문구로 대체된다.
await page.route("**/api/users/legal-docs", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "legal docs unavailable" }),
});
});
await page.goto("/onboarding");
const fallbackDetails = page.locator(".ob-legal details");
await expect(fallbackDetails.nth(0).locator("summary")).toContainText("서비스 이용약관");
await expect(fallbackDetails.nth(0).locator("summary b")).toHaveText("확인 중");
await expect(fallbackDetails.nth(1).locator("summary")).toContainText("개인정보 처리방침");
await fallbackDetails.nth(0).locator("summary").click();
await expect(fallbackDetails.nth(0).locator(".ob-doc-text")).toContainText(
"문서를 불러오는 중입니다.",
);
});
// 검증 checklist: auth-onboarding-error-notice
test("onboarding shows role=alert notices for load and save failures", async ({ page }) => {
await loginFreshLearner(page, "sweep-ob-error");
// 로드 실패: GET /users/me 5xx → 폼 하단 role=alert.
await page.route("**/api/users/me", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "profile unavailable" }),
});
});
await page.goto("/onboarding");
const loadAlert = page.locator(".ob-form > .ob-error[role='alert']");
await expect(loadAlert).toBeVisible();
await expect(loadAlert).toContainText("온보딩 정보를 불러오지 못했습니다.");
// 저장 실패: 폼을 모두 채운 뒤 POST /users/me/onboarding 5xx → role=alert, 페이지 잔류.
await page.unroute("**/api/users/me");
await page.goto("/onboarding");
await page.getByLabel("닉네임").fill("전수 순회");
await page.getByLabel("자기소개").fill("온보딩 저장 오류 알림을 검증합니다.");
await page.getByLabel("이름").fill("전수 순회 학습자");
await page.getByLabel("소속").fill("한신대학교");
await page.getByLabel("학과/부서").fill("상담심리학과");
await page.getByLabel("학년/직위").fill("3학년");
await page.getByLabel("연락처").fill("010-1234-5678");
await page.getByLabel("주소/수령지").fill("경기도 오산시 한신대학교");
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
await page.route("**/api/users/me/onboarding", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "save failed" }),
});
});
await page.getByRole("button", { name: /가입 설정 완료/ }).click();
const saveAlert = page.locator(".ob-form > .ob-error[role='alert']");
await expect(saveAlert).toBeVisible();
await expect(saveAlert).toContainText("온보딩 저장에 실패했습니다.");
await expect(page).toHaveURL(/\/onboarding(?:$|[/?#])/);
});
// 검증 checklist: auth-pending-status-panel
test("pending panel shows waiting vs suspended kicker with the account email", async ({
page,
}) => {
const email = await loginOnboardedLearner(page, "sweep-pending-panel");
const realMe = await fetchRealMe(page);
let accountStatus = "pending";
await mockAuthMe(page, () => ({
...realMe,
account_status: accountStatus,
approval_required: true,
}));
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
await expect(page.locator(".pa-copy")).toContainText(email);
const status = page.locator(".pa-status[role='status']");
await expect(status).toBeVisible();
await expect(status).toContainText("접속 권한 확인 중");
await expect(status).toContainText("승인되면 새로고침 후 온보딩 또는 역할 홈으로 이동합니다.");
// suspended 계정은 '접속 보류' 키커로 구분 표시된다.
accountStatus = "suspended";
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(page.locator(".pa-kicker")).toHaveText("접속 보류");
});
// 검증 checklist: auth-pending-refresh-button
test("pending refresh button re-checks the session and routes an approved learner home", async ({
page,
}) => {
await loginOnboardedLearner(page, "sweep-pending-refresh");
const realMe = await fetchRealMe(page);
let pendingNow = true;
await mockAuthMe(page, () =>
pendingNow
? { ...realMe, account_status: "pending", approval_required: true }
: realMe,
);
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
const refreshButton = page.getByRole("button", { name: "승인 상태 새로고침" });
await expect(refreshButton).toBeEnabled();
// 승인 후 새로고침 → GET /auth/me 재조회 → 역할 홈(/learn)으로 이동.
pendingNow = false;
await refreshButton.click();
await page.waitForURL(/\/learn(?:$|[/?#])/);
await expect(
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
).toBeVisible();
});
// 검증 checklist: auth-pending-signout-button
test("pending sign-out button ends the session and returns to the login screen", async ({
page,
}) => {
await loginOnboardedLearner(page, "sweep-pending-signout");
const realMe = await fetchRealMe(page);
await mockAuthMe(page, () => ({
...realMe,
account_status: "pending",
approval_required: true,
}));
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
await page.getByRole("button", { name: "다른 계정으로 로그인" }).click();
await page.waitForURL(/\/login(?:$|[/?#])/);
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
// 실 세션이 실제로 종료됐는지 확인한다(page.request는 fixture를 우회한다).
const me = await page.request.get("/api/auth/me");
expect(me.status(), await me.text()).toBe(401);
});
// 검증 checklist: auth-guard-pending-approval-gate
test("pending approval gate recalls unapproved users from every route and releases approved users", async ({
page,
}) => {
await loginOnboardedLearner(page, "sweep-pending-gate");
const realMe = await fetchRealMe(page);
let pendingNow = true;
await mockAuthMe(page, () =>
pendingNow
? { ...realMe, account_status: "pending", approval_required: true }
: realMe,
);
for (const blockedPath of ["/learn", "/learn/practice", "/settings", "/login"]) {
await page.goto(blockedPath);
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
}
// 승인된 사용자가 /pending에 오면 승인 홈으로 되돌린다.
pendingNow = false;
await page.goto("/pending");
await page.waitForURL(/\/learn(?:$|[/?#])/);
});
});

View file

@ -0,0 +1,904 @@
// 2026-07-27 E2E 전수 순회 — 학습자 홈(learner-home) 영역 신규 스펙.
// docs/ops/e2e-full-sweep-2026-07-27.md §2에서 "검증: 신규 spec 필요"로 표기된
// 체크리스트 항목을 검증한다. 실 API 로그인(dev-login) 위에 route fixture로
// 페르소나·회기·대시보드 응답을 고정해 결정론적으로 UI 상태를 검증하며,
// 실제 AI 엔진 턴 생성은 일절 수행하지 않는다.
import { expect, test, type Page } from "@playwright/test";
import { signInAsLearner, useRealApi } from "./support";
const ACT_ID = "lhact001";
const REV_ID = "lhrev001";
const END_ID = "lhend001";
const ARC_ID = "lharc001";
const NOW = Date.now();
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
const HOUR = 60 * 60 * 1000;
const PERSONAS_BODY = [
{
code: "P1",
display_name: "성하늘",
difficulty: "easy",
theory_target: ["humanistic"],
demographics: { age_band: "20대" },
presenting_summary: "학업 스트레스와 수면 문제를 호소",
voice_preset: null,
source: "database",
degraded: false,
},
{
code: "P2",
display_name: "김도윤",
difficulty: "moderate",
theory_target: ["cbt"],
demographics: { age_band: "30대" },
presenting_summary: "직장 내 대인관계 갈등을 호소",
voice_preset: null,
source: "database",
degraded: false,
},
];
const S_ACT = {
session_id: ACT_ID,
session_no: 4,
persona_code: "P1",
persona_name: "성하늘",
status: "active",
stage: "탐색",
started_at: iso(1 * HOUR),
ended_at: null,
review_ready: false,
archived: false,
archived_at: null,
turn_count: 6,
learner_turn_count: 3,
client_turn_count: 3,
};
const S_REV = {
session_id: REV_ID,
session_no: 3,
persona_code: "P1",
persona_name: "성하늘",
status: "ended",
stage: "정리",
started_at: iso(3 * HOUR),
ended_at: iso(2.5 * HOUR),
review_ready: true,
archived: false,
archived_at: null,
turn_count: 10,
learner_turn_count: 5,
client_turn_count: 5,
};
const S_END = {
session_id: END_ID,
session_no: 2,
persona_code: "P2",
persona_name: "김도윤",
status: "ended",
stage: "라포",
started_at: iso(5 * HOUR),
ended_at: iso(4.5 * HOUR),
review_ready: false,
archived: false,
archived_at: null,
turn_count: 4,
learner_turn_count: 2,
client_turn_count: 2,
};
const S_ARC = {
session_id: ARC_ID,
session_no: 1,
persona_code: "P1",
persona_name: "성하늘",
status: "ended",
stage: "개입",
started_at: iso(8 * HOUR),
ended_at: iso(7 * HOUR),
review_ready: false,
archived: true,
archived_at: iso(6 * HOUR),
turn_count: 8,
learner_turn_count: 4,
client_turn_count: 4,
};
const SESSIONS_BODY = {
sessions: [S_ACT, S_REV, S_END, S_ARC],
source: "runtime",
};
const DASHBOARD_BODY = {
message: "학습 현황 요약",
source: "runtime",
overview: {
total_sessions: 4,
active_sessions: 1,
review_ready_sessions: 1,
completed_sessions: 3,
archived_sessions: 1,
learner_turns: 14,
client_turns: 14,
last_practiced_at: iso(1 * HOUR),
},
growth: {
trend: "down",
avg_rapport: 0.2,
avg_score: 0.6,
evaluated_sessions: 3,
latest_score: 0.62,
first_score: 0.7,
score_delta: -0.08,
top_techniques: ["감정 반영"],
points: [
{
session_id: REV_ID,
session_no: 3,
persona_code: "P1",
stage: "정리",
started_at: iso(3 * HOUR),
ended_at: iso(2.5 * HOUR),
score: 0.62,
rapport: 0.2,
technique_count: 3,
watch_count: 2,
},
],
},
recent_feedback: [
{
session_id: REV_ID,
session_no: 3,
persona_code: "P1",
persona_name: "성하늘",
stage: "정리",
score: 0.62,
rapport: 0.2,
note: "감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.",
created_at: iso(2.5 * HOUR),
turn_seq: 6,
techniques: ["감정 반영"],
},
],
persona_progress: [
{
persona_code: "P1",
persona_name: "성하늘",
sessions: 3,
active_sessions: 1,
completed_sessions: 2,
review_ready_sessions: 1,
rapport_percent: 64,
trend: "down",
latest_stage: "탐색",
latest_score: 0.62,
latest_at: iso(1 * HOUR),
},
{
persona_code: "P2",
persona_name: "김도윤",
sessions: 1,
active_sessions: 0,
completed_sessions: 1,
review_ready_sessions: 0,
rapport_percent: 32,
trend: "insufficient",
latest_stage: "라포",
latest_score: null,
latest_at: iso(4.5 * HOUR),
},
],
achievements: [
{
id: "first-session",
label: "첫 회기 완료",
detail: "첫 회기를 끝냈습니다.",
state: "done",
},
{
id: "five-sessions",
label: "다섯 회기 도전",
detail: "다섯 번째 회기를 끝내면 열립니다.",
state: "locked",
},
],
};
const DETAIL_BODY = {
case_id: "case-lh",
session_id: ACT_ID,
persona_code: "P1",
persona_name: "성하늘",
status: "active",
stage: "탐색",
started_at: iso(1 * HOUR),
ended_at: null,
review_ready: false,
theory_mode: "humanistic",
goal_stages: ["라포", "탐색"],
effective_openness: 0.4,
duration_limit_seconds: 1200,
warning_before_end_seconds: 600,
progress: null,
turns: [
{
turn_seq: 1,
speaker: "learner",
stage: "라포",
text: "안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?",
created_at: iso(0.9 * HOUR),
},
{
turn_seq: 2,
speaker: "client",
stage: "라포",
text: "요즘은 잠이 잘 안 와요.",
created_at: iso(0.8 * HOUR),
},
],
};
// 결정 D3 — 신규 학습자 기초 우선 추천 검증용 fixture.
// P1을 고난도, P4를 기초로 두어 API 순서(P1 먼저)와 난도 오름차순(P4 먼저)이 갈리게 한다.
const STARTER_END_ID = "lhstend1";
const STARTER_PERSONAS_BODY = [
{
code: "P1",
display_name: "성하늘",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "20대" },
presenting_summary: "학업 스트레스와 수면 문제를 호소",
voice_preset: null,
source: "database",
degraded: false,
},
{
code: "P4",
display_name: "박새봄",
difficulty: "easy",
theory_target: ["cbt"],
demographics: { age_band: "30대" },
presenting_summary: "출근길 불안과 긴장을 호소",
voice_preset: null,
source: "database",
degraded: false,
},
];
const S_STARTER_END = {
session_id: STARTER_END_ID,
session_no: 1,
persona_code: "P1",
persona_name: "성하늘",
status: "ended",
stage: "라포",
started_at: iso(5 * HOUR),
ended_at: iso(4.5 * HOUR),
review_ready: false,
archived: false,
archived_at: null,
turn_count: 4,
learner_turn_count: 2,
client_turn_count: 2,
};
const EMPTY_DASHBOARD_BODY = {
message: "학습 현황 요약",
source: "runtime",
overview: {
total_sessions: 0,
active_sessions: 0,
review_ready_sessions: 0,
completed_sessions: 0,
archived_sessions: 0,
learner_turns: 0,
client_turns: 0,
last_practiced_at: null,
},
growth: {
trend: "insufficient",
avg_rapport: null,
avg_score: null,
evaluated_sessions: 0,
latest_score: null,
first_score: null,
score_delta: null,
top_techniques: [],
points: [],
},
recent_feedback: [],
persona_progress: [],
achievements: [],
};
function jsonRoute(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
/** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */
async function installLearnerHomeFixtures(
page: Page,
options: { detail?: boolean } = {},
) {
const { detail = true } = options;
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(PERSONAS_BODY)),
);
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(jsonRoute(SESSIONS_BODY)),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(DASHBOARD_BODY)),
);
if (detail) {
await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) =>
route.fulfill(jsonRoute(DETAIL_BODY)),
);
}
}
/** 결정 D3 검증용 — 난도 역순 페르소나 + 회기 0건(또는 P1 종료 1건)을 고정한다. */
async function installStarterFixtures(
page: Page,
options: { withRecords?: boolean } = {},
) {
const { withRecords = false } = options;
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(STARTER_PERSONAS_BODY)),
);
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(
jsonRoute({
sessions: withRecords ? [S_STARTER_END] : [],
source: "runtime",
}),
),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)),
);
if (withRecords) {
await page.route(new RegExp(`/api/sessions/${STARTER_END_ID}$`), (route) =>
route.fulfill(
jsonRoute({
...DETAIL_BODY,
session_id: STARTER_END_ID,
status: "ended",
stage: "라포",
ended_at: iso(4.5 * HOUR),
}),
),
);
}
}
test.describe("full-sweep learner home", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
// checklist: learner-home-guard-pending-approval
test("redirects a pending learner from /learn to /pending", async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill(
jsonRoute({
user_id: "pending-learner",
email: "pending.learner@hs.ac.kr",
display_name: "Pending Learner",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "pending",
approval_required: true,
cohort_ids: [],
consent_at: 1,
onboarding_completed_at: 1,
nickname: "Pending Learner",
self_introduction: "",
avatar_url: "",
}),
),
);
await page.goto("/learn");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.getByText("승인 대기")).toBeVisible();
await expect(page.locator(".pa-status")).toHaveAttribute("role", "status");
await expect(
page.getByRole("button", { name: "승인 상태 새로고침" }),
).toBeVisible();
});
// checklist: learner-home-head-status-live
test("announces persona and history counts in the aria-live head status", async ({
page,
}) => {
await signInAsLearner(page);
await page.goto("/learn");
const status = page.locator(".lh-head__status");
await expect(status).toHaveAttribute("aria-live", "polite");
// 실 API: 사용 가능 페르소나 수 + 기록 수(신규 학습자 → 0회)가 상태 문구로 뜬다.
await expect(status).toHaveText(/\d+명 · \d+회/, { timeout: 15_000 });
});
// checklist: learner-home-dash-metric-cards
test("renders four dashboard metric cards and routes them to history and practice", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn");
const cards = page.locator(".lh-dashboard-status");
await expect(cards.getByRole("button")).toHaveCount(4);
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
"4회",
);
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
"3회 종료 · 1회 진행",
);
await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText(
"1건",
);
await expect(cards.getByRole("button", { name: /최근 평가/ })).toContainText(
"62점",
);
await expect(cards.getByRole("button", { name: /최근 평가/ })).toContainText(
"3회 평가 · 점검 필요",
);
await expect(cards.getByRole("button", { name: /라포 흐름/ })).toContainText(
"+0.20",
);
await expect(cards.getByRole("button", { name: /라포 흐름/ })).toContainText(
"성하늘 · 3회",
);
await cards.getByRole("button", { name: /진행 회기/ }).click();
await expect(page).toHaveURL(/\/learn\/history$/);
await page.goto("/learn");
await page
.locator(".lh-dashboard-status")
.getByRole("button", { name: /라포 흐름/ })
.click();
await expect(page).toHaveURL(/\/learn\/practice$/);
});
// checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta
test("shows the spotlight recap card and coach CTA for an active session", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn");
// 리캡 카드 — 우선순위 스포트라이트(진행 중 회기)의 아바타·단계·진행률·마지막 발화.
const focus = page.locator(".lh-session-focus");
await expect(focus).toContainText("오늘 이어갈 회기");
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
await expect(focus.locator(".lh-session-focus__status")).toHaveText(
"진행 중",
);
const recap = page.getByLabel("마지막 세션 리캡");
await expect(recap).toContainText("6턴");
await expect(recap).toContainText("탐색");
await expect(recap.locator(".lh-recap__progress")).toContainText("50%");
await expect(recap.locator(".lh-recap__quote")).toContainText(
"요즘은 잠이 잘 안 와요.",
);
await expect(recap.locator(".lh-recap__learner")).toContainText(
"안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?",
);
// AI 코치 카드 — 진행 중 회기 우선 규칙 문구 + 힌트 칩 + CTA가 세션으로 이동.
const coachCard = page.getByLabel("AI 코치");
await expect(
coachCard.getByRole("heading", {
name: "진행 중인 회기를 먼저 마무리합니다.",
}),
).toBeVisible();
await expect(coachCard.getByLabel("코칭 힌트")).toContainText(
"마지막 반응 먼저 반영",
);
await coachCard.getByRole("button", { name: "이어하기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
});
// checklist: learner-home-dash-recommend-card, learner-home-dash-recent-feedback,
// learner-home-dash-review-queue
test("prioritizes review in the recommend card, recent feedback, and review queue", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn");
// 다음 연습 추천 — 리뷰 대기가 있으면 리뷰 우선 문구/이동.
const recommend = page.locator(".lh-dashboard-recommend");
await expect(
recommend.getByRole("heading", { name: "리뷰 확인을 우선합니다." }),
).toBeVisible();
await expect(recommend).toContainText("1건의 회기 피드백 확인");
await recommend.getByRole("button", { name: /리뷰 확인하기/ }).click();
await expect(page).toHaveURL(/\/learn\/history$/);
// 최근 피드백 — 첫 항목 버튼이 해당 회기 리뷰로 이동.
await page.goto("/learn");
const feedback = page.locator(".lh-dashboard-feedback");
await expect(feedback.locator(".lh-panel__badge")).toHaveText("1건");
const feedbackRow = feedback.locator(".lh-feedback-mini__row");
await expect(feedbackRow).toContainText("P1 · 정리 · 62점");
await expect(feedbackRow).toContainText(
"감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.",
);
await feedbackRow.click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
// 리뷰 대기 패널 — review_ready 상위 항목의 'AI 피드백' 버튼이 리뷰로 진입.
// 대시보드 셸은 전 폭에서 가로 탭으로 영역을 전환하므로 '기록 · 리뷰' 탭을 먼저 연다.
await page.goto("/learn");
await page.getByRole("tab", { name: "기록 · 리뷰" }).click();
await expect(
page.getByRole("heading", { name: "피드백 받을 기록입니다." }),
).toBeVisible();
const reviewButtons = page.getByRole("button", { name: "AI 피드백" });
await expect(reviewButtons).toHaveCount(1);
await reviewButtons.first().click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
});
// checklist: learner-home-dash-weakness-insights, learner-home-dash-rapport-progress,
// learner-home-dash-milestones
test("derives weakness insights, rapport bars, and milestone badges from dashboard data", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn");
// 성장 지표 패널들은 '성장 지표' 탭 아래에 배치된다(대시보드 셸 가로 탭).
await page.getByRole("tab", { name: "성장 지표" }).click();
// 보완할 부분 — growth 데이터에서 결정론으로 파생된 인사이트 최대 3건.
const weakness = page.locator(".lh-weakness");
await expect(weakness).toBeVisible();
const weaknessButtons = weakness.locator(".lh-weakness__list button");
await expect(weaknessButtons).toHaveCount(3);
await expect(weaknessButtons.nth(0)).toContainText("평가가 내려가는 흐름");
await expect(weaknessButtons.nth(1)).toContainText("라포 신호가 아직 약함");
await expect(weaknessButtons.nth(2)).toContainText("살펴볼 개입 2건");
// 라포 누적 — 회기가 있는 페르소나 상위 3명의 rapport_percent progressbar.
const rapport = page.getByLabel("페르소나별 라포 누적");
const bars = rapport.getByRole("progressbar");
await expect(bars).toHaveCount(2);
await expect(bars.nth(0)).toHaveAttribute("aria-valuenow", "64");
await expect(bars.nth(1)).toHaveAttribute("aria-valuenow", "32");
await expect(rapport).toContainText("성하늘");
await expect(rapport).toContainText("64%");
// 연습 이정표 — achievements 상태별 배지 + title 툴팁.
const milestones = page.locator(".lh-milestones");
await expect(milestones).toContainText("첫 회기 완료");
await expect(milestones.locator(".lh-milestone.is-done")).toHaveAttribute(
"title",
"첫 회기를 끝냈습니다.",
);
await expect(milestones.locator(".lh-milestone.is-locked")).toContainText(
"다섯 회기 도전",
);
// 인사이트 버튼 클릭 시 해당 화면으로 이동.
await weaknessButtons.nth(1).click();
await expect(page).toHaveURL(/\/learn\/practice$/);
});
// checklist: learner-home-dash-recap-fetch-states
test("shows recap detail loading, error fallback, and ignores stale detail responses", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page, { detail: false });
// 1) 로딩 → 성공: 상세 응답을 지연시켜 로딩 문구를 확인한 뒤 발화를 확정한다.
let releaseDetail!: () => void;
const detailGate = new Promise<void>((resolve) => {
releaseDetail = resolve;
});
const detailPattern = new RegExp(`/api/sessions/${ACT_ID}$`);
await page.route(detailPattern, async (route) => {
await detailGate;
await route.fulfill(jsonRoute(DETAIL_BODY));
});
await page.goto("/learn");
const quote = page.locator(".lh-recap__quote");
await expect(quote).toContainText("상세 발화를 확인하고 있습니다.");
releaseDetail();
await expect(quote).toContainText("요즘은 잠이 잘 안 와요.");
// 2) 오류: 상세 로드 실패 시 발화 없음 폴백 문구로 전환된다.
await page.unroute(detailPattern);
await page.route(detailPattern, (route) =>
route.fulfill(jsonRoute({ detail: "boom" }, 500)),
);
await page.reload();
await expect(quote).toContainText("아직 표시할 내담자 발화가 없습니다.");
// 3) stale 응답: session_id가 다른 상세는 무시되어 발화가 표시되지 않는다.
await page.unroute(detailPattern);
await page.route(detailPattern, (route) =>
route.fulfill(jsonRoute({ ...DETAIL_BODY, session_id: "other-session" })),
);
await page.reload();
await expect(quote).toContainText("아직 표시할 내담자 발화가 없습니다.");
await expect(quote).not.toContainText("요즘은 잠이 잘 안 와요.");
});
// checklist: learner-home-practice-persona-skeleton
test("shows persona skeletons while loading and a role=alert notice on failure", async ({
page,
}) => {
await signInAsLearner(page);
let releasePersonas!: () => void;
const personasGate = new Promise<void>((resolve) => {
releasePersonas = resolve;
});
await page.route(/\/api\/personas$/, async (route) => {
await personasGate;
await route.fulfill(jsonRoute({ detail: "boom" }, 500));
});
await page.goto("/learn/practice");
// 로딩 중 스켈레톤 3개.
await expect(page.locator(".lh-persona.is-skel")).toHaveCount(3);
// 실패 시 role=alert 안내.
releasePersonas();
const alert = page.locator(".lh-error");
await expect(alert).toHaveAttribute("role", "alert");
await expect(alert).toContainText("내담자 목록을 불러오지 못했습니다.");
await expect(alert).toContainText("잠시 뒤 다시 시도해 주세요.");
});
// checklist: learner-home-session-list-states
test("distinguishes session list loading, error, and empty states", async ({
page,
}) => {
await signInAsLearner(page);
let releaseSessions!: () => void;
const sessionsGate = new Promise<void>((resolve) => {
releaseSessions = resolve;
});
const sessionsPattern = /\/api\/sessions$/;
await page.route(sessionsPattern, async (route) => {
await sessionsGate;
await route.fulfill(jsonRoute({ detail: "boom" }, 500));
});
await page.goto("/learn/practice");
// 로딩 문구.
await expect(
page.getByText("저장된 연습 기록을 확인하고 있습니다."),
).toBeVisible();
// 오류: 인라인 경고(role=status).
releaseSessions();
const inlineAlert = page.locator(".lh-activity .lh-inline-alert");
await expect(inlineAlert).toContainText("연습 기록을 불러오지 못했습니다.");
await expect(inlineAlert).toHaveAttribute("role", "status");
// 빈 상태: 컨텍스트별 빈 메시지.
await page.unroute(sessionsPattern);
await page.route(sessionsPattern, (route) =>
route.fulfill(jsonRoute({ sessions: [], source: "runtime" })),
);
await page.reload();
const empty = page.locator(".lh-activity__empty");
await expect(empty).toContainText("저장된 기존 회기가 없습니다.");
await expect(empty).toContainText(
"첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.",
);
await expect(empty).toHaveAttribute("role", "status");
});
// checklist: learner-home-history-overview-cards, learner-home-history-filter-chips,
// learner-home-history-search
test("filters history via overview cards, filter chips, and search", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn/history");
// 요약 필터 카드 4개 + 버킷 카운트 + aria-pressed.
const overview = page.locator(".lh-history-overview");
await expect(overview.getByRole("button")).toHaveCount(4);
await expect(overview.getByRole("button", { name: /전체/ })).toContainText(
"4",
);
await expect(
overview.getByRole("button", { name: /전체/ }),
).toHaveAttribute("aria-pressed", "true");
await expect(
overview.getByRole("button", { name: /진행 중/ }),
).toHaveAttribute("aria-pressed", "false");
await expect(page.locator(".lh-history-main__head")).toContainText("4개");
// 카드 클릭으로 필터 전환.
await overview.getByRole("button", { name: /진행 중/ }).click();
await expect(
overview.getByRole("button", { name: /진행 중/ }),
).toHaveAttribute("aria-pressed", "true");
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
// 칩과 카드가 같은 필터 상태를 공유한다.
const chips = page.locator(".lh-history-filter");
await expect(chips.getByRole("button", { name: "진행 중" })).toHaveAttribute(
"aria-pressed",
"true",
);
await chips.getByRole("button", { name: "보관됨" }).click();
await expect(
overview.getByRole("button", { name: /보관됨/ }),
).toHaveAttribute("aria-pressed", "true");
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
await expect(page.getByRole("button", { name: "복원" })).toBeVisible();
// 검색 — 단계 문자열 부분 일치 즉시 필터링.
await chips.getByRole("button", { name: "전체" }).click();
await expect(page.locator(".lh-history-main__head")).toContainText("4개");
const search = page.getByLabel("기록 검색");
await search.fill("탐색");
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
await expect(page.locator(".lh-session-list")).toContainText("탐색 단계");
await search.fill("존재하지않는검색어");
await expect(page.locator(".lh-history-main__head")).toContainText("0개");
await expect(
page.getByText("조건에 맞는 회기가 없습니다."),
).toBeVisible();
});
// checklist: learner-home-history-archive-notice
test("surfaces archive failure and success notices with role=status", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
let archiveCalls = 0;
await page.route(new RegExp(`/api/sessions/${END_ID}/archive$`), (route) => {
archiveCalls += 1;
if (archiveCalls === 1) {
return route.fulfill(jsonRoute({ detail: "archive failed" }, 500));
}
return route.fulfill(
jsonRoute({
session_id: END_ID,
archived: true,
archived_at: iso(0),
session: { ...S_END, archived: true, archived_at: iso(0) },
source: "runtime",
}),
);
});
await page.goto("/learn/history");
const endCard = page.locator(".lh-session-card", { hasText: "김도윤" });
await expect(endCard).toBeVisible();
// 실패: role=status 배너에 실패 문구.
await endCard.getByRole("button", { name: "보관", exact: true }).click();
const notice = page.locator(".lh-inline-alert--archive");
await expect(notice).toContainText("보관 상태를 바꾸지 못했습니다.");
await expect(notice).toHaveAttribute("role", "status");
// 성공: 같은 배너 위치에 성공 문구.
await endCard.getByRole("button", { name: "보관", exact: true }).click();
await expect(notice).toContainText("회기를 보관했습니다.");
await expect(notice).toHaveAttribute("role", "status");
});
// checklist: learner-home-history-persona-progress
test("lists persona progress rows and jumps to practice with the persona selected", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn/history");
const progress = page.getByLabel("페르소나별 진행 정도");
await expect(progress).toContainText("2명");
const rows = progress.locator(".lh-persona-progress__row");
await expect(rows).toHaveCount(2);
// 회기 수 내림차순 정렬 — P1(3회)이 먼저 온다.
await expect(rows.nth(0)).toContainText("P1");
await expect(rows.nth(0)).toContainText(
"3회 · 1회 진행 · 1회 리뷰 · 평균 4턴",
);
await expect(rows.nth(1)).toContainText("P2");
// 행 클릭 시 해당 페르소나가 선택된 채 연습 화면으로 진입.
await rows.nth(1).click();
await expect(page).toHaveURL(/\/learn\/practice$/);
await expect(
page.getByRole("heading", { name: "김도윤" }),
).toBeVisible();
await expect(
page
.getByRole("listbox", { name: "연습 페르소나" })
.getByRole("option", { name: /P2/ }),
).toHaveAttribute("aria-selected", "true");
});
// checklist: learner-home-newbie-basic-first (결정 D3)
test("defaults a learner with no records to the easiest persona in dashboard and practice", async ({
page,
}) => {
await signInAsLearner(page);
await installStarterFixtures(page);
// (a) 대시보드 첫 회기 준비 카드가 API 순서(P1·고난도)가 아니라 기초(P4)를 보여준다.
await page.goto("/learn");
const focus = page.locator(".lh-session-focus");
await expect(focus).toContainText("첫 회기 준비");
await expect(focus.getByRole("heading", { name: "박새봄" })).toBeVisible();
await expect(focus).toContainText("기초");
await expect(page.getByLabel("마지막 세션 리캡")).toContainText("P4");
// (b) /learn/practice 기본 선택도 난도 오름차순 첫 사용 가능 페르소나(P4)다.
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(
listbox.getByRole("option", { name: /P4/ }),
).toHaveAttribute("aria-selected", "true");
await expect(
listbox.getByRole("option", { name: /P1/ }),
).toHaveAttribute("aria-selected", "false");
await expect(
page.getByRole("heading", { name: "박새봄" }),
).toBeVisible();
});
// checklist: learner-home-newbie-basic-first (결정 D3 — 기록 보유 시 무회귀)
test("keeps the recent-session rule when the learner already has records", async ({
page,
}) => {
await signInAsLearner(page);
await installStarterFixtures(page, { withRecords: true });
// (c) 기록이 있으면 스포트라이트는 최근 회기 페르소나(P1)를 유지한다.
await page.goto("/learn");
const focus = page.locator(".lh-session-focus");
await expect(focus).toContainText("오늘 이어갈 회기");
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
// 기본 선택도 기존 규칙(첫 사용 가능 페르소나 = P1)을 유지한다.
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(
listbox.getByRole("option", { name: /P1/ }),
).toHaveAttribute("aria-selected", "true");
await expect(
listbox.getByRole("option", { name: /P4/ }),
).toHaveAttribute("aria-selected", "false");
});
});

View file

@ -0,0 +1,812 @@
/**
* (2026-07-27) (/teach/personas) spec.
*
* docs/ops/e2e-full-sweep-2026-07-27.md 6 "검증: 신규 spec 필요"
* . AI route fixture로 ,
* dev-login API를 . test() checklist id를 .
*/
import { expect, test, type Page } from "@playwright/test";
import {
expectNoHorizontalOverflow,
signInAsLearner,
signInAsTeacher,
} from "./support";
const APPROVED_ID = "00000000-0000-0000-0000-000000000801";
const DRAFT_ID = "00000000-0000-0000-0000-000000000802";
const REVIEW_ID = "00000000-0000-0000-0000-000000000803";
const approvedP1 = {
persona_id: APPROVED_ID,
code: "P1",
version: 3,
status: "approved",
display_name: "서연(가명) · 고2 · 우울/자살사고",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "F-teen" },
presenting_summary: "최근 무기력과 관계 단절을 호소합니다.",
source: "database",
degraded: false,
voice_preset: null,
};
const reviewP12 = {
persona_id: REVIEW_ID,
code: "P12",
version: 1,
status: "review",
display_name: "직장 적응 훈련 페르소나",
difficulty: "moderate",
theory_target: ["cbt"],
source_provenance: "교수자 작성 초안",
is_synthetic: true,
created_at: "2026-07-15T08:00:00Z",
approved_at: null,
};
const draftP13 = {
persona_id: DRAFT_ID,
code: "P13",
version: 1,
status: "review",
display_name: "발표 불안 훈련 초안",
difficulty: "moderate",
theory_target: ["cbt"],
source_provenance: "교수 상담 기록 2024",
is_synthetic: false,
created_at: "2026-07-20T02:00:00Z",
approved_at: null,
};
function draftDetail(base: typeof draftP13) {
return {
...base,
demographics: { age_band: "20대", sex: "여", role: "대학생", context: "실습" },
presenting: { complaint: "발표 상황에서 심한 불안을 호소합니다." },
history: { family: "성취 압박이 강한 가정" },
big5: { O: 0.5, C: 0.4, E: 0.3, A: 0.6, N: 0.8 },
resistance: {
base_resistance: 0.6,
unlock_rate: 0.1,
decay_floor: 0.05,
silence_prob: 0.15,
deflection_prob: 0.25,
},
speech_style: { register: "polite", honorific: true },
affect_baseline: {
negative_affect: 0.7,
anxiety: 0.6,
hopelessness: 0.2,
anhedonia: 0.2,
sleep: 0.2,
suicide_ideation_stage: 1,
},
ccd: { core_belief: "나는 부족하다" },
dsm5_dimensional: { note: "평가 상황에서 불안 상승" },
triggers: { sore_spots: ["평가", "비교"], forbidden: ["단정"] },
};
}
function dashboardFixture(overrides: Record<string, unknown> = {}) {
return {
source: "database",
cohort_label: "E2E cohort",
total_learners: 0,
active_sessions: 0,
ended_sessions: 0,
learner_growth: [],
safety_alerts: [],
pending_reviews: [],
recent_sessions: [],
message: "persona studio full sweep fixture",
...overrides,
};
}
async function mockStudio(
page: Page,
options: {
catalog?: unknown[];
reviews?: unknown[];
dashboard?: Record<string, unknown> | null;
} = {},
) {
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(options.catalog ?? []),
});
});
await page.route("**/api/personas/review", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(options.reviews ?? []),
});
});
await page.route("**/api/teacher/dashboard", (route) => {
if (options.dashboard === null) {
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "dashboard unavailable" }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(options.dashboard ?? dashboardFixture()),
});
});
}
function meFixture(overrides: Record<string, unknown> = {}) {
return {
user_id: "00000000-0000-0000-0000-000000000901",
email: "guard-check@hs.ac.kr",
role: "teacher",
display_name: "Guard Check Teacher",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: "Guard Check",
self_introduction: "",
avatar_url: "",
...overrides,
};
}
test.describe("persona studio full sweep", () => {
// checklist: persona-studio-guard-require-auth, persona-studio-guard-pending-approval,
// persona-studio-guard-onboarding
test("redirects unauthenticated, learner, pending, and un-onboarded users away", async ({
page,
}) => {
// 미인증 → /login
await page.goto("/teach/personas");
await expect(page).toHaveURL(/\/login$/);
// learner 역할 → 자기 역할 홈(/learn)
await signInAsLearner(page);
await page.goto("/teach/personas");
await expect(page).toHaveURL(/\/learn$/);
// 미승인(teacher, pending) → /pending
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
meFixture({ account_status: "pending", onboarding_completed_at: null }),
),
}),
);
await page.goto("/teach/personas");
await expect(page).toHaveURL(/\/pending$/);
// 승인됐지만 온보딩 미완료 → /onboarding
await page.unroute("**/api/auth/me");
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(meFixture({ onboarding_completed_at: null })),
}),
);
await page.goto("/teach/personas");
await expect(page).toHaveURL(/\/onboarding$/);
});
// checklist: persona-studio-dash-usage-row, persona-studio-detail-learning-empty
test("opens approved detail from a dashboard usage row and shows empty learning state", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [approvedP1], reviews: [] });
await page.goto("/teach/personas");
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
const usageRow = page.locator(".ps-usage-row").filter({ hasText: "P1" });
await expect(usageRow).toBeVisible();
await usageRow.click();
await expect(page).toHaveURL(/view=personas/);
await expect(page).toHaveURL(/persona=approved%3AP1/);
await expect(page.getByRole("heading", { name: "페르소나 상세" })).toBeVisible();
// 평가된 회기 궤적이 없으면 학습 현황 탭은 빈 상태를 표시한다.
await page.getByRole("tab", { name: "학습 현황" }).click();
await expect(page.getByText("학습 기록 없음")).toBeVisible();
await expectNoHorizontalOverflow(page);
});
// checklist: persona-studio-dash-usage-empty, persona-studio-catalog-empty-distribution,
// persona-studio-review-empty
test("shows empty states on dashboard, catalog distribution, and review queue", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
await page.goto("/teach/personas");
await expect(page.getByText("공개 페르소나 없음")).toBeVisible();
await page.getByRole("button", { name: /카탈로그/ }).first().click();
await expect(page.getByRole("heading", { name: "페르소나 카탈로그" })).toBeVisible();
await expect(page.getByText("분포 자료 없음")).toBeVisible();
await page.getByRole("button", { name: /페르소나/ }).first().click();
await page.getByRole("tab", { name: /검수 현황/ }).click();
await expect(page.getByText("검수 대기 없음")).toBeVisible();
});
// checklist: persona-studio-list-search, persona-studio-list-origin-filter,
// persona-studio-list-empty
test("filters the persona list by search text and origin select", async ({ page }) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [approvedP1], reviews: [reviewP12] });
await page.goto("/teach/personas?view=personas");
const rows = page.locator(".ps-persona-row");
await expect(rows).toHaveCount(2);
// 이름 부분 일치 검색
await page.getByLabel("페르소나 검색").fill("직장");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("P12");
// 상태 라벨로도 검색된다 (검수 대기)
await page.getByLabel("페르소나 검색").fill("검수 대기");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("P12");
await page.getByLabel("페르소나 검색").fill("");
await expect(rows).toHaveCount(2);
// 구분 필터: P1~P11은 시스템, 그 외는 커스텀
await page.getByLabel("구분").selectOption("system");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("P1");
await page.getByLabel("구분").selectOption("custom");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("P12");
// 결과 없음 빈 상태
await page.getByLabel("구분").selectOption("all");
await page.getByLabel("페르소나 검색").fill("존재하지않는페르소나");
await expect(page.getByText("조건에 맞는 페르소나 없음")).toBeVisible();
});
// checklist: persona-studio-review-card-reject, persona-studio-review-card-edit
test("rejects a pending review card and opens the draft editor from a card", async ({
page,
}) => {
await signInAsTeacher(page);
const draftStatusPersona = { ...draftP13, status: "draft" };
await mockStudio(page, { catalog: [], reviews: [reviewP12, draftStatusPersona] });
let rejectPayload: unknown = null;
await page.route(`**/api/personas/review/${REVIEW_ID}`, async (route) => {
expect(route.request().method()).toBe("POST");
rejectPayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ...reviewP12, status: "draft" }),
});
});
await page.route(`**/api/personas/drafts/${REVIEW_ID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(draftDetail({ ...draftP13, ...reviewP12 })),
});
});
await page.goto("/teach/personas?view=personas&queue=review");
// status=draft 카드는 승인·반려가 비활성화된다.
const draftCard = page.locator(".ps-review-card").filter({ hasText: "P13" });
await expect(draftCard).toBeVisible();
await expect(draftCard.getByRole("button", { name: "반려" })).toBeDisabled();
await expect(draftCard.getByRole("button", { name: "승인" })).toBeDisabled();
// status=review 카드 반려 → decide API 호출과 결과 메시지
const reviewCard = page.locator(".ps-review-card").filter({ hasText: "P12" });
await reviewCard.getByRole("button", { name: "반려" }).click();
await expect(page.getByText("P12 v1 반려했습니다.")).toBeVisible();
expect(rejectPayload).toEqual({ action: "reject" });
// 검수 카드 수정 → mode=edit 편집기로 이동해 초안이 자동 로드된다.
await page
.locator(".ps-review-card")
.filter({ hasText: "P12" })
.getByRole("button", { name: "수정" })
.click();
await expect(page).toHaveURL(/mode=edit/);
await expect(page).toHaveURL(new RegExp(`draft=${REVIEW_ID}`));
await expect(page.getByLabel("표시 이름")).toHaveValue("직장 적응 훈련 페르소나");
});
// checklist: persona-studio-draft-autoload-param
test("auto-loads the draft named by the ?draft= query param into the editor", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [draftP13] });
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(draftDetail(draftP13)),
});
});
await page.goto(
`/teach/personas?view=personas&mode=edit&draft=${DRAFT_ID}&step=edit`,
);
await expect(page.getByText("P13 v1 초안을 불러왔습니다.")).toBeVisible();
await expect(page.getByLabel("표시 이름")).toHaveValue("발표 불안 훈련 초안");
await expect(page.getByLabel("코드")).toHaveValue("P13");
});
// checklist: persona-studio-author-rail-draft-load, persona-studio-author-rail-approved-actions
// 저작 모드는 4단계 스테퍼+인스펙터 중심의 포커스 화면을 쓰고, 좌측 레일(.ps-rail)
// 마크업은 렌더하지 않는다. 초안 로드는 목록 뷰·검수 큐·직접 URL로 진입한다.
// 항상 숨겨지던 죽은 DOM이었던 레일은 2026-07-27 소유자 결정으로 정리 완료
// (체크리스트 결함 로그 #14).
test("keeps the authoring screen focused without rendering the left rail", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [approvedP1], reviews: [draftP13] });
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
// 레일은 DOM 자체가 존재하지 않아야 한다. 초안 진입 경로(목록 뷰 행 클릭·검수
// 큐·직접 URL)는 이 파일의 다른 테스트가 검증한다.
await expect(page.locator(".ps-authoring-layout > .ps-rail")).toHaveCount(0);
});
// checklist: persona-studio-inspector-review-actions
test("edits and approves review drafts from the inspector queue", async ({ page }) => {
await signInAsTeacher(page);
// 편집 대상(P13)과 승인 대상(P12)을 분리한다. 현재 편집 중인 초안을 승인하면
// resetDraft가 성공 메시지를 지우는 별개 동작이 있어 결정 메시지 검증이 섞이지 않게 한다.
await mockStudio(page, { catalog: [], reviews: [draftP13, reviewP12] });
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(draftDetail(draftP13)),
});
});
let decidePayload: unknown = null;
await page.route(`**/api/personas/review/${REVIEW_ID}`, async (route) => {
expect(route.request().method()).toBe("POST");
decidePayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...reviewP12,
status: "approved",
approved_at: "2026-07-27T00:00:00Z",
}),
});
});
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
const editCard = page.locator(".ps-inspector .ps-review").filter({ hasText: "P13" });
await expect(editCard).toBeVisible();
await editCard.getByRole("button", { name: "편집" }).click();
await expect(page.getByText("P13 v1 초안을 불러왔습니다.")).toBeVisible();
await expect(page.getByLabel("표시 이름")).toHaveValue("발표 불안 훈련 초안");
const approveCard = page
.locator(".ps-inspector .ps-review")
.filter({ hasText: "P12" });
await approveCard.getByRole("button", { name: "승인" }).click();
await expect(page.getByText("P12 v1 승인했습니다.")).toBeVisible();
expect(decidePayload).toEqual({ action: "approve" });
});
// checklist: persona-studio-author-empty-states, persona-studio-inspector-validation,
// persona-studio-field-provenance
test("shows inspector empty states and live validation checklist while authoring", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
const inspector = page.locator(".ps-inspector");
// 인스펙터 빈 상태 (생성 근거 없음 / 결정 대기 없음)
await expect(
inspector.getByText("자료 기반 생성 결과가 아직 없습니다."),
).toBeVisible();
await expect(inspector.getByText("결정 대기 없음")).toBeVisible();
// 검증 체크리스트: 필수 항목 오류가 실시간으로 나열된다.
await expect(inspector.getByText("표시 이름이 비어 있습니다.")).toBeVisible();
await expect(inspector.getByText("주호소가 비어 있습니다.")).toBeVisible();
await expect(inspector.getByText("CCD 핵심신념이 비어 있습니다.")).toBeVisible();
await expect(inspector.getByText("역린 민감 영역이 비어 있습니다.")).toBeVisible();
// 값을 채우면 해당 오류가 사라진다.
await page.getByLabel("표시 이름").fill("검증 확인 페르소나");
await expect(inspector.getByText("표시 이름이 비어 있습니다.")).toHaveCount(0);
// 출처를 비우면 경고가 나타난다.
await page.getByLabel("출처").fill("");
await expect(inspector.getByText("출처/작성 근거가 비어 있습니다.")).toBeVisible();
await page.getByLabel("출처").fill("교수 자체 작성");
await expect(inspector.getByText("출처/작성 근거가 비어 있습니다.")).toHaveCount(0);
});
// checklist: persona-studio-theory-toggle, persona-studio-fields-resistance-numbers
test("toggles theory targets and edits the 16 resistance numeric fields", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
const segments = page.locator(".ps-segments");
const humanistic = segments.getByRole("button", { name: "humanistic", exact: true });
const cbt = segments.getByRole("button", { name: "cbt", exact: true });
await expect(humanistic).toHaveClass(/is-active/);
await cbt.click();
await expect(cbt).toHaveClass(/is-active/);
await expect(humanistic).toHaveClass(/is-active/);
await humanistic.click();
await expect(humanistic).not.toHaveClass(/is-active/);
// 마지막 남은 이론(cbt)을 해제하면 humanistic으로 되돌아간다.
await cbt.click();
await expect(cbt).not.toHaveClass(/is-active/);
await expect(humanistic).toHaveClass(/is-active/);
// 저항 탭: 저항 5 + Big5 5 + 정서 기저선 5 + 자살사고 단계 = 16개 숫자 입력
await page.getByRole("tab", { name: "저항" }).click();
await expect(page.locator('.ps-number-grid input[type="number"]')).toHaveCount(16);
await page.getByLabel("초기 저항").fill("0.8");
await expect(page.getByLabel("초기 저항")).toHaveValue("0.8");
await page.getByLabel("자살사고 단계").fill("3");
await expect(page.getByLabel("자살사고 단계")).toHaveValue("3");
await expect(page.getByLabel("자살사고 단계")).toHaveAttribute("max", "5");
});
// checklist: persona-studio-source-dropzone, persona-studio-source-file-picker,
// persona-studio-source-kind-select, persona-studio-source-name-input,
// persona-studio-generation-goal-input, persona-studio-source-text-area,
// persona-studio-generate-draft, persona-studio-inspector-evidence,
// persona-studio-status-message-line
test("registers pasted source as KB evidence and generates a draft", async ({ page }) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
const sourceDoc = {
source_id: "src-e2e-1",
doc_id: "doc-e2e-9",
chunk_count: 3,
content_hash: "abcdef1234567890",
title: "사례-정리.md",
source_kind: "textbook_guide",
pii_entities_masked: ["이름"],
external_llm_ok: true,
degraded: false,
};
const generatedDraft = {
code: "P5",
display_name: "생성된 페르소나",
difficulty: "moderate",
theory_target: ["humanistic"],
demographics: {},
presenting: { complaint: "발표 상황 회피" },
history: {},
speech_style: {},
ccd: {},
dsm5_dimensional: {},
triggers: {},
source_provenance: "상담기록 근거",
is_synthetic: false,
};
let sourcePayload: Record<string, unknown> | null = null;
let generatePayload: Record<string, unknown> | null = null;
await page.route("**/api/personas/sources", async (route) => {
sourcePayload = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(sourceDoc),
});
});
await page.route("**/api/personas/drafts/generate", async (route) => {
generatePayload = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
draft: generatedDraft,
source_summary: "근거 청크 3개로 초안을 생성했습니다.",
warnings: ["표면화 조건 근거가 부족합니다."],
pii_entities_masked: ["연락처"],
source_references: [sourceDoc],
evidence_chunks: [
{
source_id: "src-e2e-1",
chunk_id: "c-1",
score: 0.912,
heading_path: "1회기 요약",
excerpt: "내담자는 발표 상황에서 심한 긴장을 보고했다.",
},
],
}),
});
});
await page.goto("/teach/personas?view=personas&mode=create&step=generate");
// 자료 없이 생성 → role=alert 오류 문구 (상태 메시지 라인 오류 분기)
await page.getByRole("button", { name: "KB 등록 후 생성" }).click();
const errorLine = page.locator(".ps-status.is-error");
await expect(errorLine).toBeVisible();
await expect(errorLine).toHaveAttribute("role", "alert");
await expect(errorLine).toContainText("20자 이상");
// 파일 선택 → 텍스트 파일은 브라우저에서 읽어 본문에 채운다.
await page.locator(".ps-file-input").setInputFiles({
name: "상담기록-1회기.txt",
mimeType: "text/plain",
buffer: Buffer.from("내담자는 발표를 앞두고 반복적으로 불안을 호소했다.", "utf-8"),
});
await expect(page.getByLabel("파일/자료명")).toHaveValue("상담기록-1회기.txt");
await expect(page.getByLabel("자료 본문")).toHaveValue(/반복적으로 불안을 호소/);
await expect(page.getByText(/첨부 내용을 불러왔습니다/)).toBeVisible();
// 드래그&드롭 → 첫 파일을 읽어 본문을 교체한다.
const dataTransfer = await page.evaluateHandle(() => {
const dt = new DataTransfer();
dt.items.add(
new File(
["드롭한 사례 기록: 평가 상황에서 회피 행동이 반복적으로 관찰되었다."],
"드롭-사례.md",
{ type: "text/markdown" },
),
);
return dt;
});
await page.locator(".ps-upload-card").dispatchEvent("drop", { dataTransfer });
await expect(page.getByLabel("파일/자료명")).toHaveValue("드롭-사례.md");
await expect(page.getByLabel("자료 본문")).toHaveValue(/드롭한 사례 기록/);
// 자료 종류·자료명·생성 목표 입력이 생성 요청에 함께 전달된다.
await page.getByLabel("자료 종류").selectOption("textbook_guide");
await page.getByLabel("파일/자료명").fill("사례-정리.md");
await page.getByLabel("생성 목표").fill("인간중심 초심 훈련");
await page.getByRole("button", { name: "KB 등록 후 생성" }).click();
await expect(
page.getByText("첨부 자료를 RAG 근거 문서로 등록하고 초안을 생성했습니다.", {
exact: false,
}),
).toBeVisible();
expect(sourcePayload).toMatchObject({
filename: "사례-정리.md",
source_kind: "textbook_guide",
source_note: "인간중심 초심 훈련",
title: "사례-정리.md",
});
expect(String((sourcePayload as Record<string, unknown> | null)?.text)).toContain(
"드롭한 사례 기록",
);
expect(generatePayload).toMatchObject({
source_ids: ["src-e2e-1"],
source_kind: "textbook_guide",
generation_goal: "인간중심 초심 훈련",
});
// 인스펙터 생성 근거 패널: 요약·마스킹·경고·근거 문서·근거 청크
const inspector = page.locator(".ps-inspector");
await expect(
inspector.getByText("근거 청크 3개로 초안을 생성했습니다."),
).toBeVisible();
await expect(inspector.getByText("이름 마스킹됨")).toBeVisible();
await expect(inspector.getByText("연락처 마스킹됨")).toBeVisible();
await expect(inspector.getByText("표면화 조건 근거가 부족합니다.")).toBeVisible();
await expect(inspector.getByText("사례-정리.md")).toBeVisible();
await expect(inspector.getByText("chunk c-1 · 0.912")).toBeVisible();
await expect(
inspector.getByText(/내담자는 발표 상황에서 심한 긴장을 보고했다\./),
).toBeVisible();
// 생성 결과가 편집 폼에 반영됐는지 설정 단계에서 확인
await page
.getByRole("navigation", { name: "페르소나 작성 단계" })
.getByRole("button", { name: /설정/ })
.click();
await expect(page.getByLabel("표시 이름")).toHaveValue("생성된 페르소나");
await expect(page.getByLabel("코드")).toHaveValue("P5");
});
// checklist: persona-studio-save-progress, persona-studio-loading-busy-states
test("saves progress locally first and to the server with busy labels", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
let draftPosted = false;
await page.route("**/api/personas/drafts", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
draftPosted = true;
const payload = route.request().postDataJSON() as { display_name: string };
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
persona_id: "00000000-0000-0000-0000-000000000899",
code: "P1",
version: 1,
status: "draft",
display_name: payload.display_name,
difficulty: "moderate",
theory_target: ["humanistic"],
source_provenance: "clinical draft",
is_synthetic: true,
created_at: "2026-07-27T00:00:00Z",
approved_at: null,
}),
});
});
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
// 최소 검증(표시 이름) 미달 → localStorage 임시 저장만 수행
await page.getByRole("button", { name: "중간 저장" }).click();
await expect(
page.getByText("작성 중 내용을 이 브라우저에 임시 저장했습니다.", { exact: false }),
).toBeVisible();
expect(draftPosted).toBe(false);
const stored = await page.evaluate(() =>
window.localStorage.getItem("vignette:persona-studio:progress:v1"),
);
expect(stored).not.toBeNull();
// 표시 이름을 채우면 서버 초안 생성까지 수행하고, 진행 중엔 버튼이 비활성 라벨로 바뀐다.
await page.getByLabel("표시 이름").fill("임시 저장 페르소나");
await page.getByRole("button", { name: "중간 저장" }).click();
const savingButton = page.getByRole("button", { name: "저장 중" });
await expect(savingButton).toBeVisible();
await expect(savingButton).toBeDisabled();
await expect(page.getByText("P1 v1 초안을 저장했습니다.")).toBeVisible();
expect(draftPosted).toBe(true);
});
// checklist: persona-studio-load-error-banner, persona-studio-refresh-button,
// persona-studio-dashboard-degraded-note
test("surfaces catalog load failure and recovers via refresh with a degraded note", async ({
page,
}) => {
await signInAsTeacher(page);
// 1) 카탈로그 로드 실패 → role=alert 오류 배너
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "catalog unavailable" }),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "review unavailable" }),
}),
);
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "dashboard unavailable" }),
}),
);
await page.goto("/teach/personas");
const errorBanner = page.locator(".ps-error");
await expect(errorBanner).toBeVisible();
await expect(errorBanner).toHaveAttribute("role", "alert");
await expect(errorBanner).toContainText("catalog unavailable");
// 2) 카탈로그·검수 큐는 복구, 대시보드만 계속 실패 → 새로고침
await page.unroute("**/api/personas");
await page.unroute("**/api/personas/review");
await page.route("**/api/personas", async (route) => {
if (route.request().method() !== "GET") return route.fallback();
await new Promise((resolve) => setTimeout(resolve, 500));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
const refreshButton = page.locator(".ps-head__actions button").first();
await expect(refreshButton).toHaveText(/새로고침/);
await refreshButton.click();
// 로딩 중에는 '확인 중' 라벨로 비활성화된다.
await expect(refreshButton).toHaveText(/확인 중/);
await expect(refreshButton).toBeDisabled();
// 카탈로그가 복구되면 배너는 사라지고, 대시보드 실패는 경고 노트로만 남는다.
await expect(errorBanner).toHaveCount(0);
const degradedNote = page.locator(".ps-inline-note.is-warn");
await expect(degradedNote).toBeVisible();
await expect(degradedNote).toHaveAttribute("role", "status");
await expect(degradedNote).toContainText("학습 지표를 불러오지 못했습니다.");
await expect(degradedNote).toContainText(
"카탈로그와 저작 기능은 계속 사용할 수 있습니다.",
);
});
// checklist: persona-studio-detail-draft-fetch, persona-studio-detail-notfound
test("fetches draft detail for a review draft and shows the not-found state", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [draftP13] });
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(draftDetail(draftP13)),
});
});
await page.goto(`/teach/personas?view=personas&persona=draft:${DRAFT_ID}`);
await expect(page.getByRole("heading", { name: "페르소나 상세" })).toBeVisible();
await expect(
page.getByText("발표 상황에서 심한 불안을 호소합니다."),
).toBeVisible();
// 초안 상세 자동 조회 결과: 출처·핵심 신념·민감 영역
await expect(page.getByText("교수 상담 기록 2024")).toBeVisible();
await expect(page.getByText("나는 부족하다")).toBeVisible();
await expect(page.getByText("평가, 비교")).toBeVisible();
// 목록에 없는 키로 진입하면 미존재 빈 상태를 표시한다.
await page.goto("/teach/personas?view=personas&persona=approved:NOPE");
await expect(page.getByText("페르소나를 찾지 못했습니다")).toBeVisible();
});
});

View file

@ -0,0 +1,823 @@
/**
* full-sweep-professor.spec.ts 2026-07-27 E2E "신규 spec 필요"
* (/teach, /teach/analysis) .
* (docs/ops/e2e-full-sweep-2026-07-27.md · "## 5. 교수자 콘솔 (professor)")
*
* :
* - professor-guard-pending-approval / professor-guard-onboarding
* - professor-dashboard-error-banner / professor-header-refresh / professor-kpi-cards
* - professor-persona-review-error-retry / professor-persona-review-empty-loading
* - professor-analysis-sort-segment / professor-analysis-table-states
* - professor-analysis-back-to-list / professor-analysis-invalid-learner-redirect
* - professor-analysis-trend-chart / professor-analysis-stage-breakdown
*
* UI page.route fixture로 , API로
* dev-login . AI .
*/
import { expect, test, type Page } from "@playwright/test";
import { signInAsTeacher } from "./support";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve };
}
function authMeFixture(overrides: Record<string, unknown> = {}) {
return {
user_id: "00000000-0000-0000-0000-000000000901",
email: "teacher@hs.ac.kr",
role: "teacher",
display_name: "E2E Teacher",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: "E2E Teacher",
self_introduction: "",
avatar_url: "",
...overrides,
};
}
function dashboardFixture(overrides: Record<string, unknown> = {}) {
return {
source: "database",
cohort_label: "E2E cohort",
total_learners: 0,
active_sessions: 0,
ended_sessions: 0,
learner_growth: [],
safety_alerts: [],
pending_reviews: [],
recent_sessions: [],
message: "검토할 실제 회기가 없습니다.",
...overrides,
};
}
function pendingReviewFixture(index: number) {
const padded = String(index).padStart(2, "0");
return {
session_id: `professor-sweep-pending-${padded}`,
learner_id: `00000000-0000-0000-0000-0000000001${padded}`,
learner_label: `검토 학습자 ${padded}`,
persona_code: "P1",
persona_name: "서연",
session_no: index,
status: "ended",
stage: "정리",
turn_count: 6 + index,
learner_turn_count: 3,
client_turn_count: 3,
started_at: `2026-07-2${index}T07:00:00Z`,
ended_at: `2026-07-2${index}T07:32:00Z`,
review_status: "pending",
review_note: null,
reviewed_at: null,
};
}
function recentSessionFixture(index: number) {
const padded = String(index).padStart(2, "0");
return {
session_id: `professor-sweep-recent-${padded}`,
learner_id: `00000000-0000-0000-0000-0000000002${padded}`,
learner_label: `최근 학습자 ${padded}`,
persona_code: "P2",
persona_name: "민재",
session_no: index,
status: index % 2 === 0 ? "active" : "ended",
stage: "탐색",
turn_count: 4 + index,
learner_turn_count: 2 + index,
client_turn_count: 2,
started_at: `2026-07-2${index}T08:00:00Z`,
ended_at: index % 2 === 0 ? null : `2026-07-2${index}T08:40:00Z`,
};
}
function safetyAlertFixture(index: number) {
return {
id: `professor-sweep-alert-${index}`,
session_id: `professor-sweep-crisis-${index}`,
learner_id: `00000000-0000-0000-0000-0000000003${String(index).padStart(2, "0")}`,
learner_label: `위기 학습자 ${index}`,
persona_code: "P1",
ko_risk_level: "높음",
created_at: "2026-07-26T10:00:00Z",
resource_title: "자살예방 상담전화",
resource_number: "109",
};
}
function reviewPersonaFixture(overrides: Record<string, unknown> = {}) {
return {
persona_id: "00000000-0000-0000-0000-000000009911",
code: "P13",
version: 1,
status: "review",
display_name: "전수 검수 대기 페르소나",
difficulty: "moderate",
theory_target: ["humanistic"],
source_provenance: "full-sweep fixture",
is_synthetic: true,
created_at: "2026-07-25T07:00:00Z",
approved_at: null,
...overrides,
};
}
function growthLearnerFixture(
id: string,
label: string,
overrides: Record<string, unknown> = {},
) {
return {
learner_id: id,
learner_label: label,
sessions: 1,
ended_sessions: 1,
latest_at: "2026-07-01T09:00:00Z",
first_score: 0.4,
latest_score: 0.5,
score_delta: 0.1,
avg_score: 0.45,
avg_rapport: 0.2,
trend: "flat",
top_techniques: ["reflection"],
points: [],
...overrides,
};
}
function growthPointFixture(
learnerId: string,
index: number,
score: number,
rapport: number,
) {
return {
session_id: `${learnerId}-session-${index}`,
session_no: index,
persona_code: "P1",
stage: index > 1 ? "개입" : "탐색",
started_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
ended_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
score,
rapport,
technique_count: index + 1,
watch_count: 0,
};
}
function analysisSessionFixture(learnerId: string, label: string, index: number) {
return {
session_id: `${learnerId}-session-${index}`,
learner_id: learnerId,
learner_label: label,
persona_code: "P1",
persona_name: "서연",
session_no: index,
status: "ended",
stage: index > 1 ? "개입" : "탐색",
turn_count: 8 + index,
learner_turn_count: 4 + index,
client_turn_count: 4,
started_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
ended_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
review_status: "pending",
review_note: null,
reviewed_at: null,
};
}
function learnerAnalysisFixture(learnerId: string, label: string) {
const points = [
growthPointFixture(learnerId, 1, 0.5, 0.2),
growthPointFixture(learnerId, 2, 0.8, 0.4),
];
const sessions = [
analysisSessionFixture(learnerId, label, 1),
analysisSessionFixture(learnerId, label, 2),
];
return {
source: "database",
learner_id: learnerId,
learner_label: label,
total_sessions: sessions.length,
active_sessions: 0,
ended_sessions: sessions.length,
pending_reviews: sessions.length,
closed_reviews: 0,
summary: {
learner_id: learnerId,
learner_label: label,
sessions: sessions.length,
ended_sessions: sessions.length,
latest_at: "2026-07-12T09:30:00Z",
first_score: 0.5,
latest_score: 0.8,
score_delta: 0.3,
avg_score: 0.65,
avg_rapport: 0.3,
trend: "up",
top_techniques: ["reflection", "open question"],
points,
},
points,
stage_breakdown: [
{ stage: "라포", sessions: 0, turns: 0 },
{ stage: "탐색", sessions: 1, turns: 34 },
{ stage: "개입", sessions: 1, turns: 42 },
{ stage: "정리", sessions: 0, turns: 0 },
],
sessions,
message: `${label} 전체 회기 ${sessions.length}`,
};
}
async function fulfillJson(body: unknown) {
return {
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
};
}
async function routeEmptyConsole(page: Page, dashboard?: Record<string, unknown>) {
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(await fulfillJson(dashboardFixture(dashboard))),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
}
test.describe("professor console full-sweep", () => {
// checklist: professor-guard-pending-approval
test("redirects an unapproved teacher to /pending and returns an approved teacher from /pending", async ({
page,
}) => {
let accountStatus = "pending";
await page.route("**/api/auth/me", async (route) =>
route.fulfill(
await fulfillJson(authMeFixture({ account_status: accountStatus })),
),
);
await routeEmptyConsole(page);
await page.goto("/teach");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.getByText("승인 대기", { exact: true })).toBeVisible();
await expect(page.locator(".pf-root")).toHaveCount(0);
accountStatus = "approved";
await page.goto("/pending");
await expect(page).toHaveURL(/\/teach$/);
await expect(
page.getByRole("heading", { name: "검토할 실제 회기가 없습니다." }),
).toBeVisible();
});
// checklist: professor-guard-onboarding
test("redirects a teacher without onboarding from /teach to /onboarding", async ({
page,
}) => {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: `professor.sweep.onboarding.${Date.now()}@hs.ac.kr`,
role: "teacher",
display_name: "E2E Sweep Teacher",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
await page.goto("/teach");
await expect(page).toHaveURL(/\/onboarding$/);
await expect(page.locator(".pf-root")).toHaveCount(0);
});
// checklist: professor-dashboard-error-banner
test("shows a role=alert error banner when the dashboard load fails", async ({
page,
}) => {
await signInAsTeacher(page);
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "주입된 대시보드 오류" }),
}),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
await page.goto("/teach");
const banner = page.locator("section.pf-error");
await expect(banner).toBeVisible();
await expect(banner).toHaveAttribute("role", "alert");
await expect(banner).toContainText("API 500");
await expect(banner).toContainText("주입된 대시보드 오류");
});
// checklist: professor-header-refresh
test("refresh button refetches dashboard and persona review queue and disables while refreshing", async ({
page,
}) => {
// React StrictMode(dev)는 마운트 effect를 두 번 실행하므로 초기 호출 수는
// 정확히 1이 아닐 수 있다. 절대 카운트 대신 "새로고침 클릭이 추가 호출을
// 발생시킨다"를 기준선 대비 증가로 검증한다.
let dashboardCalls = 0;
let reviewCalls = 0;
let holdDashboard = false;
const dashboardGate = deferred();
await page.route("**/api/teacher/dashboard", async (route) => {
dashboardCalls += 1;
if (holdDashboard) await dashboardGate.promise;
await route.fulfill(await fulfillJson(dashboardFixture()));
});
await page.route("**/api/personas/review", async (route) => {
reviewCalls += 1;
await route.fulfill(await fulfillJson([]));
});
await signInAsTeacher(page);
await page.goto("/teach");
const refreshButton = page.getByRole("button", { name: /새로고침|확인 중/ });
await expect(refreshButton).toHaveText(/새로고침/);
await expect(refreshButton).toBeEnabled();
const baselineDashboard = dashboardCalls;
const baselineReview = reviewCalls;
holdDashboard = true;
await refreshButton.click();
await expect(refreshButton).toHaveText(/확인 중/);
await expect(refreshButton).toBeDisabled();
holdDashboard = false;
dashboardGate.resolve();
await expect(refreshButton).toHaveText(/새로고침/);
await expect(refreshButton).toBeEnabled();
await expect.poll(() => dashboardCalls).toBe(baselineDashboard + 1);
await expect.poll(() => reviewCalls).toBe(baselineReview + 1);
});
// checklist: professor-kpi-cards
test("renders six KPI cards summarizing queue, alerts, sessions, and learners", async ({
page,
}) => {
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(
await fulfillJson(
dashboardFixture({
total_learners: 5,
active_sessions: 4,
ended_sessions: 6,
pending_reviews: [pendingReviewFixture(1)],
safety_alerts: [safetyAlertFixture(1), safetyAlertFixture(2)],
recent_sessions: [1, 2, 3].map(recentSessionFixture),
message: "1건의 리뷰가 대기 중입니다.",
}),
),
),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([reviewPersonaFixture()])),
);
await signInAsTeacher(page);
await page.goto("/teach");
const kpis = page.locator(".pf-kpi");
await expect(kpis).toHaveCount(6);
const expected: Array<[string, string]> = [
["검토 대기", "1"],
["위기 알림", "2"],
["페르소나 검수", "1"],
["최근 세션", "3"],
["전체 학생", "5"],
["활성 세션", "4"],
];
for (const [label, value] of expected) {
const card = kpis.filter({ has: page.getByText(label, { exact: true }) });
await expect(card).toHaveCount(1);
await expect(card.locator("b")).toHaveText(value);
}
});
// checklist: professor-persona-review-error-retry
test("shows persona review error card with a retry button that reloads the queue", async ({
page,
}) => {
// StrictMode 이중 마운트로 초기 호출이 2회일 수 있으므로 호출 횟수가 아닌
// 플래그로 실패/복구를 전환한다.
let failReviews = true;
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(await fulfillJson(dashboardFixture())),
);
await page.route("**/api/personas/review", async (route) => {
if (failReviews) {
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "review queue exploded" }),
});
}
return route.fulfill(await fulfillJson([reviewPersonaFixture()]));
});
await signInAsTeacher(page);
await page.goto("/teach");
const errorCard = page
.locator(".pf-section--persona")
.locator('[role="alert"]');
await expect(errorCard).toBeVisible();
await expect(errorCard).toContainText("검수 데이터를 불러오지 못했습니다");
failReviews = false;
await errorCard.getByRole("button", { name: "다시 시도" }).click();
await expect(
page.locator('[data-persona-review-row="true"]').filter({
hasText: "전수 검수 대기 페르소나",
}),
).toBeVisible();
await expect(errorCard).toHaveCount(0);
});
// checklist: professor-persona-review-empty-loading
test("shows persona review loading state, then the empty state", async ({
page,
}) => {
const firstReview = deferred();
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(await fulfillJson(dashboardFixture())),
);
await page.route("**/api/personas/review", async (route) => {
await firstReview.promise;
await route.fulfill(await fulfillJson([]));
});
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.getByText("페르소나 검수 큐 확인 중")).toBeVisible();
firstReview.resolve();
await expect(page.getByText("검수 대기 페르소나 없음")).toBeVisible();
await expect(page.getByText("페르소나 검수 큐 확인 중")).toHaveCount(0);
});
// checklist: professor-analysis-sort-segment
test("re-sorts the learner table by latest, sessions, score, and delta", async ({
page,
}) => {
const growth = [
growthLearnerFixture("sweep-learner-a", "학습자 A", {
sessions: 5,
latest_at: "2026-07-01T09:00:00Z",
latest_score: 0.4,
score_delta: 0.1,
}),
growthLearnerFixture("sweep-learner-b", "학습자 B", {
sessions: 2,
latest_at: "2026-07-10T09:00:00Z",
latest_score: 0.9,
score_delta: -0.2,
}),
growthLearnerFixture("sweep-learner-c", "학습자 C", {
sessions: 8,
latest_at: "2026-07-05T09:00:00Z",
latest_score: 0.6,
score_delta: 0.5,
}),
];
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(
await fulfillJson(
dashboardFixture({ total_learners: 3, learner_growth: growth }),
),
),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
await signInAsTeacher(page);
await page.goto("/teach/analysis");
const segment = page.locator(".pf-sort-segment");
await expect(segment).toBeVisible();
const names = page.locator(
'[data-learner-overview-row="true"] .pf-learner-name-button',
);
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
await segment.getByRole("button", { name: "회기순" }).click();
await expect(names).toHaveText(["학습자 C", "학습자 A", "학습자 B"]);
await segment.getByRole("button", { name: "점수순" }).click();
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
await segment.getByRole("button", { name: "변화순" }).click();
await expect(names).toHaveText(["학습자 C", "학습자 A", "학습자 B"]);
await segment.getByRole("button", { name: "최근순" }).click();
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
});
// checklist: professor-analysis-table-states
test("distinguishes learner table loading, no-search-result, and empty states", async ({
page,
}) => {
// StrictMode 이중 마운트에서도 로딩 상태가 관측되도록, 게이트 해제 전까지
// 들어오는 모든 dashboard 요청을 잡아둔다.
const firstDashboard = deferred();
let holdDashboard = true;
let growth: unknown[] = [
growthLearnerFixture("sweep-learner-states", "상태 학습자"),
];
await page.route("**/api/teacher/dashboard", async (route) => {
if (holdDashboard) await firstDashboard.promise;
await route.fulfill(
await fulfillJson(
dashboardFixture({
total_learners: growth.length,
learner_growth: growth,
}),
),
);
});
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
await signInAsTeacher(page);
await page.goto("/teach/analysis");
// 1) 로딩 상태
await expect(page.getByText("학습자 추이 계산 중")).toBeVisible();
firstDashboard.resolve();
await expect(
page.locator('[data-learner-overview-table="true"]'),
).toBeVisible();
// 2) 검색 결과 없음
await page.getByLabel("학습자 검색").fill("존재하지-않는-학습자-zzz");
await expect(page.getByText("검색 결과 없음")).toBeVisible();
// 3) 데이터 자체가 없는 빈 상태
growth = [];
await page.getByRole("button", { name: "새로고침" }).click();
await expect(page.getByText("표시할 학습자 이력 없음")).toBeVisible();
});
// checklist: professor-analysis-back-to-list
test("returns from learner detail to the full list via 전체 목록", async ({
page,
}) => {
const learnerId = "sweep-learner-back";
const analysis = learnerAnalysisFixture(learnerId, "복귀 학습자");
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(
await fulfillJson(
dashboardFixture({
total_learners: 1,
learner_growth: [
growthLearnerFixture(learnerId, "복귀 학습자", {
points: analysis.points,
}),
],
}),
),
),
);
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
route.fulfill(await fulfillJson(analysis)),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
await signInAsTeacher(page);
await page.goto(`/teach/analysis?learner=${learnerId}`);
await expect(
page.locator('[data-learner-analysis-panel="true"]'),
).toBeVisible();
await page.getByRole("button", { name: "전체 목록" }).click();
await expect(page).toHaveURL(/\/teach\/analysis$/);
await expect(
page.locator('[data-learner-overview-table="true"]'),
).toBeVisible();
await expect(
page.locator('[data-learner-analysis-panel="true"]'),
).toHaveCount(0);
});
// checklist: professor-analysis-invalid-learner-redirect
test("clears an unknown learner query and falls back to the list", async ({
page,
}) => {
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(
await fulfillJson(
dashboardFixture({
total_learners: 1,
learner_growth: [
growthLearnerFixture("sweep-learner-known", "실존 학습자"),
],
}),
),
),
);
await page.route("**/api/teacher/learners/*/analysis", (route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "learner not found" }),
}),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
await signInAsTeacher(page);
await page.goto("/teach/analysis?learner=sweep-learner-missing");
await expect(page).toHaveURL(/\/teach\/analysis$/);
await expect(
page.locator('[data-learner-overview-table="true"]'),
).toBeVisible();
await expect(page.getByText("실존 학습자")).toBeVisible();
});
// checklist: professor-analysis-trend-chart
test("renders the trend tab bar chart with per-session tooltips", async ({
page,
}) => {
const learnerId = "sweep-learner-trend";
const analysis = learnerAnalysisFixture(learnerId, "추이 학습자");
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(
await fulfillJson(
dashboardFixture({
total_learners: 1,
learner_growth: [
growthLearnerFixture(learnerId, "추이 학습자", {
points: analysis.points,
}),
],
}),
),
),
);
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
route.fulfill(await fulfillJson(analysis)),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
await signInAsTeacher(page);
await page.goto(`/teach/analysis?learner=${learnerId}`);
const panel = page.locator('[data-learner-analysis-panel="true"]');
await expect(panel).toBeVisible();
await panel.getByRole("tab", { name: /^추이/ }).click();
const bars = panel.locator(".pf-analysis-bars .pf-growth-bar");
await expect(bars).toHaveCount(2);
await expect(bars.nth(0)).toHaveAttribute("title", "P1 1회기 · 50%");
await expect(bars.nth(1)).toHaveAttribute("title", "P1 2회기 · 80%");
});
// checklist: professor-analysis-virtualized-table (결정 D4 — 학생 분석 테이블 가상화)
test("virtualizes the 300-learner table while keeping scroll, search, and expand", async ({
page,
}) => {
// 최근순 정렬이 001→300 순서가 되도록 latest_at을 1분 간격 내림차순으로 부여한다.
const growth = Array.from({ length: 300 }, (_, index) => {
const no = String(index + 1).padStart(3, "0");
const latestAt = new Date(
Date.parse("2026-07-20T09:00:00Z") - index * 60_000,
).toISOString();
return growthLearnerFixture(`sweep-virtual-${no}`, `가상화 학습자 ${no}`, {
latest_at: latestAt,
});
});
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(
await fulfillJson(
dashboardFixture({
total_learners: growth.length,
learner_growth: growth,
}),
),
),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
await signInAsTeacher(page);
await page.goto("/teach/analysis");
const table = page.locator('[data-learner-overview-table="true"]');
await expect(table).toBeVisible();
const rows = page.locator('[data-learner-overview-row="true"]');
await expect(
rows
.first()
.getByRole("button", { name: "가상화 학습자 001", exact: true }),
).toBeVisible();
// (a) 가상화: 300명 전부가 아니라 화면 주변 행만 DOM에 렌더된다.
const renderedRows = await rows.count();
expect(renderedRows).toBeGreaterThan(0);
expect(renderedRows).toBeLessThan(60);
// (b) 스크롤 컨테이너를 끝까지 내리면 마지막 학습자 행이 나타난다.
// 동적 행 높이 측정으로 scrollHeight가 갱신되므로 나타날 때까지 반복 스크롤한다.
await expect
.poll(async () => {
await table.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
return rows
.getByRole("button", { name: "가상화 학습자 300", exact: true })
.count();
})
.toBeGreaterThan(0);
await expect(
rows.getByRole("button", { name: "가상화 학습자 300", exact: true }),
).toBeVisible();
// (c) 검색: 상위권 밖 이름을 치면 해당 행이 표시된다.
await page.getByLabel("학습자 검색").fill("가상화 학습자 250");
const filteredRow = rows.filter({ hasText: "가상화 학습자 250" });
await expect(filteredRow).toHaveCount(1);
// (d) 행 펼침 후 상세가 보인다.
await filteredRow
.getByRole("button", { name: "가상화 학습자 250 요약 펼치기" })
.click();
const expanded = page.locator('[data-learner-expanded-row="true"]');
await expect(expanded).toBeVisible();
await expect(expanded.getByText("회기별 추이")).toBeVisible();
});
// checklist: professor-analysis-stage-breakdown
test("renders stage breakdown cards with session and turn counts", async ({
page,
}) => {
const learnerId = "sweep-learner-stages";
const analysis = learnerAnalysisFixture(learnerId, "단계 학습자");
await page.route("**/api/teacher/dashboard", async (route) =>
route.fulfill(
await fulfillJson(
dashboardFixture({
total_learners: 1,
learner_growth: [
growthLearnerFixture(learnerId, "단계 학습자", {
points: analysis.points,
}),
],
}),
),
),
);
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
route.fulfill(await fulfillJson(analysis)),
);
await page.route("**/api/personas/review", async (route) =>
route.fulfill(await fulfillJson([])),
);
await signInAsTeacher(page);
await page.goto(`/teach/analysis?learner=${learnerId}`);
const panel = page.locator('[data-learner-analysis-panel="true"]');
await expect(panel).toBeVisible();
await panel.getByRole("tab", { name: /^단계 분석/ }).click();
const stageItems = panel.locator(".pf-stage-item");
await expect(stageItems).toHaveCount(4);
const explore = stageItems.filter({ hasText: "탐색" });
await expect(explore.locator("b")).toHaveText("1");
await expect(explore.locator("small")).toHaveText("34턴");
const intervene = stageItems.filter({ hasText: "개입" });
await expect(intervene.locator("small")).toHaveText("42턴");
await expect(stageItems.filter({ hasText: "라포" })).toHaveCount(1);
await expect(stageItems.filter({ hasText: "정리" })).toHaveCount(1);
});
});

View file

@ -0,0 +1,555 @@
/**
* (2026-07-27) (session-review) "신규 spec 필요" .
*
* docs/ops/e2e-full-sweep-2026-07-27.md §4 spec이
* route fixture( ·· PUT) + dev-login .
* AI .
*
* checklist id:
* - session-review-guard-require-auth / session-review-guard-pending-onboarding
* - session-review-error-state
* - session-review-btn-audio / session-review-btn-pdf
* - session-review-btn-share / session-review-share-note
* - session-review-transcript-filter / session-review-turn-jump
* - session-review-phase-flow / session-review-rubric-display
* - session-review-good-growth-points / session-review-worksheet-evidence-jump
* - session-review-teacher-worksheet-note / session-review-teacher-worksheet-reject
*/
import { expect, test, type Page } from "@playwright/test";
import {
filledReviewResponse,
routeEmptySessionReview,
routeFilledSessionReview,
routePrepostMeasures,
} from "./session-review-fixture";
import { signInAsLearner, signInAsTeacher } from "./support";
/** 리뷰 상단 가로 탭 전환(기존 session-review.spec.ts 헬퍼와 동일한 계약). */
async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
const tabs = page.locator(".sr-tabs");
await tabs.waitFor({ state: "attached", timeout: 10_000 });
await tabs.locator("button", { hasText: label }).click();
}
function routeAuthMe(
page: Page,
overrides: {
role?: "learner" | "teacher";
account_status?: string;
onboarding_completed_at?: number | null;
} = {},
) {
return page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "00000000-0000-0000-0000-000000000101",
email: "learner@hs.ac.kr",
role: overrides.role ?? "learner",
display_name: "E2E Learner",
admin_access: false,
super_admin: false,
account_status: overrides.account_status ?? "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at:
overrides.onboarding_completed_at === undefined
? 1782820001
: overrides.onboarding_completed_at,
nickname: "E2E Learner",
self_introduction: "",
avatar_url: "",
}),
});
});
}
test.describe("full-sweep session review", () => {
// checklist: session-review-guard-require-auth
test("redirects unauthenticated and role-mismatched users away from review routes", async ({
page,
}) => {
// 미인증 → /login (state.from 회수용 리다이렉트).
await page.goto("/learn/session/guard-check/review");
await expect(page).toHaveURL(/\/login$/);
// learner가 교수자 리뷰 경로에 오면 자기 역할 홈(/learn)으로 replace.
await signInAsLearner(page);
await page.goto("/teach/session/guard-check/review");
await expect(page).toHaveURL(/\/learn$/);
// teacher가 학습자 리뷰 경로에 오면 자기 역할 홈(/teach)으로 replace.
await signInAsTeacher(page);
await page.goto("/learn/session/guard-check/review");
await expect(page).toHaveURL(/\/teach$/);
});
// checklist: session-review-guard-pending-onboarding
test("recovers pending and onboarding-incomplete users from review routes", async ({
page,
}) => {
await signInAsLearner(page);
// 미승인 계정은 어느 리뷰 경로에서든 /pending으로 회수된다.
await routeAuthMe(page, { account_status: "pending" });
await page.goto("/learn/session/guard-check/review");
await expect(page).toHaveURL(/\/pending$/);
// 온보딩 미완료(승인됨)는 /onboarding으로 회수된다.
await page.unroute("**/api/auth/me");
await routeAuthMe(page, { onboarding_completed_at: null });
await page.goto("/learn/session/guard-check/review");
await expect(page).toHaveURL(/\/onboarding$/);
});
// checklist: session-review-error-state
test("shows the error card with the failure message when the review load fails", async ({
page,
}) => {
await signInAsLearner(page);
const sessionId = "review-load-error";
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "review backend down" }),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page.getByText("리뷰를 표시할 수 없습니다")).toBeVisible();
await expect(page.getByText("API 500: review backend down")).toBeVisible();
});
// checklist: session-review-btn-audio, session-review-btn-pdf
test("opens audio and pdf export urls in a new tab only when provided", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-audio-pdf";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
const audioButton = page.getByRole("button", { name: "오디오 다시 듣기" });
const pdfButton = page.getByRole("button", { name: "PDF 내보내기" });
await expect(audioButton).toBeEnabled();
await expect(pdfButton).toBeEnabled();
const [audioPopup] = await Promise.all([
page.context().waitForEvent("page"),
audioButton.click(),
]);
await expect
.poll(() => audioPopup.url())
.toContain("/mock/reviews/filled-review-visual.mp3");
await audioPopup.close();
const [pdfPopup] = await Promise.all([
page.context().waitForEvent("page"),
pdfButton.click(),
]);
await expect
.poll(() => pdfPopup.url())
.toContain("/mock/reviews/filled-review-visual.pdf");
await pdfPopup.close();
// URL이 없는 회기에서는 두 버튼 모두 비활성화된다.
const emptyId = "full-sweep-audio-pdf-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled();
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
});
// checklist: session-review-btn-share, session-review-share-note
test("creates a share url, flips the button label, and shows the url note", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-share-success";
await routeFilledSessionReview(page, sessionId);
const shareUrl = "https://vignette.example.test/share/full-sweep-share-success";
let shareRequests = 0;
await page.route(`**/api/sessions/${sessionId}/share`, async (route) => {
shareRequests += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
shareUrl,
title: "회기 리뷰 공유",
description: "테스트 공유 링크",
imageUrl: "https://vignette.example.test/share/full-sweep.png",
createdAt: "2026-07-27T00:00:00Z",
}),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
const shareButton = page.getByRole("button", { name: "공유 URL 복사" });
await expect(shareButton).toBeEnabled();
await shareButton.click();
await expect(page.getByRole("button", { name: "공유 URL 복사됨" })).toBeVisible();
const note = page.locator(".sr-share-note");
await expect(note).toBeVisible();
await expect(note).toContainText(shareUrl);
await expect(note).not.toHaveClass(/sr-share-note--error/);
expect(shareRequests).toBe(1);
});
// checklist: session-review-btn-share, session-review-share-note
test("keeps the share button usable and shows the error note when share creation fails", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-share-error";
await routeFilledSessionReview(page, sessionId);
await page.route(`**/api/sessions/${sessionId}/share`, async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "share backend unavailable" }),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await page.getByRole("button", { name: "공유 URL 복사" }).click();
const note = page.locator(".sr-share-note");
await expect(note).toBeVisible();
await expect(note).toHaveClass(/sr-share-note--error/);
await expect(note).toContainText("API 503: share backend unavailable");
// 실패 후 라벨은 '복사됨'으로 넘어가지 않고 재시도 가능해야 한다.
await expect(page.getByRole("button", { name: "공유 URL 복사" })).toBeEnabled();
});
// checklist: session-review-transcript-filter
test("toggles the transcript between all turns and learner-only turns", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-transcript-filter";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
const filters = page.locator(".sr-tx__filters");
const allChip = filters.getByRole("button", { name: "전체" });
const mineChip = filters.getByRole("button", { name: "내 발화만" });
await expect(allChip).toHaveAttribute("aria-pressed", "true");
await expect(mineChip).toHaveAttribute("aria-pressed", "false");
await expect(page.locator(".sr-turn")).toHaveCount(6);
await expect(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeVisible();
await mineChip.click();
await expect(mineChip).toHaveAttribute("aria-pressed", "true");
await expect(allChip).toHaveAttribute("aria-pressed", "false");
await expect(page.locator(".sr-turn")).toHaveCount(3);
await expect(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeHidden();
await expect(
page.getByText("스스로를 의심하게 됐던 걸까요?", { exact: false }),
).toBeVisible();
// 축어록이 없으면 두 필터 칩 모두 비활성화된다.
const emptyId = "full-sweep-transcript-filter-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await expect(page.locator(".sr-tx__filters").getByRole("button", { name: "전체" })).toBeDisabled();
await expect(
page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }),
).toBeDisabled();
});
// checklist: session-review-turn-jump
test("jumps to the referenced turn and auto-clears the learner-only filter", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-turn-jump";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }).click();
await expect(page.locator(".sr-turn")).toHaveCount(3);
// 개선점 '비교 장면 좁히기'는 내담자 발화 t3(12:41)로 점프한다.
await openReviewTab(page, "피드백");
await page.getByRole("button", { name: "12:41 발화로 이동" }).click();
await openReviewTab(page, "축어록");
// 내담자 발화 점프이므로 '내 발화만' 필터가 자동 해제되어야 한다.
await expect(
page.locator(".sr-tx__filters").getByRole("button", { name: "전체" }),
).toHaveAttribute("aria-pressed", "true");
const activeTurn = page.locator(".sr-turn--active");
await expect(activeTurn).toHaveCount(1);
await expect(activeTurn).toContainText("제가 더 나쁜 사람이 될 것 같아서");
});
// checklist: session-review-phase-flow
test("renders the phase flow bar with proportional tooltips and its empty state", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-phase-flow";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const phases = page.locator(".sr-phasebar__track .sr-phase");
await expect(phases).toHaveCount(4);
await expect(phases.nth(0)).toContainText("라포");
await expect(phases.nth(0)).toHaveAttribute("title", "라포 · 22%");
await expect(phases.nth(1)).toHaveAttribute("title", "탐색 · 34%");
await expect(phases.nth(2)).toHaveAttribute("title", "개입 · 26%");
await expect(phases.nth(3)).toHaveAttribute("title", "정리 · 18%");
const axis = page.locator(".sr-phasebar__axis");
await expect(axis).toContainText("0:00");
await expect(axis).toContainText("32:14");
// 단계 데이터가 없으면 빈 상태 문구를 보여준다.
const emptyId = "full-sweep-phase-flow-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--flow")).toContainText("흐름 데이터 없음");
});
// 결정 D7: 감정 밸런스 타임라인 — fixture 포인트로 차트 path 렌더 확인
test("renders the valence timeline chart paths from fixture points", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-valence-chart";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const chartCard = page.locator(".sr-card--chart");
await expect(chartCard.locator(".sr-chart")).toBeVisible();
// 실선(내담자)·점선(상담자 기준선) path 둘 다 다점 곡선(d="M… C…")으로 그려진다.
const paths = chartCard.locator(".sr-chart__svg path");
await expect(paths).toHaveCount(2);
const dValues = await paths.evaluateAll((els) =>
els.map((el) => el.getAttribute("d") ?? ""),
);
for (const d of dValues) {
expect(d).toMatch(/^M/);
expect(d).toContain("C");
}
// 시간축 라벨은 fixture valenceAxis 5개를 그대로 사용한다.
await expect(chartCard.locator(".sr-chart__xaxis span")).toHaveCount(5);
// 포인트가 없으면 차트 대신 빈 상태 카드가 노출된다.
const emptyId = "full-sweep-valence-chart-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--chart")).toContainText("감정 타임라인 대기");
await expect(page.locator(".sr-card--chart .sr-chart")).toHaveCount(0);
});
// checklist: session-review-rubric-display
test("renders rubric frequency bars with quality badges and the pending state", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-rubric";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const rubricCard = page.locator(".sr-card--rubric");
await expect(rubricCard.locator(".sr-rubric__row")).toHaveCount(4);
const reflectRow = rubricCard.locator(".sr-rubric__row", { hasText: "감정 반영" });
await expect(reflectRow.locator(".sr-rubric__qual")).toHaveText(/적절/);
await expect(reflectRow).toContainText("정서 확인 · 6회");
const paceRow = rubricCard.locator(".sr-rubric__row", { hasText: "개입 속도" });
await expect(paceRow.locator(".sr-rubric__qual")).toHaveText(/살펴보기/);
const reflectBar = page.getByRole("progressbar", { name: "감정 반영 사용 빈도" });
await expect(reflectBar).toBeVisible();
await expect(reflectBar).toHaveAttribute("aria-valuenow", "82");
// 평가가 없으면 대기 상태를 보여준다.
const emptyId = "full-sweep-rubric-empty";
await routeEmptySessionReview(page, emptyId);
await page.goto(`/learn/session/${emptyId}/review`);
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--rubric")).toContainText("평가 대기");
await expect(
page.locator(".sr-card--rubric").locator(".sr-rubric__row"),
).toHaveCount(0);
});
// checklist: session-review-good-growth-points
test("lists good moments and growth points with jump anchors capped at three", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-good-growth";
const response = filledReviewResponse(sessionId);
response.growthPoints = [
...response.growthPoints,
{ title: "세 번째 개선점", body: "표시 한도 검증용 항목 3.", jumpTo: null },
{ title: "네 번째 개선점", body: "최대 3개 초과분은 표시되지 않아야 한다.", jumpTo: null },
];
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(response),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const goodCard = page.locator(".sr-card--good");
await expect(goodCard).toContainText("방어를 낮춘 감정 반영");
await expect(goodCard).toContainText("정서 정상화");
await expect(goodCard.getByRole("button", { name: "05:04 발화로 이동" })).toBeVisible();
await expect(goodCard.getByRole("button", { name: "16:05 발화로 이동" })).toBeVisible();
const growthCard = page.locator(".sr-card--growth");
await expect(growthCard).toContainText("개입 전 준비도 확인");
await expect(growthCard).toContainText("비교 장면 좁히기");
await expect(growthCard).toContainText("세 번째 개선점");
await expect(growthCard.locator(".sr-point")).toHaveCount(3);
await expect(growthCard.getByText("네 번째 개선점")).toHaveCount(0);
await expect(growthCard.getByRole("button", { name: "24:18 발화로 이동" })).toBeVisible();
});
// checklist: session-review-worksheet-evidence-jump
test("jumps from a worksheet evidence quote to the transcript turn", async ({
page,
}) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = "full-sweep-ws-evidence";
await routeFilledSessionReview(page, sessionId);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "워크시트");
const evidenceButton = page
.getByRole("button", { name: "내담자 근거 발화로 이동" })
.first();
await expect(evidenceButton).toContainText("다른 친구들이랑 비교할 때마다");
await evidenceButton.click();
await openReviewTab(page, "축어록");
const activeTurn = page.locator(".sr-turn--active");
await expect(activeTurn).toHaveCount(1);
await expect(activeTurn).toContainText("계속 제가 이상한 사람 같았어요.");
});
// checklist: session-review-teacher-worksheet-note, session-review-teacher-worksheet-reject
test("saves the teacher worksheet rejection together with the review note", async ({
page,
}) => {
await signInAsTeacher(page);
const sessionId = "full-sweep-ws-reject";
const response = filledReviewResponse(sessionId);
if (!response.caseWorksheet) throw new Error("fixture must include a worksheet");
response.caseWorksheet.status = "saved_by_learner";
response.teacherReview = {
status: "pending",
note: "",
reviewedAt: null,
reviewerId: null,
updatedAt: null,
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
};
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(response),
});
});
const worksheetNote = "핵심 정서 근거를 보강해 주세요.";
let releaseSave: (() => void) | null = null;
const saveGate = new Promise<void>((resolve) => {
releaseSave = resolve;
});
let savedBody: Record<string, unknown> | null = null;
await page.route(
`**/api/teacher/sessions/${sessionId}/review-status`,
async (route) => {
savedBody = route.request().postDataJSON() as Record<string, unknown>;
await saveGate;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
status: "viewed",
note: String(savedBody.note ?? ""),
reviewer_id: "00000000-0000-0000-0000-000000000202",
reviewed_at: null,
updated_at: "2026-07-27 10:00",
worksheet_status: "rejected",
worksheet_note: String(savedBody.worksheet_note ?? ""),
worksheet_reviewed_at: "2026-07-27 10:00",
}),
});
},
);
await page.goto(`/teach/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
const worksheetPanel = page.locator(".sr-teacher-review__worksheet");
await expect(worksheetPanel.locator(".sr-teacher-review__head--sub b")).toHaveText(
"검수 대기",
);
await page
.getByPlaceholder("수정요청이나 반려 사유를 간단히 남깁니다.")
.fill(worksheetNote);
const approveButton = worksheetPanel.getByRole("button", { name: "승인" });
const changesButton = worksheetPanel.getByRole("button", { name: "수정요청" });
const rejectButton = worksheetPanel.getByRole("button", { name: "반려" });
await expect(approveButton).toBeEnabled();
await expect(rejectButton).toBeEnabled();
await rejectButton.click();
// 저장 중에는 세 결정 버튼이 모두 비활성화된다(응답은 saveGate로 붙잡아 둔 상태).
await expect(worksheetPanel.getByRole("button", { name: "저장 중" })).toBeVisible();
await expect(approveButton).toBeDisabled();
await expect(changesButton).toBeDisabled();
releaseSave?.();
await expect(worksheetPanel.locator(".sr-teacher-review__head--sub b")).toHaveText(
"반려",
);
await expect(worksheetPanel).toContainText("워크시트 검수 시각 2026-07-27 10:00");
expect(savedBody).not.toBeNull();
expect(savedBody!["worksheet_status"]).toBe("rejected");
expect(savedBody!["worksheet_note"]).toBe(worksheetNote);
expect(savedBody!["status"]).toBe("viewed");
});
});

View file

@ -0,0 +1,854 @@
/* =====================================================================
full-sweep-session.spec.ts 2026-07-27 §3( )
"검증: 신규 spec 필요" .
checklist id:
session-guard-pending-approval, session-route-empty-param-redirect,
session-consent-checkbox, session-consent-save,
session-time-alarms, session-timebar-end-button,
session-transcript-autoscroll, session-transcript-empty-state,
session-crisis-tel-link, session-live-signal, session-coach-nudge,
session-meters-collapse, session-voice-skip, session-pause-toggle,
session-keyboard-shortcuts, session-elapsed-live-region
session-mvp.spec.ts route fixture / mock WebSocket
AI (· fixture).
===================================================================== */
import { expect, test, type Page } from "@playwright/test";
import { fetchAvailablePersona, signInAsLearner } from "./support";
const fixtureSessionId = "44444444-4444-4444-8444-444444444444";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요.";
const voiceTranscript = "요즘 잠을 잘 못 자요.";
const voiceClientReply = "말해줘서 고마워요. 조금씩 이야기해볼게요.";
declare global {
interface Window {
__voiceSpeakFixture?: {
sent: string[];
};
}
}
interface SessionFixtureOptions {
/** /auth/me 응답 덮어쓰기 (consent_at: null, account_status: "pending" 등) */
authOverrides?: Record<string, unknown>;
/** POST /auth/consent 응답 상태 (기본 200) */
consentStatus?: number;
/** POST /sessions 시작 응답의 회기 시간 계약값 */
durationLimitSeconds?: number;
warningBeforeEndSeconds?: number;
/** GET /sessions/{id} 재개 스냅샷 덮어쓰기 (status/ended_at/turns 등) */
detailOverrides?: Record<string, unknown>;
/** 스트림을 위기 안전게이트 응답으로 대체 */
crisisStream?: boolean;
/** 스트림 done 이벤트에 병합할 추가 필드 (progress 등) */
doneExtra?: Record<string, unknown>;
}
async function routeSessionFixtureApi(page: Page, options: SessionFixtureOptions = {}) {
const liveCoachRequests: unknown[] = [];
const consentRequests: unknown[] = [];
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "00000000-0000-0000-0000-0000000fs3e1",
email: "sweep.session@hs.ac.kr",
display_name: "Sweep Learner",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: Math.floor(Date.now() / 1000),
onboarding_completed_at: Math.floor(Date.now() / 1000),
nickname: "Sweep Learner",
self_introduction: "세션 전수 순회 검증용 학습자입니다.",
avatar_url: "",
...options.authOverrides,
}),
});
});
await page.route("**/api/auth/consent", async (route) => {
consentRequests.push(route.request().postDataJSON());
const status = options.consentStatus ?? 200;
await route.fulfill({
status,
contentType: "application/json",
body:
status >= 400
? JSON.stringify({ detail: "consent persistence unavailable" })
: JSON.stringify({ accepted: true, consent_at: Math.floor(Date.now() / 1000) }),
});
});
await page.route("**/api/users/me/prepost-measures**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
pilot_id: "phase3-pilot-draft",
instrument_version: "pilot-prepost-scaffold-2026-06-28",
measures: [],
complete_pre_count: 0,
complete_post_count: 0,
updated_at: null,
}),
});
});
await page.route("**/api/voice/health", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ available: true, reason: null }),
});
});
// 텍스트 턴 뒤 TTS 사전요청 안전망 — 이 스펙의 done 은 turn_seq 를 싣지 않아
// 정상 흐름에서는 호출되지 않지만, 호출되더라도 결정론적으로 실패시킨다.
await page.route("**/api/voice/speech", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "voice synthesis disabled in fixture" }),
});
});
await page.route("**/api/personas", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
code: "P1",
display_name: "민서(청소년 우울)",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "10대" },
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
voice_preset: "soft-young-fem",
source: "database",
degraded: false,
},
]),
});
});
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
session_id: fixtureSessionId,
case_id: "sweep-case-001",
session_no: 1,
stage: "라포",
effective_openness: 0.21,
recall_summary: null,
degraded: false,
goal_stages: ["라포", "탐색"],
duration_limit_seconds: options.durationLimitSeconds ?? 3600,
warning_before_end_seconds: options.warningBeforeEndSeconds ?? 600,
}),
});
});
await page.route(`**/api/sessions/${fixtureSessionId}`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: fixtureSessionId,
case_id: "sweep-case-001",
persona_code: "P1",
persona_name: "민서",
session_no: 1,
status: "active",
stage: "라포",
theory_mode: "humanistic",
effective_openness: 0.21,
started_at: new Date().toISOString(),
ended_at: null,
review_ready: false,
turns: [],
goal_stages: ["라포", "탐색"],
progress: null,
duration_limit_seconds: options.durationLimitSeconds ?? 3600,
warning_before_end_seconds: options.warningBeforeEndSeconds ?? 600,
...options.detailOverrides,
}),
});
});
await page.route(`**/api/sessions/${fixtureSessionId}/stream`, async (route) => {
if (options.crisisStream) {
const crisisResource = {
title: "자살예방상담전화 109",
number: "109",
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
};
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: safety",
`data: ${JSON.stringify({
crisis_resource: crisisResource,
conversation_stopped: true,
})}`,
"",
"event: done",
`data: ${JSON.stringify({
session_id: fixtureSessionId,
stage: "라포",
effective_openness: 0.21,
turn_seq: 1,
safety_flagged: true,
crisis_resource: crisisResource,
conversation_stopped: true,
})}`,
"",
].join("\n"),
});
return;
}
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: token",
`data: ${clientReply}`,
"",
"event: done",
// turn_seq 를 싣지 않아 텍스트 턴 뒤 TTS(speakClientTurn) 경로를 결정론적으로 생략한다.
`data: ${JSON.stringify({
session_id: fixtureSessionId,
stage: "탐색",
effective_openness: 0.42,
safety_flagged: false,
...options.doneExtra,
})}`,
"",
].join("\n"),
});
});
await page.route(`**/api/sessions/${fixtureSessionId}/live-coach`, async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
quota: { remaining: 3, max: 3 },
credit_events: [],
events: [],
}),
});
return;
}
liveCoachRequests.push(request.postDataJSON());
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "ready",
tone: "pos",
focus: "reflection",
title: "감정 반영이 선명합니다",
message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.",
next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?",
rationale: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.",
sources: [],
safety_note: null,
latency_ms: 12,
persistence_source: "database",
quota: { remaining: 2, max: 3 },
credit_events: [],
}),
});
});
await page.route(`**/api/sessions/${fixtureSessionId}/end`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: fixtureSessionId,
session_no: 1,
digest_pending: true,
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
}),
});
});
return { liveCoachRequests, consentRequests };
}
/**
* ·WebSocket mock session-mvp.spec.ts .
* audio_end transcriptreplystate:speaking
* TTS (voiceStatus=speaking) .
*/
async function installVoiceSpeakingFixture(
page: Page,
transcriptText: string,
replyText: string,
) {
await page.addInitScript(
({ text, reply }) => {
const fixture = { sent: [] as string[] };
window.__voiceSpeakFixture = fixture;
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
this.readyState = "ended";
},
};
const fakeStream = {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: async () => fakeStream },
});
Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined });
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType = "audio/webm";
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start() {
this.state = "recording";
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
class FixtureWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = FixtureWebSocket.CONNECTING;
binaryType: BinaryType = "blob";
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(_url: string | URL) {
window.setTimeout(() => {
if (this.readyState !== FixtureWebSocket.CONNECTING) return;
this.readyState = FixtureWebSocket.OPEN;
this.onopen?.(new Event("open"));
this.emitJson({ type: "ready", state: "idle" });
}, 0);
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data !== "string") return;
fixture.sent.push(data);
let payload: { type?: string } = {};
try {
payload = JSON.parse(data) as { type?: string };
} catch {
return;
}
if (payload.type === "audio_end") {
window.setTimeout(() => {
this.emitJson({ type: "state", state: "thinking" });
this.emitJson({ type: "transcript", text, final: true });
this.emitJson({
type: "reply",
text: reply,
safety_flagged: false,
turn_seq: 1,
stage: "라포",
effective_openness: 0.3,
});
this.emitJson({ type: "state", state: "speaking" });
}, 0);
}
}
close(code = 1000) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.readyState = FixtureWebSocket.CLOSED;
const event = new Event("close") as CloseEvent;
Object.defineProperty(event, "code", { value: code });
this.onclose?.(event);
}
emitJson(payload: unknown) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
const event = new MessageEvent("message", { data: JSON.stringify(payload) });
this.onmessage?.(event);
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: FixtureWebSocket as unknown as typeof WebSocket,
});
},
{ text: transcriptText, reply: replyText },
);
}
async function startFixtureSession(page: Page) {
await page.goto("/learn/session/P1");
const startButton = page.getByRole("button", { name: "회기 시작" });
await expect(startButton).toBeEnabled();
await startButton.click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
}
test.describe("full sweep — counseling session", () => {
// checklist: session-guard-pending-approval
test("redirects a pending-approval learner from the session route to /pending", async ({
page,
}) => {
await routeSessionFixtureApi(page, {
authOverrides: { account_status: "pending", approval_required: true },
});
await page.goto("/learn/session/P1");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.getByText("승인 대기").first()).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toHaveCount(0);
});
// checklist: session-route-empty-param-redirect
test("redirects an empty session route param to /learn and initializes persona prestart", async ({
page,
}) => {
// 실 API — dev-login 학습자로 라우팅 규칙만 검증한다(엔진 턴 없음).
await signInAsLearner(page);
await page.goto("/learn/session/%20");
await expect(page).toHaveURL(/\/learn$/);
const persona = await fetchAvailablePersona(page);
// 소문자 코드로 진입해도 정규화된 대문자 코드로 세션 상태가 초기화된다.
await page.goto(`/learn/session/${persona.code.toLowerCase()}`);
await expect(page.getByText(`상담 연습 · ${persona.code.toUpperCase()}`)).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
});
// checklist: session-consent-checkbox, session-consent-save
test("gates consent save behind the checkbox and enables session start after saving", async ({
page,
}) => {
const api = await routeSessionFixtureApi(page, {
authOverrides: { consent_at: null },
});
await page.goto("/learn/session/P1");
const consentBlock = page.locator(".sx-consent");
await expect(consentBlock).toBeVisible();
await expect(
consentBlock.getByText(
"상담 연습 기록 저장, 개인정보 마스킹 후 평가 AI 처리, 회기 리뷰 생성을 확인합니다.",
),
).toBeVisible();
const saveButton = page.getByRole("button", { name: "동의 저장" });
const startButton = page.getByRole("button", { name: "회기 시작" });
await expect(saveButton).toBeDisabled();
await expect(startButton).toBeDisabled();
await consentBlock.getByRole("checkbox").check();
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(consentBlock).toHaveCount(0);
await expect(startButton).toBeEnabled();
expect(api.consentRequests).toHaveLength(1);
});
// checklist: session-consent-save
test("shows consent save failure message and keeps session start blocked", async ({ page }) => {
await routeSessionFixtureApi(page, {
authOverrides: { consent_at: null },
consentStatus: 503,
});
await page.goto("/learn/session/P1");
const consentBlock = page.locator(".sx-consent");
await consentBlock.getByRole("checkbox").check();
await page.getByRole("button", { name: "동의 저장" }).click();
await expect(
page.getByText("동의 상태를 저장하지 못했습니다. 잠시 뒤 다시 시도해 주세요."),
).toBeVisible();
await expect(consentBlock).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
});
// checklist: session-time-alarms, session-timebar-end-button
test("raises the pre-end warning bar, opens the end dialog on time-up, and reopens it from the timebar", async ({
page,
}) => {
await routeSessionFixtureApi(page, {
durationLimitSeconds: 5,
warningBeforeEndSeconds: 3,
});
await startFixtureSession(page);
const timebar = page.locator(".sx-timebar");
// 경고 구간(남은 3초 진입, 분 단위 표기로는 1분 전) — role=alert 시간 바가 뜬다.
await expect(timebar).toBeVisible({ timeout: 10_000 });
await expect(timebar).toHaveAttribute("role", "alert");
await expect(timebar).toContainText("종료 1분 전", { timeout: 10_000 });
// 시간 만료 — 종료 확인 다이얼로그가 자동으로 열린다.
const dialogTitle = page.locator("#sx-end-dialog-title");
await expect(dialogTitle).toHaveText("회기 시간이 끝났어요", { timeout: 10_000 });
// 다이얼로그를 닫으면 시간 만료 바의 '종료하고 리뷰 보기' 버튼이 남는다.
await page.getByRole("button", { name: "마무리 인사 나누기" }).click();
await expect(dialogTitle).toHaveCount(0);
await expect(timebar).toContainText("회기 시간이 끝났어요");
const timebarEnd = page.locator(".sx-timebar__end");
await expect(timebarEnd).toHaveText("종료하고 리뷰 보기");
await timebarEnd.click();
await expect(dialogTitle).toBeVisible();
});
// checklist: session-transcript-empty-state
test("shows the waiting empty transcript state before the first utterance", async ({ page }) => {
await routeSessionFixtureApi(page);
await startFixtureSession(page);
const empty = page.locator(".sx-transcript__empty");
await expect(empty).toBeVisible();
await expect(empty).toContainText("민서님이 당신의 첫 질문을 기다리고 있습니다.");
await expect(empty).toContainText("아래 입력창이나 마이크로 첫 발화를 시작하세요.");
});
// checklist: session-transcript-empty-state
test("shows the ended-record empty transcript state for an ended session without turns", async ({
page,
}) => {
await routeSessionFixtureApi(page, {
detailOverrides: {
status: "ended",
ended_at: new Date().toISOString(),
turns: [],
},
});
await page.goto(`/learn/session/${fixtureSessionId}`);
const empty = page.locator(".sx-transcript__empty");
await expect(empty).toBeVisible();
await expect(empty).toContainText("종료된 회기 기록입니다.");
await expect(empty).toContainText("새 발화는 추가하지 않고 리뷰에서 회기를 확인하세요.");
const compose = page.getByLabel("학습자 발화 입력");
await expect(compose).toBeDisabled();
await expect(compose).toHaveAttribute("placeholder", "종료된 회기입니다.");
});
// checklist: session-transcript-autoscroll
test("releases autoscroll when scrolling up, jumps back with the latest button, and follows new turns", async ({
page,
}) => {
const startedAt = new Date(Date.now() - 600_000).toISOString();
const turns = Array.from({ length: 30 }, (_, index) => ({
speaker: index % 2 === 0 ? "learner" : "client",
text: `이전 회기 발화 ${index + 1} — 자동 스크롤 검증을 위해 충분히 긴 문장을 유지합니다.`,
turn_seq: index + 1,
created_at: new Date(Date.now() - 600_000 + (index + 1) * 1000).toISOString(),
}));
await routeSessionFixtureApi(page, {
detailOverrides: { started_at: startedAt, turns },
});
await page.goto(`/learn/session/${fixtureSessionId}`);
await expect(
page.locator(".sx-utt__line").filter({ hasText: "이전 회기 발화 30" }),
).toBeVisible();
const scroller = page.locator(".sx-transcript__scroll");
const jumpButton = page.getByRole("button", { name: "최신으로" });
await expect(jumpButton).toHaveCount(0);
// 위로 스크롤 → 자동 따라가기 해제 + '최신으로' 복귀 버튼 노출.
await scroller.evaluate((el) => {
el.scrollTop = 0;
el.dispatchEvent(new Event("scroll"));
});
await expect(jumpButton).toBeVisible();
await jumpButton.click();
await expect(jumpButton).toHaveCount(0);
await expect
.poll(() =>
scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight),
)
.toBeLessThan(24);
// 새 발화가 오면 하단으로 계속 따라간다.
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
await expect
.poll(() =>
scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight),
)
.toBeLessThan(24);
await expect(jumpButton).toHaveCount(0);
});
// checklist: session-crisis-tel-link
test("renders the crisis resource number as a tel: link inside the safety panel", async ({
page,
}) => {
await routeSessionFixtureApi(page, { crisisStream: true });
await startFixtureSession(page);
await page.getByLabel("학습자 발화 입력").fill("저 지금 자살하고 싶어요. 도와주세요.");
await page.getByRole("button", { name: "보내기" }).click();
const crisisBlock = page.locator(".sx-crisis-resource");
await expect(crisisBlock).toBeVisible();
await expect(crisisBlock).toContainText("자살예방상담전화 109");
const telLink = crisisBlock.getByRole("link", { name: "109" });
await expect(telLink).toBeVisible();
await expect(telLink).toHaveAttribute("href", "tel:109");
});
// checklist: session-live-signal
// 모바일(≤880px) 압축 레이아웃은 라이브 신호 패널을 의도적으로 숨긴다(session.css) —
// 데스크톱 전용 UI라 desktop 프로젝트에서만 검증한다.
test("shows live signal dot with sequence, reveals text only in coached mode, and fades", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "라이브 신호 패널은 모바일에서 의도적으로 숨김");
await routeSessionFixtureApi(page);
await startFixtureSession(page);
await expect(page.locator(".sx-signal__rest")).toContainText("아직 표시할 신호가 없어요");
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
const signalOne = page.locator(".sx-signal__one");
await expect(signalOne).toBeVisible();
await expect(signalOne).toHaveAttribute("title", "내담자 응답 수신");
// 기본 '상태 신호' 모드에서는 텍스트 없이 도트만 노출된다.
await expect(page.locator(".sx-signal__one-text")).toHaveCount(0);
await expect(page.locator(".sx-signal__seq-dots i")).toHaveCount(1);
// 코칭 모드로 전환하면 신호 텍스트가 노출된다(전환 시 대기 코칭이 즉시 실행됨).
// 넛지 버튼("... 코칭 열기")과 구분하기 위해 세그먼트의 aria-label 로 지정한다.
await page.getByRole("button", { name: /코칭 모드, 남은 기회/ }).click();
await expect(page.locator(".sx-signal__one-text")).toBeVisible();
// 6초 페이드 — 마지막 신호가 is-faded 로 가라앉는다.
await expect(signalOne).toHaveClass(/is-faded/, { timeout: 10_000 });
// 몰입 모드에서는 라이브 신호 자체를 숨긴다.
await page.getByRole("button", { name: "몰입" }).click();
await expect(page.getByText("실시간 신호 없이 대화에만 집중합니다.")).toBeVisible();
await expect(page.locator(".sx-signal__one")).toHaveCount(0);
});
// checklist: session-coach-nudge
test("shows the coach nudge for a pending turn and requests coaching immediately when opened", async ({
page,
}) => {
const api = await routeSessionFixtureApi(page);
await startFixtureSession(page);
// 기본 '상태 신호'(비코칭) 모드에서 턴 완료 → 기회를 소모하지 않고 넛지로 대기.
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
const nudge = page.locator(".sx-coach-nudge");
await expect(nudge).toBeVisible();
await expect(nudge).toContainText("방금 발화에 코치 제안이 있어요");
expect(api.liveCoachRequests).toHaveLength(0);
await nudge.click();
await expect(page.locator(".sx-coach-card").getByText("감정 반영이 선명합니다")).toBeVisible();
await expect(nudge).toHaveCount(0);
expect(api.liveCoachRequests).toHaveLength(1);
expect(api.liveCoachRequests[0]).toMatchObject({ learner_text: learnerText });
});
// checklist: session-meters-collapse
// 모바일(≤880px) 압축 레이아웃은 관찰 게이지 패널을 의도적으로 숨긴다(session.css) —
// 데스크톱 전용 UI라 desktop 프로젝트에서만 검증한다.
test("renders three observation gauges from server progress and collapses to a rapport summary", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "관찰 게이지 패널은 모바일에서 의도적으로 숨김");
await routeSessionFixtureApi(page, {
doneExtra: {
progress: {
openness_percent: 46,
rapport_percent: 44,
rapport_delta_percent: 4,
resistance_percent: 52,
stages: [
{ stage: "라포", percent: 60, is_goal: true, achieved: false },
{ stage: "탐색", percent: 10, is_goal: true, achieved: false },
],
},
},
});
await startFixtureSession(page);
const meters = page.locator(".sx-meters");
const toggle = meters.locator(".sx-panel-toggle");
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(meters.locator(".sx-meter")).toHaveCount(3);
await expect(meters.getByRole("progressbar")).toHaveCount(3);
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(meters).toContainText("52% · 완화되는 중");
await expect(meters).toContainText("46%");
await expect(meters).toContainText("44% · 이번 회기 +4%p");
// 접으면 게이지가 사라지고 라포 % 요약만 남는다.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(meters.locator(".sx-meter")).toHaveCount(0);
await expect(toggle).toContainText("라포 44% · 펼치기");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(meters.locator(".sx-meter")).toHaveCount(3);
});
// checklist: session-voice-skip
test("shows the voice skip button during TTS playback and returns to idle on skip", async ({
page,
}) => {
await routeSessionFixtureApi(page);
await installVoiceSpeakingFixture(page, voiceTranscript, voiceClientReply);
await startFixtureSession(page);
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await page.getByRole("button", { name: "발화 보내기" }).click();
// reply + state:speaking → 재생 중에만 '건너뛰기'가 노출된다.
await expect(page.locator(".sx-utt").filter({ hasText: voiceClientReply })).toBeVisible();
const skipButton = page.getByRole("button", { name: "음성 건너뛰기" });
await expect(skipButton).toBeVisible();
await expect(page.locator(".sx-mic-block__l")).toHaveText("재생 중");
await skipButton.click();
await expect(skipButton).toHaveCount(0);
await expect(page.locator(".sx-mic-block__h")).toHaveText(
"음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요.",
);
await expect(page.locator(".sx-mic-block__l")).toHaveText("마이크 꺼짐");
});
// checklist: session-pause-toggle, session-elapsed-live-region
test("pauses and resumes session controls and keeps a screen-reader elapsed live region", async ({
page,
}) => {
await routeSessionFixtureApi(page);
await startFixtureSession(page);
// sr-only aria-live 경과 시간 리전.
const liveRegion = page.locator("span.sr-only[aria-live='polite']");
await expect(liveRegion).toContainText("경과 0:");
const compose = page.getByLabel("학습자 발화 입력");
await page.getByRole("button", { name: "일시정지" }).click();
await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible();
await expect(compose).toBeDisabled();
await expect(compose).toHaveAttribute("placeholder", "일시정지 중입니다.");
await expect(page.locator(".sx-mic-block__l")).toHaveText("일시정지");
await expect(page.getByRole("button", { name: "마이크 켜기" })).toBeDisabled();
await page.getByRole("button", { name: "이어가기" }).click();
await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible();
await expect(compose).toBeEnabled();
await expect(compose).toHaveAttribute("placeholder", "학습자 발화를 입력하세요.");
await expect(page.locator(".sx-mic-block__h")).toHaveText(
"마이크를 켜면 Chrome 권한 요청 후 음성으로 회기를 진행합니다.",
);
});
// checklist: session-keyboard-shortcuts
test("toggles pause with P and mic with Space only outside text inputs", async ({ page }) => {
await routeSessionFixtureApi(page);
await installVoiceSpeakingFixture(page, voiceTranscript, voiceClientReply);
await startFixtureSession(page);
// P — 일시정지 토글.
await page.keyboard.press("p");
await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible();
await page.keyboard.press("p");
await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible();
// 입력창 포커스 중에는 단축키가 무시되고 글자가 입력된다.
const compose = page.getByLabel("학습자 발화 입력");
await compose.click();
await page.keyboard.press("p");
await expect(compose).toHaveValue("p");
await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible();
// 포커스를 입력창 밖으로 옮긴 뒤 Space — 마이크 시작.
await compose.blur();
await page.keyboard.press("Space");
await expect(page.getByRole("button", { name: "발화 보내기" })).toBeVisible();
});
});

View file

@ -0,0 +1,495 @@
/* =====================================================================
full-sweep-settings.spec.ts 2026-07-27 E2E , (/settings) .
docs/ops/e2e-full-sweep-2026-07-27.md §9 "검증: 신규 spec 필요"
. test() checklist id를 .
- API(dev-login, P1~P7 ) , ·
page.route() fixture를 . AI .
===================================================================== */
import { expect, test, type Page, type Response, type TestInfo } from "@playwright/test";
import { completeOnboarding, useRealApi } from "./support";
/** 1x1 PNG — 아바타 업로드 e2e용 최소 픽셀. */
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==";
function slugFor(testInfo: TestInfo) {
let hash = 0;
for (const char of testInfo.title) {
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
}
const project = testInfo.project.name.includes("mobile") ? "mob" : "desk";
return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${hash.toString(36)}`;
}
function isApiResponse(method: string, pathnameSuffix: string) {
return (response: Response) => {
const url = new URL(response.url());
return response.request().method() === method && url.pathname.endsWith(pathnameSuffix);
};
}
async function devLogin(
page: Page,
role: "learner" | "admin",
testInfo: TestInfo,
displayName: string,
options: { onboarding?: boolean } = {},
) {
const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr";
const email = `sweep.settings.${role}.${slugFor(testInfo)}@${domain}`;
const res = await page.request.post("/api/auth/dev-login", {
data: { email, role, display_name: displayName },
});
expect(res.ok(), await res.text()).toBeTruthy();
if (options.onboarding !== false) {
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: role === "admin" ? "운영팀" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : "3학년",
phone: "010-4444-4444",
contact_address: "경기도 오산시 한신대학교",
nickname: displayName,
self_introduction: `${displayName} 설정 전수 순회 E2E 사용자입니다.`,
});
}
return email;
}
/** /settings 진입 후 프로필·환경설정이 도착해 저장 버튼들이 렌더될 때까지 대기. */
async function openLoadedSettings(page: Page) {
await page.goto("/settings");
await expect(page).toHaveURL(/\/settings$/);
await expect(page.locator(".vg-set")).toBeVisible();
// preferences 도착 → 알림 저장 버튼 렌더, profile 도착 → 계정 저장 버튼 활성화
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible({ timeout: 10_000 });
await expect(
page.locator("#set-account .vg-set__foot .vg-btn"),
).toBeEnabled({ timeout: 10_000 });
}
function pendingMeResponse(overrides: Record<string, unknown> = {}) {
return {
user_id: "e2e-pending-user",
email: "sweep.settings.pending@hs.ac.kr",
display_name: "승인 대기 학습자",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "pending",
approval_required: true,
avatar_url: "",
cohort_ids: [],
consent_at: null,
nickname: "승인대기",
self_introduction: "",
onboarding_completed_at: null,
...overrides,
};
}
test.describe("full-sweep settings", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
// checklist: settings-guard-require-auth
test("redirects an unauthenticated visitor from /settings to /login", async ({ page }) => {
await page.goto("/settings");
await expect(page).toHaveURL(/\/login$/);
// 설정 화면 본문이 렌더되지 않아야 한다.
await expect(page.locator(".vg-set")).toHaveCount(0);
});
// checklist: settings-guard-pending-approval
test("redirects a pending-approval user from /settings to /pending", async ({ page }) => {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({ json: pendingMeResponse() });
});
await page.goto("/settings");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
await expect(page.locator(".pa-status")).toContainText("접속 권한 확인 중");
await expect(page.locator(".vg-set")).toHaveCount(0);
});
// checklist: settings-guard-onboarding
test("redirects a signed-in user without onboarding from /settings to /onboarding", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Onboarding Gate", { onboarding: false });
await page.goto("/settings");
await expect(page).toHaveURL(/\/onboarding$/);
await expect(page.locator(".vg-set")).toHaveCount(0);
});
// checklist: settings-load-error-callout
test("shows a role=alert warning callout when the settings load fails", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Load Error");
await page.route("**/api/users/me/preferences", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "preferences unavailable (e2e fixture)" }),
});
});
await page.goto("/settings");
const callout = page.locator(".vg-set__callout--warn");
await expect(callout).toBeVisible({ timeout: 10_000 });
await expect(callout).toHaveAttribute("role", "alert");
await expect(callout.locator(".vg-set__callout-text")).not.toHaveText("");
});
// checklist: settings-support-error-state
test("keeps the page usable with an inline alert when only support tickets fail", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Ticket Error");
await page.route("**/api/users/support-tickets", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "support tickets unavailable (e2e fixture)" }),
});
});
await openLoadedSettings(page);
// 지원 요청 섹션 안에만 role=alert 경고가 뜨고, 페이지 전체 오류 콜아웃은 없다.
const supportAlert = page.locator("#set-support .vg-set__state--warn");
await expect(supportAlert).toBeVisible();
await expect(supportAlert).toHaveAttribute("role", "alert");
await expect(page.locator(".vg-set__callout--warn")).toHaveCount(0);
// 다른 섹션은 살아 있다: 계정 저장·알림 저장 동작 가능 상태.
await expect(page.locator("#set-account .vg-set__foot .vg-btn")).toBeEnabled();
await expect(page.getByRole("button", { name: "알림 저장" })).toBeEnabled();
});
// checklist: settings-support-empty-state
test("shows distinct support empty states for zero tickets and a null payload", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Ticket Empty");
// 1) 실 API — 방금 만든 학습자는 접수 티켓 0건.
await openLoadedSettings(page);
const emptyState = page.getByTestId("settings-support-empty");
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText("접수한 지원 요청이 없습니다");
// 2) fixture — 저장소가 null 페이로드를 반환하면 별도 문구를 보여준다.
await page.route("**/api/users/support-tickets", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: "null" });
});
await openLoadedSettings(page);
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText("지원 요청 저장소에 표시할 항목이 없습니다");
});
// checklist: settings-rail-nav-buttons
test("rail nav buttons scroll to their section and expose aria-current", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Rail Nav");
await openLoadedSettings(page);
const nav = page.getByRole("navigation", { name: "설정 섹션" });
// 학습자에게는 관리자 전용 AI 운영 항목이 없다(계정·지원 요청·동의·테마·알림·음성 6개).
await expect(nav.getByRole("button")).toHaveCount(6);
await expect(nav.getByRole("button", { name: "AI 운영" })).toHaveCount(0);
const accountItem = nav.getByRole("button", { name: "계정" });
const voiceItem = nav.getByRole("button", { name: "음성" });
await expect(accountItem).toHaveAttribute("aria-current", "location");
await voiceItem.click();
await expect(page.locator("#set-voice")).toBeInViewport();
await expect(voiceItem).toHaveAttribute("aria-current", "location");
await expect(accountItem).not.toHaveAttribute("aria-current", "location");
await accountItem.click();
await expect(page.locator("#set-account")).toBeInViewport();
await expect(accountItem).toHaveAttribute("aria-current", "location");
});
// checklist: settings-rail-nav-scrollspy
test("scroll spy syncs the active rail item to the section scrolled into view", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Scrollspy");
await openLoadedSettings(page);
const nav = page.getByRole("navigation", { name: "설정 섹션" });
await expect(nav.getByRole("button", { name: "계정" })).toHaveAttribute(
"aria-current",
"location",
);
// 클릭 없이 스크롤만으로 IntersectionObserver가 활성 섹션을 동기화해야 한다.
await page.evaluate(() => {
document.getElementById("set-voice")?.scrollIntoView({ block: "start" });
});
await expect(nav.getByRole("button", { name: "음성" })).toHaveAttribute(
"aria-current",
"location",
{ timeout: 10_000 },
);
await expect(nav.getByRole("button", { name: "계정" })).not.toHaveAttribute(
"aria-current",
"location",
);
});
// checklist: settings-theme-dark-toggle
test("dark mode switch flips data-theme immediately and persists to localStorage", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Theme Toggle");
await openLoadedSettings(page);
const readTheme = () =>
page.evaluate(() => ({
applied: document.documentElement.getAttribute("data-theme"),
stored: window.localStorage.getItem("vignette.theme"),
}));
const initialDark = (await readTheme()).applied === "dark";
const darkSwitch = page.locator("#set-appearance").getByRole("switch", { name: "다크 모드" });
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
await darkSwitch.click();
const flipped = !initialDark;
await expect(darkSwitch).toHaveAttribute("aria-checked", String(flipped));
await expect.poll(readTheme).toEqual({
applied: flipped ? "dark" : "light",
stored: flipped ? "dark" : "light",
});
// 되돌리기도 즉시 반영된다.
await darkSwitch.click();
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
await expect.poll(readTheme).toEqual({
applied: initialDark ? "dark" : "light",
stored: initialDark ? "dark" : "light",
});
});
// checklist: settings-theme-save-button, settings-saved-flash-indicator
test("theme save button PATCHes the current theme and flashes then clears 저장됨", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Theme Save");
await openLoadedSettings(page);
const appearance = page.locator("#set-appearance");
const darkSwitch = appearance.getByRole("switch", { name: "다크 모드" });
const isDark = (await darkSwitch.getAttribute("aria-checked")) === "true";
// 한 번 토글해 저장할 값을 명시적으로 만든다.
await darkSwitch.click();
const nextTheme = !isDark ? "dark" : "light";
await expect(darkSwitch).toHaveAttribute("aria-checked", String(!isDark));
const patchPromise = page.waitForResponse(
isApiResponse("PATCH", "/users/me/preferences"),
{ timeout: 10_000 },
);
await appearance.locator(".vg-set__foot .vg-btn").click();
const patchResponse = await patchPromise;
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
const requestBody = patchResponse.request().postDataJSON() as { theme?: string };
expect(requestBody.theme).toBe(nextTheme);
const saved = (await patchResponse.json()) as { theme: string };
expect(saved.theme).toBe(nextTheme);
// '저장됨' 플래시: 표시됐다가 2.4초 뒤 자동으로 사라진다.
const savedFlash = appearance.locator(".vg-set__saved");
await expect(savedFlash).toBeVisible();
await expect(savedFlash).toContainText("저장됨");
await expect(savedFlash).toHaveCount(0, { timeout: 6_000 });
});
// checklist: settings-voice-empty-state
test("voice section shows an empty state and hides slider/save without presets", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Voice Empty");
await page.route("**/api/users/me/voice-presets", async (route) => {
await route.fulfill({ json: [] });
});
await openLoadedSettings(page);
const voiceEmpty = page.getByTestId("settings-voice-empty");
await expect(voiceEmpty).toBeVisible();
await expect(voiceEmpty).toContainText("서버에 등록된 음성 프리셋이 없습니다");
await expect(page.getByLabel("말하기 속도")).toHaveCount(0);
await expect(page.getByRole("button", { name: "음성 저장" })).toHaveCount(0);
await expect(page.locator("#set-voice").getByRole("radio")).toHaveCount(0);
});
// checklist: settings-topbar-theme-toggle
test("topbar theme button drives the same global store as the settings switch", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Topbar Theme");
await openLoadedSettings(page);
const darkSwitch = page.locator("#set-appearance").getByRole("switch", { name: "다크 모드" });
const initialDark =
(await page.evaluate(() => document.documentElement.getAttribute("data-theme"))) === "dark";
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
const topbarToggle = page.getByRole("button", {
name: initialDark ? "라이트 모드로" : "다크 모드로",
});
await topbarToggle.click();
const flipped = !initialDark;
await expect
.poll(() => page.evaluate(() => document.documentElement.getAttribute("data-theme")))
.toBe(flipped ? "dark" : "light");
// 설정 섹션의 스위치가 같은 스토어를 구독해 함께 뒤집힌다.
await expect(darkSwitch).toHaveAttribute("aria-checked", String(flipped));
// 버튼 라벨도 반대 방향으로 갱신된다.
await expect(
page.getByRole("button", { name: flipped ? "라이트 모드로" : "다크 모드로" }),
).toBeVisible();
});
// checklist: settings-topbar-logout
test("topbar logout ends the server session and returns to /login", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Logout");
await openLoadedSettings(page);
const logoutPromise = page.waitForResponse(isApiResponse("POST", "/auth/logout"), {
timeout: 10_000,
});
await page.getByRole("button", { name: "로그아웃" }).click();
const logoutResponse = await logoutPromise;
expect(logoutResponse.ok(), await logoutResponse.text()).toBeTruthy();
await expect(page).toHaveURL(/\/login$/);
// 서버 세션이 실제로 종료됐는지 확인 — 쿠키로 /auth/me 재조회 시 401.
const meAfter = await page.request.get("/api/auth/me");
expect(meAfter.status()).toBe(401);
});
// checklist: settings-avatar-upload-missing
// 소유자 결정 D1: 계정 섹션에서 온보딩과 같은 업로드 컨트롤로 아바타를 교체한다.
// 업로드 성공 → PATCH /users/me 저장 → 미리보기·레일 아바타가 이미지로 전환된다.
test("settings exposes an avatar upload control (briefing requirement)", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Avatar Upload");
await openLoadedSettings(page);
// 업로드 전에는 이니셜 폴백만 렌더된다.
const account = page.locator("#set-account");
await expect(account.locator(".vg-set__avatar img")).toHaveCount(0);
const fileInput = account.locator("input[type='file']");
await expect(fileInput).toHaveAttribute("accept", "image/png,image/jpeg,image/webp");
const uploadPromise = page.waitForResponse(isApiResponse("POST", "/users/me/avatar"), {
timeout: 10_000,
});
const patchPromise = page.waitForResponse(isApiResponse("PATCH", "/users/me"), {
timeout: 10_000,
});
await fileInput.setInputFiles({
name: "sweep-settings-avatar.png",
mimeType: "image/png",
buffer: Buffer.from(PNG_1X1_BASE64, "base64"),
});
const uploadResponse = await uploadPromise;
expect(uploadResponse.ok(), await uploadResponse.text()).toBeTruthy();
const uploaded = (await uploadResponse.json()) as { avatar_url: string };
expect(uploaded.avatar_url.length).toBeGreaterThan(0);
// 업로드 성공 직후 avatar_url 이 프로필 PATCH 로 저장된다.
const patchResponse = await patchPromise;
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
// 계정 요약 미리보기가 업로드된 이미지를 가리키고, 레일 아바타도 이미지로 전환된다.
const preview = account.locator(".vg-set__avatar img");
await expect(preview).toBeVisible();
expect(await preview.getAttribute("src")).toContain(uploaded.avatar_url);
await expect(page.locator(".vg-set__rail-avatar img")).toHaveCount(1);
// 서버 프로필 재조회로 avatar_url 영속 저장을 확인한다.
const meRes = await page.request.get("/api/users/me");
expect(meRes.ok(), await meRes.text()).toBeTruthy();
const me = (await meRes.json()) as { avatar_url: string };
expect(me.avatar_url).toBe(uploaded.avatar_url);
});
// checklist: settings-consent-management-missing
// 소유자 결정 D2: 동의 섹션 — 실습 동의 상태·약관 버전 표시, learner 철회(2단계 확인)
// → 차단 안내 → 재동의 복원까지 전체 흐름을 검증한다.
test("settings exposes consent management and withdrawal UI (briefing requirement)", async ({
page,
}, testInfo) => {
// 주의: 공유 learner@hs.ac.kr 의 동의 상태를 파괴하지 않도록 반드시 테스트가
// 새로 만든 고유 dev-login 계정으로만 철회·재동의를 수행한다.
await devLogin(page, "learner", testInfo, "Sweep Consent Mgmt");
const consentSeed = await page.request.post("/api/auth/consent", {
data: { accepted: true },
});
expect(consentSeed.ok(), await consentSeed.text()).toBeTruthy();
await openLoadedSettings(page);
// 레일 내비에 동의 항목이 있고 클릭하면 섹션으로 스크롤된다.
const nav = page.getByRole("navigation", { name: "설정 섹션" });
await nav.getByRole("button", { name: "동의" }).click();
const consent = page.locator("#set-consent");
await expect(consent).toBeInViewport();
// 동의됨 상태: 실습 동의 시각 + 약관·개인정보 동의 버전이 표시된다.
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
await expect(consent.getByText("이용약관")).toBeVisible();
await expect(consent.getByText("개인정보 처리방침")).toBeVisible();
// 철회는 2단계: 첫 클릭은 인라인 확인만 띄우고, 확정 클릭에서만 DELETE 가 나간다.
await consent.getByRole("button", { name: "실습 동의 철회" }).click();
const confirmButton = consent.getByRole("button", { name: "철회 확정" });
await expect(confirmButton).toBeVisible();
const withdrawPromise = page.waitForResponse(isApiResponse("DELETE", "/auth/consent"), {
timeout: 10_000,
});
await confirmButton.click();
const withdrawResponse = await withdrawPromise;
expect(withdrawResponse.ok(), await withdrawResponse.text()).toBeTruthy();
// 차단 안내가 뜨고 서버 consent_at 이 null 로 비워진다.
await expect(
consent.getByText("새 회기 시작과 음성 연습이 차단됩니다."),
).toBeVisible();
const meAfterWithdraw = await page.request.get("/api/auth/me");
expect(meAfterWithdraw.ok(), await meAfterWithdraw.text()).toBeTruthy();
expect(
((await meAfterWithdraw.json()) as { consent_at: number | null }).consent_at,
).toBeNull();
// 다시 동의 → consent_at 복원 + 화면도 동의 시각 표기로 되돌아간다.
const reacceptPromise = page.waitForResponse(isApiResponse("POST", "/auth/consent"), {
timeout: 10_000,
});
await consent.getByRole("button", { name: "다시 동의" }).click();
const reacceptResponse = await reacceptPromise;
expect(reacceptResponse.ok(), await reacceptResponse.text()).toBeTruthy();
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
const meAfterAccept = await page.request.get("/api/auth/me");
expect(meAfterAccept.ok(), await meAfterAccept.text()).toBeTruthy();
expect(
((await meAfterAccept.json()) as { consent_at: number | null }).consent_at,
).not.toBeNull();
});
});

View file

@ -0,0 +1,269 @@
/* =====================================================================
full-sweep-shell.spec.ts 2026-07-27 §10(shell)
"신규 spec 필요" .
대상: 톱바 ·, body[data-role] ,
BootScreen, lazy Suspense fallback,
PendingApprovalGate, , /dev/avatar-preview.
API(dev-login) ,
page.route fixture(/auth/me·/auth/logout·lazy chunk) .
===================================================================== */
import { expect, test, type Page } from "@playwright/test";
import { signInAsAdmin, signInAsLearner, signInAsTeacher, useRealApi } from "./support";
const BOOT_SCREEN_TEXT = "불러오는 중…";
function meResponse(overrides: Record<string, unknown> = {}) {
return {
user_id: "shell-e2e-user",
email: "shell.e2e@hs.ac.kr",
display_name: "Shell E2E",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1,
onboarding_completed_at: 1,
nickname: "Shell E2E",
self_introduction: "",
avatar_url: "",
...overrides,
};
}
async function mockMe(page: Page, overrides: Record<string, unknown> = {}) {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(meResponse(overrides)),
});
});
}
test.describe("full sweep — shared shell, GNB, and routing guards", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
// checklist: shell-topbar-brand-link
// 로그인 상태에서 워드마크는 루트(/)로 링크되고, 클릭 시 RootRedirect가
// 역할 홈(/learn)으로 재분기한다. (미로그인 상태의 톱바는 정상 UI 흐름상
// 도달 불가 — 모든 셸 페이지가 RequireAuth 뒤에 있음 — 링크 목적지 규칙만 검증.)
test("routes the topbar brand link back to the role home through the root redirect", async ({
page,
}) => {
await signInAsLearner(page);
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
const brand = page.locator(".vg-topbar__brand");
await expect(brand).toBeVisible();
await expect(brand).toHaveAttribute("href", "/");
await expect(brand).toContainText("Vignette");
await brand.click();
// "/" 진입 → RootRedirect가 학습자 홈으로 replace 이동
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
});
// checklist: shell-topbar-logout
// 로그아웃 버튼이 서버 세션을 실제로 무효화하고 /login으로 이동시킨다.
test("logs out from the topbar and invalidates the server session", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "로그아웃" }).click();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
// 서버 세션도 무효화되었는지 실 API로 확인
const me = await page.request.get("/api/auth/me");
expect(me.status()).toBe(401);
// 보호 라우트 재진입 시 로그인으로 회수
await page.goto("/learn");
await expect(page).toHaveURL(/\/login$/);
});
// checklist: shell-topbar-logout
// 로그아웃 API가 실패해도 로컬 사용자 상태를 비워 /login으로 빠져나온다.
test("still exits to the login screen when the logout API fails", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
let logoutCalled = false;
await page.route("**/api/auth/logout", async (route) => {
logoutCalled = true;
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "logout unavailable" }),
});
});
await page.getByRole("button", { name: "로그아웃" }).click();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
expect(logoutCalled).toBeTruthy();
});
// checklist: shell-appshell-role-theming
// 역할별 셸 진입 시 body[data-role]이 learner/instructor/admin으로 설정된다.
test("applies body[data-role] theming for each role shell", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("body")).toHaveAttribute("data-role", "instructor");
await signInAsAdmin(page);
await page.goto("/admin");
await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("body")).toHaveAttribute("data-role", "admin");
});
// checklist: shell-auth-bootstrap-loading
// 세션 부트스트랩(/auth/me)이 끝나기 전에는 가드가 중립 BootScreen을 표시하고,
// 401 확정 후에야 /login으로 이동한다.
test("shows the neutral boot screen while the session bootstrap is pending", async ({
page,
}) => {
let releaseMe!: () => void;
const gate = new Promise<void>((resolve) => {
releaseMe = resolve;
});
await page.route("**/api/auth/me", async (route) => {
await gate;
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.goto("/learn");
await expect(page.getByText(BOOT_SCREEN_TEXT)).toBeVisible();
// 부트스트랩이 끝나지 않는 동안은 로그인으로 이동하지 않는다
await expect(page).toHaveURL(/\/learn$/);
releaseMe();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
});
// checklist: shell-suspense-fallback
// 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안
// Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현.
test("shows the suspense fallback while a lazy route chunk is loading", async ({ page }) => {
let releaseChunk!: () => void;
const gate = new Promise<void>((resolve) => {
releaseChunk = resolve;
});
await page.route("**/src/pages/Login.tsx*", async (route) => {
await gate;
await route.continue();
});
const meResolved = page.waitForResponse(
(res) => res.url().includes("/api/auth/me"),
{ timeout: 15_000 },
);
await page.goto("/login", { waitUntil: "domcontentloaded" });
// 인증 부트스트랩이 끝난 뒤에도(가드 BootScreen 종료) 청크가 지연되는 동안
// Suspense fallback이 같은 중립 화면을 유지해야 한다.
await meResolved;
// React 18 Suspense는 재-서스펜드 시 기존 콘텐츠를 display:none으로 숨긴 채
// fallback을 추가하므로 '보이는' BootScreen 하나를 기준으로 검증한다.
await expect(page.getByText(BOOT_SCREEN_TEXT).filter({ visible: true })).toHaveCount(1);
releaseChunk();
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveCount(0);
});
// checklist: shell-guard-pending-approval
// 미승인(account_status=pending) 사용자는 어떤 보호 경로에서든 /pending으로 회수된다.
test("redirects a pending-approval user to /pending from any protected route", async ({
page,
}) => {
await mockMe(page, { account_status: "pending" });
await page.goto("/learn");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
await expect(page.getByText("shell.e2e@hs.ac.kr")).toBeVisible();
await expect(page.getByRole("button", { name: "승인 상태 새로고침" })).toBeVisible();
await page.goto("/settings");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
});
// checklist: shell-guard-pending-approval
// 승인된 사용자가 /pending에 직접 오면 초기 경로(역할 홈)로 되돌린다.
test("returns an approved user visiting /pending to their role home", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/pending");
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
});
// checklist: shell-unknown-route-redirect
// 미정의 경로는 404 화면 없이 /로 replace되어 미인증은 /login,
// 인증 학습자는 /learn으로 재분기된다.
test("redirects unknown routes to the role-aware root destination", async ({ page }) => {
await page.goto("/definitely/not-a-route");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
await signInAsLearner(page);
await page.goto("/no-such-page");
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
});
// checklist: shell-avatar-preview-dev-page
// /dev/avatar-preview는 인증 없이 접근 가능한 dev 페이지로
// 4개 상태 애니메이션 히어로와 대표 표정 12종 그리드를 표시한다.
test("serves the seoyeon avatar dev preview without authentication", async ({ page }) => {
await page.goto("/dev/avatar-preview");
// 인증 가드에 걸리지 않고 그대로 렌더된다
await expect(page).toHaveURL(/\/dev\/avatar-preview$/);
await expect(
page.getByRole("heading", { name: "서연 아바타 미리보기 (SVG 파라미터 리그)" }),
).toBeVisible();
await expect(page.locator(".ap")).toHaveAttribute("data-avatar-rig", "svg-parameter");
// 4개 상태(speaking/idle/listening/thinking) 히어로
await expect(page.locator(".ap__hero > *")).toHaveCount(4);
// 대표 표정 12종 그리드
await expect(page.locator(".ap__grid .ap__cell")).toHaveCount(12);
// ClientAvatar 내부 figcaption(vg-avatar__label)과 구분하기 위해 직계 자식만 조회
await expect(page.locator(".ap__cell > figcaption").first()).toHaveText("neutral");
await expect(page.locator(".ap__cell > figcaption").last()).toHaveText("determined");
await expect(page.locator(".ap__count")).toContainText("총 표정 수:");
});
});

View file

@ -59,6 +59,10 @@
}
</script>
<!-- 본문/UI 기본 폰트: Pretendard Variable (DESIGN_CONCEPT §3.6) -->
<!-- 렌더 블로킹 CDN 스타일시트다. 연결 수립(DNS+TCP+TLS)을 미리 열어 LCP를 줄인다.
근본 해결은 Pretendard self-host 이며 별도 과제로 남긴다. -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css"
@ -169,7 +173,11 @@
var visible = visibleCount();
var hasAdminContent =
!isAdmin ||
Boolean(document.querySelector(".ad-head,.ad-diagnostic,.ad-status,.ad-section,.ad-panel"));
Boolean(
document.querySelector(
".ad-head,.ad-diagnostic,.ad-status,.ad-section,.ad-panel,.aic,[data-testid='admin-ai-page']",
),
);
if (reason === "watchdog" && text.length > 24 && visible > 0 && hasAdminContent) {
var old = document.getElementById("vignette-boot-diagnostic");
if (old) old.remove();

View file

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.8",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1"
@ -1995,6 +1996,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.8.tgz",
"integrity": "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@ -2008,6 +2026,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.6",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz",
"integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",

View file

@ -26,6 +26,7 @@
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.8",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1"

View file

@ -65,7 +65,7 @@
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--accent);
color: var(--text-accent);
padding-left: var(--sp-4);
margin-left: var(--sp-2);
border-left: 1px solid var(--hair);

View file

@ -35,7 +35,8 @@ export type IconName =
| "google"
| "school"
| "sun"
| "moon";
| "moon"
| "refresh";
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: IconName;
@ -49,6 +50,12 @@ export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
// 각 아이콘의 path/children (24x24 viewBox 기준). google 만 멀티컬러 예외.
const PATHS: Record<IconName, ReactNode> = {
refresh: (
<>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<path d="M21 3v5h-5" />
</>
),
home: (
<>
<path d="M3 9.5 12 4l9 5.5" />

View file

@ -10,7 +10,7 @@
font-size: var(--fs-kicker);
font-weight: 600;
letter-spacing: 0.08em;
color: var(--accent);
color: var(--text-accent);
display: inline-flex;
align-items: center;
gap: 8px;
@ -93,7 +93,7 @@
}
.vg-btn--secondary {
background: var(--surface-gradient);
color: var(--accent);
color: var(--text-accent);
border-color: color-mix(in srgb, var(--accent) 62%, var(--botanical-hair));
}
.vg-btn--secondary:hover:not(:disabled) {

View file

@ -1090,8 +1090,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
}
};
const services = health?.services ?? [];
const users = usersData?.users ?? [];
// 렌더마다 새 배열을 만들면 TanStack core row model이 재계산되며
// _autoResetPageIndex 상태 갱신 루프(무한 동기 렌더 프리즈)를 유발한다 — 반드시 메모화.
const services = useMemo(() => health?.services ?? [], [health]);
const users = useMemo(() => usersData?.users ?? [], [usersData]);
const counts = useMemo(
() => ({
ok: services.filter((service) => service.status === "ok").length,
@ -1447,6 +1449,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (user) => user.user_id,
// 페이지네이션 미사용(수동 40행 슬라이스). row model 재계산이 pageIndex 리셋
// setState를 큐잉해 무한 렌더 루프를 만들던 경로를 차단한다(결함 로그 #19).
autoResetPageIndex: false,
});
const visibleUserRows = userTable
.getRowModel()

View file

@ -41,6 +41,7 @@ import {
sessionSortTime,
sessionStatusLabel,
stageProgressValue,
starterPersona,
theoryLine,
trendLabel,
truncateText,
@ -98,7 +99,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
if (personaResult.status === "fulfilled") {
const personaRows = personaResult.value;
const firstUsableCode = personaRows.find(isUsablePersona)?.code ?? "";
// 결정 D3: 회기 기록이 0건인 신규 학습자는 난도 오름차순(기초 우선) 첫
// 사용 가능 페르소나를 기본 선택한다. 기록이 있거나 기록 조회에 실패하면
// 기존 규칙(API 순서 첫 사용 가능 페르소나)을 유지한다.
const hasNoSessionRecords =
sessionResult.status === "fulfilled" &&
(sessionResult.value.sessions ?? []).length === 0;
const firstUsableCode = hasNoSessionRecords
? (starterPersona(personaRows)?.code ?? "")
: (personaRows.find(isUsablePersona)?.code ?? "");
setPersonas(personaRows);
setSelectedCode((cur) =>
personaRows.some(
@ -1285,7 +1294,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</div>
</section>
<aside className="lh-dashboard-side" aria-label="최근 기록 패널">
<aside
className="lh-dashboard-side"
aria-label={
dashTab === "growth" ? "성장 지표 패널" : "최근 기록 패널"
}
>
<article
className={surfaceClassName("lh-panel lh-panel--records")}
>

View file

@ -35,6 +35,13 @@ const EMPTY_FORM: OnboardingForm = {
privacy_accepted: false,
};
// 서버 OnboardingRequest 검증과 동일 규칙: 숫자·하이픈·공백·괄호·점, 선행 +, 숫자 9~15자리.
function isValidPhone(raw: string): boolean {
const trimmed = raw.trim();
const digits = trimmed.replace(/\D/g, "");
return /^\+?[0-9()\-\s.]+$/.test(trimmed) && digits.length >= 9 && digits.length <= 15;
}
function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
return {
...EMPTY_FORM,
@ -121,6 +128,10 @@ export default function Onboarding() {
const submit = async () => {
if (!canSubmit || submitting) return;
if (!isValidPhone(form.phone)) {
setError("연락처는 숫자 9~15자리의 전화번호 형식으로 입력해 주세요 (예: 010-1234-5678).");
return;
}
setSubmitting(true);
setError("");
try {

View file

@ -10,10 +10,12 @@ export default function PendingApproval() {
const navigate = useNavigate();
const { user, logout, refresh } = useAuth();
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<string | null>(null);
const checkAgain = async () => {
if (checking) return;
setChecking(true);
setCheckResult(null);
try {
const nextUser = await refresh();
if (nextUser?.accountStatus === "approved") {
@ -21,6 +23,16 @@ export default function PendingApproval() {
nextUser.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(nextUser.role),
{ replace: true },
);
return;
}
const at = new Date().toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
});
if (nextUser) {
setCheckResult(`${at} 기준 아직 승인 전입니다. 잠시 후 다시 확인해 주세요.`);
} else {
setCheckResult(`${at} 상태 확인에 실패했습니다. 네트워크 연결 후 다시 시도해 주세요.`);
}
} finally {
setChecking(false);
@ -51,14 +63,17 @@ export default function PendingApproval() {
<span aria-hidden="true" />
<div>
<b> </b>
<small> .</small>
<small>
{checkResult ??
"승인되면 새로고침 후 온보딩 또는 역할 홈으로 이동합니다."}
</small>
</div>
</div>
<div className="pa-actions">
<Button
type="button"
size="lg"
leading={<Icon name="settings" size={16} />}
leading={<Icon name="refresh" size={16} />}
onClick={() => void checkAgain()}
disabled={checking}
>

View file

@ -2595,120 +2595,6 @@ export default function PersonaStudio() {
className="ps-layout ps-authoring-layout"
data-authoring-step={authoringStep}
>
<aside className="ps-rail" aria-label="페르소나 목록">
<div className={surfaceClassName("ps-rail__head")}>
<div>
<Kicker></Kicker>
<b>{catalog.length + reviews.length}</b>
</div>
<Badge tone={loadState === "ready" ? "accent" : "neutral"}>
{loadState === "ready" ? suggestedCode : "..."}
</Badge>
</div>
<div
className={surfaceClassName("ps-workflow")}
aria-label="저작 단계"
>
<Kicker> </Kicker>
{AUTHORING_STEPS.map((step, index) => (
<div
className={`ps-workflow-step ${
index < authoringStepIndex
? "is-done"
: index === authoringStepIndex
? "is-current"
: ""
}`}
key={step.label}
>
<span>{index + 1}</span>
<div>
<b>{step.label}</b>
<small>{step.desc}</small>
</div>
</div>
))}
</div>
<div className="ps-list">
{reviews.length > 0 ? (
reviews.map((persona) => (
<button
type="button"
className={surfaceClassName(
`ps-list-row ${editingId === persona.persona_id ? "is-active" : ""}`,
{ variant: "interactive", flat: true },
)}
key={`${persona.persona_id}:${persona.version}`}
onClick={() => void loadDraft(persona.persona_id)}
disabled={busy !== null}
>
<span className="ps-list-row__code">{persona.code}</span>
<span className="ps-list-row__body">
<b>{persona.display_name}</b>
<small>
v{persona.version} ·{" "}
{personaReviewStatusLabel(persona.status)}
</small>
</span>
</button>
))
) : (
<EmptyState
title="검수 큐 없음"
desc="저장된 초안이 여기에 표시됩니다."
/>
)}
</div>
<div className={surfaceClassName("ps-approved")}>
<Kicker> </Kicker>
{catalog.length > 0 ? (
catalog.map((persona) => (
<article
className="ps-approved-row"
key={`${persona.code}:${persona.version ?? "approved"}`}
data-approved-persona-row="true"
>
<span className="ps-approved-row__code">
{persona.code}
</span>
<div className="ps-approved-row__body">
<b>{persona.display_name}</b>
<small>
v{persona.version ?? 1} ·{" "}
{persona.source === "database"
? "DB 공개본"
: persona.source}
</small>
</div>
<div className="ps-approved-row__actions">
<Button
size="sm"
variant="secondary"
onClick={() => void reviseApprovedPersona(persona)}
disabled={busy !== null || !persona.persona_id}
>
</Button>
<Button
size="sm"
variant="danger"
onClick={() => void archiveApprovedPersona(persona)}
disabled={busy !== null || !persona.persona_id}
>
</Button>
</div>
</article>
))
) : (
<span className="ps-muted"> </span>
)}
</div>
</aside>
<section className="ps-editor">
<div className={surfaceClassName("ps-editor__top")}>
<div>

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useVirtualizer } from "@tanstack/react-virtual";
import { AppShell } from "../components/shell/AppShell";
import {
Badge,
@ -1003,6 +1004,16 @@ function LearnerOverviewTable({
const [expandedLearnerId, setExpandedLearnerId] = useState<string | null>(
null,
);
// 세로 가상화 스크롤 컨테이너(.pf-learner-table) 참조.
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const rowVirtualizer = useVirtualizer({
count: learners.length,
getScrollElement: () => scrollContainerRef.current,
// 접힌 행 기준 추정 높이. 실제 높이(펼침 포함)는 measureElement가 보정한다.
estimateSize: () => 62,
overscan: 8,
getItemKey: (index) => learners[index]?.learner_id ?? index,
});
const sortOptions: Array<{ key: LearnerSort; label: string }> = [
{ key: "latest", label: "최근순" },
{ key: "sessions", label: "회기순" },
@ -1019,6 +1030,12 @@ function LearnerOverviewTable({
}
}, [expandedLearnerId, learners]);
const toggleExpanded = useCallback((learnerId: string) => {
setExpandedLearnerId((current) =>
current === learnerId ? null : learnerId,
);
}, []);
return (
<section className="pf-section pf-section--learner-overview">
<div className="pf-section__head">
@ -1071,7 +1088,11 @@ function LearnerOverviewTable({
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다."
/>
) : (
<div className="pf-learner-table" data-learner-overview-table="true">
<div
className="pf-learner-table"
data-learner-overview-table="true"
ref={scrollContainerRef}
>
<div className="pf-learner-table__head" aria-hidden="true">
<span></span>
<span></span>
@ -1083,19 +1104,57 @@ function LearnerOverviewTable({
<span></span>
<span></span>
</div>
{learners.map((learner) => {
{/* 세로 가상화 본문 — 전체 높이를 확보하고 화면 주변 행만 절대 배치로 렌더한다. */}
<div
className="pf-learner-table__body"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const learner = learners[virtualRow.index];
const isExpanded = expandedLearnerId === learner.learner_id;
const expandId = `learner-expanded-${learner.learner_id}`;
const topTechniques = learner.top_techniques ?? [];
return (
<div
className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`}
key={learner.learner_id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<article
className="pf-learner-row"
data-learner-overview-row="true"
>
<LearnerOverviewRecord
learner={learner}
isExpanded={isExpanded}
onToggleExpand={toggleExpanded}
onOpen={onOpen}
/>
</div>
);
})}
</div>
</div>
)}
</Card>
</section>
);
}
// 학습자 개요 테이블의 한 행(요약 article + 펼침 상세). 가상화 래퍼(.pf-learner-record)
// 안에서 렌더되며, 펼침 시 높이 변화는 measureElement의 ResizeObserver가 재측정한다.
function LearnerOverviewRecord({
learner,
isExpanded,
onToggleExpand,
onOpen,
}: {
learner: TeacherLearnerGrowth;
isExpanded: boolean;
onToggleExpand: (learnerId: string) => void;
onOpen: (learnerId: string) => void;
}) {
const expandId = `learner-expanded-${learner.learner_id}`;
const topTechniques = learner.top_techniques ?? [];
return (
<>
<article className="pf-learner-row" data-learner-overview-row="true">
<div className="pf-learner-row__id">
<button
type="button"
@ -1112,19 +1171,13 @@ function LearnerOverviewTable({
</b>
<span>{formatDateTime(learner.latest_at)}</span>
</div>
<div
className="pf-learner-row__metric"
data-label="최근 적절성"
>
<div className="pf-learner-row__metric" data-label="최근 적절성">
<b>{formatScore(learner.latest_score)}</b>
</div>
<div className="pf-learner-row__metric" data-label="변화">
<b>{formatDelta(learner.score_delta)}</b>
</div>
<div
className="pf-learner-row__metric"
data-label="평균 라포"
>
<div className="pf-learner-row__metric" data-label="평균 라포">
<b>{formatRapport(learner.avg_rapport)}</b>
</div>
<div className="pf-learner-row__trend" data-label="추이">
@ -1140,10 +1193,7 @@ function LearnerOverviewTable({
{trendLabel(learner.trend)}
</Badge>
</div>
<div
className="pf-learner-row__tag-summary"
data-label="기법 요약"
>
<div className="pf-learner-row__tag-summary" data-label="기법 요약">
<b>{topTechniques[0] ?? "기법 부족"}</b>
{topTechniques.length > 1 ? (
<span> {topTechniques.length - 1}</span>
@ -1156,16 +1206,9 @@ function LearnerOverviewTable({
aria-expanded={isExpanded}
aria-controls={expandId}
aria-label={`${learner.learner_label} 요약 ${isExpanded ? "접기" : "펼치기"}`}
onClick={() =>
setExpandedLearnerId(
isExpanded ? null : learner.learner_id,
)
}
onClick={() => onToggleExpand(learner.learner_id)}
>
<Icon
name={isExpanded ? "arrow-up" : "arrow-down"}
size={14}
/>
<Icon name={isExpanded ? "arrow-up" : "arrow-down"} size={14} />
</button>
</div>
<div className="pf-learner-row__action">
@ -1213,8 +1256,7 @@ function LearnerOverviewTable({
<span> </span>
<b>{formatDateTime(learner.latest_at)}</b>
<small>
{learner.ended_sessions} / {" "}
{learner.sessions}
{learner.ended_sessions} / {learner.sessions}
</small>
</div>
<div className="pf-learner-detail-item pf-learner-detail-item--tags">
@ -1223,20 +1265,12 @@ function LearnerOverviewTable({
{topTechniques.slice(0, 5).map((tag) => (
<span key={tag}>{tag}</span>
))}
{topTechniques.length === 0 ? (
<span> </span>
) : null}
{topTechniques.length === 0 ? <span> </span> : null}
</div>
</div>
</div>
) : null}
</div>
);
})}
</div>
)}
</Card>
</section>
</>
);
}

View file

@ -3403,12 +3403,12 @@ export default function Session() {
</span>
<div>
<h2 id="sx-end-dialog-title">
{timeUp ? "회기 시간이 끝났어요" : "회기를 종료할까?"}
{timeUp ? "회기 시간이 끝났어요" : "회기를 종료할까?"}
</h2>
<p id="sx-end-dialog-desc">
{timeUp
? "실제 상담처럼 시간이 회기를 마무리합니다. 마지막 인사를 나누고 싶다면 잠시 계속할 수 있어요. 종료하면 축어록을 저장하고 리뷰로 이동합니다."
: "종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동다."}
: "종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동합니다."}
</p>
</div>
</div>

View file

@ -15,6 +15,7 @@ import { roleLabel, useAuth } from "../lib/auth";
import {
adminApi,
adminEngineApi,
apiUrl,
userApi,
type AdminHealthResponse,
type AdminEngineConfigResponse,
@ -108,6 +109,18 @@ function formatTicketDate(value?: number | null): string {
}).format(new Date(value * 1000));
}
/** 동의 시각 표기 — 감사 추적을 위해 연도까지 포함한다. */
function formatConsentDate(value?: number | null): string {
if (!value) return "-";
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value * 1000));
}
function SettingsLoadingState({
children,
testId,
@ -189,11 +202,13 @@ function completeNotificationPreferences(
}
export default function Settings() {
const { user } = useAuth();
const { user, refresh, acceptConsent, withdrawConsent } = useAuth();
const role = user?.role ?? "learner";
const isAdmin = role === "admin";
const [dark, setDark] = useTheme();
const [activeSection, setActiveSection] = useState("account");
// goSection의 스무스 스크롤 동안 IntersectionObserver가 활성 섹션을 되돌리지 않게 억제한다.
const spySuppressUntilRef = useRef(0);
const [profile, setProfile] = useState<UserProfileResponse | null>(null);
const [displayName, setDisplayName] = useState(user?.name ?? "");
@ -221,6 +236,15 @@ export default function Settings() {
const affiliationDirtyRef = useRef(false);
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
const loadSettingsRequestRef = useRef(0);
// 아바타 업로드 — 온보딩과 같은 계약(3MB, PNG/JPEG/WebP)을 설정에서도 쓴다.
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [avatarError, setAvatarError] = useState("");
// 실습 동의 관리 — 철회는 인라인 2단계 확인을 거친다.
const [consentConfirming, setConsentConfirming] = useState(false);
const [consentBusy, setConsentBusy] = useState(false);
const [consentWithdrawn, setConsentWithdrawn] = useState(false);
const [consentError, setConsentError] = useState("");
const flashSaved = (key: string) => {
setSavedKey(key);
@ -312,6 +336,7 @@ export default function Settings() {
const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" },
{ id: "support", label: "지원 요청", icon: "review" },
{ id: "consent", label: "동의", icon: "check" },
{ id: "appearance", label: "테마", icon: "settings" },
{ id: "notify", label: "알림", icon: "info" },
{ id: "voice", label: "음성", icon: "mic" },
@ -323,18 +348,34 @@ export default function Settings() {
}, [isAdmin]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible?.target.id) {
setActiveSection(visible.target.id.replace(/^set-/, ""));
// 활성 규칙: 뷰포트 상단 18~38% 밴드에 걸친 섹션 중 가장 아래(=방금 지나친 헤딩).
// 비율 우세 방식은 scroll-margin 정렬 지점이 밴드 하단 근처인 모바일에서
// 직전 섹션이 계속 이겨 마지막 섹션이 활성화되지 않는다(결함 로그 #17).
const pickActiveSection = () => {
const bandTop = window.innerHeight * 0.18;
const bandBottom = window.innerHeight * 0.38;
let winner: { id: string; top: number } | null = null;
for (const item of navItems) {
const section = document.getElementById(`set-${item.id}`);
if (!section) continue;
const rect = section.getBoundingClientRect();
if (rect.top > bandBottom || rect.bottom < bandTop) continue;
if (!winner || rect.top > winner.top) {
winner = { id: item.id, top: rect.top };
}
}
if (winner) setActiveSection(winner.id);
};
const observer = new IntersectionObserver(
() => {
if (Date.now() < spySuppressUntilRef.current) return;
pickActiveSection();
},
{
rootMargin: "-18% 0px -62% 0px",
threshold: [0.2, 0.55],
// 0 임계값이 없으면 긴 섹션(모바일)은 밴드 대비 비율이 0.2에 못 미쳐
// 콜백이 아예 오지 않는다(결함 로그 #17).
threshold: [0, 0.2, 0.55],
},
);
@ -343,7 +384,22 @@ export default function Settings() {
if (section) observer.observe(section);
}
return () => observer.disconnect();
// 마지막 섹션은 짧은 뷰포트에서 활성 밴드(18~38%)에 도달하지 못한다.
// 스크롤 컨테이너가 바닥에 닿으면 마지막 섹션을 활성으로 처리한다(결함 로그 #17).
const scroller = document.querySelector(".vg-main");
const onScroll = () => {
if (!scroller || Date.now() < spySuppressUntilRef.current) return;
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 4) {
const last = navItems[navItems.length - 1];
if (last) setActiveSection(last.id);
}
};
scroller?.addEventListener("scroll", onScroll, { passive: true });
return () => {
observer.disconnect();
scroller?.removeEventListener("scroll", onScroll);
};
}, [navItems]);
const visibleNotifications = useMemo(
@ -380,6 +436,7 @@ export default function Settings() {
);
const goSection = (id: string) => {
spySuppressUntilRef.current = Date.now() + 900;
setActiveSection(id);
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
@ -406,6 +463,58 @@ export default function Settings() {
flashSaved("account");
};
/** 아바타 업로드 → PATCH /users/me 저장 → 톱바·레일 아바타 갱신. */
const uploadAvatar = async (file: File | null) => {
if (!file || uploadingAvatar) return;
setUploadingAvatar(true);
setAvatarError("");
try {
const uploaded = await userApi.uploadAvatar(file);
const next = await userApi.updateMe({ avatar_url: uploaded.avatar_url });
setProfile(next);
await refresh();
flashSaved("account");
} catch (err) {
console.warn("[settings] avatar upload failed", err);
setAvatarError("아바타 업로드에 실패했습니다. PNG, JPG, WebP 파일을 3MB 이하로 올려 주세요.");
} finally {
setUploadingAvatar(false);
}
};
/** 실습 동의 철회 확정 — DELETE /auth/consent, learner 전용. */
const confirmWithdrawConsent = async () => {
if (consentBusy) return;
setConsentBusy(true);
setConsentError("");
try {
await withdrawConsent();
setConsentWithdrawn(true);
setConsentConfirming(false);
} catch (err) {
console.warn("[settings] consent withdraw failed", err);
setConsentError(err instanceof Error ? err.message : "실습 동의 철회에 실패했습니다.");
} finally {
setConsentBusy(false);
}
};
/** 실습 동의 재동의 — POST /auth/consent, 회기 시작 화면과 같은 계약. */
const reacceptConsent = async () => {
if (consentBusy) return;
setConsentBusy(true);
setConsentError("");
try {
await acceptConsent();
setConsentWithdrawn(false);
} catch (err) {
console.warn("[settings] consent accept failed", err);
setConsentError(err instanceof Error ? err.message : "실습 동의 저장에 실패했습니다.");
} finally {
setConsentBusy(false);
}
};
const savePreferences = async (key: string) => {
if (!preferencesReady || !preferences) return;
const notifications = completeNotificationPreferences(preferences.notifications);
@ -453,6 +562,10 @@ export default function Settings() {
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
const accountEmail = profile?.email ?? user?.email ?? "";
const initials = accountName.trim().slice(0, 1).toUpperCase();
// 프로필이 먼저 도착하면 프로필 값을, 아니면 auth 스토어의 절대화된 URL을 쓴다.
const avatarSrc = profile?.avatar_url ? apiUrl(profile.avatar_url) : user?.avatarUrl ?? "";
const isLearner = role === "learner";
const consentAt = user?.consentAt ?? null;
const engineHasRequiredFields = engineConfig
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
: false;
@ -490,7 +603,7 @@ export default function Settings() {
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
<div className="vg-set__rail-avatar" aria-hidden="true">
{initials}
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
</div>
<div className="vg-set__rail-copy">
<div className="vg-set__rail-name">{accountName}</div>
@ -541,7 +654,7 @@ export default function Settings() {
<div className={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
<div className="vg-set__avatar" aria-hidden="true">
{initials}
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
</div>
<div className="vg-set__profile-meta">
<div className="n">{accountName}</div>
@ -551,7 +664,24 @@ export default function Settings() {
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
</div>
</div>
<div className="vg-set__avatar-actions">
<Button
size="sm"
variant="secondary"
onClick={() => avatarInputRef.current?.click()}
disabled={loading || uploadingAvatar}
>
{uploadingAvatar ? "업로드 중" : avatarSrc ? "아바타 변경" : "아바타 올리기"}
</Button>
</div>
</div>
{avatarError ? (
<div className="vg-set__state vg-set__state--warn" role="alert">
<span className="vg-set__state-dot" aria-hidden="true" />
<span>{avatarError}</span>
</div>
) : null}
<div className="vg-set__field-grid vg-set__field-grid--account">
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
@ -626,6 +756,21 @@ export default function Settings() {
</Button>
</div>
{/* file input DOM (0= )
. ref . */}
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="vg-set__avatar-input"
aria-label="아바타 이미지"
disabled={loading || uploadingAvatar}
onChange={(event) => {
void uploadAvatar(event.currentTarget.files?.[0] ?? null);
event.currentTarget.value = "";
}}
/>
</Panel>
<Panel id="set-support" className="vg-set__group vg-set__group--wide">
@ -819,6 +964,118 @@ export default function Settings() {
</Panel>
) : null}
<Panel id="set-consent" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head">
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
<div className="vg-set__opt-grid">
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
<div className="vg-set__opt-text">
<div className="l"> </div>
<div className="h" data-testid="settings-consent-practice">
{!isLearner
? "학습자 전용 항목입니다. 실습 동의는 학습자 계정에서만 관리합니다."
: consentAt != null
? `동의 시각 ${formatConsentDate(consentAt)}`
: "미동의 상태입니다."}
</div>
</div>
{!isLearner ? (
<Badge tone="neutral"> </Badge>
) : consentAt != null && !consentConfirming ? (
<Button
size="sm"
variant="secondary"
onClick={() => setConsentConfirming(true)}
disabled={consentBusy}
>
</Button>
) : null}
</div>
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
<div className="vg-set__opt-text">
<div className="l"></div>
<div className="h">
{profileReady && profile
? `버전 ${profile.terms_version || "-"} · 동의 ${formatConsentDate(profile.terms_agreed_at)}`
: "동의 이력을 확인하는 중입니다."}
</div>
</div>
</div>
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
<div className="vg-set__opt-text">
<div className="l"> </div>
<div className="h">
{profileReady && profile
? `버전 ${profile.privacy_version || "-"} · 동의 ${formatConsentDate(profile.privacy_agreed_at)}`
: "동의 이력을 확인하는 중입니다."}
</div>
</div>
</div>
</div>
{consentError ? (
<div className="vg-set__state vg-set__state--warn" role="alert">
<span className="vg-set__state-dot" aria-hidden="true" />
<span>{consentError}</span>
</div>
) : null}
{isLearner && consentAt != null && consentConfirming ? (
<div className={surfaceClassName("vg-set__consent-block", { variant: "inset", flat: true })}>
<span> ? .</span>
<div className="vg-set__consent-actions">
<Button
size="sm"
variant="danger"
onClick={() => void confirmWithdrawConsent()}
disabled={consentBusy}
>
{consentBusy ? "철회 중" : "철회 확정"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConsentConfirming(false)}
disabled={consentBusy}
>
</Button>
</div>
</div>
) : null}
{isLearner && consentAt == null ? (
<div className={surfaceClassName("vg-set__consent-block", { variant: "inset", flat: true })}>
{consentWithdrawn ? (
<span role="status">
.
.
</span>
) : (
<span> .</span>
)}
<div className="vg-set__consent-actions">
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void reacceptConsent()}
disabled={consentBusy}
>
{consentBusy ? "저장 중" : "다시 동의"}
</Button>
</div>
</div>
) : null}
</Panel>
<Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head">
<div>

View file

@ -838,7 +838,7 @@
}
.ad-user-sort__indicator {
color: var(--accent);
color: var(--text-accent);
font-family: var(--font-num);
font-size: 13px;
}
@ -980,7 +980,7 @@
.ad-approval-head h2 {
margin: 0;
color: var(--text-strong);
font-size: var(--fs-lg);
font-size: var(--fs-h3);
line-height: 1.25;
}
@ -1278,14 +1278,31 @@
background: var(--bg-surface);
}
/* 티켓 필터 툴바.
기존에는 기본값이 7트랙 (minmax(220px,...) + 132px*3 + 140px + 버튼 2개)이라
최소 954px를 요구했다. 그런데 1180~1257px 구간은 사이드바(262px) 펼쳐지면서
툴바의 실제 컨테이너가 876~950px밖에 된다. 그래서 1180px에서 마지막
"초기화" 버튼이 left=1142 / right=1216 으로 뷰포트(1180) 36px까지 밀려나 잘렸다.
(1179px 이하에서는 max-width:1179px 규칙이 3트랙으로 접어줘서 문제가 없었다.)
해결: 기본값을 4트랙 2줄로 낮추고 모든 유연 트랙을 minmax(0, ...) 두어
컨테이너가 좁아져도 트랙이 끝까지 줄어들 있게 한다(구조적으로 넘칠 없음). */
.ad-ticket-filter {
display: grid;
grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(132px, 0.45fr)) minmax(140px, 0.5fr) max-content max-content;
grid-template-columns: minmax(0, 1.4fr) repeat(3, minmax(0, 1fr));
align-items: center;
gap: 8px;
margin: 12px 0;
}
/* 컨테이너가 954px 이상 확보되는 (뷰포트 1320px~, 컨테이너 1016px~)에서만
7트랙 줄로 편다. 최소치를 0으로 둬서 구간에서도 넘침이 불가능하다. */
@media (min-width: 1320px) {
.ad-ticket-filter {
grid-template-columns:
minmax(0, 1.6fr) repeat(3, minmax(0, 1fr)) minmax(0, 1.1fr) max-content max-content;
}
}
.ad-ticket-filter input,
.ad-ticket-filter select {
min-width: 0;
@ -1469,13 +1486,10 @@
font-size: 20px;
}
@media (max-width: 1179px) {
.ad-ticket-filter {
grid-template-columns: minmax(180px, 1fr) minmax(132px, 0.6fr) minmax(132px, 0.6fr);
}
}
/* max-width:1179px .ad-ticket-filter 3트랙 규칙을 제거했다.
1179px(컨테이너 875px) 1180px(컨테이너 876px) 실제 사용 가능한 폭이 1px 차이인데
트랙 수만 3 -> 7 튀면서 1180px에서만 넘침이 생겼다. 이제 981~1319px 구간이
위의 기본 4트랙을 공유하므로 경계에서 레이아웃이 튀지 않는다. */
@media (max-width: 980px) {
.ad-kpis,
.ad-flow-grid,

View file

@ -13,7 +13,7 @@
gap: var(--sp-5);
align-items: end;
padding-bottom: var(--sp-5);
border-bottom: 1px solid var(--line-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.axl__head h1,
@ -46,7 +46,7 @@
.axl__stats span {
min-width: 104px;
padding: 10px 12px;
border: 1px solid var(--line-subtle);
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface);
color: var(--text-muted);
@ -74,7 +74,7 @@
flex-direction: column;
gap: var(--sp-5);
padding-bottom: var(--sp-7);
border-bottom: 1px solid var(--line-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.axl__persona-head {
@ -111,7 +111,7 @@
.axl__persona-title dl > div {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--line-subtle);
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: color-mix(in srgb, var(--surface) 82%, transparent);
}
@ -148,7 +148,7 @@
align-content: start;
gap: 8px;
padding: 10px;
border: 1px solid var(--line-subtle);
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface);
overflow: hidden;

View file

@ -1,5 +1,5 @@
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
.ap__head h1{font-size:var(--fs-xl);margin:0 0 6px;}
.ap__head h1{font-size:var(--fs-h1);margin:0 0 6px;}
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;}
.ap__hero .vg-avatar__persona{color:#1c2a2a;}

View file

@ -1748,6 +1748,20 @@
padding:4px 7px;
}
}
/* 초소형 (320~420px, iPhone SE급) 전용 밴드.
기존 최소 브레이크포인트가 460px이라 구간이 비어 있었다.
320px에서 페르소나 카드는 78vw(=249.6px)이고 마크 38px + 좌우 패딩 48px + 11px를
빼면 이름 칸이 150.6px밖에 남는다. 그래서
"하린(가명) · 고3 · 진로갈등(부모기대 vs 본인욕구)" 3줄(57px) 요구하는데
기본 -webkit-line-clamp:2(38px) 걸려 마지막 19px이 통째로 사라졌다
(실측: clientHeight 38 / scrollHeight 57).
구간만 3줄까지 허용해 이름이 온전히 보이게 한다. 그보다 길어져도
-webkit-box + line-clamp를 유지하므로 세로로 깎이지 않고 말줄임()으로만 잘린다. */
@media (max-width:420px){
.lh-persona__name{
-webkit-line-clamp:3;
}
}
@media (prefers-reduced-motion:reduce){
.lh-skel__box,
.lh-skel__line{animation:none;}
@ -1764,8 +1778,8 @@
text-align: left;
padding: 9px 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.09));
background: var(--bg-inset, rgba(0, 0, 0, 0.12));
border: 1px solid var(--border-subtle);
background: var(--bg-surface-2);
color: inherit;
cursor: pointer;
}
@ -1781,7 +1795,7 @@
margin: 0;
font-size: 11.5px;
line-height: 1.5;
color: var(--text-dim);
color: var(--text-muted);
}
.lh-rapport-list {
display: grid;
@ -1806,13 +1820,13 @@
margin-left: 4px;
font-size: 10.5px;
font-weight: 620;
color: var(--text-dim);
color: var(--text-muted);
}
.lh-rapport-row__bar {
display: block;
height: 5px;
border-radius: 3px;
background: var(--bg-inset, rgba(0, 0, 0, 0.16));
background: var(--neutral-100);
overflow: hidden;
}
.lh-rapport-row__bar i {
@ -1840,10 +1854,10 @@
gap: 5px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.1));
border: 1px solid var(--border-subtle);
font-size: 11.5px;
font-weight: 680;
color: var(--text-dim);
color: var(--text-muted);
}
.lh-milestone.is-done {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);

View file

@ -14,12 +14,36 @@ import {
DIFFICULTY_LABEL,
PENDING_PERSONA_AVATAR_APPEARANCE,
demographicText,
isUsablePersona,
personaAvatarAppearance,
personaBaselineExpression,
personaMeta,
shortPersonaName,
} from "../../lib/personaViewModel";
// 결정 D3: 난도 오름차순 정렬 순서 — 알 수 없는 값은 뒤로 보낸다.
const DIFFICULTY_ORDER: Record<string, number> = {
easy: 0,
moderate: 1,
hard: 2,
};
const UNKNOWN_DIFFICULTY_ORDER = 99;
/** 회기 기록이 없는 신규 학습자에게 권하는 기초 우선 페르소나(난도 오름차순, 동률이면 code 순). */
export function starterPersona(
personas: PersonaSummary[],
): PersonaSummary | null {
const usable = personas.filter(isUsablePersona);
usable.sort((a, b) => {
const rank =
(DIFFICULTY_ORDER[a.difficulty] ?? UNKNOWN_DIFFICULTY_ORDER) -
(DIFFICULTY_ORDER[b.difficulty] ?? UNKNOWN_DIFFICULTY_ORDER);
if (rank !== 0) return rank;
return a.code.localeCompare(b.code);
});
return usable[0] ?? null;
}
export type HistoryFilter = "all" | "active" | "review" | "archived";
export const HISTORY_FILTERS: Array<{

View file

@ -79,7 +79,8 @@
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
color: var(--accent-bright);
/* --accent-bright 는 dot/진행바 fill 용 밝은 값이라 12px 글자로 쓰면 3.29:1 이다. */
color: var(--text-accent);
}
.lg-kicker .d {
@ -285,9 +286,12 @@
margin-top: var(--sp-3);
padding: 10px 12px;
border-radius: var(--radius);
background: rgba(154, 94, 20, 0.28);
border: 1px solid rgba(236, 180, 91, 0.16);
color: #f2b75f;
/* 2026-07-27: 기존 rgba(154,94,20,.28) + #f2b75f 조합은 다크 패널 전제였다.
라이트 패널 위에서는 탄색 배경에 밝은 앰버 글자가 얹혀 1.19:1까지 떨어져
사실상 읽을 없었다. 테마별로 스왑되는 warn 의미 토큰으로 교체한다. */
background: var(--warn-tint);
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
color: var(--warn-text);
font-size: 12.5px;
line-height: 1.5;
}

View file

@ -166,10 +166,14 @@
border-radius:var(--radius);
background:var(--bg-surface-2);
}
/* 약관·개인정보 동의는 법적 확인 조작이다. 라벨 박스가 실제 클릭 타깃인데
높이가 19px WCAG 2.2 SC 2.5.8(24px) 미달했다. 최소 높이를 올린다. */
.ob-checks label{
grid-template-columns:auto minmax(0,1fr);
align-items:center;
gap:10px;
min-height:28px;
cursor:pointer;
}
.ob-checks input{
width:18px;

View file

@ -37,7 +37,6 @@
gap:16px;
align-items:start;
}
.ps-rail,
.ps-inspector{
position:sticky;
top:76px;
@ -45,9 +44,7 @@
gap:14px;
min-width:0;
}
.ps-rail__head,
.ps-editor__top,
.ps-workflow,
.ps-inspector__block{
border:1px solid var(--border-subtle);
border-radius:var(--radius);
@ -55,14 +52,12 @@
padding:16px;
box-shadow:var(--shadow-sm);
}
.ps-rail__head,
.ps-editor__top{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:12px;
}
.ps-rail__head b,
.ps-editor__top h2{
display:block;
margin:4px 0 0;
@ -73,167 +68,15 @@
line-height:1.25;
letter-spacing:0;
}
.ps-list,
.ps-approved,
.ps-review-cards,
.ps-check{
display:grid;
gap:8px;
}
.ps-list-row{
width:100%;
display:grid;
grid-template-columns:42px minmax(0,1fr);
gap:10px;
align-items:center;
padding:10px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
color:inherit;
text-align:left;
cursor:pointer;
}
.ps-list-row:hover,
.ps-list-row.is-active{
border-color:var(--accent);
background:var(--accent-tint);
}
.ps-list-row:disabled{
opacity:.55;
cursor:not-allowed;
}
.ps-list-row__code{
display:grid;
place-items:center;
min-width:38px;
min-height:34px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--bg-surface-2);
font-weight:760;
font-family:var(--font-num);
}
.ps-list-row__body{
min-width:0;
}
.ps-list-row__body b,
.ps-list-row__body small{
display:block;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ps-list-row__body small,
.ps-muted,
.ps-approved-row span,
.ps-review span{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.ps-approved{
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
}
.ps-workflow{
display:grid;
gap:10px;
}
.ps-workflow-step{
display:grid;
grid-template-columns:30px minmax(0,1fr);
align-items:center;
gap:10px;
padding:10px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-muted);
}
.ps-workflow-step > span{
display:grid;
place-items:center;
width:30px;
height:30px;
border:1px solid var(--border-subtle);
border-radius:50%;
background:var(--bg-surface);
color:var(--text-muted);
font-family:var(--font-num);
font-weight:760;
}
.ps-workflow-step b,
.ps-workflow-step small{
display:block;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ps-workflow-step b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.ps-workflow-step small{
margin-top:2px;
font-size:11px;
}
.ps-workflow-step.is-current{
border-color:var(--accent);
background:var(--accent-tint);
color:var(--accent-deep);
}
.ps-workflow-step.is-current > span,
.ps-workflow-step.is-done > span{
border-color:var(--accent);
background:var(--accent);
color:var(--text-on-accent);
}
.ps-approved-row{
display:grid;
grid-template-columns:38px minmax(0,1fr);
gap:8px 10px;
align-items:center;
padding:8px 0;
border-top:1px solid var(--hair);
}
.ps-approved-row:first-of-type{border-top:0;}
.ps-approved-row__code{
font-family:var(--font-num);
font-size:var(--fs-xs);
color:var(--text-muted);
}
.ps-approved-row__body{
min-width:0;
display:grid;
gap:2px;
}
.ps-approved-row b{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
font-size:var(--fs-xs);
}
.ps-approved-row small{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color:var(--text-muted);
font-size:11px;
}
.ps-approved-row__actions{
grid-column:1 / -1;
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:6px;
}
.ps-approved-row__actions .vg-btn{
width:100%;
}
.ps-editor{
display:grid;
gap:14px;
@ -493,8 +336,8 @@
}
.ps-prompt-section__head h3{
margin:0;
color:var(--text-heading);
font-size:var(--fs-md);
color:var(--text-strong);
font-size:var(--fs-body);
line-height:1.25;
}
.ps-prompt-section__head p,
@ -516,7 +359,7 @@
gap:10px;
align-items:start;
padding:8px 0;
border-top:1px solid var(--border-faint);
border-top:1px solid var(--hair);
}
.ps-prompt-row dt{
color:var(--text-muted);
@ -671,9 +514,6 @@
.ps-layout{
grid-template-columns:1fr;
}
.ps-rail{
position:static;
}
.ps-inspector{
grid-template-columns:1fr;
}
@ -931,7 +771,7 @@
min-height:58px;
padding:9px 16px;
border:0;
border-top:1px solid var(--border-faint);
border-top:1px solid var(--hair);
background:transparent;
color:var(--text-body);
text-align:left;
@ -959,7 +799,7 @@
.ps-catalog-hero__count b{color:var(--text-strong);font-family:var(--font-num);font-size:54px;line-height:1;letter-spacing:-.06em;}
.ps-catalog-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
.ps-settings-list{display:grid;margin:18px 0 0;}
.ps-settings-list > div{display:grid;grid-template-columns:minmax(130px,.4fr) minmax(0,1fr);gap:18px;padding:11px 0;border-top:1px solid var(--border-faint);}
.ps-settings-list > div{display:grid;grid-template-columns:minmax(130px,.4fr) minmax(0,1fr);gap:18px;padding:11px 0;border-top:1px solid var(--hair);}
.ps-settings-list dt{color:var(--text-muted);font-size:var(--fs-xs);font-weight:720;}
.ps-settings-list dd{margin:0;color:var(--text-strong);font-size:var(--fs-sm);text-align:right;overflow-wrap:anywhere;}
.ps-distribution{display:grid;gap:14px;margin-top:18px;}
@ -967,7 +807,7 @@
.ps-distribution span{color:var(--text-body);font-size:var(--fs-xs);}
.ps-distribution b{color:var(--text-strong);font-family:var(--font-num);font-size:12px;}
.ps-catalog-list{margin-top:16px;border-top:1px solid var(--border-subtle);}
.ps-catalog-list button{width:100%;display:grid;grid-template-columns:48px minmax(0,1fr) minmax(70px,.25fr) 46px 20px;gap:12px;align-items:center;padding:12px 4px;border:0;border-bottom:1px solid var(--border-faint);background:transparent;color:var(--text-body);text-align:left;cursor:pointer;}
.ps-catalog-list button{width:100%;display:grid;grid-template-columns:48px minmax(0,1fr) minmax(70px,.25fr) 46px 20px;gap:12px;align-items:center;padding:12px 4px;border:0;border-bottom:1px solid var(--hair);background:transparent;color:var(--text-body);text-align:left;cursor:pointer;}
.ps-catalog-list button:hover{background:var(--bg-surface-2);}
.ps-catalog-list button > span:nth-child(2){min-width:0;display:grid;gap:3px;}
.ps-catalog-list button b,.ps-catalog-list button small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@ -987,7 +827,7 @@
.ps-persona-table{overflow:hidden;border:1px solid var(--border-subtle);border-radius:var(--radius);background:color-mix(in srgb,var(--bg-surface) 88%,transparent);}
.ps-persona-table__head,.ps-persona-row{display:grid;grid-template-columns:minmax(300px,1.5fr) minmax(135px,.55fr) minmax(100px,.4fr) minmax(110px,.42fr) 54px minmax(190px,.72fr);gap:12px;align-items:center;}
.ps-persona-table__head{padding:10px 14px;background:var(--bg-surface-2);color:var(--text-muted);font-size:10px;font-weight:780;}
.ps-persona-row{min-height:70px;padding:10px 14px;border-top:1px solid var(--border-faint);}
.ps-persona-row{min-height:70px;padding:10px 14px;border-top:1px solid var(--hair);}
.ps-persona-row:hover{background:color-mix(in srgb,var(--bg-surface-2) 72%,transparent);}
.ps-persona-row__identity{min-width:0;display:grid;grid-template-columns:44px minmax(0,1fr);gap:11px;align-items:center;padding:0;border:0;background:transparent;color:inherit;text-align:left;cursor:pointer;}
.ps-persona-row__identity > span:last-child,.ps-persona-row__usage{min-width:0;display:grid;gap:3px;}
@ -1008,7 +848,7 @@
.ps-review-card__top{display:flex;align-items:center;justify-content:space-between;gap:10px;}
.ps-review-card h3{margin:0;color:var(--text-strong);font-size:17px;}
.ps-review-card > p{margin:0;color:var(--text-muted);font-size:var(--fs-xs);line-height:1.5;}
.ps-review-card dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0;border-top:1px solid var(--border-faint);}
.ps-review-card dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0;border-top:1px solid var(--hair);}
.ps-review-card dl > div{display:grid;gap:4px;padding:10px 8px 0;}
.ps-review-card dt{color:var(--text-muted);font-size:10px;}
.ps-review-card dd{margin:0;color:var(--text-body);font-size:11px;overflow-wrap:anywhere;}
@ -1024,13 +864,13 @@
.ps-chip-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:20px;}
.ps-chip-row span{padding:5px 8px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);color:var(--text-muted);font-size:11px;}
.ps-detail-facts dl{display:grid;margin:0;}
.ps-detail-facts dl > div{display:flex;justify-content:space-between;gap:16px;padding:10px 0;border-top:1px solid var(--border-faint);}
.ps-detail-facts dl > div{display:flex;justify-content:space-between;gap:16px;padding:10px 0;border-top:1px solid var(--hair);}
.ps-detail-facts dl > div:first-child{border-top:0;}
.ps-detail-facts dt{color:var(--text-muted);font-size:var(--fs-xs);}
.ps-detail-facts dd{margin:0;color:var(--text-strong);font-size:var(--fs-xs);font-weight:720;text-align:right;}
.ps-learning-detail{display:grid;gap:14px;}
.ps-point-list{display:grid;margin-top:14px;border-top:1px solid var(--border-subtle);}
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--border-faint);color:var(--text-body);font-size:var(--fs-xs);}
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
.ps-point-list small{color:var(--text-muted);text-align:right;}
.ps-stepper{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden;background:color-mix(in srgb,var(--bg-surface) 78%,transparent);}
@ -1044,7 +884,6 @@
.ps-stepper button.is-current{background:var(--accent-tint);color:var(--accent-deep);}
.ps-stepper button.is-current > span:first-child,.ps-stepper button.is-done > span:first-child{border-color:var(--accent);background:var(--accent);color:var(--text-on-accent);}
.ps-authoring-layout{grid-template-columns:minmax(0,1fr) minmax(260px,320px);}
.ps-authoring-layout > .ps-rail{display:none;}
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs,
.ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel,
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs,
@ -1074,7 +913,7 @@
.ps-kpi > b{font-size:30px;}
.ps-performance__metrics{grid-template-columns:1fr;gap:18px;}
.ps-review-summary__grid{grid-template-columns:1fr;}
.ps-review-summary__grid > div{padding:12px 0;border-right:0;border-bottom:1px solid var(--border-faint);}
.ps-review-summary__grid > div{padding:12px 0;border-right:0;border-bottom:1px solid var(--hair);}
.ps-usage-table__head{display:none;}
.ps-usage-row{grid-template-columns:minmax(0,1fr) repeat(2,58px);gap:8px;}
.ps-usage-row strong:nth-last-child(-n+2){display:none;}
@ -1094,14 +933,14 @@
padding:14px;
}
.ps-persona-row__identity{grid-column:1 / -1;}
.ps-persona-row__usage{grid-column:1 / -1;padding:9px 0;border-top:1px solid var(--border-faint);border-bottom:1px solid var(--border-faint);}
.ps-persona-row__usage{grid-column:1 / -1;padding:9px 0;border-top:1px solid var(--hair);border-bottom:1px solid var(--hair);}
.ps-persona-row > span:nth-child(3),
.ps-persona-row > span:nth-child(4){justify-self:start;}
.ps-version{justify-self:end;}
.ps-persona-row__actions{grid-column:1 / -1;justify-content:stretch;}
.ps-persona-row__actions .vg-btn{flex:1 1 0;}
.ps-review-page__summary{grid-template-columns:1fr;}
.ps-review-page__summary > div{border-right:0;border-bottom:1px solid var(--border-faint);}
.ps-review-page__summary > div{border-right:0;border-bottom:1px solid var(--hair);}
.ps-review-board{grid-template-columns:1fr;}
.ps-drill-head{grid-template-columns:1fr;}
.ps-drill-head__actions{justify-content:flex-start;}

View file

@ -405,9 +405,26 @@
min-width:0;
display:flex;
flex-direction:column;
/* 세로 가상화 스크롤 컨테이너. height:auto + max-height라 행이 적으면
내용 높이만 차지해 데드스페이스가 생기지 않는다. */
max-height:64vh;
overflow-x:auto;
overflow-y:auto;
scrollbar-gutter:stable;
}
/* 가상화 본문 전체 목록 높이를 확보하는 relative 캔버스.
column flex 컨테이너가 인라인 height를 축소하지 않도록 flex:none이 필수다. */
.pf-learner-table__body{
position:relative;
flex:none;
min-width:1000px;
}
.pf-learner-table__body .pf-learner-record{
position:absolute;
top:0;
left:0;
width:100%;
}
.pf-learner-table__head,
.pf-learner-row{
display:grid;
@ -441,7 +458,8 @@
min-width:1000px;
border-top:1px solid var(--paper-2);
}
.pf-learner-table__head + .pf-learner-record{
/* 가상화로 첫 행이 헤더의 형제가 아니게 되어 data-index 기준으로 상단 이중선을 막는다. */
.pf-learner-record[data-index="0"]{
border-top:0;
}
.pf-learner-row{
@ -458,6 +476,15 @@
.pf-learner-row__tags{
min-width:0;
}
/* 암시적 컬럼(auto = minmax(auto,max-content)) 자식의 width:max-content 요청을
그대로 수용해 (261px) 넘겨 288px까지 늘어났고, 결과 max-width:100%
확장된 트랙 기준으로 풀려 ellipsis가 걸리지 않고 컬럼을 침범했다.
트랙을 minmax(0,1fr) 고정해야 안에서 잘린다. (2026-07-27 겹침 회귀 수정) */
.pf-learner-row__id,
.pf-learner-row__metric,
.pf-learner-row__tag-summary{
grid-template-columns:minmax(0,1fr);
}
.pf-learner-row__id{
display:grid;
gap:3px;
@ -465,7 +492,10 @@
.pf-learner-name-button{
width:max-content;
max-width:100%;
padding:0;
/* 상세 진입의 조작이다. 줄높이만큼(19px)이면 WCAG 2.2 SC 2.5.8(24px)
미달해 세로 여백으로 타깃을 채운다. */
min-height:24px;
padding:3px 0;
border:0;
color:var(--text-strong);
background:transparent;

View file

@ -946,10 +946,10 @@
background: var(--accent);
}
.sr-nonverbal--paralinguistic .sr-nonverbal__dot {
background: var(--good-solid);
background: var(--pos-solid);
}
.sr-nonverbal--prosody .sr-nonverbal__dot {
background: var(--ink-soft);
background: var(--ink-2);
}
.sr-nonverbal--audio_quality .sr-nonverbal__dot {
background: var(--text-muted);
@ -1389,7 +1389,7 @@
padding: 0;
border: none;
background: none;
color: var(--accent);
color: var(--text-accent);
cursor: pointer;
font-family: var(--font-num);
font-size: 11px;

View file

@ -2637,7 +2637,13 @@
.sx-prestart {
width: 100%;
display: grid;
grid-template-columns: minmax(220px, 0.82fr) minmax(360px, 1.72fr) minmax(260px, 0.98fr);
/* 트랙 최소치를 0으로 컨테이너가 좁아져도 트랙이 끝까지 축소되게 한다(구조적 넘침 불가).
이전 minmax(220px,) minmax(360px,) minmax(260px,) 최소 840px+gap 48px=888px
요구했는데, 뷰포트 1041~1240px 구간은 사이드바를 실제 컨테이너가 735~886px 뿐이라
3열이 축소되지 못하고 우측 "진행 초점" 패널이 최대 195px 잘려 보였다.
overflow-x:hidden 이라 스크롤도 되고 문서 오버플로도 0 이라 기존 게이트가 잡았다.
아래 2열 전환(1260px) 최소치 0 이중 안전망이다. (2026-07-27) */
grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.72fr) minmax(0, 0.98fr);
align-items: center;
gap: var(--sp-5);
max-width: none;
@ -2794,13 +2800,16 @@
line-height: 1.6;
max-width: 58ch;
}
/* 사실 목록. repeat(3, 1fr) 고정이면 항목이 1개뿐인 상태(페르소나 로딩/폴백)에서도
칸이 1/3 쪼개져 값이 세로로 길게 잘렸다(결함 #2). auto-fit 으로 바꿔 남는 트랙을
접고, 항목 ·폭에 따라 1~3열로 스스로 접히게 한다. */
.sx-prestart__facts {
margin: var(--sp-4) 0 0;
padding: var(--sp-4) 0;
padding: var(--sp-3) 0;
border-top: 1px solid var(--hair);
border-bottom: 1px solid var(--hair);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 96px), 1fr));
gap: var(--sp-4);
}
.sx-page--prestart .sx-prestart__facts {
@ -2814,16 +2823,27 @@
font-size: 11.5px;
font-weight: 700;
}
/* 값은 2줄 클램프로 잘라 쓰던 자리다. 폴백 문구("실제 연습 대상 정보를 확인하고
있습니다.") 320~421 등에서 113px 잘려 나가 클램프를 걷고 전문을 흘린다.
대신 데스크톱 화면 제약은 아래 min-width:1181px 블록에서 사실 블록 자체의
최대 높이 + 내부 스크롤로 지킨다. */
.sx-prestart__facts dd {
margin: 6px 0 0;
color: var(--text-strong);
font-size: 13.5px;
font-weight: 650;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
display: block;
overflow: visible;
overflow-wrap: anywhere;
}
/* 데스크톱은 문서 스크롤 없이 화면에 들어가야 하므로, 페르소나 요약이 아주 때만
사실 블록 안에서 스크롤되게 상한을 둔다(잘라내지 않는다). */
@media (min-width: 1181px) {
.sx-prestart__facts {
max-height: 152px;
overflow-y: auto;
}
}
.sx-prestart__chips {
display: flex;
@ -2847,9 +2867,12 @@
background: var(--clay-tint);
color: var(--clay-deep);
}
/* 아래 여백(이론모드 / 시작 버튼 ) dd 클램프를 걷어내면서 늘어난 세로
12px 되돌려 받는 자리다. 프리스타트는 1280x900 에서 문서 스크롤 없이 들어가야
한다( 파일 상단 .vg-main:has(.sx-prestart--...) 규칙과 같은 제약). */
.sx-theory {
width: min(100%, 520px);
margin: var(--sp-4) 0 0;
margin: var(--sp-3) 0 0;
padding: 0;
border: 0;
display: grid;
@ -2913,12 +2936,16 @@
font-size: 11px;
font-weight: 650;
}
/* 상태 안내문. 예전에는 목표 fieldset 과의 간격을 줄이려고 margin-top -12px
당겨 썼는데, .sx-goals 마진이 0 으로 끝나는 블록이라 -12px 그대로
fieldset 마지막 (목표 힌트) 위로 파고들었다. 320~1536 폭에서 88x12 겹침이
재현돼(프리스타트 결함 #1) 음수 마진을 걷어내고 양수 간격으로 되돌린다. */
.sx-prestart__note {
max-width: 460px;
font-size: 12.5px;
color: var(--text-muted);
line-height: 1.55;
margin-top: calc(var(--sp-3) * -1);
margin-top: var(--sp-2);
}
.sx-prestart__err {
font-size: var(--fs-sm);
@ -2956,7 +2983,7 @@
align-items: center;
gap: var(--sp-4);
flex-wrap: wrap;
margin-top: var(--sp-5);
margin-top: var(--sp-4);
}
.sx-prestart__actions span {
color: var(--text-muted);
@ -3036,7 +3063,10 @@
}
/* ── 반응형 ── */
@media (max-width: 1040px) {
/* 3열이 실제로 들어가는 최소 컨테이너는 888px 이고, 폭은 뷰포트 1241px 부터 확보된다.
기존 전환점 1040px 200px 짜리 사각지대(1041~1240) 남겨 우측 패널이 잘렸다.
안전 마진을 두고 1260px 까지 2열(사이드 패널은 하단 전체 ) 내린다. (2026-07-27) */
@media (max-width: 1260px) {
.sx-prestart {
grid-template-columns: minmax(180px, 230px) minmax(0, 1fr);
}
@ -3448,6 +3478,14 @@
.sx-page--active .sx-seg-block__label {
display: none;
}
/* 절대배치 넛지가 좁은 폭에서 음성 건너뛰기 위쪽 컨트롤을 덮어 클릭을
가로채던 문제(결함 로그 #18) 모바일에서는 플로우 배치로 전환한다. */
.sx-page--active .sx-coach-nudge {
position: static;
transform: none;
align-self: center;
margin: 0 auto 4px;
}
.sx-page--active .sx-segmented button {
font-size: 12px;
flex: 1 1 0;
@ -3590,23 +3628,22 @@
margin-top: 6px;
font-size: 18px;
}
/* 2줄 클램프를 쓰던 자리. 본문 열이 좁아지는 320~421 에서 문장이 73px 잘려
나갔다(결함 #3). 1180 이하는 어차피 세로 스크롤이 있는 구간이라 전문을 흘린다. */
.sx-page--prestart .sx-prestart__desc {
font-size: 12.5px;
line-height: 1.45;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
display: block;
overflow: visible;
}
.sx-page--prestart .sx-prestart__facts {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 132px), 1fr));
gap: 6px;
margin-top: 8px;
padding: 8px 0;
}
.sx-page--prestart .sx-prestart__facts dd {
font-size: 11.5px;
-webkit-line-clamp: 2;
}
.sx-page--prestart .sx-prestart__chips {
display: none;
@ -3766,6 +3803,19 @@
}
}
/* 프리스타트 아주 좁은 : 1180 이하 규칙이 아바타 열을 140px 고정해 두는데,
460px 아래에서는 본문 열에 128px 밖에 남지 않아 설명문·목표 버튼·사실값이 전부
짜부러졌다(결함 #2·#3 실제 원인). 구간만 세로 1열로 쌓는다. */
@media (max-width: 460px) {
.sx-page--prestart .sx-prestart {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.sx-page--prestart .sx-prestart__visual {
padding: 6px 8px;
}
}
@media (max-width: 1180px) and (max-height: 820px) {
.sx-sessionbar {
min-height: 38px;
@ -4090,6 +4140,12 @@
}
/* ── 시간 알람 바(10분 전 경고 · 시간 만료) — 강제 노출 배너 ── */
/* 시간 알람 바가 있으면 페이지 그리드가 4자식이 된다. 3행 템플릿 그대로면
타임바가 1fr 행을 차지하고 그리드가 min-content로 밀려 모바일에서
컨텍스트 패널이 타임바를 덮는 겹침이 생긴다(결함 로그 #18). */
.sx-page--active:has(> .sx-timebar) {
grid-template-rows: min-content min-content minmax(0, 1fr) min-content;
}
.sx-timebar {
display: flex;
align-items: center;

View file

@ -591,6 +591,55 @@
gap: 8px;
margin-top: 8px;
}
/* 업로드된 아바타 이미지 — 원형 프레임을 그대로 채운다(이니셜 폴백 대체). */
.vg-set__avatar img,
.vg-set__rail-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}
/* 아바타 업로드 컨트롤 — 숨은 file input + 버튼(온보딩과 같은 계약). */
.vg-set__avatar-actions {
position: relative;
margin-left: auto;
flex: none;
}
.vg-set__avatar-input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
/* ── 동의 섹션 — 철회 확인·재동의 안내 블록 ── */
.vg-set__consent-block {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-4);
margin-top: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
font-size: var(--fs-sm);
color: var(--text-body);
line-height: 1.5;
}
.vg-set__consent-block > span {
min-width: 0;
flex: 1 1 240px;
overflow-wrap: anywhere;
}
.vg-set__consent-actions {
display: flex;
gap: var(--sp-2);
flex: none;
}
/* ── 모델/엔진 메타 한 줄(읽기전용 상태 표기) ── */
.vg-set__meta {
@ -1032,9 +1081,48 @@
.vg-set__range-control {
min-width: 0;
}
/* WCAG 2.2 SC 2.5.8(타깃 최소 24px): 브라우저 기본 range 높이 16px 미달이다.
thumb 24px 잡되 투명 테두리 + background-clip:content-box 실제로 칠하는
원은 16px 남긴다. 시각 크기는 그대로, 포인터 적중 영역만 24px 넓어진다. */
.vg-set__range-control input[type="range"] {
accent-color: var(--accent);
display: block;
height: 24px;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.vg-set__range-control input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: var(--border-strong);
}
.vg-set__range-control input[type="range"]::-moz-range-track {
height: 4px;
border-radius: 999px;
background: var(--border-strong);
}
.vg-set__range-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
width: 24px;
height: 24px;
margin-top: -10px;
border: 4px solid transparent;
border-radius: 50%;
background: var(--accent);
background-clip: content-box;
}
.vg-set__range-control input[type="range"]::-moz-range-thumb {
box-sizing: border-box;
width: 24px;
height: 24px;
border: 4px solid transparent;
border-radius: 50%;
background: var(--accent);
background-clip: content-box;
}
.vg-set__meta {
margin-top: var(--sp-2);
@ -1281,15 +1369,18 @@ body[data-page="settings"] #set-support {
body[data-page="settings"] #set-engine {
order: 3;
}
body[data-page="settings"] #set-appearance {
body[data-page="settings"] #set-consent {
order: 4;
}
body[data-page="settings"] #set-voice {
body[data-page="settings"] #set-appearance {
order: 5;
}
body[data-page="settings"] #set-notify {
body[data-page="settings"] #set-voice {
order: 6;
}
body[data-page="settings"] #set-notify {
order: 7;
}
body[data-page="settings"] .vg-set__group {
padding: 18px;
border-radius: var(--radius);
@ -1404,48 +1495,92 @@ body[data-page="settings"] .vg-set__pill {
background: var(--glass-surface-inset);
}
@media (max-width: 980px) {
body[data-page="settings"] .vg-set {
grid-template-columns: 86px minmax(0, 1fr);
gap: 18px;
max-width: none;
/* 721~1080px 구간을 상단 가로 탭으로 접는다.
폭에서 좌측 레일 배치가 갈래로 깨져 서브네비 라벨 7개가 모두 0 됐다.
(1) 981~1080px: 공용 `@media (max-width:1080px) .vg-set__rail{display:grid;
grid-template-columns:minmax(220px,280px) minmax(0,1fr)}` 페이지 레일까지
덮어썼다. 레일 트랙은 244px 뿐이라 실측상 `220px 0px` 풀려 nav 0,
nav-item 28px 찌그러졌다(981·1023~1025·1079~1081 재현).
(2) 721~980px: 이전 980 미디어쿼리가 레일을 86px 아이콘 전용으로 만들며
`.vg-set__nav-item span{display:none}` 으로 라벨을 통째로 숨겼다.
경우 모두 라벨이 사라지므로, 프로젝트가 선호하는 가로 전환으로 통일한다.
244px+28px+820px = 1092px 요구하는 2열 배치는 1081px 이상에서만 쓴다. */
@media (max-width: 1080px) {
body[data-page="settings"] .vg-set__callout {
margin: var(--sp-4) 24px 0;
}
body[data-page="settings"] .vg-set {
display: block;
min-height: 100dvh;
max-width: none;
padding: 0 24px 28px;
}
/* 공용 1080 미디어쿼리의 2열 grid 무효화해 레일 전체를 가로 바로 되돌린다.
구간에서는 아래 카드와 같은 ·라운드로 맞춰 탭이 카드 밖으로 삐져나오지 않게 한다. */
body[data-page="settings"] .vg-set__rail {
padding: 18px 10px;
display: block;
position: sticky;
top: 0;
z-index: 20;
min-height: auto;
margin: 0 0 14px;
padding: 14px 16px 10px;
border-radius: var(--radius);
}
body[data-page="settings"] .vg-set__rail-title {
justify-content: center;
padding: 0 0 16px;
gap: 10px;
padding: 0 0 12px;
}
body[data-page="settings"] .vg-set__rail-title h1 {
font-size: 19px;
}
body[data-page="settings"] .vg-set__rail-title p {
display: none;
}
body[data-page="settings"] .vg-set__rail-mark {
width: 26px;
height: 26px;
}
body[data-page="settings"] .vg-set__rail-title div,
body[data-page="settings"] .vg-set__nav-item span,
body[data-page="settings"] .vg-set__rail-status {
display: none;
}
/* 탭이 많아 폭이 모자라면 가로 스크롤로 흘린다(라벨은 계속 노출). */
body[data-page="settings"] .vg-set__nav {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 6px;
overflow-x: auto;
padding: 0 0 2px;
scrollbar-width: thin;
}
body[data-page="settings"] .vg-set__nav-item {
justify-content: center;
min-height: 48px;
padding: 0;
flex: 0 0 auto;
min-height: 38px;
gap: 7px;
padding: 0 12px;
font-size: 13px;
white-space: nowrap;
}
body[data-page="settings"] .vg-set__nav-item span {
display: inline;
}
body[data-page="settings"] .vg-set__forms {
max-width: none;
}
}
/* 720px 1080 가로 배치를 그대로 이어받고 여백·타이포만 좁힌다.
display/position/overflow 같은 배치 선언은 1080 블록에 번만 두고 여기선 중복 제거. */
@media (max-width: 720px) {
body[data-page="settings"] .vg-set__callout {
margin: 12px 12px 0;
}
body[data-page="settings"] .vg-set {
display: block;
min-height: 100dvh;
padding: 0 16px 28px;
}
/* ≤720px 만 full-bleed 상단 바(모바일 헤더 패턴)로 되돌린다. */
body[data-page="settings"] .vg-set__rail {
position: sticky;
top: 0;
z-index: 20;
min-height: auto;
margin: 0 -16px 12px;
padding: 13px 16px 10px;
border-radius: 0;
@ -1456,44 +1591,27 @@ body[data-page="settings"] .vg-set__pill {
gap: 8px;
padding: 0 0 10px;
}
body[data-page="settings"] .vg-set__rail-title div {
display: block;
}
body[data-page="settings"] .vg-set__rail-title h1 {
font-size: 16px;
}
body[data-page="settings"] .vg-set__rail-title p {
display: none;
}
body[data-page="settings"] .vg-set__rail-mark {
width: 24px;
height: 24px;
}
body[data-page="settings"] .vg-set__nav {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
}
body[data-page="settings"] .vg-set__nav-item {
flex: 0 0 auto;
min-height: 34px;
gap: 5px;
padding: 0 7px;
font-size: 11px;
white-space: nowrap;
}
body[data-page="settings"] .vg-set__nav-item svg {
width: 14px;
height: 14px;
}
body[data-page="settings"] .vg-set__nav-item span {
display: inline;
}
body[data-page="settings"] .vg-set__forms {
gap: 10px;
}

View file

@ -26,6 +26,11 @@ body {
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* 한국어 조판: 기본값(normal) 한글을 음절 단위로 끊어 "오전" "오/전",
"검토 대기" "검/토 대기" 쪼개진다. 어절 단위로 끊고, 끊을 없는
토큰(URL·ID) 강제 줄바꿈한다. 세션 화면이 쓰던 규칙을 전역으로 올렸다. */
word-break: keep-all;
overflow-wrap: break-word;
}
/* 링크 기본: accent 텍스트, 밑줄은 hover에서만 (양산형 회피) */

View file

@ -16,11 +16,14 @@
--neutral-100: #f0ede6; /* 표면 hover / subtle fill */
--neutral-150: #e4dccf; /* 금빛이 아주 옅게 섞인 hairline */
--neutral-200: #d7cdbd; /* divider 진함 */
--neutral-400: #9b9a92; /* placeholder / disabled */
--neutral-400: #6f6e68; /* placeholder / disabled (기존 #9b9a92 — 2.4:1 AA 미달) */
--ink: #17231f; /* 시안의 거의 검정인 웜 포레스트 잉크 */
--ink-2: #5f635d; /* 보조 텍스트 */
--ink-3: #8f928a; /* 캡션/메타 */
/* 2026-07-27 접근성 보정: 라이트 3단 위계를 유지하면서 전부 WCAG AA(4.5:1)
넘긴다. 최악 조건은 가장 어두운 표면 --glass-canvas(#f2eee5) 기준으로 잰다.
강도 13.9:1 · 본문 6.9:1 · 캡션 4.6:1 (색상은 원래 그레이 색조 유지). */
--ink-2: #4d514b; /* 보조 텍스트 (기존 #5f635d — 위계 간격 확보용 보정) */
--ink-3: #6a6c65; /* 캡션/메타 (기존 #8f928a — 2.7:1 AA 미달이라 보정) */
--hair: #e4dccf; /* 1px 헤어라인 */
--botanical-gold: #b49a67;
--botanical-hair: rgba(180, 154, 103, 0.28);
@ -80,6 +83,10 @@
--text-body: var(--ink-2); /* 본문 보조 */
--text-muted: var(--ink-3); /* 캡션 */
--text-on-accent: #fbfaf8; /* accent 버튼 위 텍스트 */
/* accent를 "글자색"으로 전용 별칭.
--accent(#3e7a6e) 채움색 기준으로 고른 값이라 어두운 표면(--glass-canvas)
본문 크기에서 4.3:1로 AA에 미달한다. 텍스트는 항상 별칭을 쓴다. */
--text-accent: var(--accent-deep);
--border-subtle: var(--neutral-150);
--border-strong: var(--neutral-200);
@ -155,11 +162,13 @@
--neutral-100: #1b2725;
--neutral-150: #2b332f;
--neutral-200: #3b4039;
--neutral-400: #747970;
/* 2026-07-27 접근성 보정: 다크 표면 가장 밝은 카드(#223330) 기준으로도
AA(4.5:1) 넘도록 올렸다. 기존 값은 placeholder 2.97:1, 캡션 4.08:1 이었다. */
--neutral-400: #949b8f; /* placeholder / disabled (기존 #747970) */
--ink: #eee7d9;
--ink-2: #c4bfb3;
--ink-3: #8d9086;
--ink-3: #979a8f; /* 캡션/메타 (기존 #8d9086) */
--hair: #30372f;
--botanical-gold: #b9a275;
--botanical-hair: rgba(185, 162, 117, 0.26);
@ -183,9 +192,11 @@
--bg-surface-2: #111c1c;
--bg-tint: #192522;
--bg-stage: #091315;
--text-strong: #eee7d9;
--text-body: #c4bfb3;
--text-muted: #8d9086;
/* 라이트와 동일하게 primitive 별칭으로 잇는다. 이전엔 여기서 값을 다시 하드코딩해
위쪽 --ink-* 고쳐도 다크에는 반영되지 않는 이중 진실원본이었다. */
--text-strong: var(--ink);
--text-body: var(--ink-2);
--text-muted: var(--ink-3);
--text-on-accent: #0f1a17;
--border-subtle: #30372f;
--border-strong: #45483e;

View file

@ -55,6 +55,20 @@
검증 수치는 SSOT 대시보드만 소유한다. 남은 후속은 저장형 fallback/synthetic 계약, 지연을 줄이는 partial stream buffer,
fast/deep reconciliation, resident session namespace, 임상팀 루브릭/CBT/약물·평가 문구 확정.
## D-2. 전수 E2E 순회 소유자 결정 — **전건 결정 완료(2026-07-27)** → 구현 후속 [구현]
> 출처: `ops/e2e-full-sweep-2026-07-27.md` 발견 결함 로그 · `ops/backlog-2026-06-26.md` B3-신규.
> 결함 12건은 RED→GREEN으로 수정 완료(대시보드 143차). 소유자 결정 7건은 질문 도구로 전건 확정.
- [x] **설정 아바타 변경 UI 구현** — RED→GREEN 완료(full-sweep-settings 4/4, 무회귀 44/44)
- [x] **동의 철회 전체 흐름 구현** — 설정 동의 섹션+철회 2단계 확인+재동의, 실 API round-trip GREEN
- [x] **신규 학습자 기초 우선 추천 구현**`starterPersona()` 난도 오름차순, 무회귀 42/42
- [x] **학생 분석 테이블 가상화 구현**@tanstack/react-virtual, 300행 RED→GREEN, 무회귀 40/40+게이트 4/4
- [x] **저작 모드 좌측 레일 마크업 정리** — 레일 JSX·CSS 삭제, dead-code 게이트 통과
- [x] dev-login 재로그인 자동 재승인 — 결정: 현행 유지(코드 변경 없음, 종결)
- [x] **감정 밸런스 타임라인 차트 구현** — deep `turn_valence`+결정론 파생 폴백, 백엔드 421/421, 라이브 5pt 실측
> 전체 검증 수치·상세는 SSOT 대시보드 144차 노트와 `ops/e2e-full-sweep-2026-07-27.md`가 소유한다.
## E. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시)
> 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates.
> **2026-07-02 실측**: 로드맵 상당수는 이미 해결(turn_runtime·tier 제거·eval_hook 주입 등). 아래는 코드 재확인 기준 현행 상태.

View file

@ -565,6 +565,10 @@
<p class="dg-note">128차 적용(2026-07-03): 127차 후속 후보였던 live coach LLM audit 실패 은폐를 닫았다. <code>record_llm_call_audit()</code>가 DB pool 또는 <code>audit.llm_call_log</code> insert 실패로 <code>False</code>를 반환해도 기존에는 <code>generate_live_coaching()</code>이 이를 무시해 정상 <code>status=ready</code> AI 코칭처럼 보일 수 있었다. 이제 live coach는 audit hook의 <code>False</code> 또는 예외를 감사 저장 미확인으로 보고, LLM structured 응답을 버린 뒤 규칙 기반 <code>status=degraded</code> 코칭으로 내려간다. 상담 루프는 막지 않되 사용자에게 정상 AI 코칭처럼 보이지 않게 하는 조치다. 검증: <code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m py_compile apps/api/app/services/live_coach.py apps/api/app/test_session_turn_persistence.py</code>, <code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_session_turn_persistence.py -k "live_coach" -q</code> 7 passed. 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">129차 적용(2026-07-13): 재부팅 뒤 Chrome이 기존 <code>/admin</code> 탭의 문서 스크롤을 복원하면서 sticky topbar/sidebar만 남고 관리자 본문은 화면 위로 밀리던 silent blank를 재현·수정했다. RED 재현은 길이 2200px 문서에서 <code>scrollY=900</code>을 만든 뒤 persisted <code>pageshow</code>를 보내 수정 전 <code>scrollY=1300</code>과 빈 main을 확인했다. <code>App.tsx</code><code>history.scrollRestoration=manual</code>, <code>pageshow</code> reset, pathname layout-effect와 다음 frame reset을 함께 적용한다. 로컬 focused E2E는 desktop/mobile route 이동·복원 탭 4 passed, typecheck/build passed. Cloudflare Pages production <code>0daabb1b</code> 배포 뒤 custom domain은 <code>assets/index-BhyOF3rL.js</code>를 서빙하며, 15분 일회성 관리자 세션으로 운영 홈·사용자·권한·티켓과 복원 탭을 공개 사이트에서 확인한 <code>public-admin-visual</code> 3 passed 후 세션을 즉시 삭제했다. 이후 concurrent avatar 변경이 섞인 배포는 Cloudflare Pages rollback API로 <code>0daabb1b</code>에 되돌렸다.</p>
<p class="dg-note">130차 적용(2026-07-13): 운영 <code>/voice/health</code>는 OpenAI 설정을 정상으로 보고했고 실제 운영 키 직접 smoke도 98,133-byte/11.68초 MP3를 반환했지만, 운영 로그에는 사용자의 voice WebSocket 연결이 없어 텍스트 턴 AI 응답이 TTS 경로에 연결되지 않은 공백으로 좁혔다. 인증된 <code>POST /voice/speech</code><code>session_id</code>/<code>turn_seq</code>로 소유 회기와 이미 저장된 client-visible 내담자 응답을 다시 검증한 뒤 기존 voice map/OpenAI TTS로 MP3를 반환한다. Session은 텍스트 전송 user gesture에서 AudioContext를 먼저 unlock하고, 합성 Blob을 기존 Web Audio/Analyser 재생기로 보내며 새 턴이 시작되면 이전 요청을 무효화한다. 검증: backend voice 32 passed, API types check, web typecheck, <code>voice-success</code> text TTS+별도 mic 회기 UI focused 1 passed.</p>
<p class="dg-note">144차 적용(2026-07-27): 143차 전수 순회에서 소유자 결정 대기로 남긴 7건을 <b>질문 도구로 전건 결정받아 6건 구현 완료</b>(RED→GREEN, 5개 병렬 구현 에이전트). ① <b>설정 아바타 변경</b> — 계정 섹션에 온보딩과 같은 계약(3MB·PNG/JPEG/WebP)의 업로드 컨트롤+미리보기, PATCH /users/me avatar_url 저장 후 refresh로 톱바·레일까지 갱신. ② <b>동의 관리·철회 전체 흐름</b> — 새 <code>#set-consent</code> 섹션(실습 동의 시각·약관/개인정보 버전 표시), learner 철회 버튼(2단계 확인)→차단 안내→"다시 동의" 재동의, 세션 프리스타트의 기존 동의 게이트와 연결(철회 함정 상태 해소, dead code였던 <code>withdrawConsent</code> 실사용화), e2e는 고유 계정으로만 철회 수행. ③ <b>신규 학습자 기초 우선 추천</b> — 회기 0건 학습자의 기본 선택·첫 회기 준비 카드를 난도 오름차순 첫 페르소나로(<code>starterPersona()</code>), 기록 보유·조회 실패 시 기존 규칙 유지. ④ <b>학생 분석 테이블 가상화</b><code>@tanstack/react-virtual</code>(신규 의존성) 세로 가상화, 동적 행 높이 measureElement·펼침 재측정, 300행 fixture에서 렌더 행 &lt;60 검증, 데드스페이스 없게 max-height는 내용 이하로. ⑤ <b>저작 모드 좌측 레일 마크업 정리</b> — 어떤 경로에서도 보이지 않던 죽은 DOM(ps-rail)과 전용 CSS 삭제, 특성화 테스트 toHaveCount(0) 강화. ⑥ <b>감정 밸런스 타임라인 차트 구현</b>(143차 미구현 항목) — 혼합 접근: deep 평가 스키마에 <code>turn_valence</code>(seq·v 1~+1, 극단값 회피 지시) 신설 + 저장 데이터 결정론 파생 폴백(counselorBaseline=학습자 턴 rapport_signal 누적 이동평균, clientValence=deep 값 우선/client_state 극성+appropriateness 결합 폴백), 2점 미만 빈 배열·10점 초과 균등 리샘플·축 라벨 4개, hidden_turns 시 비산출로 프라이버시 계약 유지. dev-login 재승인은 현행 유지로 종결. <b>검증</b>: 각 과제 RED 관측→GREEN(에이전트별 증거 로그), 백엔드 pytest <code>421 passed</code>(신규 <code>test_review_valence.py</code> 9 포함), gateway 29, typecheck/build/design-ssot/dead-code/duplication(0.03%) 통과, focused 무회귀 44/42/40/20/14, professor·persona 레이아웃 게이트 통과, 감정 차트는 실DB 공감 5턴 세션에서 baseline·client 각 5포인트/축 4라벨 라이브 실측. 전 스위트 재완주 수치는 체크리스트 문서 §0에 기록. 부수 관찰: <code>smoke-resistance-openness-db.py</code>의 advice_jump turn_client_state ≥5 단언이 엔진 변동성에 민감(결함 로그 #23, 코드 회귀 아님). 체크리스트 415항목 미체크 0 도달. 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">144차 적용(2026-07-27): <b>브레이크포인트 경계 전수 스윕</b>. 142차 접근성 감사가 1440·390 두 폭만 봤고 기존 <code>layout-visual-gate</code>도 7개 폭(390/720/861/900/1024/1280/1440)만 검사한다는 소유자 지적에서 출발했다. CSS 전체에서 고유 width 브레이크포인트 <b>25개</b>(340~1420)를 추출해 파일→페이지로 매핑하고, 페이지마다 자기에게 영향을 주는 브레이크포인트의 <b>B-1 / B / B+1</b> 과 실기기 대표 폭을 조합해(페이지당 17~36폭 × 높이 2종) 순회하며 <b>요소 겹침 · 텍스트 잘림 · 폭 붕괴 · 화면 밖 이탈 · 문서 오버플로</b>를 계산으로 검출했다. 1차 스윕에서 <b>156개 문제 뷰포트 / 11종 결함</b>이 나왔다. <b>수정한 결함</b>: ① <b>설정 서브네비 라벨 전면 소멸</b>(721~1080px 전 구간) — 공용 <code>@media (max-width:1080px)</code><code>.vg-set__rail{display:grid; grid-template-columns:minmax(220px,280px) minmax(0,1fr)}</code><code>body[data-page="settings"]</code>까지 덮어써 244px 레일 안이 다시 2열로 쪼개졌고 둘째 트랙이 0px로 붕괴, nav 폭 0 · nav-item 28px · 라벨 span 7개(계정/AI 운영/지원 요청/동의/테마/알림/음성) 폭 0이 됐다. 721~980px 는 별개로 <code>.vg-set__nav-item span{display:none}</code>이 라벨을 통째로 숨기고 있었다. 721~1080px 를 상단 가로 탭으로 통일해 라벨을 항상 노출한다. ② <b>세션 프리스타트 목표 fieldset 겹침</b>(전 폭 72뷰포트) — <code>.sx-prestart__note</code><code>margin-top:-12px</code> 음수 마진이 앞 형제 <code>fieldset.sx-goals</code> 마지막 줄을 88x12px 파고들었다. ③ <b>프리스타트 dd·desc 세로 잘림</b><code>-webkit-line-clamp:2</code>가 좁은 폭에서 문장을 잘라먹던 것을 해제하고, 데스크톱 한 화면 제약은 <code>max-height + overflow-y:auto</code>로 대체. ④ <b>프리스타트 우측 "진행 초점" 패널 195px 잘림</b>(1041~1240px, 200개 폭) — 3열 <code>minmax(220px,…) minmax(360px,…) minmax(260px,…)</code>는 최소 888px를 요구하는데 이 구간의 실제 컨테이너는 735~886px뿐이라 트랙이 축소되지 못하고 우측 패널이 통째로 잘렸다. <code>overflow-x:hidden</code>이라 스크롤도 안 되고 <b>문서 오버플로는 0</b>이라 기존 "contained" 게이트가 구조적으로 못 잡던 유형이다. 트랙 최소치를 0으로 낮추고 2열 전환점을 1040→1260px로 올려 이중 안전망을 뒀다. ⑤ <b>티켓 필터 "초기화" 버튼 화면 밖 이탈</b>(1180~1257px) — 7트랙 grid가 최소 954px를 요구하는데 셸 사이드바를 뺀 컨테이너가 876~950px라 마지막 트랙이 밀려났다. 4트랙 2줄로 낮추고 유연 트랙 최소치를 0으로. ⑥ <b>페르소나 이름 세로 잘림</b>(320px) — 이름 칸이 150.6px뿐이라 3줄이 필요한데 <code>line-clamp:2</code>에 걸려 3번째 줄이 사라졌다. ≤420px 밴드에서 3줄 허용. <b>오탐으로 판정해 고치지 않은 것</b>: <code>.aic-ledger b</code>·<code>.lh-metric-card b</code>의 2px 세로 초과는 <code>line-height</code>가 Pretendard content-area(ascent+descent≈1.09em)보다 작아 생기는 폰트메트릭 아티팩트다. overflow hidden/visible 6x DPR 캡처를 20개 폭에서 바이트 비교해 <code>pixelDiff=0</code>(잘린 픽셀 0)을 확인했고, 1180/1280px에서는 같은 렌더링인데 반올림 방향만 바뀌어 탐지에서 빠지는 것도 증거다. <code>.vg-set__voice-info .vd</code>(의도된 <code>line-clamp:2</code>)와 <code>.vg-set__skel-sr</code>(visually-hidden), <code>.vg-avatar__stage</code>(<code>border-radius:50%</code> 원형 마스크)도 오탐이다. <b>2차 스윕</b>으로 1차가 못 본 사각지대(높이 브레이크포인트 620/820 경계, 탭 전환 등 인터랙션 후 상태, 와이드 폭 1600~2560, 초협소 320)를 14개 라우트에서 추가 순회했고 <b>새 결함 0</b>. <b>결과</b>: 진짜 결함 0(잔여 66뷰포트는 위 오탐 3종). <b>검증</b>: typecheck · check:design-ssot 통과, session.css raw color 216/216 유지, <code>layout-visual-gate</code> 15/15, session-layout+session-mvp+learner+settings <b>56/56</b>, 앞선 병렬 수리 검증에서 session-layout+settings+admin <b>55/55</b>. <b>미결(소유자 결정)</b>: 프리스타트에서 호소·대상 정보가 <code>.sx-prestart__case</code>(좌측 아바타 카드)와 <code>.sx-prestart__facts</code>(본문)에 <b>중복 렌더</b>된다(3열·2열 양쪽 모두, 이번 변경과 무관한 기존 IA 문제). 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">143차 적용(2026-07-27): 전 페이지·버튼 단위 <b>전수 E2E 순회 캠페인</b>. IA 전 라우트(로그인/온보딩/승인대기/학습자 3뷰·세션·리뷰/교수자 콘솔·분석·페르소나/관리자 5섹션/설정/아바타 랩)의 기능 412개를 소스 기준 인벤토리로 만들어 <code>docs/ops/e2e-full-sweep-2026-07-27.md</code> 체크리스트 백로그(- [ ]/- [x])로 관리하고, RED→GREEN으로 결함을 수정했다. 신규 스펙 10파일 <code>e2e/full-sweep-*.spec.ts</code>(수집 424의 절반가량)를 추가했고, 소유자 결정 대기 항목은 test.fixme + 백로그 B3-신규 6건으로 분리했다. <b>수정한 결함(RED 재현→GREEN)</b>: ① <code>/admin/ai</code> 부트 watchdog 오탐 오버레이(index.html 셀렉터에 <code>.aic</code> 추가), ② 한국어 PII 마스킹이 "유의점은·방치되는·마무리했을" 같은 일반 단어를 <code>[NAME]</code>으로 치환해 평가 문장을 훼손하던 과잉 마스킹(guardrail 꼬리 글자 가드), ③ 온보딩 연락처 무검증(서버 pydantic 검증+클라이언트 안내), ④ 리뷰 요약 조사 오류("하늘와의"→받침 인식 와/과)와 난도 원문 노출(easy→기초), ⑤ /pending 새로고침 무피드백+아이콘 오용(refresh 아이콘 신설), ⑥ 학습 대시보드 탭 패널 aria-label 고정, ⑦ 회기 종료 다이얼로그 반말 문체, ⑧ 설정 스크롤 스파이 마지막 섹션 미활성(0 임계값+밴드 최하단 규칙+바닥 폴백), ⑨ 모바일 세션 겹침 2건(타임바 <code>:has</code> 4행 템플릿, 코치 넛지 플로우 배치), ⑩ <b>관리자 콘솔 무한 렌더 루프 프리즈</b>(TanStack <code>autoResetPageIndex</code>↔setState — users/services 메모화+<code>autoResetPageIndex:false</code>, 결정적 재현 스펙 2건을 회귀 가드로 유지), ⑪ <b>복수 코호트 조용한 유실</b>(POST/PATCH <code>/admin/users</code>가 cohort_ids 첫 항목만 저장 → 콤마 직렬화+알림 SQL 배열 겹침 비교), ⑫ <b>공개 페르소나 보관 전면 불능</b>(DELETE <code>/personas/{id}</code>가 실DB에서 항상 503 — <code>archive_persona_family</code> UPDATE…FROM RETURNING의 무한정 <code>code</code> 모호성 → card. 한정, 실API RED 503→GREEN archived 실측). 마무리 탐색 순회(실브라우저 6에이전트, 68항목)는 67 GREEN·감정 밸런스 차트 데이터 소스 미구현(서버가 항상 빈 배열 — 사문 렌더 경로, 로드맵 기록) 1건을 추가로 확인. <b>검증</b>: 백엔드 pytest <code>412 passed</code>(신규 회귀 테스트 8개 포함), gateway 29 passed, typecheck/build/check:api-types/design-ssot/dead-code/duplication(0.03%) 통과, layout-visual-gate 15/15, session-layout 8/8, 최종 <code>npm run e2e</code> 재완주 — 병렬 424 수집 그린(fixme 4·모바일 의도 숨김 2 스킵), 직렬 <code>49/49 passed</code>(14.4m), exit 0. 열린 항목: B3-신규 6건(설정 아바타 변경/동의 철회·재동의 흐름/첫 추천 난도/분석 페이지네이션/저작 레일 잔존 마크업/dev-login 재승인 정책), 공개 OAuth 스모크는 기존 B2 유지. 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">142차 적용(2026-07-27): <code>design-taste-frontend</code> 스킬의 보편 규칙(§4.5 대비 검사, §6.B/6.C 모션·다크모드, §9.F/9.G AI tell)을 기준으로 <b>전 20개 라우트를 3역할·라이트/다크·데스크톱/모바일 54개 조합</b>으로 순회 감사하고 결함을 수정했다. 감사는 눈대중이 아니라 페이지마다 WCAG 대비비를 계산하는 Playwright 스크립트로 측정했다. <b>(A) 라이트 팔레트 AA 미달</b><code>--ink-3</code>(캡션·메타)가 모든 라이트 표면에서 2.7~3.1:1, <code>--neutral-400</code>(placeholder)이 2.4:1이었다. 색조를 유지한 채 <code>#6a6c65</code>/<code>#6f6e68</code>로 낮추고 위계 유지를 위해 <code>--ink-2</code><code>#4d514b</code>로 조정했다(강도 13.9 · 본문 6.9 · 캡션 4.6:1). 다크는 <code>--ink-3</code> <code>#979a8f</code>, <code>--neutral-400</code> <code>#949b8f</code>. <b>(B) 다크 토큰 이중 진실원본</b> — 다크 블록이 <code>--text-muted</code> 등을 <code>var(--ink-3)</code> 별칭 대신 값으로 하드코딩해 primitive 수정이 다크에 반영되지 않았다. 라이트와 동일하게 별칭으로 통일했다. <b>(C) 채움색을 글자색으로 전용</b><code>--accent</code>는 채움 기준값이라 어두운 표면 위 본문 크기에서 4.3:1이다. <code>--text-accent</code>(= <code>--accent-deep</code>) 별칭을 신설해 kicker·secondary 버튼·역할 라벨 등 텍스트 용도를 옮겼다. <b>(D) 로그인 안내 배너 1.19:1</b><code>.lg-config</code>가 다크 패널 전제(반투명 갈색 + 밝은 앰버)라 라이트에서 사실상 읽을 수 없었다. 테마별로 스왑되는 warn 의미 토큰으로 교체(5.3:1). <b>(E) 미정의 CSS 변수 12종</b><code>--text-dim</code>·<code>--good-solid</code>·<code>--border-faint</code>·<code>--fs-xl</code> 등이 정의 없이 참조돼 색/배경이 조용히 무효화되고 있었고, <code>learner-home</code><code>--bg-inset</code>/<code>--border-soft</code> 폴백은 다크 전제 값이 라이트 페이지에 적용되고 있었다. 전부 기존 의미 토큰으로 매핑했다. <b>(F) 학습자 분석 테이블 컬럼 침범</b><code>.pf-learner-row__id</code>의 암시적 그리드 트랙이 자식의 <code>width:max-content</code>를 수용해 261px 셀이 288px로 늘어나 <code>max-width:100%</code>가 무력화됐고, 긴 이름 28행이 옆 컬럼을 27px 침범했다. <code>minmax(0,1fr)</code>로 고정해 0건. <b>(G) 한글 조판</b><code>word-break</code> 기본값 때문에 "오전"이 "오/전", "검토 대기"가 "검/토 대기"로 쪼개졌다(5건). 세션 화면이 쓰던 <code>keep-all</code> + <code>overflow-wrap:break-word</code>를 전역으로 올려 0건. <b>(H) 탭 타깃</b> — WCAG 2.2 SC 2.5.8(24px) 기준으로 약관 동의 라벨 19px→28px, 학습자 이름 버튼 19px→25px, 설정 range 슬라이더 16px→24px(투명 테두리 + <code>background-clip:content-box</code>로 시각 크기는 유지). <b>(I) LCP</b> — 렌더 블로킹 CDN 폰트에 <code>preconnect</code>/<code>dns-prefetch</code> 추가(Pretendard self-host는 별도 과제). <b>결과</b>: 54개 조합 전부 대비 위반 0 · 탭 타깃 위반 0 · 라벨/alt 누락 0 · 가로 오버플로 0(감사 전: 대비 2,043건, 탭 타깃 355건). <b>검증</b>: <code>npm run typecheck</code>, <code>check:design-ssot</code> 통과, <code>layout-visual-gate</code> 15/15, <code>session-layout</code> 8/8, auth-visual·teacher·admin·session-review·settings·learner 포함 E2E 87 passed + 14 passed. <b>미결(소유자 결정 필요)</b>: kicker의 accent dot이 기본 <code>true</code>라 회기 화면 한 곳에 11개 이상 찍히는 점(§9.F는 장식 dot 0을 요구, 다만 DESIGN_CONCEPT가 명시한 강조 장치라 임의 변경하지 않음), 영문 대문자 eyebrow(<code>SPEND CONTROL</code>·<code>RUNTIME ROUTE</code>·<code>DAILY JOURNAL</code>·<code>SESSION</code>·<code>SUPERVISOR</code>)의 한글화, 섹션당 eyebrow 밀도(§4.7은 섹션 3개당 1개), 중점(·) 라인당 다중 사용(<code>/teach</code> 33행), <code>prefers-color-scheme</code> 무시하고 다크 고정인 기본 테마(§8.C), 관리자 AI 비용 표시 정밀도(<code>$37.6560</code>·<code>$0.102885</code>). 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">141차 적용(2026-07-15): 공통 <code>Surface inset</code>는 중첩 카드처럼 보이던 border와 radius를 제거하고 반투명 광원·blur만 유지한다. 관리자 사용자 목록은 사용자별 <code>article</code> grid와 겹치던 <code>ad-user__meta</code>를 제거하고 <code>@tanstack/react-table</code> 기반 semantic table로 전환했다. 사용자/역할/관리 권한/승인 상태/소속/코호트/활성 회기/최근 접속/작업 열을 고정하고, 헤더 클릭 정렬과 sticky header, 1440px 최소 폭을 제공한다. 좁은 화면에서는 문서가 넓어지지 않고 표 region만 가로 스크롤된다. <b>검증</b>: typecheck, 관리자 사용자 생성·승인·편집·비활성화 및 모바일 스크롤 focused E2E 4/4, layout visual gate 15/15. 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">136차 적용(2026-07-15): 135차의 학습 대시보드 한정 보태니컬 스타일을 전 페이지 공통 디자인 시스템으로 승격했다. <code>AppShell</code>은 역할·라우트와 무관하게 단일 <code>vg-shell--botanical</code> 크롬을 사용하며, 과거 관리자·교수자·대시보드 전용 톱바/GNB override를 삭제했다. GNB는 라이트/다크 고해상도 투명 원본(846×1858, 922×1706)을 사용하고 이미지 시작점을 메뉴 아래로 내려 텍스트 침범을 막는다. 데스크톱 GNB는 shell body 안에서 <code>top:0</code>, 모바일 drawer에서만 <code>top:var(--topbar-h)</code>를 사용해 상단 빈 공간을 없앴다. 공통 캔버스는 우상단 보태니컬 배경과 같은 이미지를 180° 회전한 좌하단 배경을 함께 사용하며, main은 투명하게 둔다. 표면은 셸 SSOT에서 큰 패널용 glass와 내부 행·필드·요약용 inset glass 두 단계로 통합해 학습자·교수자·관리자·설정·페르소나·회기 리뷰·진행 세션의 모바일 재배치까지 같은 반투명 그라데이션, 저채도 황금 hairline, blur/saturation을 사용한다. 로그인·온보딩·승인대기도 같은 글래스 패널을 쓴다. 라이트 테마는 흰 카드와 흰 캔버스가 겹쳐 유리 효과가 사라지던 토큰을 수정해, 반투명 본체+스페큘러 광원 2층, 안쪽 굴절선, 샴페인 hairline과 세이지/골드 캔버스 광원을 사용한다. 모바일에서도 보태니컬 캔버스를 제거하지 않는다. 학습 대시보드의 <code>오늘의 회기</code>/<code>기록 · 리뷰</code>/<code>성장 지표</code> 탭과 회기 리뷰의 <code>축어록</code>/<code>피드백</code>/<code>워크시트</code> 탭은 모든 폭에서 유지한다. Live2D 주변의 이중 외곽 컨테이너는 제거하고 투명 clipping 영역에서 캐릭터·잎 장식만 우하단으로 잘리게 했다. <code>object-separate --check</code>로 로컬 GPU 분리 런타임을 확인했고, 제공 원본이 정상 RGBA alpha(0255)라 재분리 없이 원본을 사용했다. <b>검증</b>: <code>npm run typecheck</code>, <code>npm run build</code>, layout-visual-gate <code>15/15</code>, learner/session-layout/session-review desktop+mobile <code>46/46</code>, 라이트·다크·관리자·교수자·설정·리뷰·진행 세션 1440px 및 390px 스크린샷 직접 확인. 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">135차 적용(2026-07-15): 제공된 <code>Vignette-Asset-Set</code>의 512px 최적화 PNG 14종을 <code>public/design-elements/learner-dashboard/</code>로 자산화하고 학습 대시보드(<code>/learn</code>)에만 보태니컬 시안을 적용했다. 라이트/다크 각각의 사이드바·우상단 배경과 메트릭 카드 4종·AI 코치 장식을 테마별로 교체하며, 카드 표면은 크림/차콜의 비대칭 광원 그라데이션과 저채도 황금 헤어라인을 사용한다. 이 범위 제한은 136차에서 전 페이지 공통 글래스 시스템으로 대체됐다. <code>layout-visual-gate</code>는 라이트 테마 자산 연결까지 computed style로 확인하고 테마를 다크로 복귀한다. <b>검증</b>: <code>npm run typecheck</code>, <code>npm run build</code>, layout-visual-gate <code>12/12</code>, session-layout <code>8/8</code>, learner desktop/mobile <code>14/14</code>, 라이트·다크 1440px 및 다크 390px 스크린샷 직접 확인. 로컬 작업트리 기준이며 배포 전이다.</p>

View file

@ -56,6 +56,23 @@
> 결정 상세는 대시보드 "윤찬 결정 상세" 탭, ADR은 `docs/decisions/`(`voice-s2s-poc.md`,
> `backend-node-transition.md`)에 있다. 각 결정의 구현·실측은 후속 build/GATE로 남는다.
### B3-신규 (2026-07-27 전수 E2E 순회에서 제기 → **2026-07-27 소유자 전건 결정 완료**)
> 소유자(윤찬)가 질문 도구로 7건 전건 결정(2026-07-27). 결정 내용과 구현 상태:
- [x] **설정 화면 아바타 변경 UI** — 결정: **설정에 추가**. **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **동의 관리·철회 UI와 재동의 흐름** — 결정: **전체 흐름 구현**(설정 동의 섹션 + 철회 +
재동의 경로). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **신규 학습자 첫 추천 난도** — 결정: **기초 우선 추천**(회기 기록 없는 신규 학습자는
기초 난도 페르소나를 첫 추천으로). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **학생 분석 테이블** — 결정: **지금 가상화 도입**. **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **저작 모드 좌측 레일 잔존 마크업** — 결정: **정리**(저작 모드에서 레일 미렌더). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **dev-login 재로그인 자동 재승인** — 결정: **현행 유지**(dev/E2E 편의 의도 확정). 코드 변경 없음.
- [x] **감정 밸런스 타임라인 차트**(결함 로그 #21) — 결정: **지금 구현**(평가 데이터 생산→저장→
리드모델→차트 연결). **구현 완료(RED→GREEN, 대시보드 144차)**.
> 근거·상세: `docs/ops/e2e-full-sweep-2026-07-27.md` 발견 결함 로그 #2·#4·#6·#14·#15·#16·#21.
---
## B4. 외부 거버넌스 — 한신대/데이터 steward 서면 증거

View file

@ -0,0 +1,694 @@
# E2E 전수 순회 체크리스트 — 2026-07-27
> **목적**: IA·버튼 단위로 온보딩→가입→학습자→교수자→관리자→설정 전 페이지의 모든 기능을 순회하며
> (1) 의도와 다른 동작, (2) 오류, (3) 나쁜 UI/UX를 잡아낸다.
> **방식**: RED → GREEN. 각 항목은 검증 수단(기존 spec / 신규 spec / 탐색 순회)을 명시하고,
> 실제 실행해 GREEN이 된 것만 `- [x]`로 바꾼다. 가짜 DONE 금지(실행 명령+관측 결과 필수).
> **SSOT**: 상태 결론은 `docs/dev_dashboard.html`에 동기화한다.
>
> 실행 스택: dev-up.ps1 (DB 55432 + API 8000 + web 5173 + engine 9099, `db:true engine:true` 확인됨).
## 전체 항목 수 요약
11개 에이전트가 수합한 기능 인벤토리 기준. 총 **412개 feature**, 그중 **139건**이 의심 결함(risk)으로 보고됐다.
| # | 영역 | 라우트 | 항목 수 | 의심 결함 |
|---|---|---|---|---|
| 1 | 인증·온보딩 (auth) | `/login`, `/onboarding`, `/pending` | 34 | 13 |
| 2 | 학습자 홈 (learner-home) | `/learn`, `/learn/practice`, `/learn/history` | 37 | 12 |
| 3 | 상담 세션 (session) | `/learn/session/:sessionId` | 53 | 11 |
| 4 | 회기 리뷰 (session-review) | `/learn/session/:id/review`, `/teach/session/:id/review` | 37 | 18 |
| 5 | 교수자 콘솔 (professor) | `/teach`, `/teach/analysis` | 36 | 12 |
| 6 | 페르소나 스튜디오 (persona-studio) | `/teach/personas` | 71 | 21 |
| 7 | 관리자 콘솔 (admin) | `/admin`, `/admin/users`, `/admin/access`, `/admin/tickets` | 57 | 15 |
| 8 | 관리자 AI 설정 (admin-ai) | `/admin/ai` | 27 | 9 |
| 9 | 설정 (settings) | `/settings` | 35 | 18 |
| 10 | 공통 셸·GNB·라우팅 가드 (shell) | 전역 GNB, `/`, `*`, dev 라우트 | 25 | 10 |
| | **합계** | | **412** | **139** |
검증 수단 표기 규칙:
- `기존 e2e/{파일}` — 해당 spec 시나리오가 그 기능을 명확히 검증함. 베이스라인 실행으로 GREEN 확인.
- `기존(부분) e2e/{파일} + 탐색 순회` — spec이 일부(레이아웃·인접 흐름)만 덮음. 나머지는 브라우저 순회로 확인.
- `신규 spec 필요` — 이 기능을 검증하는 spec이 없음. RED 스펙부터 작성.
- `탐색 순회` — 자동화 가치가 낮은 순수 표시·이동 항목. 브라우저 순회로만 확인.
## 0. 베이스라인 — 기존 E2E 215개 전체 실행
- [x] `npm run e2e` parallel 단계 (166 tests) 전체 GREEN — 2026-07-27 실행, **166 passed (2.6m)**
- [x] `npm run e2e` single-run 단계 (49 tests) 전체 GREEN — 2026-07-27 실행, **49 passed (15.1m)**
- [x] 실패 항목 → 아래 해당 영역에 RED로 등재 후 수정 — 베이스라인 실패 0건(등재할 항목 없음)
> 베이스라인 근거: dev-up 스택(`db:true engine:true`)에서 `npm run e2e` 1회 완주, 215/215 passed.
> 아래 각 영역에서 `검증: 기존 e2e/...`(부분 아님) 항목은 이 실행을 GREEN 증거로 삼아 체크한다.
>
> **최종 게이트(2026-07-27, 결함 수정 반영 후)**: 신규 `full-sweep-*.spec.ts` 10파일 포함
> `npm run e2e` 재완주 — 병렬 단계 424 수집(fixme 4·모바일 의도 숨김 스킵 2 제외 전 GREEN),
> 직렬 단계 **49/49 passed (14.4m)**, exit 0. 부가 게이트: 백엔드 pytest **412/412**, gateway 29/29,
> typecheck·build·check:api-types·design-ssot·dead-code·duplication(0.03%) 통과,
> layout-visual-gate **15/15**, session-layout **8/8**.
>
> **소유자 결정 구현 후 재완주(2026-07-27, 대시보드 144차)**: 아바타 변경·동의 철회/재동의·기초 우선
> 추천·분석 테이블 가상화·저작 레일 정리·감정 밸런스 차트 반영 후 `npm run e2e` 3차 완주 —
> 병렬 **432 수집**(구 fixme 2건은 실 계약 테스트로 전환 GREEN, 모바일 의도 숨김 스킵 2 제외 전 GREEN),
> 직렬 **49/49 passed (15.9m)**, exit 0. 백엔드 pytest **421/421**(`test_review_valence.py` 9 신규),
> 감정 차트는 실DB 공감 5턴 세션에서 baseline·client 각 5포인트/축 4라벨 라이브 실측.
## 1. 인증·온보딩 (auth)
- [x] `auth-login-config-load` 로그인 설정 조회 (OAuth 구성/허용 도메인) — `GET /auth/config`로 OAuth 구성·dev 로그인 가능 여부·허용 도메인을 받아 버튼 활성/라벨/배지를 결정한다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `auth-login-school-google-button` 학교 Google 계정으로 계속 버튼 (primary) — 클릭 시 `/auth/login?provider=google&next=`로 이동해 Google OAuth를 시작하고 oauthReady가 아니면 비활성화된다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `auth-login-generic-google-button` Google 계정으로 계속 버튼 (secondary) — primary와 동일한 startOAuth를 호출하며 서브라벨에 두 번째 허용 도메인을 표시한다 · 검증: 신규 spec 필요
- [x] `auth-login-oauth-status-notice` OAuth 비활성 사유 status 안내 — oauthReady가 아닐 때 role=status로 설정 확인 중/미연결/로컬 테스트 계정 사유를 아이콘과 함께 표시한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-login-oauth-error-notice` OAuth 콜백 오류 메시지 (?oauth= 쿼리) — 쿼리 사유별(not_configured·domain_not_allowed·access_denied 등) 한국어 안내문과 원본 코드를 표시한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-login-dev-role-radiogroup` 로컬 테스트 역할 선택 라디오그룹 (학습자/교수자/관리자) — DEV이고 dev_login_enabled일 때만 노출되며 세 역할 버튼으로 dev 로그인 역할을 고른다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `auth-login-dev-enter-button` 로컬 테스트 계정으로 계속 버튼 — 선택 역할의 고정 dev 계정으로 `POST /auth/dev-login` 후 initialPathForUser 규칙대로 이동한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-login-brand-domain-badges` 브랜드 패널 허용 도메인 배지 — allowed_email_domains를 배지로 나열하고 조회 중/미설정 상태를 구분 표시한다 · 검증: 신규 spec 필요
- [x] `auth-login-dev-oauth-unavailable-detection` 로컬 개발에서 OAuth 차단 감지 — redirect_uri가 localhost가 아니면 OAuth 버튼을 비활성화하고 테스트 계정 안내로 대체한다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `auth-onboarding-initial-load` 온보딩 초기 데이터 로드 (프로필+약관) — `/users/me``/users/legal-docs`를 병렬 호출해 폼을 프리필하고 약관 본문을 채운다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `auth-onboarding-avatar-upload` 상징 아바타 이미지 업로드 — 파일 선택 즉시 multipart로 업로드해 avatar_url을 폼에 반영하고 실패 시 3MB 안내를 띄운다 · 검증: 신규 spec 필요
- [x] `auth-onboarding-avatar-preview` 아바타 미리보기 (이미지/이니셜 폴백) — avatar_url이 있으면 이미지를, 없으면 닉네임 첫 글자를 원 안에 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `auth-onboarding-nickname-input` 닉네임 입력 — 필수 닉네임 텍스트 입력으로 폼 상태를 갱신한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-self-introduction-textarea` 자기소개 textarea (600자 제한) — maxLength 600의 필수 textarea로 상담 훈련 관련 자기소개를 받는다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-legal-name-input` 이름(법정 이름) 입력 — 필수 입력이며 프로필의 legal_name 또는 display_name으로 프리필된다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-affiliation-input` 소속 입력 (기본값 한신대학교) — 소속 필수 입력으로 프로필 값이 없으면 기본값이 채워진다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-department-input` 학과/부서 입력 — department 필수 텍스트 입력으로 폼 상태를 갱신한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-grade-level-input` 학년/직위 입력 — grade_level 필수 텍스트 입력으로 폼 상태를 갱신한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-phone-input` 연락처 입력 — autoComplete=tel 필수 입력으로 연락처를 받는다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `auth-onboarding-contact-address-input` 주소/수령지 입력 — autoComplete=street-address 필수 입력으로 주소를 받는다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-terms-checkbox` 서비스 이용약관 동의 체크박스 — terms_accepted를 토글하며 미체크 시 제출이 비활성화된다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-privacy-checkbox` 개인정보 수집·이용 동의 체크박스 — privacy_accepted를 토글하며 미체크 시 제출이 막힌다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-legal-doc-details` 약관/개인정보 문서 펼침 (details/summary) — 두 문서를 details로 펼쳐 제목·버전·본문을 확인하고 미로드 시 폴백을 표시한다 · 검증: 신규 spec 필요
- [x] `auth-onboarding-submit` 가입 설정 완료 제출 버튼 (form submit) — 필수 필드와 두 동의가 모두 충족될 때만 활성화되어 온보딩을 저장하고 역할 홈으로 이동한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-onboarding-error-notice` 온보딩 로드/저장 오류 알림 — 로드·저장 실패 시 role=alert 문단을 폼 하단에 표시한다 · 검증: 신규 spec 필요
- [x] `auth-pending-status-panel` 승인 대기/접속 보류 상태 표시 — suspended면 '접속 보류', 아니면 '승인 대기' 키커와 이메일·안내를 role=status로 보여준다 · 검증: 신규 spec 필요
- [x] `auth-pending-refresh-button` 승인 상태 새로고침 버튼 — `GET /auth/me`로 세션을 재조회해 승인되면 온보딩 또는 역할 홈으로 이동시킨다 · 검증: 신규 spec 필요
- [x] `auth-pending-signout-button` 다른 계정으로 로그인 버튼 (로그아웃) — 세션을 종료하고 /login으로 이동해 다른 계정 로그인을 허용한다 · 검증: 신규 spec 필요
- [x] `auth-provider-bootstrap` AuthProvider 세션 부트스트랩 — 앱 시작 시 `/auth/me`로 쿠키 세션을 복원하고 auth-expired 이벤트 수신 시 user를 비운다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `auth-guard-require-auth` RequireAuth 라우트 가드 — 미인증이면 state.from을 담아 /login으로, 역할 불일치면 자기 역할 홈으로 리다이렉트한다 · 검증: 기존 e2e/learner.spec.ts
- [x] `auth-guard-onboarding-gate` OnboardingGate 리다이렉트 — 온보딩 미완료자를 /onboarding으로 강제 이동시키고 완료자가 /login·/onboarding에 오면 되돌린다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-guard-pending-approval-gate` PendingApprovalGate 리다이렉트 — 미승인 로그인 사용자를 어느 경로에서든 /pending으로 회수한다 · 검증: 신규 spec 필요
- [x] `auth-root-redirect` 루트(/) 및 미정의 경로 리다이렉트 — / 진입 시 승인·온보딩 상태에 따라 분기하고 미정의 경로(*)는 /로 회수한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `auth-boot-screen` 인증 부트스트랩 로딩 화면 (BootScreen) — 인증 확인·lazy 라우트 로딩 동안 중앙 정렬 '불러오는 중…' 중립 화면을 띄운다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
**의심 결함(에이전트 리포트)**:
- `auth-login-config-load`: config 로드 실패 시 별도 오류 배너 없이 버튼이 영구 비활성 상태로만 남고, statusMessage는 '연결되지 않았습니다' 문구라서 네트워크 오류와 미설정을 구분해 주지 않는다.
- `auth-login-school-google-button`: '학교 Google 계정' 버튼과 일반 'Google 계정' 버튼이 완전히 동일한 provider=google 흐름을 호출한다(학교 SSO/SAML 별도 흐름 없음). 또한 startOAuth 내부의 !oauthReady 오류 세팅 분기는 버튼이 이미 disabled라 실제로 도달 불가한 사문(死文) 코드다.
- `auth-login-generic-google-button`: 두 버튼이 기능적으로 중복이라 사용자가 다른 인증 경로로 오해할 수 있다.
- `auth-login-oauth-error-notice`: 오류 문단에 role=alert/aria-live가 없어 스크린리더에 즉시 공지되지 않는다.
- `auth-login-dev-role-radiogroup`: role=radio 버튼 그룹이지만 방향키 이동 등 라디오그룹 키보드 시맨틱은 구현되어 있지 않다(Tab으로만 이동).
- `auth-login-dev-enter-button`: 실패 시 err.message를 그대로 노출하는데 ApiError 메시지가 'API 500: ...' 같은 원문이라 사용자 친화적이지 않을 수 있다.
- `auth-onboarding-initial-load`: 로드 실패 시 재시도 버튼이 없어 새로고침 외 복구 수단이 없고, 약관 로드 실패여도 동의 체크는 가능해 문서 미확인 동의가 성립한다.
- `auth-onboarding-avatar-upload`: 클라이언트 측 파일 크기/타입 사전 검증이 없어 3MB 제한을 서버 거절 후에야 알게 되고, 서버 오류 상세는 콘솔에만 남는다.
- `auth-onboarding-phone-input`: type=text 자유 입력이라 전화번호 형식 검증이 전혀 없다.
- `auth-onboarding-terms-checkbox`: 약관 본문을 펼쳐 보지 않아도 체크 가능해 열람 강제가 없다.
- `auth-onboarding-submit`: 실패 시 '온보딩 저장에 실패했습니다' 일반 문구만 표시하고 서버의 필드별 검증 상세(ApiError.detail)는 콘솔에만 버려진다. 또한 성공 후 initialPathForUser가 아닌 roleHomePath로 이동해 admin_access만 있는 비-admin 역할 사용자의 도착지가 로그인 직후 규칙(/admin)과 어긋난다.
- `auth-pending-refresh-button`: refresh가 네트워크 오류로 실패하면 AuthProvider가 user를 null로 만들어 RequireAuth가 아무 안내 없이 /login으로 튕긴다(여전히 pending인 경우에도 '아직 승인 전'이라는 피드백이 전혀 없음). 버튼 아이콘도 refresh가 아닌 'settings'라 의미가 어긋난다.
- `auth-guard-pending-approval-gate`: pending 사용자는 /login에도 접근할 수 없어(항상 /pending으로 회수) 계정 전환 경로가 PendingApproval의 로그아웃 버튼 하나뿐이다.
## 2. 학습자 홈 (learner-home)
- [x] `learner-home-guard-require-auth` RequireAuth(roles=[learner]) 라우트 가드 — 미인증은 /login, learner 권한 없는 역할은 자기 역할 홈으로 보낸다(admin·superAdmin은 통과) · 검증: 기존 e2e/learner.spec.ts
- [x] `learner-home-guard-pending-approval` PendingApprovalGate 리다이렉트 — 미승인 사용자가 /learn 계열에 오면 state.from을 담아 /pending으로 보낸다 · 검증: 신규 spec 필요
- [x] `learner-home-guard-onboarding` OnboardingGate 리다이렉트 — onboardingCompletedAt이 null이면 /onboarding으로 보낸다 · 검증: 기존 e2e/auth.spec.ts
- [x] `learner-home-nav-sidebar` 좌측 사이드바 학습자 내비게이션 — 대시보드·학습·기록·설정 NavLink를 제공하고 활성 항목을 알약으로 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `learner-home-initial-load` 마운트 시 페르소나·회기·대시보드 병렬 로드 — allSettled로 3개 API를 병렬 요청하고 각 섹션이 독립된 로딩/오류 상태를 유지한다 · 검증: 기존 e2e/learner.spec.ts + e2e/readiness.spec.ts
- [x] `learner-home-head-status-live` 헤더 상태 배지(aria-live) — 사용 가능 페르소나 수와 기록 상태를 aria-live=polite로 헤더 우측에 표시한다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-metric-cards` 학습 현황 지표 카드 4개 — 진행 회기·리뷰 대기·최근 평가·라포 흐름 카드를 버튼으로 렌더하고 각각 기록/연습 화면으로 이동시킨다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-midwidth-tabs` 중간 폭(≤1180px) 대시보드 가로 탭 3개 — 오늘의 회기·기록 리뷰·성장 지표 탭으로 그리드를 전환하고 데스크톱에서는 2컬럼을 유지한다 · 검증: 기존(부분) e2e/layout-visual-gate.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `learner-home-dash-new-session-shortcut` 오늘의 회기 작업 헤더 '새 회기 선택' 버튼 — 클릭 시 /learn/practice로 이동해 연습 대상 선택 화면을 연다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `learner-home-dash-recap-card` 스포트라이트 회기 리캡 카드 — 우선순위로 고른 회기의 상세를 불러와 아바타·단계·진행률·마지막 발화를 보여준다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-coach-cta` AI 코치 카드와 기본 행동 CTA 버튼 — 규칙 기반 코치 문구와 힌트 칩을 만들고 CTA로 세션·리뷰·연습 중 적절한 곳으로 보낸다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-recommend-card` 다음 연습 추천 카드 버튼 — 리뷰 대기 유무와 최근 평균 턴 수에 따라 문구를 바꿔 기록 또는 연습 화면으로 이동시킨다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-recent-feedback` 최근 피드백 패널(최신 1건 리뷰 이동) — 대시보드 recent_feedback 첫 항목을 버튼으로 보여주고 해당 회기 리뷰로 이동시킨다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-recent-records` 최근 기록 패널(최근 4건 + 전체 보기) — 최근 회기 4건을 컴팩트 행으로 보여주고 상태에 따라 세션 또는 리뷰로 이동시킨다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `learner-home-dash-review-queue` 리뷰 대기 패널(review_ready 상위 3건) — review_ready 상위 3건에서 'AI 피드백' 버튼으로 리뷰 화면에 진입시킨다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-weakness-insights` 보완할 부분 패널(파생 인사이트 최대 3건) — growth 데이터에서 하락 추세·낮은 라포 등을 결정론적으로 도출해 버튼으로 제시한다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-rapport-progress` 내담자와 쌓은 라포 누적 바(상위 3명) — 회기가 있는 페르소나 상위 3명의 rapport_percent를 progressbar로 표시한다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-milestones` 연습 이정표 배지(achievements) — achievements를 상태별 배지로 나열하고 상세는 title 툴팁으로만 제공한다 · 검증: 신규 spec 필요
- [x] `learner-home-dash-recap-fetch-states` 리캡 상세 로딩·오류 상태 — 스포트라이트 회기가 바뀔 때마다 상세를 다시 불러오고 stale 응답은 session_id 비교로 무시한다 · 검증: 신규 spec 필요
- [x] `learner-home-practice-persona-listbox` 연습 페르소나 선택 리스트(role=listbox/option) — 페르소나별 옵션 버튼으로 선택 상태를 바꾸고 degraded·비DB 항목은 비활성 처리한다 · 검증: 기존 e2e/learner.spec.ts
- [x] `learner-home-practice-persona-skeleton` 페르소나 목록 스켈레톤·오류 상태 — 로딩 중 스켈레톤 3개를, 실패 시 role=alert 안내를 표시한다 · 검증: 신규 spec 필요
- [x] `learner-home-practice-preview-pane` 선택 페르소나 프리뷰 — 선택 페르소나의 인적사항·주호소·난도/접근/음성을 보여주고 사용 가능 항목이 없으면 카탈로그 안내로 전환한다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `learner-home-practice-start-session` '새 회기 시작' 버튼과 회기 시작 흐름 — 사용 가능 여부를 재검증한 뒤 세션 프리스타트로 이동시킨다 · 검증: 기존 e2e/learner.spec.ts
- [x] `learner-home-practice-activity-stats` 연습 기록 통계 3칸 — 진행 중·리뷰 가능·누적 회기 수를 표시하고 로딩·오류 시 대시로 대체한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `learner-home-practice-existing-sessions` 선택 페르소나의 기존 회기 목록(최근 5건) — 같은 페르소나의 최근 5건을 상태·단계·턴·진행률과 함께 카드로 나열한다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `learner-home-session-card-continue` 회기 카드 '이어하기' 버튼(진행 중 회기) — active 회기에서 세션 화면으로 이동해 대화를 재개한다 · 검증: 기존 e2e/learner.spec.ts
- [x] `learner-home-session-card-review` 회기 카드 '리뷰'/'기록' 버튼(종료 회기) — review_ready·보관 여부에 따라 라벨을 바꿔 리뷰 화면으로 이동한다 · 검증: 기존 e2e/learner.spec.ts
- [x] `learner-home-session-card-repeat` 회기 카드 '다시 연습' 버튼 — 같은 페르소나 코드로 새 회기 준비 화면을 연다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `learner-home-session-card-archive-toggle` 회기 카드 '보관'/'복원' 토글 버튼 — 종료 회기에서만 보관/복원을 호출하고 성공 시 회기·대시보드를 재조회한다 · 검증: 기존 e2e/learner.spec.ts
- [x] `learner-home-history-overview-cards` 기록 상태 요약 필터 카드 4개 — 전체·진행 중·리뷰 필요·보관됨 버킷 카드(aria-pressed)로 historyFilter를 전환한다 · 검증: 신규 spec 필요
- [x] `learner-home-history-search` 기록 검색 입력 — 코드·이름·단계·상태·날짜 라벨을 이어붙인 문자열에 부분 일치로 즉시 필터링한다 · 검증: 신규 spec 필요
- [x] `learner-home-history-filter-chips` 기록 상태 필터 칩(도구 행) — 요약 카드와 상태를 공유하는 칩 버튼으로 동일 필터를 전환한다 · 검증: 신규 spec 필요
- [x] `learner-home-history-list` 필터·검색 적용된 회기 카드 목록 — 정렬된 회기를 필터·검색으로 걸러 액션 버튼·진행률 바와 함께 카드로 나열한다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `learner-home-history-archive-notice` 보관/복원 결과 안내 배너(role=status) — 보관·복원 성공/실패 문구를 목록 위에 표시한다 · 검증: 신규 spec 필요
- [x] `learner-home-history-archive-guide` 아카이브 기준 안내 패널(읽기 전용) — 진행 중·리뷰 필요·보관됨 각각의 의미를 정적 텍스트로 안내한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `learner-home-history-persona-progress` 페르소나별 진행 행 버튼 — 페르소나별 회기·진행·리뷰·평균 턴 요약을 행 버튼으로 보여주고 클릭 시 해당 페르소나로 연습 화면에 진입한다 · 검증: 신규 spec 필요
- [x] `learner-home-session-list-states` 회기 목록 공용 로딩·오류·빈 상태 — 로딩 문구·인라인 경고·컨텍스트별 빈 메시지를 구분 표시한다 · 검증: 신규 spec 필요
**의심 결함(에이전트 리포트)**:
- `learner-home-initial-load`: 실패 시 재시도 버튼이 어디에도 없어 사용자는 새로고침 외에 복구 수단이 없음
- `learner-home-head-status-live`: loadState가 error일 때도 삼항 조건이 '확인 중'을 영구 표시해 페르소나 로드 실패가 헤더에서는 드러나지 않음
- `learner-home-dash-midwidth-tabs`: role=tab인데 화살표 키 이동·aria-controls/tabpanel 연결이 없어 WAI-ARIA 탭 패턴 미준수
- `learner-home-dash-recap-card`: 아바타 rapport 값이 실제 데이터가 아닌 하드코딩(정리 단계 0.72, 그 외 0.38)이고, 상세 로드 실패(error)와 발화 없음(empty)이 같은 문구로 표시되어 구분 불가
- `learner-home-dash-coach-cta`: sessionLoadState=error일 때 CTA 라벨이 '기록 다시 확인'이지만 실제 동작은 재요청이 아니라 /learn/practice 이동이라 라벨과 동작이 불일치
- `learner-home-dash-recent-feedback`: 배지에는 recentFeedback.length건으로 표시하면서 목록에는 1건만 노출해 나머지 피드백에 접근할 UI가 없음
- `learner-home-dash-milestones`: 상세 설명이 title 툴팁뿐이라 터치 기기·스크린리더에서 접근 불가
- `learner-home-practice-persona-listbox`: role=listbox/option 구조인데 화살표 키 탐색·aria-activedescendant가 없어 커스텀 리스트박스 키보드 패턴 미구현(버튼 Tab 이동만 가능)
- `learner-home-practice-persona-skeleton`: '다시 시도해 주세요' 안내만 있고 실제 재시도 버튼은 제공하지 않음
- `learner-home-session-card-repeat`: 해당 페르소나가 현재 사용 불가(degraded/비DB)로 바뀐 경우에도 비활성화 없이 이동을 허용해 Session 페이지에서야 실패를 만나게 됨
- `learner-home-session-card-archive-toggle`: archiveNotice 표시 블록이 history 뷰에만 렌더링되어, practice 뷰의 기존 회기 목록에서 보관/복원(특히 실패)을 눌러도 결과 안내가 화면에 전혀 나타나지 않음
- `learner-home-history-archive-notice`: 자동 해제 타이머가 없어 다음 토글 전까지 이전 안내가 계속 남고, 실패 원인(HTTP 상태 등)은 console.warn으로만 남아 사용자에게 전달되지 않음
## 3. 상담 세션 (session)
- [x] `session-guard-require-auth` RequireAuth(learner) 라우트 가드 — 미인증은 /login(state.from 보존), learner 권한 없으면 자기 역할 홈으로 보낸다 · 검증: 기존 e2e/learner.spec.ts
- [x] `session-guard-pending-approval` PendingApprovalGate 가드 — 미승인 계정은 /pending으로 리다이렉트한다 · 검증: 신규 spec 필요
- [x] `session-guard-onboarding` OnboardingGate 가드 — 온보딩 미완료 사용자는 /onboarding으로 리다이렉트한다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-route-empty-param-redirect` 빈 라우트 파라미터 리다이렉트 — 파라미터가 비면 /learn으로 replace 이동하고, 페르소나 코드면 세션 상태를 초기화한다 · 검증: 신규 spec 필요
- [x] `session-persona-load` 페르소나 정보 로드(로딩/누락/오류 상태) — 코드를 대조해 아바타·컨텍스트를 구성하고 상태별 안내를 프리스타트에 표시한다 · 검증: 기존 e2e/learner.spec.ts
- [x] `session-resume-load` 기존 회기 재개(resume) 로드 — 세션 ID면 서버 스냅샷으로 턴·단계·경과·진행률을 복원하고 방금 시작한 회기는 재로딩을 건너뛴다 · 검증: 기존 e2e/session-layout.spec.ts
- [x] `session-theory-mode-select` 이론모드 세그먼트 선택(인간중심/CBT/통합) — 시작 전 이론모드를 고르며 페르소나 theory_target 첫 항목이 기본 선택된다 · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `session-goal-toggle` 이번 회기 목표 단계 토글(라포/탐색/개입/정리) — 목표 단계를 1~4개 토글하며 0개면 시작 버튼이 비활성화된다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-consent-checkbox` 연습 기록 처리 동의 체크박스 — 동의 이력이 없는 learner에게만 노출되며 체크해야 저장 버튼이 활성화된다 · 검증: 신규 spec 필요
- [x] `session-consent-save` 동의 저장 버튼 — 동의를 서버에 기록하고 성공 시 pos 신호, 실패 시 오류 문구를 표시한다 · 검증: 신규 spec 필요
- [x] `session-start-button` 회기 시작 버튼 — 준비·동의·목표 조건이 충족될 때만 활성화되어 세션을 생성하고 URL을 실제 세션 ID로 교체한 뒤 음성 가용성을 사전 확인한다 · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `session-prestart-status-messages` 프리스타트 상태/오류 문구 표시 — 페르소나 로딩·없음·실패·사용 불가와 시작 오류를 구분된 문구로 표시한다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-nav-back-history` 세션바 '기록으로' 버튼 — 회기 시작 후 상단 세션바에서 /learn/history로 이동한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-nav-learn-home` 세션바 '학습 홈' 버튼 — /learn 학습자 홈으로 이동한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-nav-review-sessionbar` 세션바 리뷰 이동 버튼 — 종료 후에만 노출되어 리뷰로 이동하며 reviewReady에 따라 라벨이 바뀐다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-time-alarms` 시간 알람(10분 전 경고·시간 만료) — 경고 구간 진입 시 warn 신호를 1회 띄우고 만료 시 timeUp으로 전환해 종료 다이얼로그를 자동으로 연다 · 검증: 신규 spec 필요
- [x] `session-timebar-end-button` 시간 만료 바 '종료하고 리뷰 보기' 버튼 — role=alert 시간 바에서 만료 시 노출되어 종료 확인 다이얼로그를 연다 · 검증: 신규 spec 필요
- [x] `session-phase-tracker` 회기 단계 트래커(헤더 가로 + 좌측 세로) — 4단계의 완료·진행·예정 상태와 목표 배지, 서버 progress 기반 누적 게이지를 표시한다 · 검증: 기존(부분) e2e/session-layout.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `session-ctx-collapse` 내담자 컨텍스트 패널 접기/펼치기 — aria-expanded 토글로 내담자 이름·메타·호소/대상/난도 행을 접고 펼친다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-progress-panel` 세션 진행 패널(경과/남은 시간/발화 수) — 경과·남은 시간·저장 발화 수를 표시하고 목표 달성 시 축하 문구를 띄운다 · 검증: 기존(부분) e2e/session-layout.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `session-avatar-display` 내담자 아바타 + 음성 오브 표시 — 상태·개방도·단계에서 파생한 표정을 렌더하고 TTS 재생 중 Analyser로 입모양을 구동한다 · 검증: 기존 e2e/avatar-expression.spec.ts
- [x] `session-transcript-autoscroll` 자막 자동 스크롤 + '최신으로' 점프 버튼 — 새 발화 시 하단으로 따라가되 위로 스크롤하면 해제하고 복귀 버튼을 띄운다 · 검증: 신규 spec 필요
- [x] `session-transcript-utterances` 발화 목록(부분 스트리밍/저장 실패/응답 대기 표시) — 화자·타임코드와 함께 스트리밍 캐럿·저장 실패 노트·응답 대기 인디케이터를 표시한다 · 검증: 기존 e2e/session-mvp.spec.ts · 베이스라인 215/215 GREEN
- [x] `session-transcript-empty-state` 자막 빈 상태 안내 — 발화가 없으면 종료 여부에 따라 첫 질문 대기 또는 종료 기록 문구를 보여준다 · 검증: 신규 spec 필요
- [x] `session-transcript-coach-mark` 학습자 발화의 코칭 마크(C) 버튼 — 코칭 이력이 있는 턴에 C 배지를 붙이고 클릭 시 해당 턴 필터로 이력 모달을 연다 · 검증: 기존 e2e/session-mvp.spec.ts
- [x] `session-compose-input` 학습자 발화 텍스트 입력(Enter 전송) — Enter 전송·Shift+Enter 줄바꿈이며 종료·일시정지·전송 중에는 비활성화된다 · 검증: 기존 e2e/session-mvp.spec.ts
- [x] `session-send-button` '보내기' 버튼(텍스트 턴 SSE 스트림) — 낙관적 발화 추가 후 SSE로 응답 토큰을 흘리고 done에서 단계·개방도·진행률을 반영한다 · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `session-stream-error-handling` 스트림 오류 처리(롤백·문구 매핑) — 실패 시 낙관적 발화를 제거하고 입력을 복원하며 오류 코드를 사용자 문구로 매핑한다 · 검증: 기존 e2e/session-layout.spec.ts
- [x] `session-turn-error-display` 턴 오류 알림 섹션 — turnError를 role=alert 섹션에 '엔진 연결/회기 종료' 제목과 함께 표시한다 · 검증: 기존(부분) e2e/session-layout.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-crisis-gate` 위기 안전게이트 — 위기 신호 수신 시 회기를 일시정지하고 마이크를 끄며 109 등 위기 자원과 안전 패널을 강제로 연다 · 검증: 기존 e2e/session-mvp.spec.ts + e2e/session-persistence.spec.ts
- [x] `session-crisis-tel-link` 위기 자원 전화 링크 — 위기 자원 번호를 tel: 링크로 제공해 즉시 전화를 걸 수 있게 한다 · 검증: 신규 spec 필요
- [x] `session-safety-panel-collapse` 안전 점검 패널 접기/펼치기 — 토글로 접되 위기 신호가 있으면 접힘을 무시하고 항상 펼친다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-live-signal` 라이브 신호(앰비언트 도트 + 최근 흐름) — 이벤트마다 도트를 6초 페이드로 띄우고 최근 5개 시퀀스를 누적하며 모드별로 노출을 달리한다 · 검증: 신규 spec 필요
- [x] `session-feedback-mode-segmented` 화면 모드 세그먼트(몰입/상태 신호/코칭) — 피드백 강도 3모드를 전환하고 코칭 버튼에 남은 기회 배지를 붙인다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `session-coach-auto-request` 턴 완료 후 라이브 코칭 자동 요청 — 코칭 모드에서 남은 기회를 확인해 코칭을 요청하고 409면 소진 문구를, 비코칭 모드면 넛지로 보관한다 · 검증: 기존 e2e/session-mvp.spec.ts + e2e/session-persistence.spec.ts
- [x] `session-coach-card` AI 코치 카드(기회 도트·크레딧 펄스·degraded) — 남은 기회 도트와 크레딧 펄스, 제안 문구, degraded·runtime 경고를 표시한다 · 검증: 기존 e2e/session-mvp.spec.ts
- [x] `session-coach-nudge` 코칭 넛지 버튼 — 코칭 화면이 닫힌 채 기회가 대기 중이면 넛지를 띄우고 클릭 시 코칭 모드로 전환해 즉시 요청한다 · 검증: 신규 spec 필요
- [x] `session-coach-evidence-modal` 코칭 근거 모달('근거 보기') — 판단·다음 문장·RAG 근거 자료를 다이얼로그로 보여주고 Escape·오버레이·닫기로 닫는다 · 검증: 기존 e2e/session-mvp.spec.ts
- [x] `session-coach-history-modal` 코칭 이력 모달('이력 보기'/턴 마크) — 턴별 코칭 이벤트를 시간순으로 보여주고 로딩/오류/빈 상태와 runtime 경고를 구분한다 · 검증: 기존 e2e/session-mvp.spec.ts
- [x] `session-meters-collapse` 관찰 신호 패널 접기 + 게이지 3종 — 방어·유효 개방도·라포 게이지를 서버 progress 또는 휴리스틱으로 표시하고 접으면 라포 %를 요약한다 · 검증: 신규 spec 필요
- [x] `session-mic-toggle` 마이크 버튼(음성 녹음 시작/발화 전송) — 권한 요청 후 음성 WS를 열어 PCM 청크를 보내고 다시 누르면 audio_end로 발화를 확정한다 · 검증: 기존 e2e/voice-success.spec.ts
- [x] `session-voice-ws-events` 음성 WebSocket 이벤트 처리 — ready/state/transcript/reply/tts_end/degraded/error 이벤트를 상태·자막·신호에 반영하고 바이너리는 TTS 버퍼로 쌓는다 · 검증: 기존 e2e/voice-success.spec.ts
- [x] `session-voice-error-recovery` 음성 오류/연결 종료 시 발화 실패 처리 — reply 확정 전 WS가 끊기면 대기 발화를 '저장 실패'로 표시하고 마이크를 안전 상태로 되돌린다 · 검증: 기존 e2e/session-mvp.spec.ts
- [x] `session-tts-playback` 내담자 TTS 재생(WebAudio+Analyser, Audio 폴백) — 합성 mp3를 WebAudio로 재생하며 입모양을 구동하고 실패 시 Audio 폴백·degraded 안내로 넘어간다 · 검증: 기존 e2e/voice-success.spec.ts
- [x] `session-voice-skip` 음성 '건너뛰기' 버튼 — TTS 재생 중에만 노출되어 재생·버퍼를 끊고 소켓을 닫은 뒤 idle로 복귀한다 · 검증: 신규 spec 필요
- [x] `session-pause-toggle` 일시정지/이어가기 버튼 — 일시정지 시 타이머·음성 캡처·재생을 모두 멈추고 이어가기 시 listening으로 복귀한다 · 검증: 신규 spec 필요
- [x] `session-end-open` '회기 종료' 버튼 — 종료 확인 다이얼로그를 열며 종료 처리 중에는 비활성화된다 · 검증: 기존 e2e/session-mvp.spec.ts
- [x] `session-end-dialog` 회기 종료 확인 다이얼로그 — 취소/종료 버튼 사이 Tab 포커스 트랩과 Escape·오버레이 닫기, 포커스 복원을 제공한다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `session-end-confirm` '종료하고 리뷰 보기' 확정 버튼 — 종료 API 성공 시 리뷰로 replace 이동하고 실패 시 리뷰로 넘어가지 않고 오류를 표시한다 · 검증: 기존 e2e/session-mvp.spec.ts
- [x] `session-review-button-controlbar` 컨트롤바 리뷰 이동 버튼(종료 후) — 종료 후 일시정지·종료 자리를 대체해 리뷰로 이동하며 준비 상태에 따라 라벨이 바뀐다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-keyboard-shortcuts` 키보드 단축키(Space=마이크, P=일시정지) — 입력창 포커스가 아닐 때 Space로 마이크를, P로 일시정지를 토글한다 · 검증: 신규 spec 필요
- [x] `session-elapsed-live-region` 경과 시간 스크린리더 라이브 리전 — sr-only aria-live 요소로 경과 시간을 보조기기에 알린다 · 검증: 신규 spec 필요
- [x] `session-mobile-context-strip` 모바일 컨텍스트 요약 스트립 — 모바일 폭에서 회기 상태·내담자·마이크·단계·안전 상태를 한 줄로 요약한다 · 검증: 기존(부분) e2e/session-layout.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
**의심 결함(에이전트 리포트)**:
- `session-start-button`: consent_required 외 모든 시작 실패가 단일 일반 문구로 뭉개져 원인(권한/404 등) 구분이 안 됨
- `session-nav-back-history`: 진행 중 회기에서 확인 없이 즉시 이탈 가능(회기는 서버에 남지만 이탈 경고 없음)
- `session-nav-learn-home`: 진행 중 회기 이탈 확인 다이얼로그 없음
- `session-timebar-end-button`: timeUp 이후에도 텍스트 입력이 클라이언트에서 막히지 않고 서버 session_time_over 오류에 의존함(의도된 정리 유예일 수 있으나 안내 부족)
- `session-stream-error-handling`: 오류 롤백 시 서버에 이미 저장됐을 수 있는 턴과 클라이언트 상태가 어긋나 재전송하면 중복 턴 가능
- `session-feedback-mode-segmented`: 세그먼트 3개 버튼에 type="button" 누락, 선택 상태가 aria-pressed 없이 클래스로만 표현됨
- `session-coach-evidence-modal`: 초기 포커스와 Escape만 있고 Tab 포커스 트랩이 없어 모달 밖으로 포커스 이탈 가능(종료 다이얼로그는 트랩 있음)
- `session-coach-history-modal`: 근거 모달과 동일하게 Tab 포커스 트랩 없음
- `session-mic-toggle`: WS onmessage가 소켓 생성 시점의 elapsed를 클로저로 고정해 음성 발화 타임코드가 마이크 연결 시각으로 표시됨
- `session-keyboard-shortcuts`: 한글 IME 등 contentEditable은 typing 예외에서 빠져 있고, P 단축키는 다이얼로그가 열린 상태에서도 동작해 모달 뒤 상태를 바꿀 수 있음
- `session-elapsed-live-region`: 매초 갱신되는 aria-live=polite라 스크린리더가 1초마다 경과 시간을 읽어 소음이 될 수 있음
## 4. 회기 리뷰 (session-review)
- [x] `session-review-guard-require-auth` RequireAuth 라우트 가드 — 미인증은 /login, 역할 불일치(학습자/교수자 경로)면 자기 역할 홈으로 replace 리다이렉트한다 · 검증: 신규 spec 필요
- [x] `session-review-guard-pending-onboarding` PendingApprovalGate·OnboardingGate 전역 가드 — 미승인은 /pending, 온보딩 미완료는 /onboarding으로 보내고 로딩 중 BootScreen을 띄운다 · 검증: 신규 spec 필요
- [x] `session-review-initial-load` 리뷰 데이터 초기 로드와 로딩 상태 — 축어록·평가·워크시트·교수자 검토를 한 번에 불러오고 로딩 중 빈 카드를 표시한다 · 검증: 기존 e2e/session-review.spec.ts
- [x] `session-review-error-state` 리뷰 로드 오류 상태 — sessionId 누락·로드 실패 시 '리뷰를 표시할 수 없습니다' 카드에 오류 메시지를 보여준다 · 검증: 신규 spec 필요
- [x] `session-review-teacher-auto-redirect` 교수자 자동 리다이렉트 — 학습자 경로에서 teacher 권한자가 teacherReview 응답이나 403을 만나면 교수자 리뷰 경로로 옮긴다 · 검증: 기존 e2e/session-review.spec.ts
- [x] `session-review-ready-polling` 평가 준비 상태 폴링 — 평가 대기 조건이면 1.5초 간격 최대 60회까지 리뷰를 재조회한다 · 검증: 기존 e2e/session-review.spec.ts
- [x] `session-review-tabs` 리뷰 영역 가로 탭(축어록/피드백/워크시트) — role=tablist 탭 3개로 영역을 전환하며 aria-selected로 활성 탭을 표시한다 · 검증: 기존(부분) e2e/session-review.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `session-review-btn-teach-console` 교수 콘솔로 이동 버튼 — 교수자 화면에서만 노출되며 클릭 시 /teach로 이동한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-review-btn-retry-evaluation` AI 평가 재시도 버튼 — 평가 실패 상태의 종료 회기에서만 노출되어 session_end 범위 재평가를 요청하고 리뷰를 다시 로드한다 · 검증: 기존 e2e/session-review.spec.ts + e2e/session-persistence.spec.ts
- [x] `session-review-btn-audio` 오디오 다시 듣기 버튼 — audioUrl이 있을 때만 활성화되어 새 탭으로 오디오 URL을 연다 · 검증: 신규 spec 필요
- [x] `session-review-btn-pdf` PDF 내보내기 버튼 — pdfExportUrl이 있을 때만 활성화되어 새 탭으로 내보내기 URL을 연다 · 검증: 신규 spec 필요
- [x] `session-review-btn-share` 공유 URL 생성·복사 버튼 — 종료된 회기의 학습자 화면에서 공유 링크를 만들어 클립보드에 복사하고 라벨을 바꾼다 · 검증: 신규 spec 필요
- [x] `session-review-share-note` 공유 URL·오류 노트 표시 — 생성된 URL 또는 실패 메시지를 요약 카드 하단 노트로 표시한다 · 검증: 신규 spec 필요
- [x] `session-review-transcript-filter` 축어록 전체/내 발화만 필터 토글 — aria-pressed 칩 2개로 전체·학습자 발화 보기를 전환하며 발화가 없으면 비활성화된다 · 검증: 신규 spec 필요
- [x] `session-review-turn-jump` 코멘트 타임스탬프로 발화 점프 — jumpTo 버튼으로 해당 발화를 강조·중앙 정렬하고 필요하면 필터를 자동 해제한다 · 검증: 신규 spec 필요
- [x] `session-review-transcript-display` 축어록 타임라인 표시(칩·PII 마스킹·빈 상태) — 타임스탬프·기법/비언어 칩·콜아웃을 표시하고 PII 플레이스홀더를 한국어 라벨로 치환한다 · 검증: 기존 e2e/session-review.spec.ts
- [x] `session-review-summary-band` 리뷰 요약 밴드와 준비 상태 표시 — 강조 태그 포함 요약과 근거 발화 수·평가 상태·도달 단계를 보여주고 엔진 오류 흔적은 대체 문구로 마스킹한다 · 검증: 기존(부분) e2e/session-review.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `session-review-valence-chart` 감정 밸런스 타임라인 차트 — 내담자 정서가와 상담자 기준선을 스무딩 SVG로 그리고 데이터가 없으면 빈 상태를 보여준다 · 검증: 기존(부분) e2e/session-review.spec.ts + 탐색 순회 · 빈 상태 GREEN — 차트 데이터 경로는 서버 미구현(결함 로그 #21, 로드맵)
- [x] `session-review-phase-flow` 회기 흐름 단계 바 — 단계별 weight 비율 가로 바를 그리고 구간마다 단계명·비율 툴팁을 붙인다 · 검증: 신규 spec 필요
- [x] `session-review-rubric-display` 기법 사용 분포 카드 — 기법별 사용 빈도 바와 적절/살펴보기 품질 배지를 표시하고 평가가 없으면 대기 상태를 보여준다 · 검증: 신규 spec 필요
- [x] `session-review-good-growth-points` 좋았던 순간·다음 회기 개선점 카드 — 근거 있는 강점과 개선점(최대 3개)을 점프 버튼과 함께 표시한다 · 검증: 신규 spec 필요
- [x] `session-review-client-feedback` 마지막 내담자 반응 표시 — AI 내담자가 생성한 마지막 피드백 인용문을 보여주고 없으면 빈 문구를 표시한다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-review-worksheet-edit` 사례개념화 워크시트 항목 편집 — 학습자 화면에서 섹션별 textarea 수정 시 confidence를 자동 조정하고 dirty 상태로 전환한다(교수자는 읽기 전용) · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `session-review-worksheet-save` 워크시트 저장 버튼 — 변경이 있을 때만 활성화되어 초안과 limitations를 PUT 저장하고 라벨을 저장→저장됨으로 바꾼다 · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `session-review-worksheet-evidence-jump` 워크시트 근거 발화 이동 버튼 — 워크시트 항목의 첫 근거 인용 버튼으로 해당 발화에 점프한다 · 검증: 신규 spec 필요
- [x] `session-review-prepost-load` 사전/사후 점수 원장 로드 — 마운트 시 phase3-pilot-draft 파일럿 측정값을 불러와 초안·기준선과 완료쌍 배지를 채운다 · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `session-review-prepost-input` 사전/사후 점수 입력(3척도×2시점) — 자기효능감·기술숙련도·수련만족도 사전/사후 값을 1~5로 편집하고 범위 밖 입력은 오류로 막는다 · 검증: 기존 e2e/session-review.spec.ts
- [x] `session-review-prepost-save` 사전/사후 점수 저장 버튼 — 유효 변경만 있을 때 활성화되어 키별 순차 PUT 후 원장을 재조회한다 · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `session-review-teacher-note-input` 교수자 검토 메모 입력 — 교수자 전용 textarea(2000자)에 다음 지도 메모를 작성한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `session-review-teacher-save-viewed` 메모 저장(검토 중) 버튼 — 검토 상태를 viewed로 두고 메모·워크시트 메모를 PUT 저장한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `session-review-teacher-close` 검토 완료 버튼 — 종료·평가 준비된 회기에서만 활성화되어 검토 상태를 closed로 저장하고 완료 시각을 표시한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `session-review-teacher-worksheet-note` 워크시트 검수 메모 입력 — 수정요청·반려 사유를 적는 textarea로 검수 결정·메모 저장 시 함께 PUT된다 · 검증: 신규 spec 필요
- [x] `session-review-teacher-worksheet-approve` 워크시트 승인 버튼 — 학습자 저장본이 있는 종료 회기에서 worksheet_status=approved를 저장한다 · 검증: 기존(부분) e2e/session-persistence.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `session-review-teacher-worksheet-request-changes` 워크시트 수정요청 버튼 — 같은 조건에서 worksheet_status=changes_requested를 저장하고 검수 배지를 갱신한다 · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `session-review-teacher-worksheet-reject` 워크시트 반려 버튼 — 같은 조건에서 worksheet_status=rejected를 저장하며 저장 중에는 세 결정 버튼이 모두 비활성화된다 · 검증: 신규 spec 필요
- [x] `session-review-eval-scope-notice` AI 평가 범위 안내 박스 — fast-loop/deep-loop 구현 범위와 임상팀 확정 필요 항목을 정적으로 안내한다 · 검증: 기존 e2e/session-review.spec.ts
- [x] `session-review-head-meta` 회기 헤더·상태 스탯 표시 — 내담자·날짜·소요시간·페르소나와 화면 배지, SESSION/SUPERVISOR 상태와 세션 ID를 표시한다 · 검증: 기존(부분) e2e/session-review.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
**의심 결함(에이전트 리포트)**:
- `session-review-error-state`: 오류 화면에 재시도/뒤로가기 액션이 없어 새로고침 외 복구 수단이 없다.
- `session-review-teacher-auto-redirect`: 403 detail 문자열('session does not belong to user') 완전 일치에 의존해 백엔드 문구가 바뀌면 리다이렉트가 조용히 깨진다.
- `session-review-ready-polling`: 60회 한도 도달 시 아무 안내 없이 폴링이 멈춰 사용자는 '대기' 표시가 영구 상태인지 알 수 없다. 또한 폴링마다 setData로 워크시트 초안 상태가 리셋되어 입력 중이던 학습자 텍스트가 날아갈 수 있다.
- `session-review-tabs`: role=tab을 쓰지만 화살표 키 이동·aria-controls·tabpanel 연결이 없어 ARIA 탭 패턴 접근성이 불완전하다.
- `session-review-btn-retry-evaluation`: displayEvaluationRetryError가 실제 오류 detail을 버리고 항상 동일한 일반 문구만 보여줘 실패 원인 파악이 불가능하다. 노출 조건이 '평가 실패'·'종료됨' 같은 한국어 표시 문자열 일치에 결합돼 있어 깨지기 쉽다.
- `session-review-btn-share`: clipboard.writeText 실패 시에도 catch에서 'copied' 상태로 표시해 실제 복사 실패를 숨긴다. api에 있는 공유 해지(DELETE /share)는 UI에 노출되지 않아 만든 링크를 화면에서 되돌릴 수 없다. 활성 조건이 표시 문자열 '종료됨' 일치에 의존한다.
- `session-review-share-note`: 공유 URL이 일반 텍스트로만 표시되고 앵커 링크나 재복사 버튼이 없다.
- `session-review-turn-jump`: 필터 해제 직후 requestAnimationFrame 1회로 scrollIntoView를 호출해 React 리렌더 전이면 대상 ref가 아직 없어 스크롤이 무시될 수 있는 경합이 있다.
- `session-review-transcript-display`: 비언어 칩의 상세 내용이 title 속성 툴팁에 의존해 키보드·터치 사용자에게 전달되지 않는다.
- `session-review-worksheet-edit`: worksheet prop이 바뀔 때마다 초안·dirty가 무조건 리셋되므로 백그라운드 폴링/재로딩과 겹치면 저장 전 입력이 소실된다. 이탈 시 미저장 경고(beforeunload/라우트 블록)도 없다.
- `session-review-worksheet-save`: 저장 실패 시 '워크시트를 저장하지 못했습니다' 고정 문구만 표시하고 서버 오류 상세를 버린다.
- `session-review-worksheet-evidence-jump`: evidence 배열의 첫 항목만 노출되어 두 번째 이후 근거는 접근 불가다.
- `session-review-prepost-input`: 저장된 값을 비우는 것은 영구히 불가능하며(빈칸이 invalid 처리), invalid 칸이 하나라도 있으면 다른 유효한 변경까지 통째로 저장이 막힌다.
- `session-review-prepost-save`: 키별 순차 PUT 중 하나가 실패하면 일부만 저장된 채 일반 오류 문구만 표시되고 어떤 항목이 저장됐는지 재동기화되지 않는다.
- `session-review-teacher-save-viewed`: 이미 closed인 검토에 메모 저장을 누르면 status가 viewed로 덮여 검토 완료 상태가 되돌려질 수 있다(닫힘 여부를 보존하지 않음).
- `session-review-teacher-close`: 한 번 closed로 저장하면 화면에서 다시 여는(viewed로 되돌리는) 명시적 수단이 없다(메모 저장 부작용으로만 가능).
- `session-review-teacher-worksheet-note`: 이 textarea만 name·autoComplete 속성이 없어 다른 입력과 마크업 일관성이 없다.
- `session-review-teacher-worksheet-reject`: 승인/수정요청/반려에 확인 다이얼로그가 없어 오클릭 한 번으로 결정이 즉시 저장된다.
## 5. 교수자 콘솔 (professor)
- [x] `professor-guard-require-auth-teacher` RequireAuth(teacher) 라우트 가드 — 미인증은 /login, teacher 권한이 없으면 자기 역할 홈으로 리다이렉트한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-guard-pending-approval` PendingApprovalGate 리다이렉트 — 미승인 계정이 /teach 계열에 오면 /pending으로 보내고 승인 계정이 /pending에 오면 되돌린다 · 검증: 신규 spec 필요
- [x] `professor-guard-onboarding` OnboardingGate 리다이렉트 — 온보딩 미완료 사용자가 /teach 계열에 오면 /onboarding으로 보낸다 · 검증: 신규 spec 필요
- [x] `professor-dashboard-initial-load` 교수자 대시보드 초기 로드 — 검토 대기·최근 세션·안전 알림·학습자 성장 데이터를 불러와 로딩/오류/갱신 시각을 관리한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-dashboard-error-banner` 대시보드 오류 배너 — 로드 실패 시 role=alert 배너에 오류 메시지를 표시한다 · 검증: 신규 spec 필요
- [x] `professor-persona-review-initial-load` 페르소나 검수 큐 초기 로드 — 검수 대기 페르소나를 불러오며 404는 빈 큐로, 5xx만 오류 카드로 처리한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-header-refresh` 새로고침 버튼 — 대시보드와 뷰별 데이터(검수 큐 또는 학습자 분석)를 함께 재조회하고 진행 중에는 비활성화된다 · 검증: 신규 spec 필요
- [x] `professor-open-first-pending-review` 첫 검토 열기 버튼 — 검토 대기가 있을 때만 헤더에 노출되어 첫 번째 회기의 교수자 리뷰로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `professor-nav-to-analysis` 학생 분석 이동 버튼 — 콘솔 헤더에서 /teach/analysis로 이동한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-nav-to-console` 콘솔로 이동 버튼 — 분석 뷰 헤더에서 /teach 콘솔로 되돌아간다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `professor-triage-status-strip` 검토 큐 상태 스트립 — 코호트 라벨·마지막 갱신 시각·리뷰 대기/누적 회기 수를 role=status로 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `professor-kpi-cards` KPI 카드 6종 — 검토 대기·위기 알림·페르소나 검수·최근 세션·전체 학생·활성 세션 수를 표시 전용 카드로 요약한다 · 검증: 신규 spec 필요
- [x] `professor-pending-review-list` 검토 대기 회기 목록 — 종료됐지만 닫지 않은 회기를 행 버튼으로 나열하고 클릭 시 교수자 리뷰로 이동한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-safety-alert-list` 위기 알림(109) 목록 — safety_alerts를 학습자·페르소나·위험도·발생시각·안내 자원과 함께 나열한다 · 검증: 기존 e2e/session-persistence.spec.ts
- [x] `professor-persona-review-approve` 페르소나 검수 승인 버튼 — review 상태 페르소나에만 노출되어 approve 결정을 저장하고 결과에 따라 행 갱신 또는 큐 제거를 한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-persona-review-open-studio` 스튜디오에서 검수 버튼 — `/teach/personas?draft={id}`로 이동해 스튜디오에서 상세 검수를 잇는다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `professor-persona-review-error-retry` 검수 큐 오류 카드 + 다시 시도 — 로드·결정 실패 시 role=alert 카드와 재조회 버튼을 제공한다 · 검증: 신규 spec 필요
- [x] `professor-persona-review-empty-loading` 검수 큐 로딩/빈 상태 — 로딩 중 '검수 큐 확인 중', 비면 '검수 대기 페르소나 없음'을 표시한다 · 검증: 신규 spec 필요
- [x] `professor-open-persona-studio` 페르소나 스튜디오 열기 버튼 — 콘텐츠 저작실 카드에서 초안 건수 메타와 함께 /teach/personas로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `professor-recent-session-table` 최근 회기 기록 테이블 — 최근 세션을 행 버튼으로 나열하고 종료 여부에 따라 상세 리뷰/진행 기록 라벨로 리뷰 화면에 진입시킨다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-analysis-learner-search` 학습자 검색 입력 — 학습자 라벨·ID·추이·기법 태그를 합친 문자열에 부분 일치로 실시간 필터링한다 · 검증: 기존 e2e/teacher.spec.ts · 베이스라인 215/215 GREEN
- [x] `professor-analysis-sort-segment` 학습자 정렬 세그먼트 (최근순/회기순/점수순/변화순) — 네 기준 중 하나로 학습자 목록을 재정렬한다 · 검증: 신규 spec 필요
- [x] `professor-analysis-table-states` 학습자 테이블 로딩/빈/검색결과없음 상태 — 데이터 없음·로딩·검색 결과 없음을 각각 다른 문구로 구분한다 · 검증: 신규 spec 필요
- [x] `professor-analysis-row-expand` 학습자 행 펼침 토글 — aria-expanded 버튼으로 회기별 스파크바·점수 변화·기법 태그 요약을 한 행씩 펼친다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-analysis-open-learner-detail` 학습자 상세 진입 (이름 버튼 · 상세 보기 버튼) — `?learner={id}` 쿼리를 설정해 드릴다운 화면으로 전환한다(URL 공유·뒤로가기 가능) · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-analysis-detail-load` 학습자 상세 분석 로드 — learner 파라미터 변경마다 상세를 조회하고 로딩·오류·미선택 상태를 구분하며 낡은 응답을 무시한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `professor-analysis-back-to-list` 전체 목록 복귀 버튼 — learner 쿼리를 지워 검색 테이블로 돌아간다 · 검증: 신규 spec 필요
- [x] `professor-analysis-invalid-learner-redirect` 존재하지 않는 학습자 파라미터 자동 복귀 — learner_growth에 없는 ID면 쿼리를 지워 전체 목록으로 되돌린다 · 검증: 신규 spec 필요
- [x] `professor-analysis-depth-strip` 분석 깊이 표시 스트립 — 현재 드릴다운 깊이(1 목록 → 2 상세 → 3 리뷰)를 표시 전용으로 안내한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `professor-analysis-summary-metrics` 학습자 상세 요약 메트릭 — 전체 회기·완료/진행·검토 대기·최근 적절성·변화·평균 라포 6개 지표를 상단에 요약한다 · 검증: 기존(부분) e2e/layout-visual-gate.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `professor-analysis-tabs` 상세 분석 탭 (페르소나별 회기/추이/전체 회기/단계 분석) — 4개 탭으로 콘텐츠를 전환하고 각 탭에 건수 배지를 표시한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `professor-analysis-persona-group-drill` 페르소나별 회기 그룹 아코디언 — 페르소나 요약 그룹 버튼을 눌러 그룹 내 회기 목록을 한 번에 하나씩 펼친다 · 검증: 기존 e2e/layout-visual-gate.spec.ts
- [x] `professor-analysis-session-row-open` 회기 행 열기 (페르소나 탭·전체 회기 탭 공용) — 회기 요약 행 버튼 클릭 시 교수자 리뷰 화면으로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `professor-analysis-trend-chart` 추이 탭 회기별 막대 차트 — 회기별 점수를 막대로 그리고 title 툴팁에 페르소나·회기·점수를 담는다 · 검증: 신규 spec 필요
- [x] `professor-analysis-stage-breakdown` 단계 분석 탭 — 상담 단계별 회기 수와 턴 수를 카드 목록으로 표시한다 · 검증: 신규 spec 필요
- [x] `professor-analysis-list-reuses-dashboard` 학습자 목록 데이터 소스 — 분석 뷰 검색 테이블이 별도 API 없이 teacher/dashboard의 learner_growth를 재사용한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
**의심 결함(에이전트 리포트)**:
- `professor-dashboard-error-banner`: 배너 자체에 재시도 버튼이 없음 — 헤더의 새로고침 버튼까지 시선 이동 필요. 오류 상태에서도 아래 큐 섹션들이 빈 상태 문구로 그대로 렌더되어 '데이터 없음'과 '로드 실패'가 혼동될 수 있음.
- `professor-kpi-cards`: KPI 카드가 해당 섹션으로 스크롤/이동하는 링크가 아니어서 숫자만 보이는 죽은 UI — 특히 '위기 알림' 같은 긴급 지표도 클릭 동작이 없음.
- `professor-safety-alert-list`: 표시 전용이라 알림 확인(acknowledge)·해결 처리 액션이 전혀 없고, session_id를 보여주면서도 해당 회기 리뷰로 이동하는 링크가 없어 위기 대응 동선이 끊김.
- `professor-persona-review-approve`: 승인 실패 시 setPersonaReviewError로 패널 전체가 에러 카드로 대체되어 검수 목록이 통째로 사라짐(단건 실패가 전역 오류로 승격). 또한 busy 비교가 정확히 '해당 persona:action'만이라 다른 행의 승인 버튼은 여전히 눌러져 동시 다발 요청 가능. 확인(confirm) 단계 없이 즉시 승인됨.
- `professor-recent-session-table`: 테이블이 div+button 구조이고 헤더 행이 aria-hidden이라 스크린리더는 열 제목 없이 셀 텍스트만 듣게 됨 (data-label 속성은 시각용). 진짜 table/grid 시맨틱 부재.
- `professor-analysis-sort-segment`: 활성 표시가 CSS 클래스(is-active)뿐이고 aria-pressed/aria-current가 없어 보조기기에서 현재 정렬 기준을 알 수 없음.
- `professor-analysis-detail-load`: 상세 로드 오류 카드에 재시도 버튼이 없음 — 헤더 새로고침으로만 복구 가능.
- `professor-analysis-invalid-learner-redirect`: learner_growth가 아직 로드되지 않은 짧은 순간에는 판정을 보류하지만, 대시보드 로드가 실패하면 learnerGrowth가 빈 배열로 남아 유효한 딥링크도 상세 제목이 '학습자 이력' 폴백으로 표시됨.
- `professor-analysis-depth-strip`: nav 요소지만 각 단계가 클릭 불가한 span이라 브레드크럼처럼 보이면서 동작하지 않음 — 1단계 클릭으로 목록 복귀를 기대하는 사용자에게 혼란.
- `professor-analysis-tabs`: role=tab/aria-selected는 있으나 tabpanel 연결(aria-controls/id)과 화살표 키 로빙 탭인덱스가 없어 WAI-ARIA 탭 패턴 미완성.
- `professor-analysis-trend-chart`: 차트 정보가 title 속성 툴팁에만 있어 키보드/터치/스크린리더로는 개별 막대 값을 알 수 없음.
- `professor-analysis-stage-breakdown`: 다른 탭과 달리 stage_breakdown이 빈 배열일 때 EmptyState가 없어 제목만 있고 내용이 없는 화면이 됨.
## 6. 페르소나 스튜디오 (persona-studio)
- [x] `persona-studio-guard-require-auth` RequireAuth 라우트 가드 (teacher/admin) — 미인증은 /login, teacher/admin이 아니면 자기 역할 홈으로 보낸다 · 검증: 신규 spec 필요
- [x] `persona-studio-guard-pending-approval` PendingApprovalGate 가드 — 미승인 사용자는 /pending으로 리다이렉트한다 · 검증: 신규 spec 필요
- [x] `persona-studio-guard-onboarding` OnboardingGate 가드 — 온보딩 미완료 사용자는 /onboarding으로 리다이렉트한다 · 검증: 신규 spec 필요
- [x] `persona-studio-initial-load` 초기 데이터 로드 (카탈로그·검수 큐·교수 대시보드) — 3개 API를 병렬 호출하며 카탈로그 실패만 전체 오류, 검수 큐 404는 빈 목록, 대시보드 실패는 경고로 처리한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-load-error-banner` 로드 오류 배너 — 카탈로그 로드 실패 시 role=alert 섹션에 오류 메시지를 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-dashboard-degraded-note` 학습 지표 실패 시 부분 동작 안내 — 대시보드 호출 실패 시 경고 노트만 띄우고 카탈로그·저작 기능은 계속 쓰게 한다 · 검증: 신규 spec 필요
- [x] `persona-studio-status-message-line` 폼 오류/성공 메시지 라인 — formError가 있으면 role=alert 오류를, 없으면 성공/안내 문장을 헤더 아래에 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-refresh-button` 새로고침 버튼 — 카탈로그·검수 큐·대시보드를 다시 호출하며 로딩·busy 중에는 비활성화된다 · 검증: 신규 spec 필요
- [x] `persona-studio-workspace-tabs` 워크스페이스 탭 (대시보드/카탈로그/페르소나) — view 쿼리 기반으로 탭을 전환하며 나머지 쿼리 파라미터를 모두 제거한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-create-open` 새로운 페르소나 만들기 버튼 — 초안 상태를 초기화하고 localStorage 임시본이 있으면 복원 메시지와 함께 되살린 뒤 작성 단계로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-dash-review-more` 검수 현황 더보기 버튼 — `view=personas&queue=review`로 이동해 검수 대기 목록을 연다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-dash-usage-row` 페르소나별 학습 현황 행 클릭 — 공개 페르소나 상위 8개 행을 클릭하면 승인본 상세로 이동한다 · 검증: 신규 spec 필요
- [x] `persona-studio-dash-usage-empty` 공개 페르소나 없음 빈 상태 — 카탈로그가 비면 '공개 페르소나 없음' 빈 상태를 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-catalog-view-all` 전체 페르소나 보기 버튼 — 카탈로그 화면에서 `view=personas` 목록으로 이동한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-catalog-row` 공개본 상태 목록 행 클릭 — 공개 페르소나 행을 클릭하면 승인본 상세로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `persona-studio-catalog-empty-distribution` 이론 분포 빈 상태 — 공개 페르소나가 없으면 이론 분포 대신 '분포 자료 없음'을 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-list-subtabs` 목록 서브탭 (전체 페르소나/검수 현황) — queue 파라미터를 all/review로 바꾸고 열린 상세를 닫으며 건수를 표시한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-list-search` 페르소나 검색 입력 — 코드·이름·이론·상태 라벨 문자열에 ko-KR 소문자 부분일치로 필터링한다 · 검증: 신규 spec 필요
- [x] `persona-studio-list-origin-filter` 구분 필터 셀렉트 (전체/시스템/새로 만든) — P1~P11·seed 출처는 시스템, 나머지는 커스텀으로 분류해 필터링한다 · 검증: 신규 spec 필요
- [x] `persona-studio-list-row-open-detail` 목록 행 식별자/상세 버튼 — 행의 코드·이름 또는 상세 버튼으로 `persona={key}` 상세 화면을 연다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-list-approved-revise` 공개본 수정 버튼 (새 버전 초안 생성) — 승인본에서 revision 초안을 만들어 편집기로 불러오고 승인 전까지 공개본을 유지한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-list-approved-archive` 공개본 삭제(보관) 버튼 — confirm 후 DELETE로 보관 처리하고 같은 대상 편집 중이면 상태를 초기화한 뒤 목록을 새로고침한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-list-draft-edit` 초안 행 수정 버튼 — `mode=edit&draft={id}`로 이동해 초안 상세를 편집 폼에 자동 로드한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-list-empty` 목록 필터 결과 빈 상태 — 검색·필터 결과가 없으면 '조건에 맞는 페르소나 없음'을 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-review-card-detail` 검수 카드 상세 버튼 — `persona=draft:{id}`로 이동해 해당 초안 상세를 연다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-review-card-edit` 검수 카드 수정 버튼 — 해당 초안을 mode=edit 편집기로 연다 · 검증: 신규 spec 필요
- [x] `persona-studio-review-card-approve` 검수 카드 승인 버튼 — approve 결정으로 공개본으로 승격하고 편집 상태를 초기화한 뒤 목록을 새로고침한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-review-card-reject` 검수 카드 반려 버튼 — reject 결정을 저장하며 status가 review가 아니거나 busy면 비활성화된다 · 검증: 신규 spec 필요
- [x] `persona-studio-review-empty` 검수 대기 없음 빈 상태 — review/draft 항목이 없으면 '검수 대기 없음'을 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-detail-back` 상세 → 목록으로 버튼 — persona·detailTab 쿼리를 제거해 목록으로 돌아간다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-detail-tabs` 상세 서브탭 (소개·정보/학습 현황/설정 내용) — detailTab 쿼리로 상세 화면 3개 탭을 전환한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-detail-draft-fetch` 초안 상세 자동 조회 — persona 키가 draft:면 초안 상세를 조회해 주호소·핵심 신념·민감 영역·출처를 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-detail-revise-button` 상세 헤더 수정 버튼 — 승인본이면 revision 초안을 만들어, 초안이면 바로 편집기로 이동한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-detail-learning-empty` 학습 기록 없음 빈 상태 — 평가된 회기 궤적이 없으면 빈 상태를 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-detail-notfound` 상세 대상 미존재 빈 상태 — persona 키가 목록에 없으면 '페르소나를 찾지 못했습니다'를 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-author-stepper` 저작 4단계 스테퍼 (자료/초안/설정/검토) — step 쿼리로 단계를 이동하며 검토 단계 진입 시 프롬프트 미리보기로 자동 전환한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-author-back-to-list` 저작 화면 목록으로 버튼 — `view=personas`로 돌아가 저작 모드를 종료하며 busy 중에는 비활성화된다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-author-rail-draft-load` 좌측 레일 초안 목록 클릭 로드 — 검수 큐 초안을 클릭하면 상세를 불러와 현재 편집 폼을 교체한다 · 검증: 신규 spec 필요 · 현행 설계 특성화 GREEN(저작 모드 레일 숨김 — 결함 로그 #14 소유자 확인)
- [x] `persona-studio-author-rail-approved-actions` 좌측 레일 공개본 수정/삭제 버튼 — 공개본별로 revision 초안 생성 또는 confirm 후 보관 처리를 수행한다 · 검증: 신규 spec 필요 · 현행 설계 특성화 GREEN(저작 모드 레일 숨김 — 결함 로그 #14 소유자 확인)
- [x] `persona-studio-author-empty-states` 저작 레일/인스펙터 빈 상태 — 초안·공개본·결정 대상이 없을 때 각각의 빈 문구를 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-source-dropzone` 자료 파일 드래그&드롭 업로드 — 드롭된 첫 파일만 처리해 엑셀은 서버 변환, 텍스트는 브라우저에서 읽어 본문에 채운다 · 검증: 신규 spec 필요
- [x] `persona-studio-source-file-picker` 파일 선택 버튼 + 숨김 file input — 엑셀은 multipart 업로드로 표→텍스트 변환·KB 등록하고 그 외는 본문에 읽어 넣는다 · 검증: 신규 spec 필요
- [x] `persona-studio-source-kind-select` 자료 종류 셀렉트 — client_record/textbook_guide/mixed_notes 중 근거 자료 종류를 선택해 요청에 함께 보낸다 · 검증: 신규 spec 필요
- [x] `persona-studio-source-name-input` 파일/자료명 입력 — 등록될 근거 문서의 파일명·제목으로 쓰이며 비우면 기본값이 적용된다 · 검증: 신규 spec 필요
- [x] `persona-studio-generation-goal-input` 생성 목표 입력 — 초안 생성 요청의 generation_goal과 근거 문서 source_note로 전달된다 · 검증: 신규 spec 필요
- [x] `persona-studio-source-text-area` 자료 본문 붙여넣기 textarea — 내담자 기록·교재 기준을 붙여넣으면 생성 시 KB 근거 문서로 먼저 등록된다(20자 이상) · 검증: 신규 spec 필요
- [x] `persona-studio-generate-draft` KB 등록 후 생성 버튼 — 붙여넣은 텍스트를 근거 문서로 등록한 뒤 RAG 초안 생성을 호출하고 결과·마스킹·근거 청크를 인스펙터에 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-studio-tabs` 저작 섹션 탭 7종 (개요/임상/저항/회기/말투/안전/프롬프트) — role=tablist로 편집 섹션을 전환하며 섹션별 작성 가이드를 함께 보여준다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-field-code` 코드 입력 (자동 제안·중복 검사) — 대문자로 정규화하고 비면 다음 P{n}을 제안하며 카탈로그·검수 큐와 중복이면 검증 오류를 낸다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-field-display-name` 표시 이름 입력 — 표시 이름을 입력하며 비면 저장·검수 요청 모두 검증 오류가 된다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-field-difficulty` 난이도 셀렉트 (easy/moderate/hard) — 페르소나 난이도를 선택한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-field-synthetic` 합성 여부 셀렉트 (synthetic/clinical source) — 합성 초안인지 실제 임상 근거 기반인지 is_synthetic 플래그를 설정한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-theory-toggle` 대상 이론 토글 (humanistic/cbt/integrative) — 세그먼트 버튼으로 이론을 다중 선택·해제하며 마지막 하나를 해제하면 humanistic으로 되돌린다 · 검증: 신규 spec 필요
- [x] `persona-studio-fields-demographics` 인구통계 입력 4종 (연령대/성별·젠더/역할·학년/맥락) — demographics 객체의 네 키를 자유 텍스트로 편집한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-fields-presenting` 주호소·첫 회기 입장 발화 textarea — presenting.complaint와 first_session_opening을 편집하며 주호소는 검수 요청 시 필수다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-field-provenance` 출처(source_provenance) 입력 — 작성 근거·출처를 기록하며 비면 경고로 표시된다 · 검증: 신규 spec 필요
- [x] `persona-studio-fields-clinical` 임상 탭 입력 (가족/학교·직장/관계/촉발 사건/강점 + CCD 5종 + 증상 메모) — history 5개와 CCD·dsm5 메모를 편집하며 핵심신념은 검수 요청 시 필수다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-fields-resistance-numbers` 저항 탭 숫자 입력 16종 (저항 5·Big5 5·정서 기저선 5·자살사고 단계) — 0~1 파라미터와 자살사고 단계를 number 입력으로 편집한다 · 검증: 신규 spec 필요
- [x] `persona-studio-fields-scenario` 회기 탭 입력 (회기별 시나리오 리스트/표면화 조건/추가 프롬프트 계약) — session_scenarios 리스트와 surface·prompt_contract 장문을 편집한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-fields-speech` 말투 탭 입력 (어조/문장 길이/존댓말/군말·말버릇·비언어 단서) — speech_style의 셀렉트와 세 리스트를 편집한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-fields-safety` 안전 탭 입력 (역린 민감 영역/상담자 금기/역린 반응/내담자 금기어) — triggers의 리스트와 reaction 장문을 편집하며 sore_spots는 검수 요청 시 필수다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-listfield-add-remove` 리스트 필드 공통 항목 추가/삭제 — 모든 ListField에서 빈 행 추가와 X 버튼 삭제를 제공하고 저장 시 빈 항목을 거른다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-prompt-preview` 프롬프트 미리보기 패널 (읽기 전용) — 구조화 데이터를 8개 섹션 런타임 계약으로 렌더하고 빈 섹션은 '작성된 항목 없음'으로 표시한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-author-prev-next` 이전/다음 단계 버튼 — step 쿼리를 앞뒤로 옮기며 마지막 단계에서는 검수 요청 버튼으로 대체된다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `persona-studio-save-progress` 중간 저장 버튼 — 작성 내용을 localStorage에 항상 저장하고 최소 검증을 통과하면 서버 초안으로도 생성·갱신한다 · 검증: 신규 spec 필요
- [x] `persona-studio-submit-review` 검수 요청 버튼 — 전체 검증 통과 시 submit_for_review로 저장해 상태를 review로 올리고 localStorage 임시본을 삭제한다 · 검증: 기존 e2e/teacher.spec.ts
- [x] `persona-studio-inspector-validation` 인스펙터 검증 체크리스트 — 필수 오류·경고를 실시간으로 나열하고 모두 통과하면 완료 배지를 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-inspector-evidence` 인스펙터 생성 근거 패널 — 생성 요약·PII 마스킹·경고·등록 근거 문서·상위 3개 근거 청크를 표시한다 · 검증: 신규 spec 필요
- [x] `persona-studio-inspector-review-actions` 인스펙터 검수 큐 편집/반려/승인 버튼 — 저작 화면 우측에서 초안을 편집기로 로드하거나 즉시 승인·반려 결정을 보낸다 · 검증: 신규 spec 필요
- [x] `persona-studio-draft-autoload-param` `?draft=` 파라미터 초안 자동 로드 — draft 파라미터로 진입하면 로드 완료 후 해당 초안을 한 번 자동으로 편집 폼에 채운다 · 검증: 신규 spec 필요
- [x] `persona-studio-loading-busy-states` busy 상태별 버튼 라벨/비활성화 — load/save/submit/decide/generate/archive 작업 중 관련 버튼을 진행 라벨로 바꾸고 비활성화한다 · 검증: 신규 spec 필요
**의심 결함(에이전트 리포트)**:
- `persona-studio-load-error-banner`: 오류 배너에 재시도 버튼이 없어 헤더의 새로고침 버튼을 찾아야 재시도 가능
- `persona-studio-status-message-line`: formError가 message를 항상 가리므로 직전 성공 안내가 오류 발생 시 소실됨
- `persona-studio-workspace-tabs`: 저작(mode=create/edit) 중 탭 클릭 시 확인 없이 편집 화면을 이탈해 미저장 서버 반영분이 사라질 수 있음(localStorage 임시본만 남음)
- `persona-studio-create-open`: 복원된 임시본은 검수 요청 성공 때만 삭제되므로 오래된 초안이 새 작성 세션에 계속 되살아날 수 있음
- `persona-studio-dash-usage-row`: role=table/row 구조인데 행 전체가 button이고 cell 역할이 없어 스크린리더 표 탐색이 어긋남
- `persona-studio-list-origin-filter`: 검색어·필터가 쿼리스트링에 저장되지 않아 상세 진입 후 돌아오면 초기화됨
- `persona-studio-list-approved-revise`: persona_id가 없는 시드 공개본은 버튼이 disabled이나 이유 툴팁이 없어 사용자가 원인을 알 수 없음
- `persona-studio-list-approved-archive`: 네이티브 window.confirm 사용으로 앱 디자인·접근성과 불일치하고 E2E에서 다이얼로그 처리가 필요
- `persona-studio-detail-draft-fetch`: 조회 실패를 catch에서 조용히 무시(null 처리)해 사용자는 작성 정보 섹션이 왜 안 보이는지 알 수 없음
- `persona-studio-detail-notfound`: 빈 상태에 목록으로 돌아가는 버튼이 없어 사용자가 직접 URL/탭을 조작해야 함
- `persona-studio-author-stepper`: 단계 이동에 완료 조건 검증이 없어 자료 없이도 검토 단계로 건너뛸 수 있음(의도된 자유 이동일 수 있음)
- `persona-studio-author-back-to-list`: 미저장 변경이 있어도 확인 없이 이탈함(localStorage 임시본은 중간 저장을 눌렀을 때만 존재)
- `persona-studio-author-rail-draft-load`: 현재 작성 중이던 내용을 확인 없이 덮어씀
- `persona-studio-source-dropzone`: 여러 파일을 드롭해도 첫 파일만 조용히 처리되고 나머지는 무시됨
- `persona-studio-source-file-picker`: accept에 .xls가 빠져 있는데 코드 분기는 .xls도 처리함(파일 선택 대화상자에서 .xls가 걸러짐); 텍스트 분기는 확장자 검증 없이 아무 파일이나 text()로 읽음
- `persona-studio-theory-toggle`: 마지막 항목 해제 시 조용히 humanistic으로 바뀌어 사용자가 의도치 않은 값을 가질 수 있음(안내 없음)
- `persona-studio-fields-resistance-numbers`: min/max가 HTML 속성뿐이라 직접 타이핑하면 범위 밖 값(예: 7, -3)이 상태에 그대로 저장되고 JS 클램핑·검증이 없음
- `persona-studio-generate-draft`: 재클릭 시 같은 붙여넣기 텍스트가 매번 새 근거 문서로 중복 등록됨; 생성 성공 시 기존 편집 내용을 확인 없이 덮어씀
- `persona-studio-save-progress`: localStorage에는 sourceText 등 원문 자료가 평문 저장됨(내담자 실제 기록일 경우 브라우저에 PII 잔존)
- `persona-studio-inspector-review-actions`: 검수 보드와 달리 status!=='review'(작성 중 draft)여도 승인/반려 버튼이 활성화되어 UI 가드가 불일치함(서버 거부에 의존)
- `persona-studio-draft-autoload-param`: Professor 페이지는 mode 없이 `?draft=`만 붙여 이동하는데 자동 로드는 isAuthoring(mode=create/edit)일 때만 동작해 해당 진입 경로에서 초안이 로드되지 않음
## 7. 관리자 콘솔 (admin)
- [x] `admin-guard-require-auth` RequireAuth admin 가드 — 미인증은 /login, admin·superAdmin·adminAccess가 없으면 자기 역할 홈으로 리다이렉트한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-guard-pending-approval` PendingApprovalGate 승인 대기 가드 — 미승인 사용자가 /admin 계열에 오면 /pending으로 보내고 승인 사용자가 /pending에 있으면 되돌린다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-guard-onboarding-bypass` OnboardingGate admin 우회 — 온보딩 미완료여도 admin 접근 권한이 있으면 /admin 경로는 그대로 통과시킨다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-error-boundary` AdminErrorBoundary 렌더 예외 진단 — 렌더 예외 시 빈 화면 대신 section·path·error·componentStack 진단 패널을 띄우고 섹션 변경 시 리셋한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-blank-content-probe` 빈 본문 감지 프로브 — 마운트 800ms 후 본문 텍스트·높이·가시 노드를 검사해 비어 있으면 진단 패널을 표시한다 · 검증: 기존 e2e/public-admin-visual.spec.ts
- [x] `admin-data-diagnostics-panel` 관리자 데이터 계약 진단 패널 — users/tickets 응답 정규화 중 생긴 경고를 최대 8건까지 나열하고 초과분은 개수로 요약한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-unknown-section-panel` 알 수 없는 섹션 폴백 패널 — 정의되지 않은 섹션이 전달되면 섹션·경로·asset 진단 패널을 표시한다 · 검증: 신규 spec 필요 · 도달 불가 방어 코드(라우터가 섹션을 하드코딩, 미정의 경로는 루트 회수) — E2E 불가, 코드 확인으로 종결
- [x] `admin-overview-refresh-button` 운영 홈 새로고침 버튼 — 헬스·사용자·사용량·업타임·티켓 5개 API를 병렬 재조회하고 로딩 중 비활성화한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-overview-status-banner` 서비스 상태 배너 — health 상태에 따라 색상·문구를 바꾸고 환경·엔진 모드·갱신 시각을 role=status로 표시한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-overview-kpi-cards` 운영 요약 KPI 4종 — 활성 회기·최근 15분 온라인 사용자·등록 사용자 수·헬스 ok/total을 카드로 표시한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-overview-flow-panel` 실시간 교육 흐름 패널 — 학습자 회기 수·온라인 교수자/관리자 수·최근 7일 AI 계량 턴 수를 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-overview-availability-panel` 가용성 패널 — 현재 헬스, 24시간 샘플 정상률(샘플 없음/미연결 구분), 최근 중단 시각을 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-overview-cost-panel` AI 비용 패널 — 7일 비용·예산 상태·사용률 바·평가 캐시 hit-rate·일별 비용 추이를 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-overview-ticket-mini-panel` 운영 티켓 미니 패널 — 미해결·높은 우선순위·24시간 정체 요약과 상위 활성 티켓 2건을 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-overview-services-list` 서비스 리소스 목록 — health.services를 상태 Dot·Badge·detail·load 바로 렌더하고 로딩 스켈레톤과 빈 문구를 갖는다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-overview-health-error` 헬스 조회 실패 InlineError — health 호출 실패 시 오류 메시지를 role=alert 섹션으로 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-tabs` 사용자 관리 탭바(승인/목록/등록/활동) — 대기 건수를 표시한 4개 탭을 role=tablist + aria-selected로 전환한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-users-approval-queue` 가입 승인 대기 큐 — pending 사용자를 접수일 내림차순으로 나열하고 집계 배지·스켈레톤·빈 상태를 제공한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-users-approve-button` 가입 승인 버튼 — 대기 사용자의 account_status를 approved로 PATCH하고 목록·드래프트를 갱신한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-users-suspend-button` 가입 보류 버튼 — 대기 사용자의 account_status를 suspended로 PATCH해 보류 처리한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-readonly-guard` DB 미연결 읽기 전용 경고 — durable이 false면 경고를 띄우고 승인·등록·편집·저장·비활성화 등 쓰기 UI를 모두 비활성화한다 · 검증: 기존(부분) e2e/readiness.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-users-search-input` 사용자 검색 입력 — 이메일·이름·역할·권한·상태·소속·코호트 문자열에 부분 일치로 필터링하고 카운트를 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-table-sort` 사용자 표 컬럼 정렬 — TanStack Table로 8개 헤더를 클릭 정렬(기본 last_seen_at 내림차순)하고 aria-sort를 갱신한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-table-scroll-region` 사용자 표 가로 스크롤 영역 — 작은 화면에서 표를 tabIndex=0, role=region 가로 스크롤 컨테이너로 감싼다 · 검증: 기존 e2e/layout-visual-gate.spec.ts + e2e/admin.spec.ts
- [x] `admin-users-row-cap` 표 40행 렌더 제한 안내 — 상위 40행만 렌더하고 초과분은 검색으로 좁히라는 안내문을 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-edit-name-input` 행 내 표시 이름 편집 — 각 행의 표시 이름을 드래프트로 수정하며 DB 미연결·저장 중에는 비활성화한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-users-edit-role-select` 행 내 역할 셀렉트 — 역할을 드래프트로 변경하되 관리자 옵션은 superAdmin만 선택 가능하다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-users-admin-access-toggle` 관리자 페이지 권한 체크박스 — admin_access를 토글하며 superAdmin이 아니거나 대상이 super_admin이면 비활성화한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-status-select` 행 내 승인 상태 셀렉트 — 승인 대기/승인됨/보류를 드래프트로 바꾸고 현재 서버 값은 옆 배지로 구분한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-users-affiliation-input` 행 내 소속 편집 — 소속 문자열을 드래프트로 수정한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-cohort-input` 행 내 코호트 편집 — 콤마 구분 문자열을 trim·빈값 제거해 cohort_ids 배열로 반영한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-save-button` 행 저장 버튼 — 드래프트가 서버 값과 다를 때만 활성화되어 PATCH하고 응답으로 로컬 상태를 교체한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-users-deactivate-button` 사용자 비활성화 버튼 — DELETE 후 목록·드래프트에서 즉시 제거하며 처리 중 라벨로 중복 클릭을 막는다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-users-register-form` 새 사용자 등록 폼 — 이메일·이름·역할·권한·상태·코호트·소속을 POST하고 성공 시 폼 초기화 후 목록 탭으로 이동한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-users-activity-tab` 활동 요약 탭 — 역할 분포를 ProgressBar로, last_seen 상위 8명의 최근 활동을 표로 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-users-states` 사용자 로딩/빈/오류 상태 — 스켈레톤, InlineError와 재시도 안내, 검색 0건·등록 0건 문구를 구분해 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-access-tabs` 접근 권한 탭바(역할/그룹/매트릭스) — 3개 탭을 role=tablist로 전환한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `admin-access-roles-cards` 역할 정책 카드 — 관리자/교수자/학습자 3개 역할의 범위·권한 칩·위험 문구를 하드코딩 상수로 표시한다 · 검증: 기존(부분) e2e/public-admin-visual.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `admin-access-groups-cards` 그룹 정책 카드 — 운영자/학과 관리자/슈퍼바이저 그룹의 범위와 접근 칩을 '정책 초안' 배지와 함께 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-access-matrix-table` 권한 매트릭스 표 — 리소스별 역할 접근 수준을 정적 5행 표로 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-tickets-refresh-button` 티켓 새로고침 버튼 — 현재 필터 조합으로 티켓 목록을 재조회하고 로딩 중 비활성화한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-search-input` 티켓 검색 입력 — 제목·본문·신고자·경로를 서버 search 파라미터로 필터링하며 값 변경 시 자동 재조회한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-status-filter` 상태 필터 셀렉트 — 전체/미해결/분류됨/처리 중/해결/종결을 status 파라미터로 서버 필터링한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-category-filter` 카테고리 필터 셀렉트 — 계정·세션·음성·콘텐츠·안전·기타를 category 파라미터로 서버 필터링한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-priority-filter` 우선순위 필터 셀렉트 — 긴급/높음/보통/낮음을 priority 파라미터로 서버 필터링한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-assigned-group-input` 담당 그룹 필터 입력 — assigned_group 파라미터로 담당 그룹을 서버 필터링한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-stale-toggle` 정체만 체크박스 — stale_only=true로 24시간 이상 미변경 티켓만 조회한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-clear-filters-button` 필터 초기화 버튼 — 모든 필터를 기본값으로 되돌리며 필터가 없으면 비활성화된다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-category-queue-chips` 카테고리 큐 칩 버튼 — 건수가 있는 카테고리를 칩으로 나열하고 클릭 시 해당 필터를 적용하며 활성 칩을 강조한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-kpi-cards` 티켓 요약 KPI 4종 — 전체 접수·미해결·높은 우선순위·정체 건수를 summary 기반 카드로 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-tickets-active-list` 미해결 티켓 큐 목록 — 활성 티켓 최대 24건을 카테고리·신고자·중복 관계·처리 이력·배지와 함께 렌더한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-ticket-inprogress-button` 티켓 '처리 중' 버튼 — 상태를 in_progress로 PATCH하고 pendingRef로 중복 클릭을 즉시 차단한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-ticket-resolve-button` 티켓 '해결' 버튼 — 상태를 resolved로 PATCH하고 성공 시 큐에서 이력으로 내린다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-ticket-link-duplicate-button` 중복 후보 '연결' 버튼 — 중복 후보가 있을 때만 노출되어 parent_ticket_id를 후보로 PATCH해 연결한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-ticket-unlink-duplicate-button` 중복 연결 '해제' 버튼 — parent_ticket_id를 빈 값으로 PATCH해 중복 연결을 해제한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
- [x] `admin-tickets-resolved-history` 최근 해결 이력 섹션 — 해결·종결 티켓 최대 6건을 읽기 전용 '이력' 카드로 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-tickets-states` 티켓 로딩/빈/오류 상태 — 최초 로딩·활성 0건 빈 상태·실패 InlineError를 구분해 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
**의심 결함(에이전트 리포트)**:
- `admin-data-diagnostics-panel`: users는 필드 단위 정규화가 있지만 tickets 배열은 as AdminSupportTicket[] 캐스트만 함 — reporter 등 누락 시 ticket.reporter.email 접근에서 렌더 크래시(에러 바운더리로만 방어)
- `admin-overview-refresh-button`: disabled 조건이 loading||usersLoading||usageLoading뿐이라 uptime·tickets 로딩 중에는 중복 클릭 가능
- `admin-users-tabs`: role=tab이지만 화살표 키 내비게이션·aria-controls·tabpanel 연결이 없어 ARIA 탭 패턴 미완성
- `admin-users-row-cap`: 페이지네이션이 없어 41번째 이후 사용자는 검색 없이는 도달 불가
- `admin-users-status-select`: super_admin 계정의 승인 상태도 일반 관리자가 보류로 바꿀 수 있게 UI가 열려 있음(서버 검증 의존)
- `admin-users-deactivate-button`: 확인 대화상자 없이 한 번의 클릭으로 계정이 비활성화됨(파괴적 작업 컨펌 누락)
- `admin-users-register-form`: 이메일 형식 검증 없이 비어 있지만 않으면 제출됨; 오류는 상단 usersError 배너로만 표시되어 폼 필드와 연결 안 됨
- `admin-users-states`: '새로고침을 눌러 다시 시도하세요' 문구가 있지만 users 탭 화면에는 재시도 버튼이 없음(새로고침 버튼은 overview에만 존재)
- `admin-access-tabs`: users 탭바와 동일하게 키보드 화살표 내비게이션 미구현
- `admin-access-roles-cards`: 화면 스스로 '역할·그룹 저장 API는 아직 분리되지 않았다'고 명시한 정적 화면 — 조작 요소가 전혀 없어 실데이터와 어긋날 수 있음
- `admin-tickets-search-input`: 디바운스가 없어 키 입력마다 GET /admin/tickets가 발사됨(담당 그룹 입력도 동일)
- `admin-tickets-assigned-group-input`: 키 입력마다 즉시 재조회(디바운스 없음)
- `admin-tickets-category-queue-chips`: 활성 칩을 다시 클릭해도 해제(전체로 복귀)되지 않음 — 해제하려면 셀렉트나 초기화 버튼을 써야 함
- `admin-ticket-resolve-button`: 성공 시 로컬 상태 교체와 loadTickets 재조회를 둘 다 수행해 요청이 중복됨(경미)
- `admin-tickets-states`: loadTickets가 시작·성공 시 ticketsError를 초기화하지 않아 한 번 실패하면 이후 조회가 성공해도 오류 배너가 계속 남음(티켓 상태 변경 성공 시에만 지워짐)
## 8. 관리자 AI 설정 (admin-ai)
- [x] `admin-ai-guard-require-auth` RequireAuth(admin) 라우트 가드 — 미인증은 /login, admin 권한이 없으면 자기 역할 홈으로 리다이렉트한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-ai-guard-pending-approval` PendingApprovalGate 리다이렉트 — 미승인 사용자가 /admin/ai에 오면 /pending으로 리다이렉트한다 · 검증: 신규 spec 필요
- [x] `admin-ai-guard-onboarding-bypass` OnboardingGate 관리자 경로 우회 — 온보딩 미완료여도 admin 권한 보유 시 /admin/* 경로를 통과시킨다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-ai-shell-sidebar-nav` AppShell 관리자 사이드바 내비게이션 — navRole=admin 셸로 관리자 메뉴 링크를 제공하고 현재 페이지를 활성 표시한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-ai-initial-load` 초기 데이터 병렬 로드 — usage·health·engine-config를 allSettled로 호출해 성공분만 반영하고 실패 메시지는 중복 제거해 배너에 잇는다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-ai-refresh-button` 새로고침 버튼 — 세 가지 데이터를 모두 다시 불러오며 로딩 중 비활성화된다 · 검증: 신규 spec 필요
- [x] `admin-ai-error-alert` 오류 배너(role=alert) — 로드·저장 실패 시 오류 메시지를 role=alert 배너로 띄우고 다음 요청 시작 시 초기화한다 · 검증: 신규 spec 필요
- [x] `admin-ai-aria-busy` 로딩 중 aria-busy 표시 — 페이지 컨테이너에 로딩 동안 aria-busy=true를 설정한다 · 검증: 신규 spec 필요
- [x] `admin-ai-window-toggle` 계량 기간 토글(7/30/90일) — 기간을 바꾸면 usage만 재조회하고 늦게 도착한 응답은 active 플래그로 무시한다 · 검증: 신규 spec 필요
- [x] `admin-ai-source-indicator` 계량 데이터 원천 표시 — durable+database면 '운영 DB 원장'을, 아니면 런타임 임시 집계와 소실 경고를 표시한다 · 검증: 기존 e2e/readiness.spec.ts
- [x] `admin-ai-ledger-cards` 핵심 지표 카드 5종 — 누적 비용·계량 호출·입출력 토큰·호출당 비용을 usage에서 계산해 표시한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-ai-budget-panel` 예산 상태·사용률 트랙 — budget.status를 배지로, used_ratio를 100% 캡 진행 바로 표시하고 disabled면 설정 안내를 보여준다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `admin-ai-coverage-track` DB 계량 커버리지 트랙 — metered_turns/total_turns 비율을 진행 바로 표시하고 미계량 턴 수를 안내한다 · 검증: 신규 spec 필요
- [x] `admin-ai-daily-chart` 일별 비용 막대 차트 — daily_cost를 상대 높이 막대로 렌더하고 날짜·비용 툴팁과 role=img aria-label을 제공한다 · 검증: 신규 spec 필요
- [x] `admin-ai-daily-chart-empty` 일별 차트 빈 상태 — 선택 기간에 계량된 호출이 없으면 안내 문구를 보여준다 · 검증: 신규 spec 필요
- [x] `admin-ai-provider-table` Provider·모델별 사용 원장 테이블 — by_provider 행을 비용 내림차순으로 정렬해 호출·토큰·비용·비중을 표로 보여준다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `admin-ai-provider-table-empty` 모델 원장 빈 상태 — 계량 행이 없으면 '모델별 계량 행이 아직 없습니다.'를 보여준다 · 검증: 신규 spec 필요
- [x] `admin-ai-engine-status-pill` 엔진 런타임 상태 배지 — health.services의 engine 서비스를 정상/중단/제한 운영으로 표시하고 미확인 시 '확인 불가'를 보여준다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `admin-ai-engine-mode-radiogroup` AI 운영 방식 라디오그룹(4종) — claude_cli·claude_api·openai·solar를 role=radio 버튼으로 제공하며 저장 버튼을 눌러야 서버에 반영된다 · 검증: 기존 e2e/admin.spec.ts + e2e/db-persistence.spec.ts
- [x] `admin-ai-engine-url-input` 연결 주소 입력 — engine_url을 제어 컴포넌트로 편집하며 게이트웨이·API base URL 힌트를 제공한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `admin-ai-engine-model-input` 기본 모델 입력 — 기본 model 문자열을 편집하며 평가 모델 override가 없을 때 적용된다고 안내한다 · 검증: 기존 e2e/db-persistence.spec.ts
- [x] `admin-ai-engine-config-meta` 엔진 설정 메타 정보 — 저장 원천(DB 영구/런타임)·현재 소스·최근 변경 시각·변경자를 dl로 표시한다 · 검증: 신규 spec 필요
- [x] `admin-ai-engine-save-button` 운영 설정 저장 버튼 — 모드·URL·모델을 PATCH 저장하고 health를 재조회하며 2.4초간 '저장됨' 배지를 띄운다 · 검증: 기존 e2e/admin.spec.ts + e2e/db-persistence.spec.ts
- [x] `admin-ai-engine-loading-empty` 엔진 설정 로딩/실패 빈 상태 — engine-config가 없으면 '불러오는 중입니다' 문구를 표시한다 · 검증: 신규 spec 필요
- [x] `admin-ai-cache-panel` 평가 캐시 효율 패널 — enabled 배지·hit-rate·요청/적중/미스/저장/엔트리/축출 카운트와 원문 미저장 안내를 표시한다 · 검증: 신규 spec 필요
- [x] `admin-ai-integrity-panel` DB 계량 상태(무결성) 패널 — 계량 원천·durable 여부·미계량 턴 수와 '감사 필요/누락 없음' 판정을 표시한다 · 검증: 기존(부분) e2e/readiness.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `admin-ai-note-no-source-pack-kb` [조사 노트] source pack sync·KB 관리 부재 — 이 페이지에는 source pack sync·KB 관리 UI가 없고 관련 코드는 PersonaStudio·Session에만 존재한다 · 검증: 기존 e2e/kb-source-packs.spec.ts
**의심 결함(에이전트 리포트)**:
- `admin-ai-initial-load`: 부분 실패 시 성공 데이터와 오류 배너가 공존해 어떤 패널이 실패했는지 구분하기 어렵다. engine-config 실패 시 엔진 패널은 '불러오는 중' 문구가 영구히 남는다(재시도 UI 없음).
- `admin-ai-refresh-button`: 엔진 설정 폼을 수정하고 저장하기 전에 새로고침을 누르면 편집 중이던 engine_url·model·mode가 서버 값으로 조용히 덮어써진다(변경사항 유실 경고 없음).
- `admin-ai-aria-busy`: aria-busy 외에 시각적 로딩 스피너/스켈레톤이 없어 데이터 미도착 시 지표가 '—'로만 보인다.
- `admin-ai-window-toggle`: 기간 변경 실패 시 이전 기간의 usage 데이터가 화면에 그대로 남은 채 오류 배너만 떠서 기간 라벨(예: '90일 DB 집계')과 실제 표시 데이터가 불일치할 수 있다.
- `admin-ai-daily-chart`: 막대별 상세가 title 속성에만 있어 키보드·스크린리더로는 개별 일자 값에 접근할 수 없다(전체 aria-label만 존재).
- `admin-ai-engine-mode-radiogroup`: ENGINE_MODE_LABEL에는 messages_api 라벨이 있지만 선택지(ENGINE_MODES)에는 없어, 서버가 engine_mode='messages_api'를 반환하면 어떤 라디오도 선택 표시되지 않는다. 또 role=radio이지만 방향키 이동 등 라디오 키보드 패턴이 구현되어 있지 않다.
- `admin-ai-engine-url-input`: URL 형식 검증이 없어(공백만 아니면 저장 가능) 잘못된 주소도 그대로 PATCH된다.
- `admin-ai-engine-save-button`: 저장 성공 후 health 재조회 실패는 catch(() => health)로 조용히 무시되며 이때 스테일 클로저의 이전 health를 쓴다. window.setTimeout(2400ms)을 언마운트 시 정리하지 않아 페이지 이탈 후 setState가 호출될 수 있다. 저장 확인(confirm) 없이 즉시 운영 엔진이 교체된다.
- `admin-ai-engine-loading-empty`: GET 실패 시에도 같은 '불러오는 중' 문구가 영구 표시되어 실패 상태를 로딩으로 오인하게 한다(패널 내 재시도 수단 없음).
## 9. 설정 (settings)
- [x] `settings-guard-require-auth` RequireAuth 가드 (미인증 → /login) — 로딩 중 BootScreen을 띄우고 미인증이면 state.from을 담아 /login으로 보낸다(역할 제한 없음) · 검증: 신규 spec 필요
- [x] `settings-guard-pending-approval` PendingApprovalGate (미승인 → /pending) — 미승인 사용자가 /settings에 접근하면 /pending으로 리다이렉트한다 · 검증: 신규 spec 필요
- [x] `settings-guard-onboarding` OnboardingGate (온보딩 미완료 → /onboarding) — onboardingCompletedAt이 null이면 /onboarding으로 보낸다(/settings는 관리자 예외 경로가 아님) · 검증: 신규 spec 필요
- [x] `settings-initial-load` 설정 데이터 일괄 로드 — 프로필·환경설정·지원 티켓·음성 프리셋을 병렬 조회하고 관리자면 엔진 설정·헬스도 추가 조회한다 · 검증: 기존 e2e/settings.spec.ts
- [x] `settings-load-error-callout` 로드 실패 경고 콜아웃 — 로드 실패 시 화면 상단에 role=alert 경고 콜아웃으로 오류 메시지를 표시한다 · 검증: 신규 spec 필요
- [x] `settings-section-skeletons` 섹션별 로딩 스켈레톤 — 데이터 도착 전 role=status 스켈레톤을 띄우고 스크린리더용 텍스트를 숨김 제공한다 · 검증: 기존 e2e/settings.spec.ts
- [x] `settings-rail-nav-buttons` 좌측 레일 섹션 내비게이션 버튼 — 계정·지원·(관리자 AI 운영)·테마·알림·음성 버튼으로 해당 섹션에 스크롤하고 aria-current를 표시한다 · 검증: 신규 spec 필요
- [x] `settings-rail-nav-scrollspy` 스크롤 스파이 (IntersectionObserver) — 화면에 가장 많이 보이는 섹션을 감지해 레일 활성 항목을 자동 동기화한다 · 검증: 신규 spec 필요
- [x] `settings-rail-account-summary` 레일 계정 요약 카드·역할 pill — 이니셜 아바타·표시 이름·이메일·역할·소속 배지를 요약하고 관리자에게는 AI 헬스 pill을 더한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `settings-account-display-name-input` 표시 이름 입력 — 입력 시 dirty ref를 켜서 이후 도착하는 서버 로드값이 입력을 덮어쓰지 않게 한다 · 검증: 기존 e2e/settings.spec.ts
- [x] `settings-account-email-readonly` 이메일 (읽기 전용) — 로그인으로 확인된 이메일을 disabled 입력으로 표시만 한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `settings-account-affiliation-input` 소속 입력 — 강의·연구 운영에서 표시할 소속 문자열을 입력받는다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `settings-account-save-button` 계정 저장 버튼 — 표시 이름·소속을 PATCH 저장하고 응답으로 폼을 동기화한 뒤 '저장됨'을 2.4초 띄운다 · 검증: 기존 e2e/settings.spec.ts + e2e/db-persistence.spec.ts
- [x] `settings-support-ticket-list` 내 지원 요청 티켓 목록 — 내가 접수한 티켓을 상태·카테고리·우선순위 배지, 담당 그룹, 해결 메모와 함께 나열한다 · 검증: 기존 e2e/settings.spec.ts
- [x] `settings-support-error-state` 지원 요청 로드 실패 상태 — 티켓 조회만 실패하면 페이지를 깨뜨리지 않고 섹션 안에 role=alert 경고를 표시한다 · 검증: 신규 spec 필요
- [x] `settings-support-empty-state` 지원 요청 빈 상태 — 0건이면 '접수한 지원 요청이 없습니다', null이면 '저장소에 표시할 항목이 없습니다'를 표시한다 · 검증: 신규 spec 필요
- [x] `settings-engine-mode-radiogroup` AI 운영 방식 세그먼트 라디오 (관리자) — 4가지 연결 방식 버튼 중 하나를 골라 로컬 상태에 반영한다 · 검증: 기존 e2e/settings.spec.ts
- [x] `settings-engine-url-input` AI 연결 주소 입력 (관리자) — 응답 생성 서비스 연결 URL을 로컬 상태와 ref에 반영한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `settings-engine-model-input` 기본 모델 입력 (관리자) — 기본 모델 이름을 입력받고 최근 변경자·저장 상태 메타를 함께 표시한다 · 검증: 기존 e2e/settings.spec.ts
- [x] `settings-engine-save-button` 운영 설정 저장 버튼 (관리자) — 운영 방식·주소·모델을 PATCH 저장한 뒤 헬스를 재조회해 상태 칩을 갱신한다 · 검증: 기존 e2e/settings.spec.ts + e2e/db-persistence.spec.ts
- [x] `settings-engine-health-chip` 응답 생성 헬스 상태 칩 (관리자) — engine 서비스 상태를 정상/제한 운영/중단/확인 중 텍스트와 detail·metric으로 표시한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `settings-theme-dark-toggle` 다크 모드 토글 — role=switch 토글로 즉시 data-theme을 바꾸고 localStorage에 저장해 전역 구독자와 동기화한다 · 검증: 신규 spec 필요
- [x] `settings-theme-save-button` 테마 저장 버튼 — 현재 다크 여부를 theme으로 서버 환경설정에 PATCH 저장하고 '저장됨'을 표시한다 · 검증: 신규 spec 필요
- [x] `settings-voice-preset-radiogroup` 음성 프리셋 선택 라디오 카드 — 서버 프리셋 목록을 role=radio 카드로 나열하고 클릭 시 voice_preset_id를 로컬 상태에 반영한다 · 검증: 기존 e2e/voice.spec.ts
- [x] `settings-voice-rate-slider` 말하기 속도 슬라이더 — 0.8x~1.2x range 입력으로 voice_rate를 조절하고 현재값을 실시간 표시한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `settings-voice-save-button` 음성 저장 버튼 — 프리셋 ID와 말하기 속도를 서버 환경설정에 PATCH 저장하고 '저장됨'을 표시한다 · 검증: 기존 e2e/settings.spec.ts
- [x] `settings-voice-empty-state` 음성 프리셋 빈 상태 — 등록 프리셋이 없으면 안내를 표시하고 슬라이더·저장 버튼을 숨긴다 · 검증: 신규 spec 필요
- [x] `settings-notify-toggles` 알림 항목 토글 (역할 필터) — 공통 3종에 더해 담당 학습자 진행은 teacher/admin, 가입 승인은 admin에게만 role=switch로 표시한다 · 검증: 기존 e2e/settings.spec.ts
- [x] `settings-notify-save-button` 알림 저장 버튼 — 토글 상태를 theme·voice 값과 함께 환경설정에 PATCH 저장하고 '저장됨'을 표시한다 · 검증: 기존 e2e/settings.spec.ts + e2e/db-persistence.spec.ts
- [x] `settings-saved-flash-indicator` '저장됨' 플래시 피드백 — 각 섹션 저장 성공 시 체크 아이콘과 문구를 2.4초 표시했다 자동으로 지운다 · 검증: 신규 spec 필요
- [x] `settings-topbar-theme-toggle` 톱바 테마 전환 버튼 (공용 셸) — 상단 Topbar의 해/달 버튼이 설정 섹션의 다크 모드 토글과 같은 전역 스토어를 전환한다 · 검증: 신규 spec 필요
- [x] `settings-topbar-logout` 로그아웃 버튼 (공용 Topbar) — 설정 화면의 로그아웃은 공용 Topbar 아이콘에 있으며 서버 로그아웃 후 /login으로 이동한다 · 검증: 신규 spec 필요
- [x] `settings-topbar-role-switch` 역할 공간 전환 링크 (공용 Topbar) — 복수 역할 권한자에게 학습자/교수자/관리자 홈 이동 링크를 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `settings-avatar-upload-missing` 아바타 업로드 UI (부재) — 브리핑상 설정에 있어야 할 아바타 업로드가 없고 업로드 API는 온보딩에서만 쓰인다 · 검증: 신규 spec 필요 · **구현 완료(2026-07-27 소유자 결정: 설정에 추가)** — 신규 spec GREEN(full-sweep-settings 4/4 desktop+mobile)
- [x] `settings-consent-management-missing` 동의 관리·철회 UI (부재) — 브리핑상 설정에 있어야 할 동의 관리·철회가 없고 withdrawConsent는 어떤 UI에서도 호출되지 않는다 · 검증: 신규 spec 필요 · **구현 완료(2026-07-27 소유자 결정: 전체 흐름)** — 철회·재동의 실 API round-trip GREEN(full-sweep-settings 44/44 무회귀)
**의심 결함(에이전트 리포트)**:
- `settings-initial-load`: supportTickets만 개별 catch되고 나머지는 하나라도 실패하면 페이지 전체 오류로 전환됨(부분 성공 데이터 폐기). 서버 theme 값이 로드 시 setDark를 호출해 사용자가 Topbar에서 방금 바꾼 로컬 테마를 되돌릴 수 있음.
- `settings-load-error-callout`: 재시도 버튼이 없어 사용자는 새로고침 외 복구 수단이 없음.
- `settings-rail-account-summary`: 서버 profile의 avatar_url을 무시하고 항상 이니셜만 렌더 — Topbar는 업로드된 아바타 이미지를 보여줘 화면 간 불일치.
- `settings-account-save-button`: saveProfile에 try/catch가 없어 실패 시 unhandled rejection — 사용자에게 오류 표시가 전혀 없고 저장 실패를 알 수 없음. 저장 중 busy 상태도 없어 연타 가능.
- `settings-support-ticket-list`: 읽기 전용 목록만 있고 이 화면에서 새 지원 요청을 접수하는 UI가 없음.
- `settings-support-empty-state`: 빈 상태가 로딩용 SettingsLoadingState(점멸 dot, role=status) 컴포넌트를 재사용해 영구 로딩 중처럼 오인될 수 있음. 두 빈 상태가 같은 testId(settings-support-empty)를 공유.
- `settings-engine-mode-radiogroup`: role=radio 버튼 그룹에 화살표 키 이동(roving tabindex)이 없어 키보드 접근성 미흡. 라벨 맵에는 messages_api가 있으나 선택지에는 없어 서버가 그 값을 반환하면 아무 라디오도 체크되지 않음.
- `settings-engine-url-input`: URL 형식 검증이 전혀 없음(공백만 아니면 저장 가능).
- `settings-engine-save-button`: 선택 모드를 React 상태 대신 document.querySelector('[data-engine-mode][aria-checked=true]') DOM 조회로 읽는 안티패턴 — 마크업 변경에 취약. PATCH 실패 시 try/catch 없음(unhandled rejection, 사용자 피드백 없음).
- `settings-engine-health-chip`: 헬스 조회 실패를 조용히 삼키고 null 처리 — 실패해도 '확인 중'으로만 남아 장애와 로딩을 구분할 수 없음. 기본 status fallback이 'degraded'라 로딩 중에도 경고색 칩으로 렌더됨.
- `settings-theme-save-button`: savePreferences에 try/catch 없음(실패 무통보). 저장 안 하면 서버 theme이 남아 다음 /settings 방문 로드 시 로컬 토글값을 되돌림 — 토글 즉시 반영과 서버 저장이 이중 상태.
- `settings-voice-preset-radiogroup`: 카드 우측 SpeakerIcon(재생 삼각형)이 role=presentation·aria-hidden 장식일 뿐 미리듣기 기능이 없음 — 재생 버튼처럼 보이는 죽은 어포던스. 화살표 키 라디오 이동도 없음.
- `settings-voice-save-button`: try/catch 없음(실패 무통보). savePreferences가 theme·notifications까지 통째로 전송해 다른 섹션의 미저장 변경도 함께 확정됨.
- `settings-notify-save-button`: try/catch 없음(실패 무통보). 알림 알림 자체는 저장만 되고 실제 알림 전달 채널이 이 화면에서 확인 불가.
- `settings-saved-flash-indicator`: role=status/aria-live가 없어 스크린리더는 저장 완료를 인지하지 못함.
- `settings-topbar-logout`: auth.tsx의 logout이 API 실패를 무시하고 로컬 세션만 지우는 구조인지 확인 필요 — Settings 본문에는 로그아웃·계정 삭제 등 세션 관리 UI가 전무.
- `settings-avatar-upload-missing`: 온보딩 이후 아바타를 바꿀 UI가 앱 어디에도 없음.
- `settings-consent-management-missing`: 동의 철회 경로가 UI에 전혀 노출되지 않아 규정(동의 철회권) 관점에서 공백 — dead code 상태의 withdrawConsent.
## 10. 공통 셸·GNB·라우팅 가드 (shell)
- [x] `shell-topbar-brand-link` 톱바 브랜드 로고 링크 — 워드마크 클릭 시 로그인 상태면 루트로, 미로그인이면 /login으로 이동한다 · 검증: 신규 spec 필요
- [x] `shell-topbar-role-context-label` 역할 컨텍스트 라벨 — 톱바에 현재 역할 라벨 또는 페이지가 넘긴 contextLabel을 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `shell-topbar-space-switcher` 공간 전환 링크 그룹 — 권한에 따라 학습자/교수자/관리자 공간 링크를 노출하고 현재 경로 접두 일치 시 활성 표시한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `shell-topbar-theme-toggle` 다크/라이트 테마 토글 — 아이콘 버튼으로 data-theme을 전환하고 localStorage에 저장하며 Settings와 같은 스토어를 공유한다 · 검증: 기존(부분) e2e/auth-visual.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `shell-topbar-user-chip` 사용자 아바타·이름 표시 — avatarUrl 이미지(없으면 이니셜)와 이름을 톱바 우측에 표시한다(클릭 동작 없음) · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `shell-topbar-logout` 로그아웃 버튼 — 로그아웃 API 호출 후(실패해도) 로컬 사용자 상태를 비우고 /login으로 replace 이동한다 · 검증: 신규 spec 필요
- [x] `shell-sidebar-learner-nav` 학습자 사이드바 메뉴 — 대시보드·학습·기록·설정 4개 NavLink를 표시하고 활성 항목을 알약으로 강조한다 · 검증: 기존(부분) e2e/layout-visual-gate.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `shell-sidebar-teacher-nav` 교수자 사이드바 메뉴 — 콘솔·학생 분석·페르소나·설정 4개 NavLink를 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `shell-sidebar-admin-nav` 관리자 사이드바 메뉴 — 관리자 5 + 교수자 3 + 학습자 3 + 설정 1, 총 12개 NavLink를 한 목록으로 표시한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `shell-sidebar-ethics-footnote` 사이드바 윤리 고지 — 교육용 시뮬레이션이며 실제 치료·진단을 대체하지 않는다는 고지를 항상 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `shell-appshell-focus-modes` 셸 레이아웃 변형(hideNav/hideTopbar/bleed/wide) — 페이지 props로 사이드바 숨김·전체화면·풀블리드·넓은 작업폭을 제공하고 미인증 시 사이드바를 자동 숨긴다 · 검증: 기존 e2e/session-layout.spec.ts
- [x] `shell-appshell-role-theming` body[data-role] 역할 테마 적용 — navRole에 따라 body에 data-role을 설정해 역할별 팔레트를 적용하고 언마운트 시 복원한다 · 검증: 신규 spec 필요
- [x] `shell-auth-bootstrap-loading` 인증 부트스트랩 로딩 화면 — 앱 시작 시 세션을 복원하고 완료 전까지 모든 가드가 중립 BootScreen을 표시한다 · 검증: 신규 spec 필요
- [x] `shell-suspense-fallback` lazy 라우트 청크 로딩 fallback — 모든 페이지가 lazy import이므로 청크 로딩 동안 Suspense fallback으로 BootScreen을 표시한다 · 검증: 신규 spec 필요
- [x] `shell-guard-require-auth` RequireAuth 미인증 리다이렉트 — 보호 라우트에서 미인증이면 원래 경로를 state.from에 담아 /login으로 replace 리다이렉트한다 · 검증: 기존 e2e/learner.spec.ts
- [x] `shell-guard-role-restriction` RequireAuth 역할 제한 리다이렉트 — 역할 불일치 시 빈 화면 대신 자기 역할 홈으로 보내며 admin·adminAccess 예외를 적용한다 · 검증: 기존 e2e/admin.spec.ts + e2e/teacher.spec.ts
- [x] `shell-guard-pending-approval` PendingApprovalGate 승인 대기 리다이렉트 — 미승인 사용자는 어떤 경로든 /pending으로 보내고 승인 사용자가 /pending에 오면 초기 경로로 되돌린다 · 검증: 신규 spec 필요
- [x] `shell-guard-onboarding` OnboardingGate 온보딩 리다이렉트 — 온보딩 미완료자는 /onboarding으로(admin의 /admin*만 예외), 완료자가 /login·/onboarding에 오면 initialPathForUser로 되돌린다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `shell-root-redirect` 루트 경로 역할별 분기 — / 접근 시 미승인은 /pending, 인증 사용자는 initialPathForUser, 미인증은 /login으로 replace 이동한다 · 검증: 기존 e2e/admin.spec.ts
- [x] `shell-unknown-route-redirect` 미정의 경로 리다이렉트 — 정의되지 않은 모든 경로는 404 화면 없이 /로 replace되어 역할 홈으로 재분기된다 · 검증: 신규 spec 필요
- [x] `shell-route-error-boundary` 라우트 렌더 오류 fallback — 렌더 예외 시 role=alert 진단 화면을 띄우고 경로가 바뀌면 오류 상태를 자동 리셋한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `shell-scroll-reset-on-navigate` 경로 변경 시 스크롤 최상단 리셋 — scrollRestoration을 manual로 두고 경로 변경·bfcache 복원 시 스크롤을 최상단으로 되돌린다 · 검증: 기존 e2e/admin.spec.ts
- [x] `shell-auth-expired-listener` 세션 만료 이벤트 자동 로그아웃 — API 계층이 401에서 발행하는 auth-expired 이벤트를 받아 사용자 상태를 비워 /login으로 유도한다 · 검증: 기존 e2e/learner.spec.ts
- [x] `shell-avatar-expression-board` Live2D 페르소나 표정 보드 — P4~P7 모델별 공유 표정 라이브러리 전체를 모델 경로·모션·페이드 메타와 함께 정적 렌더하는 QA 보드다 · 검증: 기존 e2e/avatar-expression-lab.spec.ts
- [x] `shell-avatar-preview-dev-page` 서연 SVG 아바타 dev 미리보기 — 인증 없이 접근 가능한 dev 페이지로 4개 상태 애니메이션과 대표 표정 12종 그리드를 표시한다 · 검증: 신규 spec 필요
**의심 결함(에이전트 리포트)**:
- `shell-topbar-space-switcher`: 일반 학습자·교수자에게는 아예 미노출(의도). 컨테이너가 nav가 아닌 div에 aria-label만 부여됐고, accessibleRolesFor 결과를 canAccessRole로 재필터링하는 중복 로직이 있음.
- `shell-sidebar-admin-nav`: 12개 항목이 역할 그룹 구분(구분선·소제목) 없이 한 열에 나열되고 review 아이콘이 5개 항목에 중복 사용되어 스캔성이 낮음.
- `shell-auth-bootstrap-loading`: me 호출 실패 사유(네트워크 오류 vs 미인증)를 구분하지 않고 catch에서 조용히 user=null 처리해 오프라인 시 로그인 화면으로 떨어짐.
- `shell-suspense-fallback`: 청크 로드 실패(배포 후 stale 해시)는 Suspense가 아닌 ErrorBoundary로 전파되는데 별도 새로고침 유도 UI 없이 진단 표만 노출됨.
- `shell-guard-onboarding`: 온보딩 미완료 admin은 /admin*만 예외라 /settings·/teach 접근 시 /onboarding으로 튕김. 미인증 방문자의 /dev/avatar-preview는 통과되지만 로그인된 미승인·미온보딩 사용자는 이 dev 페이지도 게이트에 걸려 접근 불가.
- `shell-root-redirect`: onboardingCompletedAt==null 분기와 최종 분기가 동일하게 initialPathForUser를 호출해 사실상 죽은 중복 분기.
- `shell-unknown-route-redirect`: 전용 404 페이지가 없어 오타 URL이 조용히 홈으로 이동, 사용자가 링크 오류를 인지하지 못함.
- `shell-route-error-boundary`: 진단 화면에 componentStack 등 내부 정보가 그대로 노출되며(개발 편의 의도로 보임) 재시도/홈으로 버튼이 없어 사용자는 URL을 직접 바꿔야 복구됨.
- `shell-avatar-expression-board`: 학습자 사이드바 등 어떤 GNB에도 링크가 없어 URL 직접 입력으로만 접근 가능한 숨은 라우트. 콘텐츠가 QA용인데 learner 라우트(/learn/*)에 위치.
- `shell-avatar-preview-dev-page`: 인증 가드 없는 dev 라우트가 프로덕션 라우터에 그대로 포함되어 누구나 페르소나 리그를 열람 가능(코드 주석상 의도지만 배포 노출 여부 재검토 필요).
## 발견 결함 로그 (RED → GREEN 추적)
| # | 영역 | 증상 | 재현 spec | 상태 |
|---|---|---|---|---|
| 1 | auth | 온보딩 연락처에 임의 텍스트("전화번호아님텍스트") 입력 시 클라이언트·서버 모두 통과되어 DB에 저장됨 | 서버: `test_user_onboarding_validation.py`(#13) · 클라: `Onboarding.tsx` `isValidPhone` + 구체 오류 문구 | **FIXED** (typecheck GREEN, e2e 재검증은 최종 게이트에서) |
| 2 | learner-home | 신규 학습자 첫 추천이 고난도 P1인데 AI 코치 문구는 "낮은 난도부터"를 권함 — 상호 모순 | 소유자 결정(기초 우선) → `starterPersona()` 난도 오름차순 기본 선택, RED→GREEN(full-sweep-learner-home 신규 2건) | **FIXED** (42/42 무회귀) |
| 3 | learner-home | 대시보드 탭 전환 후에도 패널 aria-label이 "최근 기록 패널"로 고정 | `LearnerHome.tsx` — dashTab에 따라 "성장 지표 패널"로 전환 | **FIXED** (typecheck GREEN) |
| 4 | professor | 학생 분석 테이블이 347명 전행 렌더(페이지네이션·가상화 없음) — 규모 성능·탐색성 우려 | 소유자 결정(지금 가상화) → @tanstack/react-virtual 세로 가상화(동적 행 높이·펼침 재측정), RED(300행 전량 렌더 관측)→GREEN | **FIXED** (40/40 무회귀 + professor 게이트 4/4) |
| 5 | admin-ai | `/admin/ai` 정상 렌더에도 부트 watchdog 진단 오버레이 표시(`asset=module-not-discovered` 오탐) | `admin.spec.ts` "keeps the boot diagnostic overlay off a rendered AI operations page" — RED 재현 후 `index.html` 셀렉터에 `.aic` 추가 | **FIXED** (1 passed 12.2s) |
| 6 | auth/admin | 관리자가 보류(pending) 처리한 사용자가 dev-login 재로그인 시 자동 재승인됨 — dev 한정 | 소유자 결정: **현행 유지**(dev/E2E 편의 의도 확정, 프로덕션은 dev-login 자체 폐쇄) | **CLOSED** (의도 확정, 코드 변경 없음) |
| 7 | auth | /pending "승인 상태 새로고침"이 여전히 미승인일 때 아무 피드백 없음(확인 수행 여부 불명) | `PendingApproval.tsx` — 미승인/실패 시각 포함 안내 문구 + refresh 아이콘 신설 | **FIXED** (typecheck GREEN) |
| 8 | 백엔드 마스킹 | 평가/코칭 문장의 일반 단어(유의점은·방치되는·마무리했을·연결감과·표현함을·어지러움은)가 `[NAME]`으로 오탐 치환되어 문장 훼손 | `test_orchestrator_masking.py::test_mask_pii_does_not_mask_evaluator_abstract_words_as_names` — RED 재현 후 `guardrail.py` 꼬리 글자 가드 추가 | **FIXED** (마스킹 18/18, 의존 스위트 69/69, 백엔드 전체 401/401) |
| 9 | settings | 온보딩 입력 항목 및 동의 철회를 설정 화면에서 수정할 수단 없음 | 소유자 결정 → 아바타 변경(#15)·동의 철회/재동의(#16) 구현 완료. 닉네임·자기소개·연락처·주소 편집은 후속 확장 후보로 남김 | **부분 FIXED** (#15·#16 해결) |
| 10 | session | 회기 종료 확인 다이얼로그만 반말 문체("종료할까?", "이동한다") — 앱 전반 존댓말과 불일치 | `Session.tsx` — "종료할까요?"/"이동합니다."로 통일 | **FIXED** (typecheck GREEN) |
| 11 | 백엔드 리드모델 | 리뷰 요약 "하늘**와**의 회기는" — 받침 있는 이름 뒤 조사 오류 | `test_review_summary_josa.py` — RED 재현 후 `_josa_wa_gwa` 헬퍼 추가 | **FIXED** (3/3, 백엔드 409/409) |
| 12 | 백엔드 리드모델 | 리뷰 헤더 난도가 "P4 · easy" 원문 노출(타 화면은 "기초") | `test_review_summary_josa.py::ReviewPersonaLabelTest``_persona_label` 한글 매핑 추가 | **FIXED** (백엔드 410/410) |
| 13 | auth | 온보딩 서버 검증 부재(#1의 서버측) — `OnboardingRequest`/`UserProfilePatch` phone 자유 문자열 허용 | `test_user_onboarding_validation.py` — RED 3건 재현 후 `_validate_phone_format` 추가 | **FIXED** (5/5, 백엔드 406/406 당시) · 클라이언트 측 안내는 #1에서 계속 |
| 14 | persona-studio | 저작 모드 좌측 레일(.ps-rail)이 무조건 `display:none` — 숨겨진 인터랙티브 마크업 DOM 잔존 | 소유자 결정(정리) → 레일 JSX·전용 CSS 삭제, 특성화 테스트 toHaveCount(0) RED→GREEN | **FIXED** (20/20 무회귀 + persona 게이트 2/2 + dead-code 통과) |
| 15 | settings | 설정에 아바타 변경 UI 부재(업로드 API는 온보딩 전용) — #9 세분화 | 소유자 결정(설정에 추가) → 계정 섹션 업로드 컨트롤+미리보기+PATCH avatar_url, fixme 해제 RED→GREEN | **FIXED** (4/4 desktop+mobile, 44/44 무회귀) |
| 16 | settings/auth | 동의 철회 UI 부재 + `withdrawConsent` dead code — #9 세분화 | 소유자 결정(전체 흐름) → 설정 동의 섹션(상태·버전 표시+철회 2단계 확인+재동의), 세션 프리스타트 기존 재동의 게이트와 연결, fixme 해제 RED→GREEN | **FIXED** (철회·재동의 실 API round-trip GREEN) |
| 17 | settings | 스크롤 스파이가 마지막 섹션(음성)을 활성화하지 못함 — 0 임계값 부재 + 비율 우세 휴리스틱이 scroll-margin 정렬 지점과 충돌(모바일에서 두드러짐) | `full-sweep-settings.spec.ts` 모바일 2건 RED 재현 → `Settings.tsx` 밴드 최하단 섹션 규칙 + 스무스 스크롤 억제 + 바닥 폴백 | **FIXED** (settings desktop+mobile 26/26) |
| 18 | session | 모바일 겹침 2건: (a) 절대배치 코치 넛지가 음성 건너뛰기 클릭 가로챔, (b) 타임바 등장 시 3행 그리드 템플릿 밀림으로 컨텍스트 패널이 타임바 종료 버튼을 덮음 | `full-sweep-session.spec.ts` 모바일 RED 재현 → `session.css` `:has(> .sx-timebar)` 4행 템플릿 + 모바일 넛지 플로우 배치 | **FIXED** (session desktop+mobile 28/28, session-layout 8/8, layout-visual-gate 15/15) |
| 19 | admin | **관리자 콘솔 무한 렌더 루프 프리즈** — users API 실패(usersData=null) 상태 탭 클릭·실DB 스케일 티켓 검색 입력 시 TanStack `_autoResetPageIndex`↔setState 동기 루프로 영구 프리즈 | 에이전트 CDP 채증 + 결정적 재현 스펙 2건 → `Admin.tsx` users/services 메모화 + `autoResetPageIndex:false` | **FIXED** (재현 2건 GREEN 포함 admin 19/19) |
| 20 | admin/백엔드 | **복수 코호트 조용한 유실** — POST/PATCH `/admin/users`가 cohort_ids 첫 항목만 저장(`_cohort_value`가 첫 값만 취함) | 의도적 RED e2e + `test_admin_ops.py::CohortSerializationTest` → 콤마 직렬화 + `notifications.py` 배열 겹침 비교 | **FIXED** (백엔드 412/412, e2e 재실행 GREEN) |
| 21 | session-review/백엔드 | 감정 밸런스 타임라인 데이터 소스가 항상 빈 배열 — 사문 렌더 경로 | 소유자 결정(지금 구현) → 혼합 접근: deep 스키마 `turn_valence` 신설(LLM 산출)+저장 데이터 결정론 파생 폴백(counselorBaseline=rapport_signal 누적 평균), `test_review_valence.py` RED 8건→GREEN 9/9 | **FIXED** (백엔드 421/421, e2e 차트 렌더 검증 포함 14/14) |
| 22 | persona-studio/백엔드 | **공개 페르소나 보관 전면 불능** — DELETE `/personas/{id}`가 실DB에서 항상 503. `archive_persona_family`의 UPDATE…FROM RETURNING에서 무한정 `code`가 target.code와 모호(AmbiguousColumnError) | 실API RED 재현(QAX1 초안 생성→DELETE 503) → RETURNING 컬럼 card. 한정 → 재시작 후 같은 호출 200/archived | **FIXED** (백엔드 412/412 + 실API GREEN) |
| 23 | 스모크 스크립트 | `smoke-resistance-openness-db.py`의 advice_jump 단언(turn_client_state ≥5)이 엔진 변동성에 민감 — 조언점프 5턴 중 2턴에서 evaluator가 client_state 라벨을 비워 3행만 저장(2026-07-27 실행, 코드 회귀 아님 — 당일 diff는 deep-loop 한정) | 스모크 실행 로그 | OPEN·스모크 단언 완화 검토(예: ≥3 또는 재시도) |