diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index 76575d8..b488349 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -291,11 +291,18 @@ def _row_value(row, key: str, default=None): def _cohort_ids(cohort: str | None) -> list[str]: - return [cohort] if cohort else [] + """단일 TEXT 컬럼(콤마 직렬화)을 cohort id 리스트로 복원한다.""" + if not cohort: + return [] + return [part for part in (piece.strip() for piece in cohort.split(",")) if part] def _cohort_value(cohort_ids: list[str] | None) -> str | None: - return (cohort_ids or [None])[0] + """cohort id 리스트를 콤마 직렬화해 저장한다. 첫 항목만 남기던 유실 버그 수정.""" + cleaned = [part for part in ((item or "").strip() for item in cohort_ids or []) if part] + if not cleaned: + return None + return ",".join(cleaned) async def _runtime_tables_ready(conn) -> bool: diff --git a/apps/api/app/persona_repository.py b/apps/api/app/persona_repository.py index 8fc0df8..b5e39fb 100644 --- a/apps/api/app/persona_repository.py +++ b/apps/api/app/persona_repository.py @@ -36,6 +36,12 @@ _REVIEW_COLUMNS = """ source_provenance, is_synthetic, created_at, approved_at """ +# UPDATE ... FROM target 처럼 다른 테이블이 스코프에 있는 RETURNING에서는 +# 무한정 `code`가 target.code와 모호해진다(AmbiguousColumnError → 보관 503). +_REVIEW_COLUMNS_CARD_QUALIFIED = ", ".join( + f"card.{column.strip()}" for column in _REVIEW_COLUMNS.split(",") +) + _PERSONA_STATUSES = {"draft", "review", "approved", "archived"} _REVIEW_QUEUE_STATUSES = ("draft", "review") PersonaReviewAction = Literal["approve", "reject"] @@ -818,7 +824,7 @@ async def archive_persona_family( FROM target WHERE upper(card.code) = upper(target.code) AND card.status <> 'archived' - RETURNING {_REVIEW_COLUMNS} + RETURNING {_REVIEW_COLUMNS_CARD_QUALIFIED} ) SELECT {_REVIEW_COLUMNS} FROM archived diff --git a/apps/api/app/routes/users.py b/apps/api/app/routes/users.py index e7c81eb..10454a5 100644 --- a/apps/api/app/routes/users.py +++ b/apps/api/app/routes/users.py @@ -7,8 +7,10 @@ import time from pathlib import Path from typing import Literal +import re + from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from ..auth_types import RoleName from ..auth_sessions import ( @@ -128,6 +130,20 @@ class UserProfileResponse(BaseModel): onboarding_required: bool = True +# 연락처: 선행 + 허용, 숫자·하이픈·공백·괄호·점만, 숫자 9~15자리. +_PHONE_ALLOWED_RE = re.compile(r"^\+?[0-9()\-\s.]+$") + + +def _validate_phone_format(value: str) -> str: + trimmed = value.strip() + digits = re.sub(r"\D", "", trimmed) + if not _PHONE_ALLOWED_RE.fullmatch(trimmed) or not 9 <= len(digits) <= 15: + raise ValueError( + "연락처는 숫자 9~15자리의 전화번호 형식이어야 합니다 (예: 010-1234-5678)." + ) + return trimmed + + class UserProfilePatch(BaseModel): display_name: str | None = Field(default=None, min_length=1, max_length=80) affiliation: str | None = Field(default=None, max_length=120) @@ -140,6 +156,13 @@ class UserProfilePatch(BaseModel): self_introduction: str | None = Field(default=None, max_length=600) avatar_url: str | None = Field(default=None, max_length=500) + @field_validator("phone") + @classmethod + def _check_phone(cls, value: str | None) -> str | None: + if value is None: + return value + return _validate_phone_format(value) + class OnboardingRequest(BaseModel): legal_name: str = Field(..., min_length=1, max_length=80) @@ -154,6 +177,11 @@ class OnboardingRequest(BaseModel): terms_accepted: bool privacy_accepted: bool + @field_validator("phone") + @classmethod + def _check_phone(cls, value: str) -> str: + return _validate_phone_format(value) + class AvatarUploadResponse(BaseModel): avatar_url: str diff --git a/apps/api/app/services/evaluator.py b/apps/api/app/services/evaluator.py index d616ac7..242f218 100644 --- a/apps/api/app/services/evaluator.py +++ b/apps/api/app/services/evaluator.py @@ -264,6 +264,13 @@ class TechniqueDistribution(BaseModel): underused: list[str] = Field(default_factory=list) # 과소/미사용 군집 +class TurnValencePoint(BaseModel): + """deep-loop 턴별 내담자 정서가 — 축어록 seq(1-based) 지목 + v(−1~+1).""" + + seq: int # 마스킹 축어록의 1-based 턴 번호 + v: float # 정서가(−1 매우 부정 ~ +1 매우 긍정) + + class SessionEvaluation(BaseModel): """deep-loop 회기말/단계전환 정밀 평가 결과. @@ -282,6 +289,9 @@ class SessionEvaluation(BaseModel): supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합 supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합 alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시 + turn_valence: list[TurnValencePoint] = Field( + default_factory=list + ) # 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천) theory_mode: Optional[str] = None error: Optional[str] = None @@ -378,6 +388,19 @@ def _deep_schema() -> dict[str, Any]: "supervisor_rationale": {"type": "string"}, "supervisor_critique": {"type": "string"}, "alternative_utterances": {"type": "array", "items": {"type": "string"}}, + # 선택 필드 — 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천). + "turn_valence": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "seq": {"type": "integer", "minimum": 1}, + "v": {"type": "number", "minimum": -1, "maximum": 1}, + }, + "required": ["seq", "v"], + }, + }, }, "required": ["strengths", "improvements"], } @@ -583,7 +606,10 @@ def build_deep_messages( "- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n" "- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n" "- supervisor_critique: 과도/부족/평가적 시각 등 주의점(critique) 종합.\n" - "- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개." + "- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개.\n" + "- turn_valence: *내담자* 발화 각각의 정서가를 {seq, v}로 산출. seq 는 축어록의 " + "1-based 턴 번호, v 는 −1(매우 부정)~+1(매우 긍정). 근거 없는 극단값을 피하고 " + "불확실하면 0 근처로." ), ] ) @@ -854,6 +880,18 @@ async def evaluate_session( dev = _parse_intent_deviation(d) if dev is not None: base.intent_deviations.append(dev) + for item in payload.get("turn_valence") or []: + if not isinstance(item, dict): + continue + seq = item.get("seq") + v = item.get("v") + if not isinstance(seq, int) or isinstance(seq, bool) or seq < 1: + continue + if not isinstance(v, (int, float)) or isinstance(v, bool): + continue + base.turn_valence.append( + TurnValencePoint(seq=seq, v=max(-1.0, min(1.0, float(v)))) + ) _evaluator_cache_put(cache_key, base.model_dump()) return base @@ -888,6 +926,7 @@ __all__ = [ "ClientStateRead", "TurnEvaluation", "TechniqueDistribution", + "TurnValencePoint", "SessionEvaluation", "aggregate_distribution", "evaluate_turn", diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index 40f66cf..1062d80 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -46,6 +46,10 @@ _KOREAN_SURNAME_CHARS = ( _KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}" _KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?" _KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}" +# 인명 마지막 글자로 사실상 쓰이지 않는 용언 활용/명사화 꼬리 글자. 문맥 단서가 +# 없는 인명 패턴이 "유의점은·방치되는·마무리했을·연결감과·표현함을·어지러움은" +# 같은 일반 단어를 이름으로 오탐해 평가 문장을 훼손하는 것을 막는다(정밀도 가드). +_KOREAN_NAME_TAIL_GUARD = "(?(?:(?:저는|나는|제가|내가)\s*)?)" - rf"(?P{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" + rf"(?P{_KOREAN_FULL_NAME_BEFORE_SUFFIX}){_KOREAN_NAME_TAIL_GUARD}" r"(?P\s*(?:입니다|이에요|예요|이고|이고요))" r"(?=$|[\s,.;!?。])" ), @@ -128,7 +132,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ( "NAME", re.compile( - rf"(?{_KOREAN_CONTEXTLESS_NAME})" + rf"(?{_KOREAN_CONTEXTLESS_NAME}){_KOREAN_NAME_TAIL_GUARD}" r"(?P(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))" ), ), diff --git a/apps/api/app/services/notifications.py b/apps/api/app/services/notifications.py index 31507e3..6e81e0e 100644 --- a/apps/api/app/services/notifications.py +++ b/apps/api/app/services/notifications.py @@ -431,7 +431,11 @@ async def _session_review_payload_and_recipients( OR lower(u.email) = ANY($1::text[]) OR ( u.role = 'instructor' - AND ($2 = '' OR u.cohort = $2) + AND ( + $2 = '' + OR string_to_array(COALESCE(u.cohort, ''), ',') + && string_to_array($2, ',') + ) ) ) AND COALESCE((p.notifications->>'learner_progress')::boolean, true) diff --git a/apps/api/app/session_read_model.py b/apps/api/app/session_read_model.py index dd9d4e8..3effaad 100644 --- a/apps/api/app/session_read_model.py +++ b/apps/api/app/session_read_model.py @@ -750,6 +750,24 @@ def _client_name(raw: str) -> str: return name or raw.strip() or "내담자" +_DIFFICULTY_KO = {"easy": "기초", "moderate": "중간", "hard": "고난도"} + + +def _persona_label(code: str, difficulty: str) -> str: + return f"{code} · {_DIFFICULTY_KO.get(difficulty, difficulty)}" + + +def _josa_wa_gwa(name: str) -> str: + """이름 마지막 글자의 받침 유무로 와/과를 고른다. 한글이 아니면 병기한다.""" + tail = name[-1] if name else "" + if "가" <= tail <= "힣": + has_final = (ord(tail) - 0xAC00) % 28 != 0 + if has_final: + return "과" + return "와" + return "와(과)" + + def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str: if not turns: return ( @@ -760,7 +778,7 @@ def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ client_count = sum(1 for turn in turns if turn.speaker == "client") return ( f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. " - f"{client_name}와의 회기는 {reached_phase} 단계까지 진행되었고, " + f"{client_name}{_josa_wa_gwa(client_name)}의 회기는 {reached_phase} 단계까지 진행되었고, " f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. " "평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다." ) @@ -1405,6 +1423,185 @@ def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]: return events +# ─ 감정 밸런스 타임라인(valence) 파생 — 순수 함수 ────────────────────────────── +_VALENCE_MAX_POINTS = 10 + +# client_state_read 코드 → 정서가 극성(taxonomy.ClientState 코드 기준 휴리스틱 맵). +_CLIENT_STATE_VALENCE: dict[str, float] = { + # 음의 극성 — 방어·위축·위기 신호 + "involuntary": -0.5, + "defensive": -0.7, + "suicidal_ideation_admit": -0.9, + "negative_self_perception": -0.7, + "conflicted": -0.4, + "lack_of_confidence": -0.4, + "compliant_surface": -0.2, + "externalizing": -0.3, + "apparent_competence": -0.2, + "active_passivity": -0.3, + "self_harm_disclosure": -0.9, + "somatic_complaint": -0.4, + "affect_masking": -0.3, + "focus_drift_fusion": -0.3, + # 양의 극성 — 개방·접촉·진전 신호 + "affect_contact": 0.6, + "thought_organizing": 0.5, + "responds_to_exploration": 0.5, + "expresses_plan": 0.7, + "defense_loosening": 0.6, + "seeks_guidance": 0.2, +} + + +def _clamp_valence(value: float) -> float: + return max(-1.0, min(1.0, value)) + + +def _valence_t( + created_at: float, first_turn_ts: float, duration_seconds: float +) -> float: + """턴 시각 → 회기 진행률(0~1 클램프).""" + if duration_seconds <= 0: + return 0.0 + return max(0.0, min(1.0, (created_at - first_turn_ts) / duration_seconds)) + + +def _finalize_valence_points( + points: list[ReviewValencePoint], +) -> list[ReviewValencePoint]: + """2개 미만이면 빈 배열(차트 빈 상태), 10개 초과면 균등 리샘플(양 끝점 유지).""" + if len(points) < 2: + return [] + if len(points) <= _VALENCE_MAX_POINTS: + return points + last = len(points) - 1 + indices: list[int] = [] + for i in range(_VALENCE_MAX_POINTS): + idx = round(i * last / (_VALENCE_MAX_POINTS - 1)) + if not indices or idx != indices[-1]: + indices.append(idx) + return [points[i] for i in indices] + + +def counselor_baseline_points( + turns: list[TurnRecord], + *, + first_turn_ts: float, + duration_seconds: float, +) -> list[ReviewValencePoint]: + """학습자 턴 rapport_signal 누적 이동평균 → 상담자 기준선 궤적.""" + points: list[ReviewValencePoint] = [] + total = 0.0 + count = 0 + for turn in turns: + if turn.speaker != "counselor": + continue + ev = session_metrics.turn_eval(turn) + if ev is None or str(ev.get("error") or "").strip(): + continue + rapport = session_metrics.turn_rapport(ev) + if rapport is None: + continue + count += 1 + total += rapport + points.append( + ReviewValencePoint( + t=_valence_t(turn.created_at, first_turn_ts, duration_seconds), + v=_clamp_valence(total / count), + ) + ) + return _finalize_valence_points(points) + + +def _fallback_client_valence_points( + turns: list[TurnRecord], + *, + first_turn_ts: float, + duration_seconds: float, +) -> list[ReviewValencePoint]: + """폴백 — 상담자 턴 평가의 client_state_read 극성과 appropriateness(0~1) 결합.""" + points: list[ReviewValencePoint] = [] + for turn in turns: + if turn.speaker != "counselor": + continue + ev = session_metrics.turn_eval(turn) + if ev is None or str(ev.get("error") or "").strip(): + continue + polarities: list[float] = [] + for state in ev.get("client_state_read") or []: + code = state.get("code") if isinstance(state, dict) else state + mapped = _CLIENT_STATE_VALENCE.get(str(code or "").strip()) + if mapped is not None: + polarities.append(mapped) + score01 = session_metrics.turn_score(ev) + parts: list[float] = [] + if polarities: + parts.append(0.7 * (sum(polarities) / len(polarities))) + if score01 is not None: + parts.append(0.3 * (score01 * 2.0 - 1.0)) + if not parts: + continue + points.append( + ReviewValencePoint( + t=_valence_t(turn.created_at, first_turn_ts, duration_seconds), + v=_clamp_valence(sum(parts)), + ) + ) + return points + + +def client_valence_points( + turns: list[TurnRecord], + evaluation_payload: dict[str, object], + *, + first_turn_ts: float, + duration_seconds: float, +) -> list[ReviewValencePoint]: + """내담자 정서가 궤적 — deep 평가 turn_valence 우선, 없으면 턴 평가 기반 폴백.""" + raw = ( + evaluation_payload.get("turn_valence") + if isinstance(evaluation_payload, dict) + else None + ) + points: list[ReviewValencePoint] = [] + if isinstance(raw, list): + for item in raw: + if not isinstance(item, dict): + continue + seq = item.get("seq") + v = item.get("v") + if isinstance(seq, bool) or not isinstance(seq, int): + continue + if isinstance(v, bool) or not isinstance(v, (int, float)): + continue + index = seq - 1 + if index < 0 or index >= len(turns): + continue + points.append( + ReviewValencePoint( + t=_valence_t( + turns[index].created_at, first_turn_ts, duration_seconds + ), + v=_clamp_valence(float(v)), + ) + ) + points.sort(key=lambda point: point.t) + if not points: + points = _fallback_client_valence_points( + turns, first_turn_ts=first_turn_ts, duration_seconds=duration_seconds + ) + return _finalize_valence_points(points) + + +def _valence_axis( + duration_seconds: int, *, has_points: bool, fallback: list[str] +) -> list[str]: + """포인트가 있으면 시간 라벨 4개(0~회기말 균등), 없으면 기존 axis 유지.""" + if not has_points or duration_seconds <= 0: + return fallback + return [_offset_label(duration_seconds * i / 3) for i in range(4)] + + def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse: sess = read_input.session visible_turns = learner_visible_turns(sess) @@ -1455,6 +1652,27 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes ) ) + # 감정 밸런스 타임라인 — 학습자 기준선 + 내담자 정서가(비공개 턴 존재 시 비산출) + counselor_baseline: list[ReviewValencePoint] = [] + client_valence: list[ReviewValencePoint] = [] + if not hidden_turns: + counselor_baseline = counselor_baseline_points( + visible_turns, + first_turn_ts=first_turn_ts, + duration_seconds=duration_seconds, + ) + client_valence = client_valence_points( + visible_turns, + evaluation_payload, + first_turn_ts=first_turn_ts, + duration_seconds=duration_seconds, + ) + valence_axis = _valence_axis( + duration_seconds, + has_points=bool(counselor_baseline or client_valence), + fallback=axis, + ) + if not turns: session_signal = "기록 없음" elif sess.ended: @@ -1533,7 +1751,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes client=ReviewClient( name=client_name, initial=client_initial, - persona=f"{sess.persona_code} · {sess.persona.difficulty}", + persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)), ), date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"), durationLabel=_duration_label(duration_seconds), @@ -1545,9 +1763,9 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes summary=summary, phases=_phase_segments(stage_labels), phaseAxis=axis, - valenceAxis=axis, - clientValence=[], - counselorBaseline=[], + valenceAxis=valence_axis, + clientValence=client_valence, + counselorBaseline=counselor_baseline, turns=turns, rubric=rubric, goodMoments=good_moments, diff --git a/apps/api/app/test_admin_ops.py b/apps/api/app/test_admin_ops.py index 209da92..ccf7657 100644 --- a/apps/api/app/test_admin_ops.py +++ b/apps/api/app/test_admin_ops.py @@ -21,6 +21,26 @@ class _Acquire: return None +class CohortSerializationTest(unittest.TestCase): + """2026-07-27 전수 순회 결함 #20: 서버가 cohort_ids 첫 항목만 저장하던 유실 회귀 가드.""" + + def test_multi_cohort_round_trip_preserves_every_id(self) -> None: + from .auth_sessions import _cohort_ids, _cohort_value + + self.assertEqual(_cohort_value(["co-a", "co-b"]), "co-a,co-b") + self.assertEqual(_cohort_ids("co-a,co-b"), ["co-a", "co-b"]) + self.assertEqual(_cohort_ids(_cohort_value(["x-1", "x-2", "x-3"])), ["x-1", "x-2", "x-3"]) + + def test_cohort_value_trims_and_drops_empty_entries(self) -> None: + from .auth_sessions import _cohort_ids, _cohort_value + + self.assertEqual(_cohort_value([" co-a ", "", " ", "co-b"]), "co-a,co-b") + self.assertIsNone(_cohort_value([])) + self.assertIsNone(_cohort_value(None)) + self.assertEqual(_cohort_ids(None), []) + self.assertEqual(_cohort_ids(" co-a , ,co-b "), ["co-a", "co-b"]) + + class AdminOpsTest(unittest.IsolatedAsyncioTestCase): async def test_usage_from_database_orders_by_aggregated_token_sum(self) -> None: case = self diff --git a/apps/api/app/test_orchestrator_masking.py b/apps/api/app/test_orchestrator_masking.py index 97771eb..770c90a 100644 --- a/apps/api/app/test_orchestrator_masking.py +++ b/apps/api/app/test_orchestrator_masking.py @@ -214,6 +214,21 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase): ) self.assertNotIn("NAME", masked.entities) + def test_mask_pii_does_not_mask_evaluator_abstract_words_as_names(self) -> None: + # 실제 교수자 리뷰 화면에서 관측된 오탐 문형: 성씨 문자로 시작하는 일반 + # 명사/용언(유의점·방치되는·마무리했을·연결감·표현함·어지러움)이 + # 조사와 결합하면 문맥-없는 인명 패턴에 걸려 평가 문장이 훼손된다. + text = ( + "유의점은 세 가지다. 정서 신호가 방치되는 것을 피하고, 회기를 " + "마무리했을 때 연결감과 작업동맹을 지키며, 감정을 표현함을 존중한다. " + "어지러움은 신체 신호로 다룬다." + ) + + masked = guardrail.mask_pii(text) + + self.assertEqual(masked.text_masked, text) + self.assertNotIn("NAME", masked.entities) + def test_prepare_turn_masks_korean_pii_from_engine_messages(self) -> None: ctx = orchestrator.prepare_turn( session_id="masking-session", diff --git a/apps/api/app/test_review_summary_josa.py b/apps/api/app/test_review_summary_josa.py new file mode 100644 index 0000000..4bfcc32 --- /dev/null +++ b/apps/api/app/test_review_summary_josa.py @@ -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() diff --git a/apps/api/app/test_review_valence.py b/apps/api/app/test_review_valence.py new file mode 100644 index 0000000..b7171c9 --- /dev/null +++ b/apps/api/app/test_review_valence.py @@ -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() diff --git a/apps/api/app/test_user_onboarding_validation.py b/apps/api/app/test_user_onboarding_validation.py new file mode 100644 index 0000000..f228593 --- /dev/null +++ b/apps/api/app/test_user_onboarding_validation.py @@ -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() diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 0308261..590f576 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -781,6 +781,28 @@ test.describe("admin route guards", () => { await expectNoHorizontalOverflow(page); }); + test("keeps the boot diagnostic overlay off a rendered AI operations page", async ({ page }) => { + await mockAdminSession(page, { + user_id: "ai-watchdog-admin", + email: "ai-watchdog-admin@twentyoz.kr", + display_name: "AI Watchdog Admin", + role: "admin", + admin_access: true, + super_admin: true, + onboarding_completed_at: 1_782_900_000, + }); + + await page.goto("/admin/ai"); + + await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible(); + + // index.html watchdog은 로드 후 3.5초/8초에 부트 진단을 판정한다. + // 정상 렌더된 화면에서는 두 판정 창이 지난 뒤에도 진단 오버레이가 없어야 한다. + await page.waitForTimeout(8_600); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible(); + }); + test("resets scroll when moving from long admin pages to access policy", async ({ page }) => { await mockAdminSession(page, { user_id: "scroll-admin", diff --git a/apps/web/e2e/full-sweep-admin-ai.spec.ts b/apps/web/e2e/full-sweep-admin-ai.spec.ts new file mode 100644 index 0000000..0ac55df --- /dev/null +++ b/apps/web/e2e/full-sweep-admin-ai.spec.ts @@ -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 { + 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 { + 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 | undefined; + engineConfig: AdminEngineConfigResponse; + /** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */ + engineConfigStatus?: number; +} + +function createMockState(overrides: Partial = {}): 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((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; + 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 | 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 | 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("기록 없음"); + }); +}); diff --git a/apps/web/e2e/full-sweep-admin.spec.ts b/apps/web/e2e/full-sweep-admin.spec.ts new file mode 100644 index 0000000..100919d --- /dev/null +++ b/apps/web/e2e/full-sweep-admin.spec.ts @@ -0,0 +1,1232 @@ +// full-sweep-admin.spec.ts — 관리자 콘솔(admin) 전수 점검 신규 스펙. +// docs/ops/e2e-full-sweep-2026-07-27.md §7 "관리자 콘솔 (admin)"에서 +// "검증: 신규 spec 필요"로 표기된 체크리스트 항목을 검증한다. +// UI 상태 고정(가드·로딩·오류·정렬·필터 파라미터)은 page.route fixture로, +// 서버 왕복이 필요한 항목(보류/소속·코호트 저장/티켓 처리·해제)은 실 API로 검증한다. +// AI 엔진 턴 생성은 어디에서도 수행하지 않는다. +// +// 이 스위프에서 발견된 앱 결함 2건: +// 1) [프리즈] usersData=null(users API 실패) 상태 또는 실 DB 스케일의 티켓 검색 입력에서 +// TanStack Table _autoResetPageIndex ↔ React setState 무한 마이크로태스크 루프로 +// 관리자 콘솔 렌더러가 영구 프리즈된다. → test.fixme 재현 테스트 2건으로 격리. +// 2) [코호트 유실] 서버(POST/PATCH /admin/users)가 cohort_ids 배열의 첫 항목만 저장한다. +// → "persists every comma-separated cohort id" 테스트가 의도적 RED. + +import { expect, test, type Page, type Request, type Response, type Route } from "@playwright/test"; +import { signInAsAdmin, useRealApi } from "./support"; + +type AdminEndpointKey = "health" | "users" | "usage" | "uptime" | "tickets"; + +interface MockOverride { + status?: number; + detail?: string; + delayMs?: number; + body?: unknown; +} + +interface AdminMockState { + auth: Record; + users: Array>; + tickets: Array>; + overrides: Partial>; + calls: Record; + userPatches: Array<{ userId: string; body: Record }>; +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function makeMockUser(overrides: Record = {}): Record { + const id = String(overrides.user_id ?? "mock-user"); + return { + user_id: id, + email: `${id}@hs.ac.kr`, + display_name: `Mock ${id}`, + role: "learner", + admin_access: false, + super_admin: false, + account_status: "approved", + affiliation: "", + cohort_ids: [], + active_sessions: 0, + created_at: 1_783_000_000, + last_seen_at: 1_783_000_000, + source: "database", + ...overrides, + }; +} + +function makeMockTicket(overrides: Record = {}): Record { + const id = String(overrides.ticket_id ?? "mock-ticket"); + return { + ticket_id: id, + reporter: { + email: "reporter@hs.ac.kr", + display_name: "Mock Reporter", + role: "learner", + }, + category: "other", + priority: "normal", + status: "open", + subject: `Mock 티켓 ${id}`, + body: "fixture ticket body", + source_path: "/__e2e__/full-sweep", + assigned_group: "", + parent_ticket_id: null, + duplicate_count: 0, + duplicate_parent_candidate_id: null, + child_ticket_count: 0, + event_count: 0, + resolved_at: null, + created_at: 1_783_000_000, + updated_at: 1_783_100_000, + ...overrides, + }; +} + +function mockTicketsBody(state: AdminMockState): Record { + const active = state.tickets.filter( + (ticket) => ticket.status !== "resolved" && ticket.status !== "closed", + ); + const byCategory: Record = {}; + for (const ticket of active) { + const category = String(ticket.category); + byCategory[category] = (byCategory[category] ?? 0) + 1; + } + return { + source: "database", + durable: true, + generated_at: 1_783_990_800, + tickets: state.tickets, + summary: { + total: state.tickets.length, + open_count: active.length, + high_priority_count: active.filter( + (ticket) => ticket.priority === "high" || ticket.priority === "urgent", + ).length, + stale_count: 0, + by_category: byCategory, + by_priority: {}, + by_status: {}, + }, + }; +} + +const MOCK_HEALTH_BODY = { + status: "ok", + environment: "dev", + engine_mode: "openai", + services: [ + { + key: "engine", + name: "AI 엔진", + status: "ok", + detail: "응답 정상", + metric: "120ms", + load: 0.2, + }, + ], +}; + +const MOCK_USAGE_BODY = { + source: "database", + durable: true, + generated_at: 1_783_990_800, + window_days: 7, + 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: [], +}; + +const MOCK_UPTIME_BODY = { + source: "database", + durable: true, + window_hours: 24, + sample_count: 0, + ok_ratio: 0, + degraded_events: 0, + down_events: 0, + last_down_at: null, +}; + +/** + * 관리자 콘솔 전용 route fixture. + * state를 반환하며, 테스트가 state.auth / state.users / state.tickets / state.overrides를 + * 수정하면 다음 요청부터 즉시 반영된다(재라우팅 불필요). + */ +async function installAdminMock( + page: Page, + authOverrides: Record = {}, +): Promise { + const state: AdminMockState = { + auth: { + user_id: "full-sweep-admin", + email: "full-sweep-admin@twentyoz.kr", + display_name: "Full Sweep Admin", + role: "admin", + admin_access: true, + super_admin: true, + account_status: "approved", + approval_required: false, + cohort_ids: [], + consent_at: null, + onboarding_completed_at: 1_782_900_000, + nickname: "", + self_introduction: "", + avatar_url: "", + ...authOverrides, + }, + users: [], + tickets: [], + overrides: {}, + calls: { health: [], users: [], usage: [], uptime: [], tickets: [] }, + userPatches: [], + }; + + const fulfillJson = (route: Route, body: unknown, status = 200) => + route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); + + const respond = async (route: Route, key: AdminEndpointKey, defaultBody: () => unknown) => { + const override = state.overrides[key]; + if (override?.delayMs) await sleep(override.delayMs); + if (override?.status && override.status >= 400) { + await fulfillJson( + route, + { detail: override.detail ?? `${key} mock failure` }, + override.status, + ); + return; + } + await fulfillJson(route, override?.body ?? defaultBody()); + }; + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + const path = url.pathname; + + if (method === "GET" && path.endsWith("/auth/me")) { + await fulfillJson(route, state.auth); + return; + } + if (method === "GET" && path.endsWith("/admin/health")) { + state.calls.health.push(url.toString()); + await respond(route, "health", () => MOCK_HEALTH_BODY); + return; + } + if (method === "GET" && path.endsWith("/admin/users")) { + state.calls.users.push(url.toString()); + await respond(route, "users", () => ({ + source: "database", + durable: true, + users: state.users, + })); + return; + } + if (method === "GET" && path.endsWith("/admin/usage")) { + state.calls.usage.push(url.toString()); + await respond(route, "usage", () => MOCK_USAGE_BODY); + return; + } + if (method === "GET" && path.endsWith("/admin/uptime")) { + state.calls.uptime.push(url.toString()); + await respond(route, "uptime", () => MOCK_UPTIME_BODY); + return; + } + if (method === "GET" && path.endsWith("/admin/tickets")) { + state.calls.tickets.push(url.toString()); + await respond(route, "tickets", () => mockTicketsBody(state)); + return; + } + if (method === "PATCH" && /\/admin\/users\/[^/]+$/.test(path)) { + const userId = decodeURIComponent(path.split("/").pop() ?? ""); + const body = (request.postDataJSON() ?? {}) as Record; + state.userPatches.push({ userId, body }); + const existing = state.users.find((user) => user.user_id === userId) ?? makeMockUser({ + user_id: userId, + }); + const merged = { ...existing, ...body }; + state.users = state.users.map((user) => (user.user_id === userId ? merged : user)); + await fulfillJson(route, merged); + return; + } + + await fulfillJson(route, { detail: `unmocked API request: ${method} ${path}` }, 404); + }); + + return state; +} + +function isTicketsRequest(check: (params: URLSearchParams) => boolean) { + return (request: Request) => { + if (request.method() !== "GET") return false; + const url = new URL(request.url()); + return url.pathname.endsWith("/admin/tickets") && check(url.searchParams); + }; +} + +function isAdminGet(pathSuffix: string) { + return (request: Request) => + request.method() === "GET" && new URL(request.url()).pathname.endsWith(pathSuffix); +} + +function isUsersListResponse(response: Response) { + const url = new URL(response.url()); + return ( + response.request().method() === "GET" && + url.pathname.endsWith("/admin/users") && + (response.headers()["content-type"]?.includes("application/json") ?? false) + ); +} + +function isUserPatchResponse(userId: string) { + return (response: Response) => + response.request().method() === "PATCH" && + new URL(response.url()).pathname.endsWith(`/admin/users/${userId}`); +} + +function isTicketPatchResponse(ticketId: string) { + return (response: Response) => + response.request().method() === "PATCH" && + new URL(response.url()).pathname.endsWith(`/admin/tickets/${ticketId}`); +} + +test.describe("admin console full-sweep (fixtures)", () => { + // 검증 checklist: admin-guard-pending-approval + test("redirects unapproved users to /pending and approved users back out of it", async ({ + page, + }) => { + const mock = await installAdminMock(page, { account_status: "pending" }); + + await page.goto("/admin"); + await expect(page).toHaveURL(/\/pending$/); + await expect( + page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }), + ).toBeVisible(); + await expect(page.locator(".pa-kicker")).toHaveText("승인 대기"); + + await page.goto("/admin/tickets"); + await expect(page).toHaveURL(/\/pending$/); + + mock.auth.account_status = "suspended"; + await page.goto("/admin"); + await expect(page).toHaveURL(/\/pending$/); + await expect(page.locator(".pa-kicker")).toHaveText("접속 보류"); + + mock.auth.account_status = "approved"; + await page.goto("/pending"); + await expect(page).toHaveURL(/\/admin$/); + await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); + }); + + // 검증 checklist: admin-overview-refresh-button + test("overview refresh button refetches all five admin APIs and disables while loading", async ({ + page, + }) => { + const mock = await installAdminMock(page); + + await page.goto("/admin"); + const refreshButton = page.getByRole("button", { name: /새로고침|확인 중/ }); + await expect(refreshButton).toHaveText(/새로고침/); + await expect(refreshButton).toBeEnabled(); + + mock.overrides.health = { delayMs: 700 }; + const refetches = Promise.all([ + page.waitForRequest(isAdminGet("/admin/health")), + page.waitForRequest(isAdminGet("/admin/users")), + page.waitForRequest(isAdminGet("/admin/usage")), + page.waitForRequest(isAdminGet("/admin/uptime")), + page.waitForRequest(isAdminGet("/admin/tickets")), + ]); + await refreshButton.click(); + await expect(refreshButton).toHaveText("확인 중"); + await expect(refreshButton).toBeDisabled(); + await refetches; + await expect(refreshButton).toHaveText("새로고침"); + await expect(refreshButton).toBeEnabled(); + }); + + // 검증 checklist: admin-overview-health-error + test("overview shows a role=alert inline error when the health API fails", async ({ page }) => { + const mock = await installAdminMock(page); + mock.overrides.health = { status: 500, detail: "헬스 저장소 접근 불가" }; + + await page.goto("/admin"); + + await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); + const inlineError = page.locator(".ad-error"); + await expect(inlineError).toBeVisible(); + await expect(inlineError).toHaveAttribute("role", "alert"); + await expect(inlineError).toContainText("헬스 저장소 접근 불가"); + // 다른 데이터 소스는 정상이므로 화면 나머지는 렌더된다. + await expect(page.locator(".ad-kpis").first()).toBeVisible(); + }); + + // 검증 checklist: admin-users-search-input + test("user search filters by role, cohort, and email substrings with a match count", async ({ + page, + }) => { + const mock = await installAdminMock(page); + mock.users = [ + makeMockUser({ + user_id: "alice", + email: "alice@hs.ac.kr", + display_name: "Alice Lee", + role: "learner", + affiliation: "한신대 상담", + cohort_ids: ["cohort-alpha"], + }), + makeMockUser({ + user_id: "bob", + email: "bob@hs.ac.kr", + display_name: "Bob Kim", + role: "teacher", + affiliation: "상담대학원", + }), + makeMockUser({ + user_id: "carol", + email: "carol@twentyoz.kr", + display_name: "Carol Admin", + role: "admin", + admin_access: true, + }), + ]; + + await page.goto("/admin/users"); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + const rows = page.locator(".ad-user-table tbody tr"); + await expect(rows).toHaveCount(3); + await expect(page.locator(".ad-users-toolbar")).toContainText("3 / 3명"); + + const search = page.getByLabel("사용자 검색"); + await search.fill("교수자"); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("bob@hs.ac.kr"); + await expect(page.locator(".ad-users-toolbar")).toContainText("1 / 3명"); + + await search.fill("cohort-alpha"); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("alice@hs.ac.kr"); + + await search.fill("@twentyoz.kr"); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("carol@twentyoz.kr"); + + await search.fill("zzz-no-such-user"); + await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible(); + }); + + // 검증 checklist: admin-users-table-sort + test("user table sorts by columns with aria-sort and defaults to last_seen_at descending", async ({ + page, + }) => { + const mock = await installAdminMock(page); + mock.users = [ + makeMockUser({ + user_id: "alpha", + email: "alpha@hs.ac.kr", + display_name: "Alpha User", + active_sessions: 5, + last_seen_at: 1_000, + }), + makeMockUser({ + user_id: "beta", + email: "beta@hs.ac.kr", + display_name: "Beta User", + active_sessions: 1, + last_seen_at: 3_000, + }), + makeMockUser({ + user_id: "gamma", + email: "gamma@hs.ac.kr", + display_name: "Gamma User", + active_sessions: 3, + last_seen_at: 2_000, + }), + ]; + + await page.goto("/admin/users"); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + const rows = page.locator(".ad-user-table tbody tr"); + await expect(rows).toHaveCount(3); + + // 기본 정렬: last_seen_at 내림차순. + const lastSeenHeader = page.getByRole("columnheader", { name: /최근 접속/ }); + await expect(lastSeenHeader).toHaveAttribute("aria-sort", "descending"); + await expect(rows.nth(0)).toContainText("beta@hs.ac.kr"); + await expect(rows.nth(2)).toContainText("alpha@hs.ac.kr"); + + // 사용자(identity) 컬럼 클릭 → 오름차순 + aria-sort 갱신. + const identityHeader = page.getByRole("columnheader", { name: /사용자/ }); + await identityHeader.getByRole("button").click(); + await expect(identityHeader).toHaveAttribute("aria-sort", "ascending"); + await expect(lastSeenHeader).toHaveAttribute("aria-sort", "none"); + await expect(rows.nth(0)).toContainText("alpha@hs.ac.kr"); + + // 활성 회기 컬럼: 오름차순 → 내림차순 토글. + const sessionsHeader = page.getByRole("columnheader", { name: /활성 회기/ }); + await sessionsHeader.getByRole("button").click(); + await expect(sessionsHeader).toHaveAttribute("aria-sort", "ascending"); + const ascending = await rows.locator("td:nth-child(7)").allTextContents(); + expect(ascending.map(Number)).toEqual([1, 3, 5]); + await sessionsHeader.getByRole("button").click(); + await expect(sessionsHeader).toHaveAttribute("aria-sort", "descending"); + const descending = await rows.locator("td:nth-child(7)").allTextContents(); + expect(descending.map(Number)).toEqual([5, 3, 1]); + }); + + // 검증 checklist: admin-users-row-cap + test("user table renders at most 40 rows and explains how to reach the rest", async ({ + page, + }) => { + const mock = await installAdminMock(page); + mock.users = Array.from({ length: 45 }, (_, index) => + makeMockUser({ + user_id: `bulk-${String(index + 1).padStart(3, "0")}`, + email: `bulk-${String(index + 1).padStart(3, "0")}@hs.ac.kr`, + display_name: `Bulk User ${index + 1}`, + last_seen_at: 100_000 - index, + }), + ); + + await page.goto("/admin/users"); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + + await expect(page.locator(".ad-user-table tbody tr")).toHaveCount(40); + await expect(page.locator(".ad-users-toolbar")).toContainText("45 / 45명"); + await expect(page.getByText(/나머지\s*5명을 더 좁혀 볼 수/)).toBeVisible(); + }); + + // 검증 checklist: admin-users-admin-access-toggle + test("admin access checkbox is superAdmin-only and blocked for super_admin targets", async ({ + page, + }) => { + const mock = await installAdminMock(page, { super_admin: true }); + mock.users = [ + makeMockUser({ + user_id: "normal", + email: "normal@hs.ac.kr", + display_name: "Normal User", + last_seen_at: 2_000, + }), + makeMockUser({ + user_id: "root", + email: "root@twentyoz.kr", + display_name: "Root Admin", + role: "admin", + admin_access: true, + super_admin: true, + last_seen_at: 1_000, + }), + ]; + + await page.goto("/admin/users"); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + + const normalToggle = page.getByLabel("normal@hs.ac.kr 관리자 페이지 권한"); + const rootToggle = page.getByLabel("root@twentyoz.kr 관리자 페이지 권한"); + await expect(normalToggle).toBeEnabled(); + await expect(rootToggle).toBeDisabled(); + + // 토글 → 저장 시 admin_access가 PATCH로 반영된다. + await normalToggle.check(); + const normalRow = page.locator(".ad-user-table tbody tr").filter({ hasText: "normal@hs.ac.kr" }); + const saveButton = normalRow.getByRole("button", { name: "저장" }); + await expect(saveButton).toBeEnabled(); + const patchPromise = page.waitForResponse(isUserPatchResponse("normal")); + await saveButton.click(); + await patchPromise; + expect(mock.userPatches.at(-1)).toMatchObject({ + userId: "normal", + body: { admin_access: true }, + }); + + // superAdmin이 아니면 어떤 대상도 토글할 수 없다. + mock.auth.super_admin = false; + await page.reload(); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + await expect(page.getByLabel("normal@hs.ac.kr 관리자 페이지 권한")).toBeDisabled(); + await expect(page.getByLabel("root@twentyoz.kr 관리자 페이지 권한")).toBeDisabled(); + }); + + // 검증 checklist: admin-users-activity-tab + test("activity tab shows role distribution meters and the eight most recent users", async ({ + page, + }) => { + const mock = await installAdminMock(page); + const roles = [ + "learner", + "learner", + "learner", + "learner", + "learner", + "teacher", + "teacher", + "admin", + "admin", + ]; + mock.users = roles.map((role, index) => + makeMockUser({ + user_id: `activity-${index + 1}`, + email: `activity-${index + 1}@hs.ac.kr`, + display_name: `Activity User ${index + 1}`, + role, + last_seen_at: 90_000 - index * 1_000, + }), + ); + + await page.goto("/admin/users"); + await page.getByRole("tab", { name: "활동 요약" }).click(); + + await expect(page.getByRole("heading", { name: "역할 분포" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "최근 활동" })).toBeVisible(); + + const meters = page.locator(".ad-role-meter"); + await expect(meters).toHaveCount(3); + await expect(meters.nth(0)).toContainText("학습자"); + await expect(meters.nth(0)).toContainText("5명"); + await expect(meters.nth(1)).toContainText("교수자"); + await expect(meters.nth(1)).toContainText("2명"); + await expect(meters.nth(2)).toContainText("관리자"); + await expect(meters.nth(2)).toContainText("2명"); + + const activityRows = page.locator(".ad-activity-table > div"); + await expect(activityRows).toHaveCount(8); + await expect(activityRows.first()).toContainText("Activity User 1"); + await expect(page.locator(".ad-activity-table")).not.toContainText("Activity User 9"); + }); + + // 검증 checklist: admin-users-states + // [앱 결함 재현 — test.fixme로 스위트에서 제외] + // /admin/users가 실패해 usersData가 null로 남으면 Admin.tsx의 + // `const users = usersData?.users ?? []`가 렌더마다 새 배열을 만들고, + // 이 불안정한 data가 TanStack useReactTable의 _autoResetPageIndex 큐 + // (resetPageIndex → setPagination → React setState)와 결합해 무한 마이크로태스크 + // 루프를 만든다. 그 상태에서 신뢰된 사용자 입력(탭 클릭)이 최초의 동기 갱신을 + // 촉발하는 순간 관리자 화면 전체가 영구 프리즈된다(스크린샷·evaluate조차 불가). + // 합성 dispatchEvent 클릭으로는 재현되지 않고 실제(trusted) 입력 경로에서만 발생한다. + // 아래 탭 클릭이 그 지점이며, 오류 상태의 재시도 안내 문구에 마우스로는 도달할 수 없다. + // 근본 원인 수정 후 fixme를 해제하면 이 테스트가 회귀 가드가 된다. + test("users load failure shows retry guidance and stays interactive", async ({ page }) => { + const mock = await installAdminMock(page); + mock.overrides.users = { delayMs: 1_500, status: 500, detail: "사용자 저장소 중단" }; + + await page.goto("/admin/users"); + // 로딩 스켈레톤(승인 큐)이 먼저 보인다. + await expect(page.locator(".ad-user--skeleton").first()).toBeVisible(); + // 실패 후 InlineError가 표시된다. + await expect(page.locator(".ad-error")).toContainText("사용자 저장소 중단"); + // 앱 결함: 이 클릭에서 페이지가 무한 렌더 루프로 프리즈되어 테스트가 타임아웃된다. + await page.getByRole("tab", { name: "사용자 목록" }).click(); + await expect( + page.getByText("사용자 목록을 불러오지 못했습니다. 새로고침을 눌러 다시 시도하세요."), + ).toBeVisible(); + }); + + // 검증 checklist: admin-users-states + // 로딩 스켈레톤·오류 InlineError는 상호작용 없이(프리즈 결함 우회) 검증하고, + // 오류 상태의 목록 탭 재시도 안내는 위 fixme 재현 테스트가 담당한다. + test("users list distinguishes loading, error, list-empty, and search-empty states", async ({ + page, + }) => { + const mock = await installAdminMock(page); + + // 로딩 스켈레톤 → 실패 InlineError (클릭 없이 표시만 검증). + mock.overrides.users = { delayMs: 1_500, status: 500, detail: "사용자 저장소 중단" }; + await page.goto("/admin/users"); + await expect(page.locator(".ad-user--skeleton").first()).toBeVisible(); + await expect(page.locator(".ad-error")).toContainText("사용자 저장소 중단"); + + // 등록 0건 빈 상태. + delete mock.overrides.users; + mock.users = []; + await page.reload(); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toBeVisible(); + + // 검색 0건 빈 상태는 등록 0건 문구와 구분된다. + mock.users = [ + makeMockUser({ user_id: "solo", email: "solo@hs.ac.kr", display_name: "Solo User" }), + ]; + await page.reload(); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + await expect(page.locator(".ad-user-table tbody tr")).toHaveCount(1); + await page.getByLabel("사용자 검색").fill("no-match-full-sweep"); + await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible(); + await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0); + }); + + // 검증 checklist: admin-tickets-search-input, admin-tickets-status-filter, + // admin-tickets-category-filter, admin-tickets-priority-filter, + // admin-tickets-assigned-group-input, admin-tickets-stale-toggle, + // admin-tickets-clear-filters-button + test("ticket filters drive server query params and the clear button resets them", async ({ + page, + }) => { + const mock = await installAdminMock(page); + mock.tickets = [makeMockTicket({ ticket_id: "filter-1", subject: "필터 대상 티켓" })]; + + await page.goto("/admin/tickets"); + await expect(page.getByRole("heading", { name: "사용자 문제 큐" })).toBeVisible(); + const clearButton = page.getByRole("button", { name: "초기화" }); + await expect(clearButton).toBeDisabled(); + + const searchRequest = page.waitForRequest( + isTicketsRequest((params) => params.get("search") === "로그인 오류"), + ); + await page.getByLabel("티켓 검색").fill("로그인 오류"); + await searchRequest; + + const statusRequest = page.waitForRequest( + isTicketsRequest( + (params) => params.get("status") === "open" && params.get("search") === "로그인 오류", + ), + ); + await page.getByLabel("상태 필터").selectOption("open"); + await statusRequest; + + const categoryRequest = page.waitForRequest( + isTicketsRequest((params) => params.get("category") === "safety"), + ); + await page.getByLabel("카테고리 필터").selectOption("safety"); + await categoryRequest; + + const priorityRequest = page.waitForRequest( + isTicketsRequest((params) => params.get("priority") === "urgent"), + ); + await page.getByLabel("우선순위 필터").selectOption("urgent"); + await priorityRequest; + + const groupRequest = page.waitForRequest( + isTicketsRequest((params) => params.get("assigned_group") === "ops"), + ); + await page.getByLabel("담당 그룹 필터").fill("ops"); + await groupRequest; + + const staleRequest = page.waitForRequest( + isTicketsRequest( + (params) => + params.get("stale_only") === "true" && + params.get("search") === "로그인 오류" && + params.get("status") === "open" && + params.get("category") === "safety" && + params.get("priority") === "urgent" && + params.get("assigned_group") === "ops", + ), + ); + await page.locator(".ad-ticket-filter__check input").check(); + await staleRequest; + + await expect(clearButton).toBeEnabled(); + const resetRequest = page.waitForRequest( + isTicketsRequest( + (params) => + !params.has("search") && + !params.has("status") && + !params.has("category") && + !params.has("priority") && + !params.has("assigned_group") && + !params.has("stale_only") && + params.get("window_days") === "30", + ), + ); + await clearButton.click(); + await resetRequest; + await expect(page.getByLabel("티켓 검색")).toHaveValue(""); + await expect(page.getByLabel("상태 필터")).toHaveValue("all"); + await expect(page.getByLabel("카테고리 필터")).toHaveValue("all"); + await expect(page.getByLabel("우선순위 필터")).toHaveValue("all"); + await expect(page.getByLabel("담당 그룹 필터")).toHaveValue(""); + await expect(page.locator(".ad-ticket-filter__check input")).not.toBeChecked(); + await expect(clearButton).toBeDisabled(); + }); + + // 검증 checklist: admin-tickets-category-queue-chips + test("category queue chips list counted categories and apply the category filter", async ({ + page, + }) => { + const mock = await installAdminMock(page); + mock.tickets = [ + makeMockTicket({ ticket_id: "chip-1", category: "safety", subject: "안전 티켓 1" }), + makeMockTicket({ ticket_id: "chip-2", category: "safety", subject: "안전 티켓 2" }), + makeMockTicket({ ticket_id: "chip-3", category: "other", subject: "기타 티켓" }), + ]; + + await page.goto("/admin/tickets"); + const chips = page.locator(".ad-ticket-queues button"); + await expect(chips).toHaveCount(2); + // 건수 내림차순: 안전(2) → 기타(1). + await expect(chips.nth(0)).toContainText("안전"); + await expect(chips.nth(0)).toContainText("2"); + await expect(chips.nth(1)).toContainText("기타"); + await expect(chips.nth(1)).toContainText("1"); + + const filterRequest = page.waitForRequest( + isTicketsRequest((params) => params.get("category") === "safety"), + ); + await chips.nth(0).click(); + await filterRequest; + await expect(chips.nth(0)).toHaveClass(/is-active/); + await expect(page.getByLabel("카테고리 필터")).toHaveValue("safety"); + }); + + // 검증 checklist: admin-tickets-refresh-button + test("ticket refresh button reloads with current filters and disables while loading", async ({ + page, + }) => { + const mock = await installAdminMock(page); + mock.tickets = [makeMockTicket({ ticket_id: "refresh-1", subject: "새로고침 대상 티켓" })]; + + await page.goto("/admin/tickets"); + const statusRequest = page.waitForRequest( + isTicketsRequest((params) => params.get("status") === "open"), + ); + await page.getByLabel("상태 필터").selectOption("open"); + await statusRequest; + + const refreshButton = page.getByRole("button", { name: /새로고침|확인 중/ }); + await expect(refreshButton).toBeEnabled(); + mock.overrides.tickets = { delayMs: 700 }; + const refreshRequest = page.waitForRequest( + isTicketsRequest((params) => params.get("status") === "open"), + ); + await refreshButton.click(); + await expect(refreshButton).toHaveText("확인 중"); + await expect(refreshButton).toBeDisabled(); + await refreshRequest; + await expect(refreshButton).toHaveText("새로고침"); + await expect(refreshButton).toBeEnabled(); + }); + + // 검증 checklist: admin-tickets-states + test("tickets view distinguishes initial loading, empty queue, and failure states", async ({ + page, + }) => { + const mock = await installAdminMock(page); + mock.tickets = []; + mock.overrides.tickets = { delayMs: 1_500 }; + + await page.goto("/admin/tickets"); + await expect(page.getByText("티켓 큐를 불러오는 중입니다")).toBeVisible(); + await expect(page.getByText("미해결 운영 티켓이 없습니다")).toBeVisible(); + + mock.overrides.tickets = { status: 500, detail: "티켓 저장소 오류" }; + await page.getByRole("button", { name: "새로고침" }).click(); + const inlineError = page.locator(".ad-error"); + await expect(inlineError).toBeVisible(); + await expect(inlineError).toHaveAttribute("role", "alert"); + await expect(inlineError).toContainText("티켓 저장소 오류"); + }); +}); + +test.describe("admin console full-sweep (real API)", () => { + test.beforeEach(async ({ page }) => { + await useRealApi(page); + }); + + // 검증 checklist: admin-users-suspend-button + test("suspends a pending signup from the approval queue via PATCH", async ({ page }, testInfo) => { + test.setTimeout(60_000); + await signInAsAdmin(page); + + const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; + const email = `full-sweep-suspend.${slug}@hs.ac.kr`.toLowerCase(); + let createdUserId: string | null = null; + + try { + const create = await page.request.post("/api/admin/users", { + data: { + email, + display_name: `보류 대상 ${testInfo.project.name}`, + role: "learner", + admin_access: false, + account_status: "pending", + affiliation: "E2E 검증", + cohort_ids: [], + }, + }); + expect(create.ok(), await create.text()).toBeTruthy(); + const created = (await create.json()) as { user_id: string }; + createdUserId = created.user_id; + + const usersResponsePromise = page.waitForResponse(isUsersListResponse); + await page.goto("/admin/users"); + expect((await usersResponsePromise).ok()).toBeTruthy(); + + const approvalCard = page.locator(".ad-approval").filter({ hasText: email }); + await expect(approvalCard).toBeVisible(); + await expect(approvalCard).toContainText("승인 대기"); + + const patchPromise = page.waitForResponse(isUserPatchResponse(created.user_id)); + await approvalCard.getByRole("button", { name: "보류" }).click(); + const patchResponse = await patchPromise; + expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy(); + const updated = (await patchResponse.json()) as { + user_id: string; + account_status: string; + }; + expect(updated).toMatchObject({ + user_id: created.user_id, + account_status: "suspended", + }); + // 보류 처리된 사용자는 승인 대기 큐에서 사라진다. + await expect(approvalCard).toHaveCount(0); + } finally { + if (createdUserId) { + const cleanup = await page.request.delete(`/api/admin/users/${createdUserId}`); + if (!cleanup.ok()) { + console.warn( + `E2E suspend-user cleanup failed: ${cleanup.status()} ${await cleanup.text()}`, + ); + } + } + } + }); + + // 검증 checklist: admin-users-affiliation-input, admin-users-cohort-input + // 코호트는 단일 값 + trim·빈값 제거까지를 여기서 검증한다. 복수 코호트 영속은 + // 서버가 첫 항목만 저장하는 결함이 있어 별도 RED 테스트로 분리했다(아래 참고). + test("saves inline affiliation and trimmed cohort edits through PATCH", async ({ + page, + }, testInfo) => { + test.setTimeout(60_000); + await signInAsAdmin(page); + + const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; + const email = `full-sweep-edit.${slug}@hs.ac.kr`.toLowerCase(); + let createdUserId: string | null = null; + + try { + const create = await page.request.post("/api/admin/users", { + data: { + email, + display_name: `편집 대상 ${testInfo.project.name}`, + role: "learner", + admin_access: false, + account_status: "approved", + affiliation: "원래 소속", + cohort_ids: [], + }, + }); + expect(create.ok(), await create.text()).toBeTruthy(); + const created = (await create.json()) as { user_id: string }; + createdUserId = created.user_id; + + const usersResponsePromise = page.waitForResponse(isUsersListResponse); + await page.goto("/admin/users"); + expect((await usersResponsePromise).ok()).toBeTruthy(); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + await page.getByLabel("사용자 검색").fill(email); + + const row = page.locator(".ad-user-table tbody tr").filter({ hasText: email }); + await expect(row).toBeVisible(); + + const affiliationInput = row.getByLabel(`${email} 소속`); + const cohortInput = row.getByLabel(`${email} 코호트`); + await affiliationInput.fill("산학협력단"); + await expect(affiliationInput).toHaveValue("산학협력단"); + await page.evaluate(() => new Promise(requestAnimationFrame)); + // 콤마 구분 문자열은 trim되고 빈 항목은 제거된다. + await cohortInput.fill(" co-a , , "); + await expect(cohortInput).toHaveValue("co-a"); + await page.evaluate(() => new Promise(requestAnimationFrame)); + + const saveButton = row.getByRole("button", { name: "저장" }); + await expect(saveButton).toBeEnabled(); + const patchPromise = page.waitForResponse(isUserPatchResponse(created.user_id)); + await saveButton.click(); + const patchResponse = await patchPromise; + expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy(); + const updated = (await patchResponse.json()) as { + affiliation: string; + cohort_ids: string[]; + }; + expect(updated).toMatchObject({ + affiliation: "산학협력단", + cohort_ids: ["co-a"], + }); + await expect(affiliationInput).toHaveValue("산학협력단"); + await expect(cohortInput).toHaveValue("co-a"); + } finally { + if (createdUserId) { + const cleanup = await page.request.delete(`/api/admin/users/${createdUserId}`); + if (!cleanup.ok()) { + console.warn( + `E2E edit-user cleanup failed: ${cleanup.status()} ${await cleanup.text()}`, + ); + } + } + } + }); + + // 검증 checklist: admin-users-cohort-input — 앱(서버) 결함으로 의도적 RED + // [앱 결함] UI는 "co-a, co-b"를 ["co-a","co-b"]로 정확히 파싱해 PATCH하지만, + // 서버(POST/PATCH /admin/users)가 cohort_ids 배열의 첫 항목만 저장한다. + // (직접 API 검증: PATCH cohort_ids ["co-a","co-b"] → 응답 ["co-a"], + // ["co-b","co-a"] → ["co-b"], POST ["x-1","x-2","x-3"] → ["x-1"]) + // 체크리스트가 요구하는 "콤마 구분 문자열을 cohort_ids 배열로 반영"이 + // 복수 코호트에서 조용히 데이터 유실로 끝나므로 RED로 남긴다. + test("persists every comma-separated cohort id through PATCH", async ({ page }, testInfo) => { + test.setTimeout(60_000); + await signInAsAdmin(page); + + const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; + const email = `full-sweep-cohorts.${slug}@hs.ac.kr`.toLowerCase(); + let createdUserId: string | null = null; + + try { + const create = await page.request.post("/api/admin/users", { + data: { + email, + display_name: `코호트 대상 ${testInfo.project.name}`, + role: "learner", + admin_access: false, + account_status: "approved", + affiliation: "", + cohort_ids: [], + }, + }); + expect(create.ok(), await create.text()).toBeTruthy(); + const created = (await create.json()) as { user_id: string }; + createdUserId = created.user_id; + + const usersResponsePromise = page.waitForResponse(isUsersListResponse); + await page.goto("/admin/users"); + expect((await usersResponsePromise).ok()).toBeTruthy(); + await page.getByRole("tab", { name: "사용자 목록" }).click(); + await page.getByLabel("사용자 검색").fill(email); + + const row = page.locator(".ad-user-table tbody tr").filter({ hasText: email }); + await expect(row).toBeVisible(); + const cohortInput = row.getByLabel(`${email} 코호트`); + await cohortInput.fill("co-a, co-b"); + await expect(cohortInput).toHaveValue("co-a, co-b"); + await page.evaluate(() => new Promise(requestAnimationFrame)); + + const saveButton = row.getByRole("button", { name: "저장" }); + await expect(saveButton).toBeEnabled(); + const patchPromise = page.waitForResponse(isUserPatchResponse(created.user_id)); + await saveButton.click(); + const patchResponse = await patchPromise; + expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy(); + const updated = (await patchResponse.json()) as { cohort_ids: string[] }; + // 앱 결함: 서버가 ["co-a"]만 반환한다. 기대는 두 코호트 모두 저장. + expect(updated.cohort_ids).toEqual(["co-a", "co-b"]); + } finally { + if (createdUserId) { + const cleanup = await page.request.delete(`/api/admin/users/${createdUserId}`); + if (!cleanup.ok()) { + console.warn( + `E2E cohort-user cleanup failed: ${cleanup.status()} ${await cleanup.text()}`, + ); + } + } + } + }); + + // 검증 checklist: admin-ticket-inprogress-button + test("moves a ticket to in_progress with a single PATCH even on rapid double clicks", async ({ + page, + }, testInfo) => { + test.setTimeout(60_000); + await signInAsAdmin(page); + + const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; + const subject = `[E2E] full-sweep 처리 중 ${slug}`; + let createdTicketId: string | null = null; + + try { + const create = await page.request.post("/api/users/support-tickets", { + data: { + category: "other", + priority: "urgent", + subject, + body: "E2E 처리 중 버튼 검증입니다. 실제 장애가 아닙니다.", + source_path: "/__e2e__/full-sweep/in-progress", + }, + }); + expect(create.ok(), await create.text()).toBeTruthy(); + const created = (await create.json()) as { ticket_id: string }; + createdTicketId = created.ticket_id; + + // 주의: 실 DB 스케일에서 티켓 검색 입력은 콘솔 프리즈 결함을 촉발한다 + // (아래 fixme 재현 테스트 참고). 여기서는 기존 admin.spec.ts와 동일하게 + // 검색 없이 큐 상단에서 카드를 직접 찾는다(긴급 우선순위 + 최신 접수). + const ticketsResponsePromise = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname.endsWith("/admin/tickets"), + ); + await page.goto("/admin/tickets"); + await ticketsResponsePromise; + + const card = page.locator(".ad-ticket").filter({ hasText: subject }); + await expect(card).toBeVisible(); + + let patchCount = 0; + page.on("request", (request) => { + if ( + request.method() === "PATCH" && + new URL(request.url()).pathname.endsWith(`/admin/tickets/${created.ticket_id}`) + ) { + patchCount += 1; + } + }); + + const inProgressButton = card.getByRole("button", { name: "처리 중" }); + const patchPromise = page.waitForResponse(isTicketPatchResponse(created.ticket_id)); + // 빠른 중복 클릭 — pendingRef가 두 번째 요청을 즉시 차단해야 한다. + await inProgressButton.evaluate((element) => { + (element as HTMLButtonElement).click(); + (element as HTMLButtonElement).click(); + }); + const patchResponse = await patchPromise; + expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy(); + const updated = (await patchResponse.json()) as { status: string }; + expect(updated.status).toBe("in_progress"); + expect(patchCount).toBe(1); + + await expect(card).toContainText("처리 중"); + await expect(card.getByRole("button", { name: "처리 중" })).toBeDisabled(); + expect(patchCount).toBe(1); + } finally { + if (createdTicketId) { + const cleanup = await page.request.patch(`/api/admin/tickets/${createdTicketId}`, { + data: { status: "resolved", resolution_note: "E2E cleanup" }, + }); + if (!cleanup.ok()) { + console.warn( + `E2E in-progress ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`, + ); + } + } + } + }); + + // 검증 checklist: admin-ticket-unlink-duplicate-button + test("unlinks a duplicate ticket by clearing parent_ticket_id", async ({ page }, testInfo) => { + test.setTimeout(60_000); + await signInAsAdmin(page); + + const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; + const subject = `[E2E] full-sweep 중복 해제 ${slug}`; + const createdTicketIds: string[] = []; + + try { + for (let i = 0; i < 2; i += 1) { + const create = await page.request.post("/api/users/support-tickets", { + data: { + category: "other", + priority: "normal", + subject, + body: "E2E 중복 연결 해제 검증입니다. 실제 장애가 아닙니다.", + source_path: "/__e2e__/full-sweep/unlink", + }, + }); + expect(create.ok(), await create.text()).toBeTruthy(); + const created = (await create.json()) as { ticket_id: string }; + createdTicketIds.push(created.ticket_id); + } + const [parentId, childId] = createdTicketIds; + + // 선연결: 자식 티켓을 부모에 수동 연결해 둔다. + const link = await page.request.patch(`/api/admin/tickets/${childId}`, { + data: { parent_ticket_id: parentId }, + }); + expect(link.ok(), await link.text()).toBeTruthy(); + const linked = (await link.json()) as { parent_ticket_id: string | null }; + expect(linked.parent_ticket_id).toBe(parentId); + + // 주의: 실 DB 스케일에서 티켓 검색 입력은 콘솔 프리즈 결함을 촉발한다 + // (아래 fixme 재현 테스트 참고). 검색 없이 큐에서 직접 카드를 찾는다. + const ticketsResponsePromise = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname.endsWith("/admin/tickets"), + ); + await page.goto("/admin/tickets"); + await ticketsResponsePromise; + + const linkedCard = page + .locator(".ad-ticket") + .filter({ hasText: subject }) + .filter({ has: page.getByRole("button", { name: "해제" }) }); + await expect(linkedCard).toBeVisible(); + await expect(linkedCard).toContainText("중복 연결"); + + const patchPromise = page.waitForResponse(isTicketPatchResponse(childId)); + await linkedCard.getByRole("button", { name: "해제" }).click(); + const patchResponse = await patchPromise; + expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy(); + const updated = (await patchResponse.json()) as { parent_ticket_id: string | null }; + expect(updated.parent_ticket_id ?? "").toBe(""); + + // 해제 후 해제 버튼이 있는 카드는 더 이상 없다. + await expect(linkedCard).toHaveCount(0); + } finally { + for (const ticketId of createdTicketIds) { + const cleanup = await page.request.patch(`/api/admin/tickets/${ticketId}`, { + data: { status: "resolved", parent_ticket_id: "", resolution_note: "E2E cleanup" }, + }); + if (!cleanup.ok()) { + console.warn( + `E2E unlink ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`, + ); + } + } + } + }); + + // 검증 checklist: admin-tickets-search-input — 앱 결함 재현(test.fixme로 스위트에서 제외) + // [앱 결함] 실 DB 스케일(/admin/users 약 1,400명 + 활성 티켓 24건 초과)에서 + // /admin/tickets의 "티켓 검색" 입력에 값을 넣는 순간 관리자 콘솔 전체가 + // 무한 동기 렌더 루프로 영구 프리즈된다(fill 액션·스크린샷·evaluate 모두 응답 없음). + // CDP Debugger로 채증한 루프: TanStack Table `table._autoResetPageIndex` + // → `setPageIndex` → `setPagination` → React `setState` → Admin 재렌더 → + // core row model 재계산 → autoReset 재큐잉(Promise.then 마이크로태스크) → 무한 반복. + // 같은 페이지·같은 조작이 fixture(소규모·안정 데이터)에서는 재현되지 않고 + // (위 "ticket filters drive server query params" 테스트는 GREEN), + // 실 dev DB 데이터로는 두 번 연속 결정적으로 재현됐다. 상태 버튼 클릭(해결/처리 중)은 + // 프리즈되지 않으며 필터 상태 갱신 계열 입력에서만 발생한다. + // 검색 파라미터 배선 자체는 fixture 테스트가 검증하므로, 이 테스트는 실 스케일 + // 회귀 가드다. 근본 원인 수정 후 fixme를 해제하라. + test("regression guard: ticket search input stays responsive at real data scale", async ({ + page, + }) => { + test.setTimeout(60_000); + await signInAsAdmin(page); + + await page.goto("/admin/tickets"); + await expect(page.getByRole("heading", { name: "사용자 문제 큐" })).toBeVisible(); + + const searchRequest = page.waitForRequest( + isTicketsRequest((params) => params.get("search") === "freeze-probe"), + ); + // 실 DB 스케일에서는 이 fill에서 렌더러가 프리즈되어 어떤 요청도 나가지 않는다. + await page.getByLabel("티켓 검색").fill("freeze-probe"); + await searchRequest; + }); +}); diff --git a/apps/web/e2e/full-sweep-auth.spec.ts b/apps/web/e2e/full-sweep-auth.spec.ts new file mode 100644 index 0000000..54cad0c --- /dev/null +++ b/apps/web/e2e/full-sweep-auth.spec.ts @@ -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 { + 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 { + 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> { + const res = await page.request.get("/api/auth/me"); + expect(res.ok(), await res.text()).toBeTruthy(); + return (await res.json()) as Record; +} + +/** GET /auth/me만 지정한 본문으로 고정한다. 그 외 메서드/요청은 실 API로 보낸다. */ +async function mockAuthMe(page: Page, body: () => Record) { + 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: "oauth-start-stub", + }); + }); + + 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(?:$|[/?#])/); + }); +}); diff --git a/apps/web/e2e/full-sweep-learner-home.spec.ts b/apps/web/e2e/full-sweep-learner-home.spec.ts new file mode 100644 index 0000000..86c27eb --- /dev/null +++ b/apps/web/e2e/full-sweep-learner-home.spec.ts @@ -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((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((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((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"); + }); +}); diff --git a/apps/web/e2e/full-sweep-persona-studio.spec.ts b/apps/web/e2e/full-sweep-persona-studio.spec.ts new file mode 100644 index 0000000..b3158b8 --- /dev/null +++ b/apps/web/e2e/full-sweep-persona-studio.spec.ts @@ -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 = {}) { + 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 | 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 = {}) { + 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 | null = null; + let generatePayload: Record | null = null; + await page.route("**/api/personas/sources", async (route) => { + sourcePayload = route.request().postDataJSON() as Record; + 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; + 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 | 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(); + }); +}); diff --git a/apps/web/e2e/full-sweep-professor.spec.ts b/apps/web/e2e/full-sweep-professor.spec.ts new file mode 100644 index 0000000..774d4af --- /dev/null +++ b/apps/web/e2e/full-sweep-professor.spec.ts @@ -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((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function authMeFixture(overrides: Record = {}) { + 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 = {}) { + 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 = {}) { + 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 = {}, +) { + 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) { + 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); + }); +}); diff --git a/apps/web/e2e/full-sweep-session-review.spec.ts b/apps/web/e2e/full-sweep-session-review.spec.ts new file mode 100644 index 0000000..13d6a25 --- /dev/null +++ b/apps/web/e2e/full-sweep-session-review.spec.ts @@ -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((resolve) => { + releaseSave = resolve; + }); + let savedBody: Record | null = null; + await page.route( + `**/api/teacher/sessions/${sessionId}/review-status`, + async (route) => { + savedBody = route.request().postDataJSON() as Record; + 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"); + }); +}); diff --git a/apps/web/e2e/full-sweep-session.spec.ts b/apps/web/e2e/full-sweep-session.spec.ts new file mode 100644 index 0000000..9a3fa95 --- /dev/null +++ b/apps/web/e2e/full-sweep-session.spec.ts @@ -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; + /** POST /auth/consent 응답 상태 (기본 200) */ + consentStatus?: number; + /** POST /sessions 시작 응답의 회기 시간 계약값 */ + durationLimitSeconds?: number; + warningBeforeEndSeconds?: number; + /** GET /sessions/{id} 재개 스냅샷 덮어쓰기 (status/ended_at/turns 등) */ + detailOverrides?: Record; + /** 스트림을 위기 안전게이트 응답으로 대체 */ + crisisStream?: boolean; + /** 스트림 done 이벤트에 병합할 추가 필드 (progress 등) */ + doneExtra?: Record; +} + +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 수신 시 transcript→reply→state: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(); + }); +}); diff --git a/apps/web/e2e/full-sweep-settings.spec.ts b/apps/web/e2e/full-sweep-settings.spec.ts new file mode 100644 index 0000000..73fa564 --- /dev/null +++ b/apps/web/e2e/full-sweep-settings.spec.ts @@ -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 = {}) { + 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(); + }); +}); diff --git a/apps/web/e2e/full-sweep-shell.spec.ts b/apps/web/e2e/full-sweep-shell.spec.ts new file mode 100644 index 0000000..a9333bc --- /dev/null +++ b/apps/web/e2e/full-sweep-shell.spec.ts @@ -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 = {}) { + 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 = {}) { + 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((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((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("총 표정 수:"); + }); +}); diff --git a/apps/web/index.html b/apps/web/index.html index 55f0c44..2613d76 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -59,6 +59,10 @@ } + + + 24 && visible > 0 && hasAdminContent) { var old = document.getElementById("vignette-boot-diagnostic"); if (old) old.remove(); diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index bff0273..9287372 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.8", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1" @@ -1995,6 +1996,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.8.tgz", + "integrity": "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -2008,6 +2026,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz", + "integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 236fab4..0a9fd0b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.8", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1" diff --git a/apps/web/src/components/shell/shell.css b/apps/web/src/components/shell/shell.css index 5b88778..9790391 100644 --- a/apps/web/src/components/shell/shell.css +++ b/apps/web/src/components/shell/shell.css @@ -65,7 +65,7 @@ font-size: 13px; font-weight: 600; letter-spacing: 0.04em; - color: var(--accent); + color: var(--text-accent); padding-left: var(--sp-4); margin-left: var(--sp-2); border-left: 1px solid var(--hair); diff --git a/apps/web/src/components/ui/Icon.tsx b/apps/web/src/components/ui/Icon.tsx index e435654..eab7090 100644 --- a/apps/web/src/components/ui/Icon.tsx +++ b/apps/web/src/components/ui/Icon.tsx @@ -35,7 +35,8 @@ export type IconName = | "google" | "school" | "sun" - | "moon"; + | "moon" + | "refresh"; export interface IconProps extends Omit, "name"> { name: IconName; @@ -49,6 +50,12 @@ export interface IconProps extends Omit, "name"> { // 각 아이콘의 path/children (24x24 viewBox 기준). google 만 멀티컬러 예외. const PATHS: Record = { + refresh: ( + <> + + + + ), home: ( <> diff --git a/apps/web/src/components/ui/ui.css b/apps/web/src/components/ui/ui.css index c710d3f..159ed2b 100644 --- a/apps/web/src/components/ui/ui.css +++ b/apps/web/src/components/ui/ui.css @@ -10,7 +10,7 @@ font-size: var(--fs-kicker); font-weight: 600; letter-spacing: 0.08em; - color: var(--accent); + color: var(--text-accent); display: inline-flex; align-items: center; gap: 8px; @@ -93,7 +93,7 @@ } .vg-btn--secondary { background: var(--surface-gradient); - color: var(--accent); + color: var(--text-accent); border-color: color-mix(in srgb, var(--accent) 62%, var(--botanical-hair)); } .vg-btn--secondary:hover:not(:disabled) { diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index 09502ed..8ba57fd 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -1090,8 +1090,10 @@ export default function Admin({ section = "overview" }: AdminProps) { } }; - const services = health?.services ?? []; - const users = usersData?.users ?? []; + // 렌더마다 새 배열을 만들면 TanStack core row model이 재계산되며 + // _autoResetPageIndex 상태 갱신 루프(무한 동기 렌더 프리즈)를 유발한다 — 반드시 메모화. + const services = useMemo(() => health?.services ?? [], [health]); + const users = useMemo(() => usersData?.users ?? [], [usersData]); const counts = useMemo( () => ({ ok: services.filter((service) => service.status === "ok").length, @@ -1447,6 +1449,9 @@ export default function Admin({ section = "overview" }: AdminProps) { getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getRowId: (user) => user.user_id, + // 페이지네이션 미사용(수동 40행 슬라이스). row model 재계산이 pageIndex 리셋 + // setState를 큐잉해 무한 렌더 루프를 만들던 경로를 차단한다(결함 로그 #19). + autoResetPageIndex: false, }); const visibleUserRows = userTable .getRowModel() diff --git a/apps/web/src/pages/LearnerHome.tsx b/apps/web/src/pages/LearnerHome.tsx index 70a7028..59a6177 100644 --- a/apps/web/src/pages/LearnerHome.tsx +++ b/apps/web/src/pages/LearnerHome.tsx @@ -41,6 +41,7 @@ import { sessionSortTime, sessionStatusLabel, stageProgressValue, + starterPersona, theoryLine, trendLabel, truncateText, @@ -98,7 +99,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) { if (personaResult.status === "fulfilled") { const personaRows = personaResult.value; - const firstUsableCode = personaRows.find(isUsablePersona)?.code ?? ""; + // 결정 D3: 회기 기록이 0건인 신규 학습자는 난도 오름차순(기초 우선) 첫 + // 사용 가능 페르소나를 기본 선택한다. 기록이 있거나 기록 조회에 실패하면 + // 기존 규칙(API 순서 첫 사용 가능 페르소나)을 유지한다. + const hasNoSessionRecords = + sessionResult.status === "fulfilled" && + (sessionResult.value.sessions ?? []).length === 0; + const firstUsableCode = hasNoSessionRecords + ? (starterPersona(personaRows)?.code ?? "") + : (personaRows.find(isUsablePersona)?.code ?? ""); setPersonas(personaRows); setSelectedCode((cur) => personaRows.some( @@ -1285,7 +1294,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) { -