전수 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]: 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: 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: async def _runtime_tables_ready(conn) -> bool:

View file

@ -36,6 +36,12 @@ _REVIEW_COLUMNS = """
source_provenance, is_synthetic, created_at, approved_at 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"} _PERSONA_STATUSES = {"draft", "review", "approved", "archived"}
_REVIEW_QUEUE_STATUSES = ("draft", "review") _REVIEW_QUEUE_STATUSES = ("draft", "review")
PersonaReviewAction = Literal["approve", "reject"] PersonaReviewAction = Literal["approve", "reject"]
@ -818,7 +824,7 @@ async def archive_persona_family(
FROM target FROM target
WHERE upper(card.code) = upper(target.code) WHERE upper(card.code) = upper(target.code)
AND card.status <> 'archived' AND card.status <> 'archived'
RETURNING {_REVIEW_COLUMNS} RETURNING {_REVIEW_COLUMNS_CARD_QUALIFIED}
) )
SELECT {_REVIEW_COLUMNS} SELECT {_REVIEW_COLUMNS}
FROM archived FROM archived

View file

@ -7,8 +7,10 @@ import time
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
import re
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status 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_types import RoleName
from ..auth_sessions import ( from ..auth_sessions import (
@ -128,6 +130,20 @@ class UserProfileResponse(BaseModel):
onboarding_required: bool = True 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): class UserProfilePatch(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=80) display_name: str | None = Field(default=None, min_length=1, max_length=80)
affiliation: str | None = Field(default=None, max_length=120) 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) self_introduction: str | None = Field(default=None, max_length=600)
avatar_url: str | None = Field(default=None, max_length=500) 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): class OnboardingRequest(BaseModel):
legal_name: str = Field(..., min_length=1, max_length=80) legal_name: str = Field(..., min_length=1, max_length=80)
@ -154,6 +177,11 @@ class OnboardingRequest(BaseModel):
terms_accepted: bool terms_accepted: bool
privacy_accepted: bool privacy_accepted: bool
@field_validator("phone")
@classmethod
def _check_phone(cls, value: str) -> str:
return _validate_phone_format(value)
class AvatarUploadResponse(BaseModel): class AvatarUploadResponse(BaseModel):
avatar_url: str avatar_url: str

View file

@ -264,6 +264,13 @@ class TechniqueDistribution(BaseModel):
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집 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): class SessionEvaluation(BaseModel):
"""deep-loop 회기말/단계전환 정밀 평가 결과. """deep-loop 회기말/단계전환 정밀 평가 결과.
@ -282,6 +289,9 @@ class SessionEvaluation(BaseModel):
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합 supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합 supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시 alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
turn_valence: list[TurnValencePoint] = Field(
default_factory=list
) # 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천)
theory_mode: Optional[str] = None theory_mode: Optional[str] = None
error: Optional[str] = None error: Optional[str] = None
@ -378,6 +388,19 @@ def _deep_schema() -> dict[str, Any]:
"supervisor_rationale": {"type": "string"}, "supervisor_rationale": {"type": "string"},
"supervisor_critique": {"type": "string"}, "supervisor_critique": {"type": "string"},
"alternative_utterances": {"type": "array", "items": {"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"], "required": ["strengths", "improvements"],
} }
@ -583,7 +606,10 @@ def build_deep_messages(
"- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n" "- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n"
"- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n" "- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n"
"- supervisor_critique: 과도/부족/평가적 시각 등 주의점(critique) 종합.\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) dev = _parse_intent_deviation(d)
if dev is not None: if dev is not None:
base.intent_deviations.append(dev) 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()) _evaluator_cache_put(cache_key, base.model_dump())
return base return base
@ -888,6 +926,7 @@ __all__ = [
"ClientStateRead", "ClientStateRead",
"TurnEvaluation", "TurnEvaluation",
"TechniqueDistribution", "TechniqueDistribution",
"TurnValencePoint",
"SessionEvaluation", "SessionEvaluation",
"aggregate_distribution", "aggregate_distribution",
"evaluate_turn", "evaluate_turn",

View file

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

View file

@ -431,7 +431,11 @@ async def _session_review_payload_and_recipients(
OR lower(u.email) = ANY($1::text[]) OR lower(u.email) = ANY($1::text[])
OR ( OR (
u.role = 'instructor' 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) 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 "내담자" 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: def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str:
if not turns: if not turns:
return ( 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") client_count = sum(1 for turn in turns if turn.speaker == "client")
return ( return (
f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. " f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. "
f"{client_name}의 회기는 {reached_phase} 단계까지 진행되었고, " f"{client_name}{_josa_wa_gwa(client_name)}의 회기는 {reached_phase} 단계까지 진행되었고, "
f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. " f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. "
"평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다." "평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다."
) )
@ -1405,6 +1423,185 @@ def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
return events 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: def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse:
sess = read_input.session sess = read_input.session
visible_turns = learner_visible_turns(sess) 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: if not turns:
session_signal = "기록 없음" session_signal = "기록 없음"
elif sess.ended: elif sess.ended:
@ -1533,7 +1751,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
client=ReviewClient( client=ReviewClient(
name=client_name, name=client_name,
initial=client_initial, 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"), date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
durationLabel=_duration_label(duration_seconds), durationLabel=_duration_label(duration_seconds),
@ -1545,9 +1763,9 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
summary=summary, summary=summary,
phases=_phase_segments(stage_labels), phases=_phase_segments(stage_labels),
phaseAxis=axis, phaseAxis=axis,
valenceAxis=axis, valenceAxis=valence_axis,
clientValence=[], clientValence=client_valence,
counselorBaseline=[], counselorBaseline=counselor_baseline,
turns=turns, turns=turns,
rubric=rubric, rubric=rubric,
goodMoments=good_moments, goodMoments=good_moments,

View file

@ -21,6 +21,26 @@ class _Acquire:
return None 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): class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
async def test_usage_from_database_orders_by_aggregated_token_sum(self) -> None: async def test_usage_from_database_orders_by_aggregated_token_sum(self) -> None:
case = self case = self

View file

@ -214,6 +214,21 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
) )
self.assertNotIn("NAME", masked.entities) 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: def test_prepare_turn_masks_korean_pii_from_engine_messages(self) -> None:
ctx = orchestrator.prepare_turn( ctx = orchestrator.prepare_turn(
session_id="masking-session", 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); 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 }) => { test("resets scroll when moving from long admin pages to access policy", async ({ page }) => {
await mockAdminSession(page, { await mockAdminSession(page, {
user_id: "scroll-admin", 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> </script>
<!-- 본문/UI 기본 폰트: Pretendard Variable (DESIGN_CONCEPT §3.6) --> <!-- 본문/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 <link
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css"
@ -169,7 +173,11 @@
var visible = visibleCount(); var visible = visibleCount();
var hasAdminContent = var hasAdminContent =
!isAdmin || !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) { if (reason === "watchdog" && text.length > 24 && visible > 0 && hasAdminContent) {
var old = document.getElementById("vignette-boot-diagnostic"); var old = document.getElementById("vignette-boot-diagnostic");
if (old) old.remove(); if (old) old.remove();

View file

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.8",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^6.30.1" "react-router-dom": "^6.30.1"
@ -1995,6 +1996,23 @@
"react-dom": ">=16.8" "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": { "node_modules/@tanstack/table-core": {
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@ -2008,6 +2026,16 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,6 +41,7 @@ import {
sessionSortTime, sessionSortTime,
sessionStatusLabel, sessionStatusLabel,
stageProgressValue, stageProgressValue,
starterPersona,
theoryLine, theoryLine,
trendLabel, trendLabel,
truncateText, truncateText,
@ -98,7 +99,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
if (personaResult.status === "fulfilled") { if (personaResult.status === "fulfilled") {
const personaRows = personaResult.value; 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); setPersonas(personaRows);
setSelectedCode((cur) => setSelectedCode((cur) =>
personaRows.some( personaRows.some(
@ -1285,7 +1294,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</div> </div>
</section> </section>
<aside className="lh-dashboard-side" aria-label="최근 기록 패널"> <aside
className="lh-dashboard-side"
aria-label={
dashTab === "growth" ? "성장 지표 패널" : "최근 기록 패널"
}
>
<article <article
className={surfaceClassName("lh-panel lh-panel--records")} className={surfaceClassName("lh-panel lh-panel--records")}
> >

View file

@ -35,6 +35,13 @@ const EMPTY_FORM: OnboardingForm = {
privacy_accepted: false, 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 { function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
return { return {
...EMPTY_FORM, ...EMPTY_FORM,
@ -121,6 +128,10 @@ export default function Onboarding() {
const submit = async () => { const submit = async () => {
if (!canSubmit || submitting) return; if (!canSubmit || submitting) return;
if (!isValidPhone(form.phone)) {
setError("연락처는 숫자 9~15자리의 전화번호 형식으로 입력해 주세요 (예: 010-1234-5678).");
return;
}
setSubmitting(true); setSubmitting(true);
setError(""); setError("");
try { try {

View file

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

View file

@ -2595,120 +2595,6 @@ export default function PersonaStudio() {
className="ps-layout ps-authoring-layout" className="ps-layout ps-authoring-layout"
data-authoring-step={authoringStep} 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"> <section className="ps-editor">
<div className={surfaceClassName("ps-editor__top")}> <div className={surfaceClassName("ps-editor__top")}>
<div> <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 { useNavigate, useSearchParams } from "react-router-dom";
import { useVirtualizer } from "@tanstack/react-virtual";
import { AppShell } from "../components/shell/AppShell"; import { AppShell } from "../components/shell/AppShell";
import { import {
Badge, Badge,
@ -1003,6 +1004,16 @@ function LearnerOverviewTable({
const [expandedLearnerId, setExpandedLearnerId] = useState<string | null>( const [expandedLearnerId, setExpandedLearnerId] = useState<string | null>(
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 }> = [ const sortOptions: Array<{ key: LearnerSort; label: string }> = [
{ key: "latest", label: "최근순" }, { key: "latest", label: "최근순" },
{ key: "sessions", label: "회기순" }, { key: "sessions", label: "회기순" },
@ -1019,6 +1030,12 @@ function LearnerOverviewTable({
} }
}, [expandedLearnerId, learners]); }, [expandedLearnerId, learners]);
const toggleExpanded = useCallback((learnerId: string) => {
setExpandedLearnerId((current) =>
current === learnerId ? null : learnerId,
);
}, []);
return ( return (
<section className="pf-section pf-section--learner-overview"> <section className="pf-section pf-section--learner-overview">
<div className="pf-section__head"> <div className="pf-section__head">
@ -1071,7 +1088,11 @@ function LearnerOverviewTable({
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다." 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"> <div className="pf-learner-table__head" aria-hidden="true">
<span></span> <span></span>
<span></span> <span></span>
@ -1083,19 +1104,57 @@ function LearnerOverviewTable({
<span></span> <span></span>
<span></span> <span></span>
</div> </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 isExpanded = expandedLearnerId === learner.learner_id;
const expandId = `learner-expanded-${learner.learner_id}`;
const topTechniques = learner.top_techniques ?? [];
return ( return (
<div <div
className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`} className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`}
key={learner.learner_id} key={learner.learner_id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{ transform: `translateY(${virtualRow.start}px)` }}
> >
<article <LearnerOverviewRecord
className="pf-learner-row" learner={learner}
data-learner-overview-row="true" 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"> <div className="pf-learner-row__id">
<button <button
type="button" type="button"
@ -1112,19 +1171,13 @@ function LearnerOverviewTable({
</b> </b>
<span>{formatDateTime(learner.latest_at)}</span> <span>{formatDateTime(learner.latest_at)}</span>
</div> </div>
<div <div className="pf-learner-row__metric" data-label="최근 적절성">
className="pf-learner-row__metric"
data-label="최근 적절성"
>
<b>{formatScore(learner.latest_score)}</b> <b>{formatScore(learner.latest_score)}</b>
</div> </div>
<div className="pf-learner-row__metric" data-label="변화"> <div className="pf-learner-row__metric" data-label="변화">
<b>{formatDelta(learner.score_delta)}</b> <b>{formatDelta(learner.score_delta)}</b>
</div> </div>
<div <div className="pf-learner-row__metric" data-label="평균 라포">
className="pf-learner-row__metric"
data-label="평균 라포"
>
<b>{formatRapport(learner.avg_rapport)}</b> <b>{formatRapport(learner.avg_rapport)}</b>
</div> </div>
<div className="pf-learner-row__trend" data-label="추이"> <div className="pf-learner-row__trend" data-label="추이">
@ -1140,10 +1193,7 @@ function LearnerOverviewTable({
{trendLabel(learner.trend)} {trendLabel(learner.trend)}
</Badge> </Badge>
</div> </div>
<div <div className="pf-learner-row__tag-summary" data-label="기법 요약">
className="pf-learner-row__tag-summary"
data-label="기법 요약"
>
<b>{topTechniques[0] ?? "기법 부족"}</b> <b>{topTechniques[0] ?? "기법 부족"}</b>
{topTechniques.length > 1 ? ( {topTechniques.length > 1 ? (
<span> {topTechniques.length - 1}</span> <span> {topTechniques.length - 1}</span>
@ -1156,16 +1206,9 @@ function LearnerOverviewTable({
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-controls={expandId} aria-controls={expandId}
aria-label={`${learner.learner_label} 요약 ${isExpanded ? "접기" : "펼치기"}`} aria-label={`${learner.learner_label} 요약 ${isExpanded ? "접기" : "펼치기"}`}
onClick={() => onClick={() => onToggleExpand(learner.learner_id)}
setExpandedLearnerId(
isExpanded ? null : learner.learner_id,
)
}
> >
<Icon <Icon name={isExpanded ? "arrow-up" : "arrow-down"} size={14} />
name={isExpanded ? "arrow-up" : "arrow-down"}
size={14}
/>
</button> </button>
</div> </div>
<div className="pf-learner-row__action"> <div className="pf-learner-row__action">
@ -1213,8 +1256,7 @@ function LearnerOverviewTable({
<span> </span> <span> </span>
<b>{formatDateTime(learner.latest_at)}</b> <b>{formatDateTime(learner.latest_at)}</b>
<small> <small>
{learner.ended_sessions} / {" "} {learner.ended_sessions} / {learner.sessions}
{learner.sessions}
</small> </small>
</div> </div>
<div className="pf-learner-detail-item pf-learner-detail-item--tags"> <div className="pf-learner-detail-item pf-learner-detail-item--tags">
@ -1223,20 +1265,12 @@ function LearnerOverviewTable({
{topTechniques.slice(0, 5).map((tag) => ( {topTechniques.slice(0, 5).map((tag) => (
<span key={tag}>{tag}</span> <span key={tag}>{tag}</span>
))} ))}
{topTechniques.length === 0 ? ( {topTechniques.length === 0 ? <span> </span> : null}
<span> </span>
) : null}
</div> </div>
</div> </div>
</div> </div>
) : null} ) : null}
</div> </>
);
})}
</div>
)}
</Card>
</section>
); );
} }

View file

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

View file

@ -15,6 +15,7 @@ import { roleLabel, useAuth } from "../lib/auth";
import { import {
adminApi, adminApi,
adminEngineApi, adminEngineApi,
apiUrl,
userApi, userApi,
type AdminHealthResponse, type AdminHealthResponse,
type AdminEngineConfigResponse, type AdminEngineConfigResponse,
@ -108,6 +109,18 @@ function formatTicketDate(value?: number | null): string {
}).format(new Date(value * 1000)); }).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({ function SettingsLoadingState({
children, children,
testId, testId,
@ -189,11 +202,13 @@ function completeNotificationPreferences(
} }
export default function Settings() { export default function Settings() {
const { user } = useAuth(); const { user, refresh, acceptConsent, withdrawConsent } = useAuth();
const role = user?.role ?? "learner"; const role = user?.role ?? "learner";
const isAdmin = role === "admin"; const isAdmin = role === "admin";
const [dark, setDark] = useTheme(); const [dark, setDark] = useTheme();
const [activeSection, setActiveSection] = useState("account"); const [activeSection, setActiveSection] = useState("account");
// goSection의 스무스 스크롤 동안 IntersectionObserver가 활성 섹션을 되돌리지 않게 억제한다.
const spySuppressUntilRef = useRef(0);
const [profile, setProfile] = useState<UserProfileResponse | null>(null); const [profile, setProfile] = useState<UserProfileResponse | null>(null);
const [displayName, setDisplayName] = useState(user?.name ?? ""); const [displayName, setDisplayName] = useState(user?.name ?? "");
@ -221,6 +236,15 @@ export default function Settings() {
const affiliationDirtyRef = useRef(false); const affiliationDirtyRef = useRef(false);
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null); const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
const loadSettingsRequestRef = useRef(0); 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) => { const flashSaved = (key: string) => {
setSavedKey(key); setSavedKey(key);
@ -312,6 +336,7 @@ export default function Settings() {
const base: NavItem[] = [ const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" }, { id: "account", label: "계정", icon: "users" },
{ id: "support", label: "지원 요청", icon: "review" }, { id: "support", label: "지원 요청", icon: "review" },
{ id: "consent", label: "동의", icon: "check" },
{ id: "appearance", label: "테마", icon: "settings" }, { id: "appearance", label: "테마", icon: "settings" },
{ id: "notify", label: "알림", icon: "info" }, { id: "notify", label: "알림", icon: "info" },
{ id: "voice", label: "음성", icon: "mic" }, { id: "voice", label: "음성", icon: "mic" },
@ -323,18 +348,34 @@ export default function Settings() {
}, [isAdmin]); }, [isAdmin]);
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( // 활성 규칙: 뷰포트 상단 18~38% 밴드에 걸친 섹션 중 가장 아래(=방금 지나친 헤딩).
(entries) => { // 비율 우세 방식은 scroll-margin 정렬 지점이 밴드 하단 근처인 모바일에서
const visible = entries // 직전 섹션이 계속 이겨 마지막 섹션이 활성화되지 않는다(결함 로그 #17).
.filter((entry) => entry.isIntersecting) const pickActiveSection = () => {
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; const bandTop = window.innerHeight * 0.18;
if (visible?.target.id) { const bandBottom = window.innerHeight * 0.38;
setActiveSection(visible.target.id.replace(/^set-/, "")); 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", 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); 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]); }, [navItems]);
const visibleNotifications = useMemo( const visibleNotifications = useMemo(
@ -380,6 +436,7 @@ export default function Settings() {
); );
const goSection = (id: string) => { const goSection = (id: string) => {
spySuppressUntilRef.current = Date.now() + 900;
setActiveSection(id); setActiveSection(id);
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}; };
@ -406,6 +463,58 @@ export default function Settings() {
flashSaved("account"); 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) => { const savePreferences = async (key: string) => {
if (!preferencesReady || !preferences) return; if (!preferencesReady || !preferences) return;
const notifications = completeNotificationPreferences(preferences.notifications); const notifications = completeNotificationPreferences(preferences.notifications);
@ -453,6 +562,10 @@ export default function Settings() {
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자"; const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
const accountEmail = profile?.email ?? user?.email ?? ""; const accountEmail = profile?.email ?? user?.email ?? "";
const initials = accountName.trim().slice(0, 1).toUpperCase(); 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 const engineHasRequiredFields = engineConfig
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0 ? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
: false; : false;
@ -490,7 +603,7 @@ export default function Settings() {
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약"> <div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
<div className="vg-set__rail-avatar" aria-hidden="true"> <div className="vg-set__rail-avatar" aria-hidden="true">
{initials} {avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
</div> </div>
<div className="vg-set__rail-copy"> <div className="vg-set__rail-copy">
<div className="vg-set__rail-name">{accountName}</div> <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={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
<div className="vg-set__avatar" aria-hidden="true"> <div className="vg-set__avatar" aria-hidden="true">
{initials} {avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
</div> </div>
<div className="vg-set__profile-meta"> <div className="vg-set__profile-meta">
<div className="n">{accountName}</div> <div className="n">{accountName}</div>
@ -551,7 +664,24 @@ export default function Settings() {
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null} {affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
</div> </div>
</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>
</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="vg-set__field-grid vg-set__field-grid--account">
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}> <div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
@ -626,6 +756,21 @@ export default function Settings() {
</Button> </Button>
</div> </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>
<Panel id="set-support" className="vg-set__group vg-set__group--wide"> <Panel id="set-support" className="vg-set__group vg-set__group--wide">
@ -819,6 +964,118 @@ export default function Settings() {
</Panel> </Panel>
) : null} ) : 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"> <Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head"> <div className="vg-set__group-head">
<div> <div>

View file

@ -838,7 +838,7 @@
} }
.ad-user-sort__indicator { .ad-user-sort__indicator {
color: var(--accent); color: var(--text-accent);
font-family: var(--font-num); font-family: var(--font-num);
font-size: 13px; font-size: 13px;
} }
@ -980,7 +980,7 @@
.ad-approval-head h2 { .ad-approval-head h2 {
margin: 0; margin: 0;
color: var(--text-strong); color: var(--text-strong);
font-size: var(--fs-lg); font-size: var(--fs-h3);
line-height: 1.25; line-height: 1.25;
} }
@ -1278,14 +1278,31 @@
background: var(--bg-surface); 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 { .ad-ticket-filter {
display: grid; 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; align-items: center;
gap: 8px; gap: 8px;
margin: 12px 0; 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 input,
.ad-ticket-filter select { .ad-ticket-filter select {
min-width: 0; min-width: 0;
@ -1469,13 +1486,10 @@
font-size: 20px; font-size: 20px;
} }
@media (max-width: 1179px) { /* max-width:1179px .ad-ticket-filter 3트랙 규칙을 제거했다.
.ad-ticket-filter { 1179px(컨테이너 875px) 1180px(컨테이너 876px) 실제 사용 가능한 폭이 1px 차이인데
grid-template-columns: minmax(180px, 1fr) minmax(132px, 0.6fr) minmax(132px, 0.6fr); 트랙 수만 3 -> 7 튀면서 1180px에서만 넘침이 생겼다. 이제 981~1319px 구간이
} 위의 기본 4트랙을 공유하므로 경계에서 레이아웃이 튀지 않는다. */
}
@media (max-width: 980px) { @media (max-width: 980px) {
.ad-kpis, .ad-kpis,
.ad-flow-grid, .ad-flow-grid,

View file

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

View file

@ -1,5 +1,5 @@
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);} .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__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{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;} .ap__hero .vg-avatar__persona{color:#1c2a2a;}

View file

@ -1748,6 +1748,20 @@
padding:4px 7px; 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){ @media (prefers-reduced-motion:reduce){
.lh-skel__box, .lh-skel__box,
.lh-skel__line{animation:none;} .lh-skel__line{animation:none;}
@ -1764,8 +1778,8 @@
text-align: left; text-align: left;
padding: 9px 11px; padding: 9px 11px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.09)); border: 1px solid var(--border-subtle);
background: var(--bg-inset, rgba(0, 0, 0, 0.12)); background: var(--bg-surface-2);
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
} }
@ -1781,7 +1795,7 @@
margin: 0; margin: 0;
font-size: 11.5px; font-size: 11.5px;
line-height: 1.5; line-height: 1.5;
color: var(--text-dim); color: var(--text-muted);
} }
.lh-rapport-list { .lh-rapport-list {
display: grid; display: grid;
@ -1806,13 +1820,13 @@
margin-left: 4px; margin-left: 4px;
font-size: 10.5px; font-size: 10.5px;
font-weight: 620; font-weight: 620;
color: var(--text-dim); color: var(--text-muted);
} }
.lh-rapport-row__bar { .lh-rapport-row__bar {
display: block; display: block;
height: 5px; height: 5px;
border-radius: 3px; border-radius: 3px;
background: var(--bg-inset, rgba(0, 0, 0, 0.16)); background: var(--neutral-100);
overflow: hidden; overflow: hidden;
} }
.lh-rapport-row__bar i { .lh-rapport-row__bar i {
@ -1840,10 +1854,10 @@
gap: 5px; gap: 5px;
padding: 5px 10px; padding: 5px 10px;
border-radius: 999px; 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-size: 11.5px;
font-weight: 680; font-weight: 680;
color: var(--text-dim); color: var(--text-muted);
} }
.lh-milestone.is-done { .lh-milestone.is-done {
border-color: color-mix(in srgb, var(--accent) 45%, transparent); border-color: color-mix(in srgb, var(--accent) 45%, transparent);

View file

@ -14,12 +14,36 @@ import {
DIFFICULTY_LABEL, DIFFICULTY_LABEL,
PENDING_PERSONA_AVATAR_APPEARANCE, PENDING_PERSONA_AVATAR_APPEARANCE,
demographicText, demographicText,
isUsablePersona,
personaAvatarAppearance, personaAvatarAppearance,
personaBaselineExpression, personaBaselineExpression,
personaMeta, personaMeta,
shortPersonaName, shortPersonaName,
} from "../../lib/personaViewModel"; } 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 type HistoryFilter = "all" | "active" | "review" | "archived";
export const HISTORY_FILTERS: Array<{ export const HISTORY_FILTERS: Array<{

View file

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

View file

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

View file

@ -37,7 +37,6 @@
gap:16px; gap:16px;
align-items:start; align-items:start;
} }
.ps-rail,
.ps-inspector{ .ps-inspector{
position:sticky; position:sticky;
top:76px; top:76px;
@ -45,9 +44,7 @@
gap:14px; gap:14px;
min-width:0; min-width:0;
} }
.ps-rail__head,
.ps-editor__top, .ps-editor__top,
.ps-workflow,
.ps-inspector__block{ .ps-inspector__block{
border:1px solid var(--border-subtle); border:1px solid var(--border-subtle);
border-radius:var(--radius); border-radius:var(--radius);
@ -55,14 +52,12 @@
padding:16px; padding:16px;
box-shadow:var(--shadow-sm); box-shadow:var(--shadow-sm);
} }
.ps-rail__head,
.ps-editor__top{ .ps-editor__top{
display:flex; display:flex;
align-items:flex-start; align-items:flex-start;
justify-content:space-between; justify-content:space-between;
gap:12px; gap:12px;
} }
.ps-rail__head b,
.ps-editor__top h2{ .ps-editor__top h2{
display:block; display:block;
margin:4px 0 0; margin:4px 0 0;
@ -73,167 +68,15 @@
line-height:1.25; line-height:1.25;
letter-spacing:0; letter-spacing:0;
} }
.ps-list,
.ps-approved,
.ps-review-cards, .ps-review-cards,
.ps-check{ .ps-check{
display:grid; display:grid;
gap:8px; 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{ .ps-review span{
color:var(--text-muted); color:var(--text-muted);
font-size:var(--fs-xs); 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{ .ps-editor{
display:grid; display:grid;
gap:14px; gap:14px;
@ -493,8 +336,8 @@
} }
.ps-prompt-section__head h3{ .ps-prompt-section__head h3{
margin:0; margin:0;
color:var(--text-heading); color:var(--text-strong);
font-size:var(--fs-md); font-size:var(--fs-body);
line-height:1.25; line-height:1.25;
} }
.ps-prompt-section__head p, .ps-prompt-section__head p,
@ -516,7 +359,7 @@
gap:10px; gap:10px;
align-items:start; align-items:start;
padding:8px 0; padding:8px 0;
border-top:1px solid var(--border-faint); border-top:1px solid var(--hair);
} }
.ps-prompt-row dt{ .ps-prompt-row dt{
color:var(--text-muted); color:var(--text-muted);
@ -671,9 +514,6 @@
.ps-layout{ .ps-layout{
grid-template-columns:1fr; grid-template-columns:1fr;
} }
.ps-rail{
position:static;
}
.ps-inspector{ .ps-inspector{
grid-template-columns:1fr; grid-template-columns:1fr;
} }
@ -931,7 +771,7 @@
min-height:58px; min-height:58px;
padding:9px 16px; padding:9px 16px;
border:0; border:0;
border-top:1px solid var(--border-faint); border-top:1px solid var(--hair);
background:transparent; background:transparent;
color:var(--text-body); color:var(--text-body);
text-align:left; 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-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-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{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 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-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;} .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 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-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{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: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 > 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;} .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{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,.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-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: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{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;} .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__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 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 > 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 dl > div{display:grid;gap:4px;padding:10px 8px 0;}
.ps-review-card dt{color:var(--text-muted);font-size:10px;} .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;} .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{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-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{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 dl > div:first-child{border-top:0;}
.ps-detail-facts dt{color:var(--text-muted);font-size:var(--fs-xs);} .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-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-learning-detail{display:grid;gap:14px;}
.ps-point-list{display:grid;margin-top:14px;border-top:1px solid var(--border-subtle);} .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 b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
.ps-point-list small{color:var(--text-muted);text-align:right;} .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);} .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{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-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{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-tabs,
.ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel, .ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel,
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs, .ps-authoring-layout[data-authoring-step="generate"] .ps-tabs,
@ -1074,7 +913,7 @@
.ps-kpi > b{font-size:30px;} .ps-kpi > b{font-size:30px;}
.ps-performance__metrics{grid-template-columns:1fr;gap:18px;} .ps-performance__metrics{grid-template-columns:1fr;gap:18px;}
.ps-review-summary__grid{grid-template-columns:1fr;} .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-table__head{display:none;}
.ps-usage-row{grid-template-columns:minmax(0,1fr) repeat(2,58px);gap:8px;} .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;} .ps-usage-row strong:nth-last-child(-n+2){display:none;}
@ -1094,14 +933,14 @@
padding:14px; padding:14px;
} }
.ps-persona-row__identity{grid-column:1 / -1;} .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(3),
.ps-persona-row > span:nth-child(4){justify-self:start;} .ps-persona-row > span:nth-child(4){justify-self:start;}
.ps-version{justify-self:end;} .ps-version{justify-self:end;}
.ps-persona-row__actions{grid-column:1 / -1;justify-content:stretch;} .ps-persona-row__actions{grid-column:1 / -1;justify-content:stretch;}
.ps-persona-row__actions .vg-btn{flex:1 1 0;} .ps-persona-row__actions .vg-btn{flex:1 1 0;}
.ps-review-page__summary{grid-template-columns:1fr;} .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-review-board{grid-template-columns:1fr;}
.ps-drill-head{grid-template-columns:1fr;} .ps-drill-head{grid-template-columns:1fr;}
.ps-drill-head__actions{justify-content:flex-start;} .ps-drill-head__actions{justify-content:flex-start;}

View file

@ -405,9 +405,26 @@
min-width:0; min-width:0;
display:flex; display:flex;
flex-direction:column; flex-direction:column;
/* 세로 가상화 스크롤 컨테이너. height:auto + max-height라 행이 적으면
내용 높이만 차지해 데드스페이스가 생기지 않는다. */
max-height:64vh;
overflow-x:auto; overflow-x:auto;
overflow-y:auto;
scrollbar-gutter:stable; 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-table__head,
.pf-learner-row{ .pf-learner-row{
display:grid; display:grid;
@ -441,7 +458,8 @@
min-width:1000px; min-width:1000px;
border-top:1px solid var(--paper-2); 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; border-top:0;
} }
.pf-learner-row{ .pf-learner-row{
@ -458,6 +476,15 @@
.pf-learner-row__tags{ .pf-learner-row__tags{
min-width:0; 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{ .pf-learner-row__id{
display:grid; display:grid;
gap:3px; gap:3px;
@ -465,7 +492,10 @@
.pf-learner-name-button{ .pf-learner-name-button{
width:max-content; width:max-content;
max-width:100%; max-width:100%;
padding:0; /* 상세 진입의 조작이다. 줄높이만큼(19px)이면 WCAG 2.2 SC 2.5.8(24px)
미달해 세로 여백으로 타깃을 채운다. */
min-height:24px;
padding:3px 0;
border:0; border:0;
color:var(--text-strong); color:var(--text-strong);
background:transparent; background:transparent;

View file

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

View file

@ -2637,7 +2637,13 @@
.sx-prestart { .sx-prestart {
width: 100%; width: 100%;
display: grid; 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; align-items: center;
gap: var(--sp-5); gap: var(--sp-5);
max-width: none; max-width: none;
@ -2794,13 +2800,16 @@
line-height: 1.6; line-height: 1.6;
max-width: 58ch; max-width: 58ch;
} }
/* 사실 목록. repeat(3, 1fr) 고정이면 항목이 1개뿐인 상태(페르소나 로딩/폴백)에서도
칸이 1/3 쪼개져 값이 세로로 길게 잘렸다(결함 #2). auto-fit 으로 바꿔 남는 트랙을
접고, 항목 ·폭에 따라 1~3열로 스스로 접히게 한다. */
.sx-prestart__facts { .sx-prestart__facts {
margin: var(--sp-4) 0 0; margin: var(--sp-4) 0 0;
padding: var(--sp-4) 0; padding: var(--sp-3) 0;
border-top: 1px solid var(--hair); border-top: 1px solid var(--hair);
border-bottom: 1px solid var(--hair); border-bottom: 1px solid var(--hair);
display: grid; 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); gap: var(--sp-4);
} }
.sx-page--prestart .sx-prestart__facts { .sx-page--prestart .sx-prestart__facts {
@ -2814,16 +2823,27 @@
font-size: 11.5px; font-size: 11.5px;
font-weight: 700; font-weight: 700;
} }
/* 값은 2줄 클램프로 잘라 쓰던 자리다. 폴백 문구("실제 연습 대상 정보를 확인하고
있습니다.") 320~421 등에서 113px 잘려 나가 클램프를 걷고 전문을 흘린다.
대신 데스크톱 화면 제약은 아래 min-width:1181px 블록에서 사실 블록 자체의
최대 높이 + 내부 스크롤로 지킨다. */
.sx-prestart__facts dd { .sx-prestart__facts dd {
margin: 6px 0 0; margin: 6px 0 0;
color: var(--text-strong); color: var(--text-strong);
font-size: 13.5px; font-size: 13.5px;
font-weight: 650; font-weight: 650;
line-height: 1.4; line-height: 1.4;
overflow: hidden; display: block;
display: -webkit-box; overflow: visible;
-webkit-line-clamp: 2; overflow-wrap: anywhere;
-webkit-box-orient: vertical; }
/* 데스크톱은 문서 스크롤 없이 화면에 들어가야 하므로, 페르소나 요약이 아주 때만
사실 블록 안에서 스크롤되게 상한을 둔다(잘라내지 않는다). */
@media (min-width: 1181px) {
.sx-prestart__facts {
max-height: 152px;
overflow-y: auto;
}
} }
.sx-prestart__chips { .sx-prestart__chips {
display: flex; display: flex;
@ -2847,9 +2867,12 @@
background: var(--clay-tint); background: var(--clay-tint);
color: var(--clay-deep); color: var(--clay-deep);
} }
/* 아래 여백(이론모드 / 시작 버튼 ) dd 클램프를 걷어내면서 늘어난 세로
12px 되돌려 받는 자리다. 프리스타트는 1280x900 에서 문서 스크롤 없이 들어가야
한다( 파일 상단 .vg-main:has(.sx-prestart--...) 규칙과 같은 제약). */
.sx-theory { .sx-theory {
width: min(100%, 520px); width: min(100%, 520px);
margin: var(--sp-4) 0 0; margin: var(--sp-3) 0 0;
padding: 0; padding: 0;
border: 0; border: 0;
display: grid; display: grid;
@ -2913,12 +2936,16 @@
font-size: 11px; font-size: 11px;
font-weight: 650; font-weight: 650;
} }
/* 상태 안내문. 예전에는 목표 fieldset 과의 간격을 줄이려고 margin-top -12px
당겨 썼는데, .sx-goals 마진이 0 으로 끝나는 블록이라 -12px 그대로
fieldset 마지막 (목표 힌트) 위로 파고들었다. 320~1536 폭에서 88x12 겹침이
재현돼(프리스타트 결함 #1) 음수 마진을 걷어내고 양수 간격으로 되돌린다. */
.sx-prestart__note { .sx-prestart__note {
max-width: 460px; max-width: 460px;
font-size: 12.5px; font-size: 12.5px;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.55; line-height: 1.55;
margin-top: calc(var(--sp-3) * -1); margin-top: var(--sp-2);
} }
.sx-prestart__err { .sx-prestart__err {
font-size: var(--fs-sm); font-size: var(--fs-sm);
@ -2956,7 +2983,7 @@
align-items: center; align-items: center;
gap: var(--sp-4); gap: var(--sp-4);
flex-wrap: wrap; flex-wrap: wrap;
margin-top: var(--sp-5); margin-top: var(--sp-4);
} }
.sx-prestart__actions span { .sx-prestart__actions span {
color: var(--text-muted); 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 { .sx-prestart {
grid-template-columns: minmax(180px, 230px) minmax(0, 1fr); grid-template-columns: minmax(180px, 230px) minmax(0, 1fr);
} }
@ -3448,6 +3478,14 @@
.sx-page--active .sx-seg-block__label { .sx-page--active .sx-seg-block__label {
display: none; 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 { .sx-page--active .sx-segmented button {
font-size: 12px; font-size: 12px;
flex: 1 1 0; flex: 1 1 0;
@ -3590,23 +3628,22 @@
margin-top: 6px; margin-top: 6px;
font-size: 18px; font-size: 18px;
} }
/* 2줄 클램프를 쓰던 자리. 본문 열이 좁아지는 320~421 에서 문장이 73px 잘려
나갔다(결함 #3). 1180 이하는 어차피 세로 스크롤이 있는 구간이라 전문을 흘린다. */
.sx-page--prestart .sx-prestart__desc { .sx-page--prestart .sx-prestart__desc {
font-size: 12.5px; font-size: 12.5px;
line-height: 1.45; line-height: 1.45;
overflow: hidden; display: block;
display: -webkit-box; overflow: visible;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
} }
.sx-page--prestart .sx-prestart__facts { .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; gap: 6px;
margin-top: 8px; margin-top: 8px;
padding: 8px 0; padding: 8px 0;
} }
.sx-page--prestart .sx-prestart__facts dd { .sx-page--prestart .sx-prestart__facts dd {
font-size: 11.5px; font-size: 11.5px;
-webkit-line-clamp: 2;
} }
.sx-page--prestart .sx-prestart__chips { .sx-page--prestart .sx-prestart__chips {
display: none; 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) { @media (max-width: 1180px) and (max-height: 820px) {
.sx-sessionbar { .sx-sessionbar {
min-height: 38px; min-height: 38px;
@ -4090,6 +4140,12 @@
} }
/* ── 시간 알람 바(10분 전 경고 · 시간 만료) — 강제 노출 배너 ── */ /* ── 시간 알람 바(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 { .sx-timebar {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -591,6 +591,55 @@
gap: 8px; gap: 8px;
margin-top: 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 { .vg-set__meta {
@ -1032,9 +1081,48 @@
.vg-set__range-control { .vg-set__range-control {
min-width: 0; 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"] { .vg-set__range-control input[type="range"] {
accent-color: var(--accent); accent-color: var(--accent);
display: block; 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 { .vg-set__meta {
margin-top: var(--sp-2); margin-top: var(--sp-2);
@ -1281,15 +1369,18 @@ body[data-page="settings"] #set-support {
body[data-page="settings"] #set-engine { body[data-page="settings"] #set-engine {
order: 3; order: 3;
} }
body[data-page="settings"] #set-appearance { body[data-page="settings"] #set-consent {
order: 4; order: 4;
} }
body[data-page="settings"] #set-voice { body[data-page="settings"] #set-appearance {
order: 5; order: 5;
} }
body[data-page="settings"] #set-notify { body[data-page="settings"] #set-voice {
order: 6; order: 6;
} }
body[data-page="settings"] #set-notify {
order: 7;
}
body[data-page="settings"] .vg-set__group { body[data-page="settings"] .vg-set__group {
padding: 18px; padding: 18px;
border-radius: var(--radius); border-radius: var(--radius);
@ -1404,48 +1495,92 @@ body[data-page="settings"] .vg-set__pill {
background: var(--glass-surface-inset); background: var(--glass-surface-inset);
} }
@media (max-width: 980px) { /* 721~1080px 구간을 상단 가로 탭으로 접는다.
body[data-page="settings"] .vg-set { 폭에서 좌측 레일 배치가 갈래로 깨져 서브네비 라벨 7개가 모두 0 됐다.
grid-template-columns: 86px minmax(0, 1fr); (1) 981~1080px: 공용 `@media (max-width:1080px) .vg-set__rail{display:grid;
gap: 18px; grid-template-columns:minmax(220px,280px) minmax(0,1fr)}` 페이지 레일까지
max-width: none; 덮어썼다. 레일 트랙은 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 { 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 { body[data-page="settings"] .vg-set__rail-title {
justify-content: center; gap: 10px;
padding: 0 0 16px; 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 { body[data-page="settings"] .vg-set__rail-status {
display: none; 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 { body[data-page="settings"] .vg-set__nav-item {
justify-content: center; flex: 0 0 auto;
min-height: 48px; min-height: 38px;
padding: 0; 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 { body[data-page="settings"] .vg-set__forms {
max-width: none; max-width: none;
} }
} }
/* 720px 1080 가로 배치를 그대로 이어받고 여백·타이포만 좁힌다.
display/position/overflow 같은 배치 선언은 1080 블록에 번만 두고 여기선 중복 제거. */
@media (max-width: 720px) { @media (max-width: 720px) {
body[data-page="settings"] .vg-set__callout { body[data-page="settings"] .vg-set__callout {
margin: 12px 12px 0; margin: 12px 12px 0;
} }
body[data-page="settings"] .vg-set { body[data-page="settings"] .vg-set {
display: block;
min-height: 100dvh;
padding: 0 16px 28px; padding: 0 16px 28px;
} }
/* ≤720px 만 full-bleed 상단 바(모바일 헤더 패턴)로 되돌린다. */
body[data-page="settings"] .vg-set__rail { body[data-page="settings"] .vg-set__rail {
position: sticky;
top: 0;
z-index: 20;
min-height: auto;
margin: 0 -16px 12px; margin: 0 -16px 12px;
padding: 13px 16px 10px; padding: 13px 16px 10px;
border-radius: 0; border-radius: 0;
@ -1456,44 +1591,27 @@ body[data-page="settings"] .vg-set__pill {
gap: 8px; gap: 8px;
padding: 0 0 10px; padding: 0 0 10px;
} }
body[data-page="settings"] .vg-set__rail-title div {
display: block;
}
body[data-page="settings"] .vg-set__rail-title h1 { body[data-page="settings"] .vg-set__rail-title h1 {
font-size: 16px; font-size: 16px;
} }
body[data-page="settings"] .vg-set__rail-title p {
display: none;
}
body[data-page="settings"] .vg-set__rail-mark { body[data-page="settings"] .vg-set__rail-mark {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
body[data-page="settings"] .vg-set__nav { body[data-page="settings"] .vg-set__nav {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
} }
body[data-page="settings"] .vg-set__nav-item { body[data-page="settings"] .vg-set__nav-item {
flex: 0 0 auto;
min-height: 34px; min-height: 34px;
gap: 5px; gap: 5px;
padding: 0 7px; padding: 0 7px;
font-size: 11px; font-size: 11px;
white-space: nowrap;
} }
body[data-page="settings"] .vg-set__nav-item svg { body[data-page="settings"] .vg-set__nav-item svg {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
body[data-page="settings"] .vg-set__nav-item span {
display: inline;
}
body[data-page="settings"] .vg-set__forms { body[data-page="settings"] .vg-set__forms {
gap: 10px; gap: 10px;
} }

View file

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

View file

@ -16,11 +16,14 @@
--neutral-100: #f0ede6; /* 표면 hover / subtle fill */ --neutral-100: #f0ede6; /* 표면 hover / subtle fill */
--neutral-150: #e4dccf; /* 금빛이 아주 옅게 섞인 hairline */ --neutral-150: #e4dccf; /* 금빛이 아주 옅게 섞인 hairline */
--neutral-200: #d7cdbd; /* divider 진함 */ --neutral-200: #d7cdbd; /* divider 진함 */
--neutral-400: #9b9a92; /* placeholder / disabled */ --neutral-400: #6f6e68; /* placeholder / disabled (기존 #9b9a92 — 2.4:1 AA 미달) */
--ink: #17231f; /* 시안의 거의 검정인 웜 포레스트 잉크 */ --ink: #17231f; /* 시안의 거의 검정인 웜 포레스트 잉크 */
--ink-2: #5f635d; /* 보조 텍스트 */ /* 2026-07-27 접근성 보정: 라이트 3단 위계를 유지하면서 전부 WCAG AA(4.5:1)
--ink-3: #8f928a; /* 캡션/메타 */ 넘긴다. 최악 조건은 가장 어두운 표면 --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 헤어라인 */ --hair: #e4dccf; /* 1px 헤어라인 */
--botanical-gold: #b49a67; --botanical-gold: #b49a67;
--botanical-hair: rgba(180, 154, 103, 0.28); --botanical-hair: rgba(180, 154, 103, 0.28);
@ -80,6 +83,10 @@
--text-body: var(--ink-2); /* 본문 보조 */ --text-body: var(--ink-2); /* 본문 보조 */
--text-muted: var(--ink-3); /* 캡션 */ --text-muted: var(--ink-3); /* 캡션 */
--text-on-accent: #fbfaf8; /* accent 버튼 위 텍스트 */ --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-subtle: var(--neutral-150);
--border-strong: var(--neutral-200); --border-strong: var(--neutral-200);
@ -155,11 +162,13 @@
--neutral-100: #1b2725; --neutral-100: #1b2725;
--neutral-150: #2b332f; --neutral-150: #2b332f;
--neutral-200: #3b4039; --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: #eee7d9;
--ink-2: #c4bfb3; --ink-2: #c4bfb3;
--ink-3: #8d9086; --ink-3: #979a8f; /* 캡션/메타 (기존 #8d9086) */
--hair: #30372f; --hair: #30372f;
--botanical-gold: #b9a275; --botanical-gold: #b9a275;
--botanical-hair: rgba(185, 162, 117, 0.26); --botanical-hair: rgba(185, 162, 117, 0.26);
@ -183,9 +192,11 @@
--bg-surface-2: #111c1c; --bg-surface-2: #111c1c;
--bg-tint: #192522; --bg-tint: #192522;
--bg-stage: #091315; --bg-stage: #091315;
--text-strong: #eee7d9; /* 라이트와 동일하게 primitive 별칭으로 잇는다. 이전엔 여기서 값을 다시 하드코딩해
--text-body: #c4bfb3; 위쪽 --ink-* 고쳐도 다크에는 반영되지 않는 이중 진실원본이었다. */
--text-muted: #8d9086; --text-strong: var(--ink);
--text-body: var(--ink-2);
--text-muted: var(--ink-3);
--text-on-accent: #0f1a17; --text-on-accent: #0f1a17;
--border-subtle: #30372f; --border-subtle: #30372f;
--border-strong: #45483e; --border-strong: #45483e;

View file

@ -55,6 +55,20 @@
검증 수치는 SSOT 대시보드만 소유한다. 남은 후속은 저장형 fallback/synthetic 계약, 지연을 줄이는 partial stream buffer, 검증 수치는 SSOT 대시보드만 소유한다. 남은 후속은 저장형 fallback/synthetic 계약, 지연을 줄이는 partial stream buffer,
fast/deep reconciliation, resident session namespace, 임상팀 루브릭/CBT/약물·평가 문구 확정. 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. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시) ## E. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시)
> 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates. > 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates.
> **2026-07-02 실측**: 로드맵 상당수는 이미 해결(turn_runtime·tier 제거·eval_hook 주입 등). 아래는 코드 재확인 기준 현행 상태. > **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">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">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">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">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">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> <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`, > 결정 상세는 대시보드 "윤찬 결정 상세" 탭, ADR은 `docs/decisions/`(`voice-s2s-poc.md`,
> `backend-node-transition.md`)에 있다. 각 결정의 구현·실측은 후속 build/GATE로 남는다. > `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 서면 증거 ## 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 또는 재시도) |