전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리 (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가. RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset 루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉 마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤 스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등. - 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규 학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual), 저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence + 결정론 파생 폴백). - 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의 CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect. - 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest 421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과, layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드 142~144차 노트.
This commit is contained in:
parent
0ae94499f2
commit
4511383cd9
55 changed files with 9333 additions and 573 deletions
|
|
@ -291,11 +291,18 @@ def _row_value(row, key: str, default=None):
|
||||||
|
|
||||||
|
|
||||||
def _cohort_ids(cohort: str | None) -> list[str]:
|
def _cohort_ids(cohort: str | None) -> list[str]:
|
||||||
return [cohort] if cohort else []
|
"""단일 TEXT 컬럼(콤마 직렬화)을 cohort id 리스트로 복원한다."""
|
||||||
|
if not cohort:
|
||||||
|
return []
|
||||||
|
return [part for part in (piece.strip() for piece in cohort.split(",")) if part]
|
||||||
|
|
||||||
|
|
||||||
def _cohort_value(cohort_ids: list[str] | None) -> str | None:
|
def _cohort_value(cohort_ids: list[str] | None) -> str | None:
|
||||||
return (cohort_ids or [None])[0]
|
"""cohort id 리스트를 콤마 직렬화해 저장한다. 첫 항목만 남기던 유실 버그 수정."""
|
||||||
|
cleaned = [part for part in ((item or "").strip() for item in cohort_ids or []) if part]
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return ",".join(cleaned)
|
||||||
|
|
||||||
|
|
||||||
async def _runtime_tables_ready(conn) -> bool:
|
async def _runtime_tables_ready(conn) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ _REVIEW_COLUMNS = """
|
||||||
source_provenance, is_synthetic, created_at, approved_at
|
source_provenance, is_synthetic, created_at, approved_at
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# UPDATE ... FROM target 처럼 다른 테이블이 스코프에 있는 RETURNING에서는
|
||||||
|
# 무한정 `code`가 target.code와 모호해진다(AmbiguousColumnError → 보관 503).
|
||||||
|
_REVIEW_COLUMNS_CARD_QUALIFIED = ", ".join(
|
||||||
|
f"card.{column.strip()}" for column in _REVIEW_COLUMNS.split(",")
|
||||||
|
)
|
||||||
|
|
||||||
_PERSONA_STATUSES = {"draft", "review", "approved", "archived"}
|
_PERSONA_STATUSES = {"draft", "review", "approved", "archived"}
|
||||||
_REVIEW_QUEUE_STATUSES = ("draft", "review")
|
_REVIEW_QUEUE_STATUSES = ("draft", "review")
|
||||||
PersonaReviewAction = Literal["approve", "reject"]
|
PersonaReviewAction = Literal["approve", "reject"]
|
||||||
|
|
@ -818,7 +824,7 @@ async def archive_persona_family(
|
||||||
FROM target
|
FROM target
|
||||||
WHERE upper(card.code) = upper(target.code)
|
WHERE upper(card.code) = upper(target.code)
|
||||||
AND card.status <> 'archived'
|
AND card.status <> 'archived'
|
||||||
RETURNING {_REVIEW_COLUMNS}
|
RETURNING {_REVIEW_COLUMNS_CARD_QUALIFIED}
|
||||||
)
|
)
|
||||||
SELECT {_REVIEW_COLUMNS}
|
SELECT {_REVIEW_COLUMNS}
|
||||||
FROM archived
|
FROM archived
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
|
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
from ..auth_types import RoleName
|
from ..auth_types import RoleName
|
||||||
from ..auth_sessions import (
|
from ..auth_sessions import (
|
||||||
|
|
@ -128,6 +130,20 @@ class UserProfileResponse(BaseModel):
|
||||||
onboarding_required: bool = True
|
onboarding_required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# 연락처: 선행 + 허용, 숫자·하이픈·공백·괄호·점만, 숫자 9~15자리.
|
||||||
|
_PHONE_ALLOWED_RE = re.compile(r"^\+?[0-9()\-\s.]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_phone_format(value: str) -> str:
|
||||||
|
trimmed = value.strip()
|
||||||
|
digits = re.sub(r"\D", "", trimmed)
|
||||||
|
if not _PHONE_ALLOWED_RE.fullmatch(trimmed) or not 9 <= len(digits) <= 15:
|
||||||
|
raise ValueError(
|
||||||
|
"연락처는 숫자 9~15자리의 전화번호 형식이어야 합니다 (예: 010-1234-5678)."
|
||||||
|
)
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
class UserProfilePatch(BaseModel):
|
class UserProfilePatch(BaseModel):
|
||||||
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||||
affiliation: str | None = Field(default=None, max_length=120)
|
affiliation: str | None = Field(default=None, max_length=120)
|
||||||
|
|
@ -140,6 +156,13 @@ class UserProfilePatch(BaseModel):
|
||||||
self_introduction: str | None = Field(default=None, max_length=600)
|
self_introduction: str | None = Field(default=None, max_length=600)
|
||||||
avatar_url: str | None = Field(default=None, max_length=500)
|
avatar_url: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
@field_validator("phone")
|
||||||
|
@classmethod
|
||||||
|
def _check_phone(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return _validate_phone_format(value)
|
||||||
|
|
||||||
|
|
||||||
class OnboardingRequest(BaseModel):
|
class OnboardingRequest(BaseModel):
|
||||||
legal_name: str = Field(..., min_length=1, max_length=80)
|
legal_name: str = Field(..., min_length=1, max_length=80)
|
||||||
|
|
@ -154,6 +177,11 @@ class OnboardingRequest(BaseModel):
|
||||||
terms_accepted: bool
|
terms_accepted: bool
|
||||||
privacy_accepted: bool
|
privacy_accepted: bool
|
||||||
|
|
||||||
|
@field_validator("phone")
|
||||||
|
@classmethod
|
||||||
|
def _check_phone(cls, value: str) -> str:
|
||||||
|
return _validate_phone_format(value)
|
||||||
|
|
||||||
|
|
||||||
class AvatarUploadResponse(BaseModel):
|
class AvatarUploadResponse(BaseModel):
|
||||||
avatar_url: str
|
avatar_url: str
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,13 @@ class TechniqueDistribution(BaseModel):
|
||||||
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
|
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
|
||||||
|
|
||||||
|
|
||||||
|
class TurnValencePoint(BaseModel):
|
||||||
|
"""deep-loop 턴별 내담자 정서가 — 축어록 seq(1-based) 지목 + v(−1~+1)."""
|
||||||
|
|
||||||
|
seq: int # 마스킹 축어록의 1-based 턴 번호
|
||||||
|
v: float # 정서가(−1 매우 부정 ~ +1 매우 긍정)
|
||||||
|
|
||||||
|
|
||||||
class SessionEvaluation(BaseModel):
|
class SessionEvaluation(BaseModel):
|
||||||
"""deep-loop 회기말/단계전환 정밀 평가 결과.
|
"""deep-loop 회기말/단계전환 정밀 평가 결과.
|
||||||
|
|
||||||
|
|
@ -282,6 +289,9 @@ class SessionEvaluation(BaseModel):
|
||||||
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
|
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
|
||||||
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
|
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
|
||||||
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
|
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
|
||||||
|
turn_valence: list[TurnValencePoint] = Field(
|
||||||
|
default_factory=list
|
||||||
|
) # 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천)
|
||||||
theory_mode: Optional[str] = None
|
theory_mode: Optional[str] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -378,6 +388,19 @@ def _deep_schema() -> dict[str, Any]:
|
||||||
"supervisor_rationale": {"type": "string"},
|
"supervisor_rationale": {"type": "string"},
|
||||||
"supervisor_critique": {"type": "string"},
|
"supervisor_critique": {"type": "string"},
|
||||||
"alternative_utterances": {"type": "array", "items": {"type": "string"}},
|
"alternative_utterances": {"type": "array", "items": {"type": "string"}},
|
||||||
|
# 선택 필드 — 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천).
|
||||||
|
"turn_valence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"properties": {
|
||||||
|
"seq": {"type": "integer", "minimum": 1},
|
||||||
|
"v": {"type": "number", "minimum": -1, "maximum": 1},
|
||||||
|
},
|
||||||
|
"required": ["seq", "v"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["strengths", "improvements"],
|
"required": ["strengths", "improvements"],
|
||||||
}
|
}
|
||||||
|
|
@ -583,7 +606,10 @@ def build_deep_messages(
|
||||||
"- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n"
|
"- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n"
|
||||||
"- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n"
|
"- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n"
|
||||||
"- supervisor_critique: 과도/부족/평가적 시각 등 주의점(critique) 종합.\n"
|
"- supervisor_critique: 과도/부족/평가적 시각 등 주의점(critique) 종합.\n"
|
||||||
"- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개."
|
"- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개.\n"
|
||||||
|
"- turn_valence: *내담자* 발화 각각의 정서가를 {seq, v}로 산출. seq 는 축어록의 "
|
||||||
|
"1-based 턴 번호, v 는 −1(매우 부정)~+1(매우 긍정). 근거 없는 극단값을 피하고 "
|
||||||
|
"불확실하면 0 근처로."
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
@ -854,6 +880,18 @@ async def evaluate_session(
|
||||||
dev = _parse_intent_deviation(d)
|
dev = _parse_intent_deviation(d)
|
||||||
if dev is not None:
|
if dev is not None:
|
||||||
base.intent_deviations.append(dev)
|
base.intent_deviations.append(dev)
|
||||||
|
for item in payload.get("turn_valence") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
seq = item.get("seq")
|
||||||
|
v = item.get("v")
|
||||||
|
if not isinstance(seq, int) or isinstance(seq, bool) or seq < 1:
|
||||||
|
continue
|
||||||
|
if not isinstance(v, (int, float)) or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
base.turn_valence.append(
|
||||||
|
TurnValencePoint(seq=seq, v=max(-1.0, min(1.0, float(v))))
|
||||||
|
)
|
||||||
_evaluator_cache_put(cache_key, base.model_dump())
|
_evaluator_cache_put(cache_key, base.model_dump())
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
@ -888,6 +926,7 @@ __all__ = [
|
||||||
"ClientStateRead",
|
"ClientStateRead",
|
||||||
"TurnEvaluation",
|
"TurnEvaluation",
|
||||||
"TechniqueDistribution",
|
"TechniqueDistribution",
|
||||||
|
"TurnValencePoint",
|
||||||
"SessionEvaluation",
|
"SessionEvaluation",
|
||||||
"aggregate_distribution",
|
"aggregate_distribution",
|
||||||
"evaluate_turn",
|
"evaluate_turn",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ _KOREAN_SURNAME_CHARS = (
|
||||||
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
|
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
|
||||||
_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?"
|
_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?"
|
||||||
_KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}"
|
_KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}"
|
||||||
|
# 인명 마지막 글자로 사실상 쓰이지 않는 용언 활용/명사화 꼬리 글자. 문맥 단서가
|
||||||
|
# 없는 인명 패턴이 "유의점은·방치되는·마무리했을·연결감과·표현함을·어지러움은"
|
||||||
|
# 같은 일반 단어를 이름으로 오탐해 평가 문장을 훼손하는 것을 막는다(정밀도 가드).
|
||||||
|
_KOREAN_NAME_TAIL_GUARD = "(?<![했됐되된될것듯점음움됨함감])"
|
||||||
_KOREAN_NAME_STOPWORDS = {
|
_KOREAN_NAME_STOPWORDS = {
|
||||||
"연락",
|
"연락",
|
||||||
"연락처",
|
"연락처",
|
||||||
|
|
@ -109,7 +113,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||||
"NAME",
|
"NAME",
|
||||||
re.compile(
|
re.compile(
|
||||||
r"(?P<prefix>(?:(?:저는|나는|제가|내가)\s*)?)"
|
r"(?P<prefix>(?:(?:저는|나는|제가|내가)\s*)?)"
|
||||||
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
|
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX}){_KOREAN_NAME_TAIL_GUARD}"
|
||||||
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요))"
|
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요))"
|
||||||
r"(?=$|[\s,.;!?。])"
|
r"(?=$|[\s,.;!?。])"
|
||||||
),
|
),
|
||||||
|
|
@ -128,7 +132,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||||
(
|
(
|
||||||
"NAME",
|
"NAME",
|
||||||
re.compile(
|
re.compile(
|
||||||
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME})"
|
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME}){_KOREAN_NAME_TAIL_GUARD}"
|
||||||
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
|
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,11 @@ async def _session_review_payload_and_recipients(
|
||||||
OR lower(u.email) = ANY($1::text[])
|
OR lower(u.email) = ANY($1::text[])
|
||||||
OR (
|
OR (
|
||||||
u.role = 'instructor'
|
u.role = 'instructor'
|
||||||
AND ($2 = '' OR u.cohort = $2)
|
AND (
|
||||||
|
$2 = ''
|
||||||
|
OR string_to_array(COALESCE(u.cohort, ''), ',')
|
||||||
|
&& string_to_array($2, ',')
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND COALESCE((p.notifications->>'learner_progress')::boolean, true)
|
AND COALESCE((p.notifications->>'learner_progress')::boolean, true)
|
||||||
|
|
|
||||||
|
|
@ -750,6 +750,24 @@ def _client_name(raw: str) -> str:
|
||||||
return name or raw.strip() or "내담자"
|
return name or raw.strip() or "내담자"
|
||||||
|
|
||||||
|
|
||||||
|
_DIFFICULTY_KO = {"easy": "기초", "moderate": "중간", "hard": "고난도"}
|
||||||
|
|
||||||
|
|
||||||
|
def _persona_label(code: str, difficulty: str) -> str:
|
||||||
|
return f"{code} · {_DIFFICULTY_KO.get(difficulty, difficulty)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _josa_wa_gwa(name: str) -> str:
|
||||||
|
"""이름 마지막 글자의 받침 유무로 와/과를 고른다. 한글이 아니면 병기한다."""
|
||||||
|
tail = name[-1] if name else ""
|
||||||
|
if "가" <= tail <= "힣":
|
||||||
|
has_final = (ord(tail) - 0xAC00) % 28 != 0
|
||||||
|
if has_final:
|
||||||
|
return "과"
|
||||||
|
return "와"
|
||||||
|
return "와(과)"
|
||||||
|
|
||||||
|
|
||||||
def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str:
|
def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str:
|
||||||
if not turns:
|
if not turns:
|
||||||
return (
|
return (
|
||||||
|
|
@ -760,7 +778,7 @@ def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[
|
||||||
client_count = sum(1 for turn in turns if turn.speaker == "client")
|
client_count = sum(1 for turn in turns if turn.speaker == "client")
|
||||||
return (
|
return (
|
||||||
f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. "
|
f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. "
|
||||||
f"{client_name}와의 회기는 {reached_phase} 단계까지 진행되었고, "
|
f"{client_name}{_josa_wa_gwa(client_name)}의 회기는 {reached_phase} 단계까지 진행되었고, "
|
||||||
f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. "
|
f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. "
|
||||||
"평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다."
|
"평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다."
|
||||||
)
|
)
|
||||||
|
|
@ -1405,6 +1423,185 @@ def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
# ─ 감정 밸런스 타임라인(valence) 파생 — 순수 함수 ──────────────────────────────
|
||||||
|
_VALENCE_MAX_POINTS = 10
|
||||||
|
|
||||||
|
# client_state_read 코드 → 정서가 극성(taxonomy.ClientState 코드 기준 휴리스틱 맵).
|
||||||
|
_CLIENT_STATE_VALENCE: dict[str, float] = {
|
||||||
|
# 음의 극성 — 방어·위축·위기 신호
|
||||||
|
"involuntary": -0.5,
|
||||||
|
"defensive": -0.7,
|
||||||
|
"suicidal_ideation_admit": -0.9,
|
||||||
|
"negative_self_perception": -0.7,
|
||||||
|
"conflicted": -0.4,
|
||||||
|
"lack_of_confidence": -0.4,
|
||||||
|
"compliant_surface": -0.2,
|
||||||
|
"externalizing": -0.3,
|
||||||
|
"apparent_competence": -0.2,
|
||||||
|
"active_passivity": -0.3,
|
||||||
|
"self_harm_disclosure": -0.9,
|
||||||
|
"somatic_complaint": -0.4,
|
||||||
|
"affect_masking": -0.3,
|
||||||
|
"focus_drift_fusion": -0.3,
|
||||||
|
# 양의 극성 — 개방·접촉·진전 신호
|
||||||
|
"affect_contact": 0.6,
|
||||||
|
"thought_organizing": 0.5,
|
||||||
|
"responds_to_exploration": 0.5,
|
||||||
|
"expresses_plan": 0.7,
|
||||||
|
"defense_loosening": 0.6,
|
||||||
|
"seeks_guidance": 0.2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_valence(value: float) -> float:
|
||||||
|
return max(-1.0, min(1.0, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _valence_t(
|
||||||
|
created_at: float, first_turn_ts: float, duration_seconds: float
|
||||||
|
) -> float:
|
||||||
|
"""턴 시각 → 회기 진행률(0~1 클램프)."""
|
||||||
|
if duration_seconds <= 0:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, min(1.0, (created_at - first_turn_ts) / duration_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_valence_points(
|
||||||
|
points: list[ReviewValencePoint],
|
||||||
|
) -> list[ReviewValencePoint]:
|
||||||
|
"""2개 미만이면 빈 배열(차트 빈 상태), 10개 초과면 균등 리샘플(양 끝점 유지)."""
|
||||||
|
if len(points) < 2:
|
||||||
|
return []
|
||||||
|
if len(points) <= _VALENCE_MAX_POINTS:
|
||||||
|
return points
|
||||||
|
last = len(points) - 1
|
||||||
|
indices: list[int] = []
|
||||||
|
for i in range(_VALENCE_MAX_POINTS):
|
||||||
|
idx = round(i * last / (_VALENCE_MAX_POINTS - 1))
|
||||||
|
if not indices or idx != indices[-1]:
|
||||||
|
indices.append(idx)
|
||||||
|
return [points[i] for i in indices]
|
||||||
|
|
||||||
|
|
||||||
|
def counselor_baseline_points(
|
||||||
|
turns: list[TurnRecord],
|
||||||
|
*,
|
||||||
|
first_turn_ts: float,
|
||||||
|
duration_seconds: float,
|
||||||
|
) -> list[ReviewValencePoint]:
|
||||||
|
"""학습자 턴 rapport_signal 누적 이동평균 → 상담자 기준선 궤적."""
|
||||||
|
points: list[ReviewValencePoint] = []
|
||||||
|
total = 0.0
|
||||||
|
count = 0
|
||||||
|
for turn in turns:
|
||||||
|
if turn.speaker != "counselor":
|
||||||
|
continue
|
||||||
|
ev = session_metrics.turn_eval(turn)
|
||||||
|
if ev is None or str(ev.get("error") or "").strip():
|
||||||
|
continue
|
||||||
|
rapport = session_metrics.turn_rapport(ev)
|
||||||
|
if rapport is None:
|
||||||
|
continue
|
||||||
|
count += 1
|
||||||
|
total += rapport
|
||||||
|
points.append(
|
||||||
|
ReviewValencePoint(
|
||||||
|
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
|
||||||
|
v=_clamp_valence(total / count),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _finalize_valence_points(points)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_client_valence_points(
|
||||||
|
turns: list[TurnRecord],
|
||||||
|
*,
|
||||||
|
first_turn_ts: float,
|
||||||
|
duration_seconds: float,
|
||||||
|
) -> list[ReviewValencePoint]:
|
||||||
|
"""폴백 — 상담자 턴 평가의 client_state_read 극성과 appropriateness(0~1) 결합."""
|
||||||
|
points: list[ReviewValencePoint] = []
|
||||||
|
for turn in turns:
|
||||||
|
if turn.speaker != "counselor":
|
||||||
|
continue
|
||||||
|
ev = session_metrics.turn_eval(turn)
|
||||||
|
if ev is None or str(ev.get("error") or "").strip():
|
||||||
|
continue
|
||||||
|
polarities: list[float] = []
|
||||||
|
for state in ev.get("client_state_read") or []:
|
||||||
|
code = state.get("code") if isinstance(state, dict) else state
|
||||||
|
mapped = _CLIENT_STATE_VALENCE.get(str(code or "").strip())
|
||||||
|
if mapped is not None:
|
||||||
|
polarities.append(mapped)
|
||||||
|
score01 = session_metrics.turn_score(ev)
|
||||||
|
parts: list[float] = []
|
||||||
|
if polarities:
|
||||||
|
parts.append(0.7 * (sum(polarities) / len(polarities)))
|
||||||
|
if score01 is not None:
|
||||||
|
parts.append(0.3 * (score01 * 2.0 - 1.0))
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
points.append(
|
||||||
|
ReviewValencePoint(
|
||||||
|
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
|
||||||
|
v=_clamp_valence(sum(parts)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def client_valence_points(
|
||||||
|
turns: list[TurnRecord],
|
||||||
|
evaluation_payload: dict[str, object],
|
||||||
|
*,
|
||||||
|
first_turn_ts: float,
|
||||||
|
duration_seconds: float,
|
||||||
|
) -> list[ReviewValencePoint]:
|
||||||
|
"""내담자 정서가 궤적 — deep 평가 turn_valence 우선, 없으면 턴 평가 기반 폴백."""
|
||||||
|
raw = (
|
||||||
|
evaluation_payload.get("turn_valence")
|
||||||
|
if isinstance(evaluation_payload, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
points: list[ReviewValencePoint] = []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
seq = item.get("seq")
|
||||||
|
v = item.get("v")
|
||||||
|
if isinstance(seq, bool) or not isinstance(seq, int):
|
||||||
|
continue
|
||||||
|
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||||
|
continue
|
||||||
|
index = seq - 1
|
||||||
|
if index < 0 or index >= len(turns):
|
||||||
|
continue
|
||||||
|
points.append(
|
||||||
|
ReviewValencePoint(
|
||||||
|
t=_valence_t(
|
||||||
|
turns[index].created_at, first_turn_ts, duration_seconds
|
||||||
|
),
|
||||||
|
v=_clamp_valence(float(v)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
points.sort(key=lambda point: point.t)
|
||||||
|
if not points:
|
||||||
|
points = _fallback_client_valence_points(
|
||||||
|
turns, first_turn_ts=first_turn_ts, duration_seconds=duration_seconds
|
||||||
|
)
|
||||||
|
return _finalize_valence_points(points)
|
||||||
|
|
||||||
|
|
||||||
|
def _valence_axis(
|
||||||
|
duration_seconds: int, *, has_points: bool, fallback: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
"""포인트가 있으면 시간 라벨 4개(0~회기말 균등), 없으면 기존 axis 유지."""
|
||||||
|
if not has_points or duration_seconds <= 0:
|
||||||
|
return fallback
|
||||||
|
return [_offset_label(duration_seconds * i / 3) for i in range(4)]
|
||||||
|
|
||||||
|
|
||||||
def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse:
|
def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse:
|
||||||
sess = read_input.session
|
sess = read_input.session
|
||||||
visible_turns = learner_visible_turns(sess)
|
visible_turns = learner_visible_turns(sess)
|
||||||
|
|
@ -1455,6 +1652,27 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 감정 밸런스 타임라인 — 학습자 기준선 + 내담자 정서가(비공개 턴 존재 시 비산출)
|
||||||
|
counselor_baseline: list[ReviewValencePoint] = []
|
||||||
|
client_valence: list[ReviewValencePoint] = []
|
||||||
|
if not hidden_turns:
|
||||||
|
counselor_baseline = counselor_baseline_points(
|
||||||
|
visible_turns,
|
||||||
|
first_turn_ts=first_turn_ts,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
)
|
||||||
|
client_valence = client_valence_points(
|
||||||
|
visible_turns,
|
||||||
|
evaluation_payload,
|
||||||
|
first_turn_ts=first_turn_ts,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
)
|
||||||
|
valence_axis = _valence_axis(
|
||||||
|
duration_seconds,
|
||||||
|
has_points=bool(counselor_baseline or client_valence),
|
||||||
|
fallback=axis,
|
||||||
|
)
|
||||||
|
|
||||||
if not turns:
|
if not turns:
|
||||||
session_signal = "기록 없음"
|
session_signal = "기록 없음"
|
||||||
elif sess.ended:
|
elif sess.ended:
|
||||||
|
|
@ -1533,7 +1751,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
||||||
client=ReviewClient(
|
client=ReviewClient(
|
||||||
name=client_name,
|
name=client_name,
|
||||||
initial=client_initial,
|
initial=client_initial,
|
||||||
persona=f"{sess.persona_code} · {sess.persona.difficulty}",
|
persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)),
|
||||||
),
|
),
|
||||||
date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
|
date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
|
||||||
durationLabel=_duration_label(duration_seconds),
|
durationLabel=_duration_label(duration_seconds),
|
||||||
|
|
@ -1545,9 +1763,9 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
||||||
summary=summary,
|
summary=summary,
|
||||||
phases=_phase_segments(stage_labels),
|
phases=_phase_segments(stage_labels),
|
||||||
phaseAxis=axis,
|
phaseAxis=axis,
|
||||||
valenceAxis=axis,
|
valenceAxis=valence_axis,
|
||||||
clientValence=[],
|
clientValence=client_valence,
|
||||||
counselorBaseline=[],
|
counselorBaseline=counselor_baseline,
|
||||||
turns=turns,
|
turns=turns,
|
||||||
rubric=rubric,
|
rubric=rubric,
|
||||||
goodMoments=good_moments,
|
goodMoments=good_moments,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,26 @@ class _Acquire:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CohortSerializationTest(unittest.TestCase):
|
||||||
|
"""2026-07-27 전수 순회 결함 #20: 서버가 cohort_ids 첫 항목만 저장하던 유실 회귀 가드."""
|
||||||
|
|
||||||
|
def test_multi_cohort_round_trip_preserves_every_id(self) -> None:
|
||||||
|
from .auth_sessions import _cohort_ids, _cohort_value
|
||||||
|
|
||||||
|
self.assertEqual(_cohort_value(["co-a", "co-b"]), "co-a,co-b")
|
||||||
|
self.assertEqual(_cohort_ids("co-a,co-b"), ["co-a", "co-b"])
|
||||||
|
self.assertEqual(_cohort_ids(_cohort_value(["x-1", "x-2", "x-3"])), ["x-1", "x-2", "x-3"])
|
||||||
|
|
||||||
|
def test_cohort_value_trims_and_drops_empty_entries(self) -> None:
|
||||||
|
from .auth_sessions import _cohort_ids, _cohort_value
|
||||||
|
|
||||||
|
self.assertEqual(_cohort_value([" co-a ", "", " ", "co-b"]), "co-a,co-b")
|
||||||
|
self.assertIsNone(_cohort_value([]))
|
||||||
|
self.assertIsNone(_cohort_value(None))
|
||||||
|
self.assertEqual(_cohort_ids(None), [])
|
||||||
|
self.assertEqual(_cohort_ids(" co-a , ,co-b "), ["co-a", "co-b"])
|
||||||
|
|
||||||
|
|
||||||
class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_usage_from_database_orders_by_aggregated_token_sum(self) -> None:
|
async def test_usage_from_database_orders_by_aggregated_token_sum(self) -> None:
|
||||||
case = self
|
case = self
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,21 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
|
||||||
)
|
)
|
||||||
self.assertNotIn("NAME", masked.entities)
|
self.assertNotIn("NAME", masked.entities)
|
||||||
|
|
||||||
|
def test_mask_pii_does_not_mask_evaluator_abstract_words_as_names(self) -> None:
|
||||||
|
# 실제 교수자 리뷰 화면에서 관측된 오탐 문형: 성씨 문자로 시작하는 일반
|
||||||
|
# 명사/용언(유의점·방치되는·마무리했을·연결감·표현함·어지러움)이
|
||||||
|
# 조사와 결합하면 문맥-없는 인명 패턴에 걸려 평가 문장이 훼손된다.
|
||||||
|
text = (
|
||||||
|
"유의점은 세 가지다. 정서 신호가 방치되는 것을 피하고, 회기를 "
|
||||||
|
"마무리했을 때 연결감과 작업동맹을 지키며, 감정을 표현함을 존중한다. "
|
||||||
|
"어지러움은 신체 신호로 다룬다."
|
||||||
|
)
|
||||||
|
|
||||||
|
masked = guardrail.mask_pii(text)
|
||||||
|
|
||||||
|
self.assertEqual(masked.text_masked, text)
|
||||||
|
self.assertNotIn("NAME", masked.entities)
|
||||||
|
|
||||||
def test_prepare_turn_masks_korean_pii_from_engine_messages(self) -> None:
|
def test_prepare_turn_masks_korean_pii_from_engine_messages(self) -> None:
|
||||||
ctx = orchestrator.prepare_turn(
|
ctx = orchestrator.prepare_turn(
|
||||||
session_id="masking-session",
|
session_id="masking-session",
|
||||||
|
|
|
||||||
50
apps/api/app/test_review_summary_josa.py
Normal file
50
apps/api/app/test_review_summary_josa.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""리뷰 요약 문장의 한국어 조사(와/과) 선택 회귀 테스트.
|
||||||
|
|
||||||
|
2026-07-27 전수 순회에서 발견: 받침 있는 내담자 이름(하늘) 뒤에도 "와의"가
|
||||||
|
붙어 "하늘와의 회기는"으로 표기됐다. 마지막 글자 받침 유무에 따라 와/과를
|
||||||
|
골라야 한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from .session_read_model import ReviewTurn, _review_summary
|
||||||
|
|
||||||
|
|
||||||
|
def _turns() -> list[ReviewTurn]:
|
||||||
|
return [
|
||||||
|
ReviewTurn(id="t1", ts="0:00", speaker="learner", who="나", text="안녕하세요."),
|
||||||
|
ReviewTurn(id="t2", ts="0:01", speaker="client", who="하늘", text="...네."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewPersonaLabelTest(unittest.TestCase):
|
||||||
|
def test_difficulty_is_localized_to_korean(self) -> None:
|
||||||
|
from .session_read_model import _persona_label
|
||||||
|
|
||||||
|
self.assertEqual(_persona_label("P4", "easy"), "P4 · 기초")
|
||||||
|
self.assertEqual(_persona_label("P2", "moderate"), "P2 · 중간")
|
||||||
|
self.assertEqual(_persona_label("P1", "hard"), "P1 · 고난도")
|
||||||
|
# 알 수 없는 값은 원문 유지(빈 라벨로 숨기지 않는다)
|
||||||
|
self.assertEqual(_persona_label("P9", "custom"), "P9 · custom")
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSummaryJosaTest(unittest.TestCase):
|
||||||
|
def test_name_with_final_consonant_uses_gwa(self) -> None:
|
||||||
|
summary = _review_summary(client_name="하늘", reached_phase="라포", turns=_turns())
|
||||||
|
self.assertIn("하늘과의 회기는", summary)
|
||||||
|
self.assertNotIn("하늘와의", summary)
|
||||||
|
|
||||||
|
def test_name_without_final_consonant_uses_wa(self) -> None:
|
||||||
|
summary = _review_summary(client_name="지우", reached_phase="라포", turns=_turns())
|
||||||
|
self.assertIn("지우와의 회기는", summary)
|
||||||
|
self.assertNotIn("지우과의", summary)
|
||||||
|
|
||||||
|
def test_non_hangul_tail_falls_back_to_gwa_slash_wa(self) -> None:
|
||||||
|
summary = _review_summary(client_name="P4", reached_phase="라포", turns=_turns())
|
||||||
|
self.assertIn("P4와(과)의 회기는", summary)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
287
apps/api/app/test_review_valence.py
Normal file
287
apps/api/app/test_review_valence.py
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
"""감정 밸런스 타임라인(리뷰 valence 차트) 파생 로직 회귀 테스트."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import session_read_model
|
||||||
|
from .engine_client import GenerateResponse
|
||||||
|
from .services import evaluator, persona as persona_service, state_machine
|
||||||
|
from .store import InProcSession, TurnRecord
|
||||||
|
|
||||||
|
|
||||||
|
def _session(*, created_at: float = 1_000.0) -> InProcSession:
|
||||||
|
card = persona_service.P1
|
||||||
|
return InProcSession(
|
||||||
|
session_id="review-valence-session",
|
||||||
|
case_id="review-valence-case",
|
||||||
|
learner_id="00000000-0000-0000-0000-000000000201",
|
||||||
|
persona_code=card.code,
|
||||||
|
theory_mode="humanistic",
|
||||||
|
persona=card,
|
||||||
|
state=state_machine.SessionState(
|
||||||
|
resistance=card.base_resistance(),
|
||||||
|
ideation_stage=card.ideation_baseline(),
|
||||||
|
),
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _turn(
|
||||||
|
seq: int,
|
||||||
|
speaker: str,
|
||||||
|
created_at: float,
|
||||||
|
evaluation: dict[str, Any] | None = None,
|
||||||
|
) -> TurnRecord:
|
||||||
|
return TurnRecord(
|
||||||
|
turn_seq=seq,
|
||||||
|
speaker=speaker,
|
||||||
|
stage="rapport",
|
||||||
|
text=f"발화 {seq}",
|
||||||
|
text_masked=f"발화 {seq}",
|
||||||
|
created_at=created_at,
|
||||||
|
evaluation=evaluation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CounselorBaselineTest(unittest.TestCase):
|
||||||
|
def test_cumulative_average_and_t_normalization(self) -> None:
|
||||||
|
base = 1_000.0
|
||||||
|
turns = [
|
||||||
|
_turn(1, "counselor", base, {"rapport_signal": 0.5}),
|
||||||
|
_turn(2, "client", base + 10.0),
|
||||||
|
_turn(3, "counselor", base + 30.0, {"rapport_signal": -0.5}),
|
||||||
|
_turn(4, "client", base + 40.0),
|
||||||
|
_turn(5, "counselor", base + 60.0, {"rapport_signal": 0.5}),
|
||||||
|
]
|
||||||
|
points = session_read_model.counselor_baseline_points(
|
||||||
|
turns, first_turn_ts=base, duration_seconds=120
|
||||||
|
)
|
||||||
|
self.assertEqual(len(points), 3)
|
||||||
|
self.assertAlmostEqual(points[0].v, 0.5)
|
||||||
|
self.assertAlmostEqual(points[1].v, 0.0)
|
||||||
|
self.assertAlmostEqual(points[2].v, 0.5 / 3.0)
|
||||||
|
self.assertAlmostEqual(points[0].t, 0.0)
|
||||||
|
self.assertAlmostEqual(points[1].t, 0.25)
|
||||||
|
self.assertAlmostEqual(points[2].t, 0.5)
|
||||||
|
|
||||||
|
def test_resamples_to_at_most_ten_points(self) -> None:
|
||||||
|
base = 1_000.0
|
||||||
|
turns = [
|
||||||
|
_turn(i + 1, "counselor", base + i * 10.0, {"rapport_signal": 0.1})
|
||||||
|
for i in range(12)
|
||||||
|
]
|
||||||
|
points = session_read_model.counselor_baseline_points(
|
||||||
|
turns, first_turn_ts=base, duration_seconds=110
|
||||||
|
)
|
||||||
|
self.assertEqual(len(points), 10)
|
||||||
|
# 균등 리샘플이어도 양 끝점은 유지된다.
|
||||||
|
self.assertAlmostEqual(points[0].t, 0.0)
|
||||||
|
self.assertAlmostEqual(points[-1].t, 1.0)
|
||||||
|
# t 는 단조 증가·클램프 범위 내.
|
||||||
|
for prev, cur in zip(points, points[1:]):
|
||||||
|
self.assertLessEqual(prev.t, cur.t)
|
||||||
|
for point in points:
|
||||||
|
self.assertGreaterEqual(point.v, -1.0)
|
||||||
|
self.assertLessEqual(point.v, 1.0)
|
||||||
|
|
||||||
|
def test_fewer_than_two_points_returns_empty(self) -> None:
|
||||||
|
base = 1_000.0
|
||||||
|
turns = [
|
||||||
|
_turn(1, "counselor", base, {"rapport_signal": 0.4}),
|
||||||
|
_turn(2, "client", base + 10.0),
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
session_read_model.counselor_baseline_points(
|
||||||
|
turns, first_turn_ts=base, duration_seconds=60
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientValenceTest(unittest.TestCase):
|
||||||
|
def test_prefers_payload_turn_valence(self) -> None:
|
||||||
|
base = 1_000.0
|
||||||
|
turns = [
|
||||||
|
_turn(1, "counselor", base),
|
||||||
|
_turn(2, "client", base + 30.0),
|
||||||
|
_turn(3, "counselor", base + 60.0),
|
||||||
|
_turn(4, "client", base + 90.0),
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"turn_valence": [
|
||||||
|
{"seq": 2, "v": 0.4},
|
||||||
|
{"seq": 4, "v": -0.6},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
points = session_read_model.client_valence_points(
|
||||||
|
turns, payload, first_turn_ts=base, duration_seconds=120
|
||||||
|
)
|
||||||
|
self.assertEqual(len(points), 2)
|
||||||
|
self.assertAlmostEqual(points[0].t, 0.25)
|
||||||
|
self.assertAlmostEqual(points[0].v, 0.4)
|
||||||
|
self.assertAlmostEqual(points[1].t, 0.75)
|
||||||
|
self.assertAlmostEqual(points[1].v, -0.6)
|
||||||
|
|
||||||
|
def test_fallback_derives_from_turn_evaluations(self) -> None:
|
||||||
|
base = 1_000.0
|
||||||
|
negative_eval = {
|
||||||
|
"client_state_read": [{"code": "defensive", "label_ko": "방어"}],
|
||||||
|
"appropriateness": "warn",
|
||||||
|
}
|
||||||
|
positive_eval = {
|
||||||
|
"client_state_read": [{"code": "defense_loosening", "label_ko": "방어 완화"}],
|
||||||
|
"appropriateness": "pos",
|
||||||
|
}
|
||||||
|
turns = [
|
||||||
|
_turn(1, "counselor", base, negative_eval),
|
||||||
|
_turn(2, "client", base + 30.0),
|
||||||
|
_turn(3, "counselor", base + 60.0, positive_eval),
|
||||||
|
_turn(4, "client", base + 90.0),
|
||||||
|
]
|
||||||
|
points = session_read_model.client_valence_points(
|
||||||
|
turns, {}, first_turn_ts=base, duration_seconds=120
|
||||||
|
)
|
||||||
|
self.assertEqual(len(points), 2)
|
||||||
|
self.assertLess(points[0].v, 0.0)
|
||||||
|
self.assertGreater(points[1].v, 0.0)
|
||||||
|
for point in points:
|
||||||
|
self.assertGreaterEqual(point.v, -1.0)
|
||||||
|
self.assertLessEqual(point.v, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildSessionReviewValenceTest(unittest.TestCase):
|
||||||
|
def test_review_fills_valence_arrays_and_axis(self) -> None:
|
||||||
|
base = 1_000.0
|
||||||
|
sess = _session(created_at=base)
|
||||||
|
sess.ended = True
|
||||||
|
sess.ended_at = base + 120.0
|
||||||
|
for i in range(4):
|
||||||
|
counselor_eval = {
|
||||||
|
"rapport_signal": 0.2 + i * 0.1,
|
||||||
|
"client_state_read": [{"code": "affect_contact", "label_ko": "정서 접촉/표현"}],
|
||||||
|
"appropriateness": "pos",
|
||||||
|
}
|
||||||
|
sess.turns.append(
|
||||||
|
_turn(i * 2 + 1, "counselor", base + i * 30.0, counselor_eval)
|
||||||
|
)
|
||||||
|
sess.turns.append(_turn(i * 2 + 2, "client", base + i * 30.0 + 15.0))
|
||||||
|
evaluation_record = {
|
||||||
|
"status": "ready",
|
||||||
|
"payload": {
|
||||||
|
"strengths": ["감정 반영이 좋았습니다."],
|
||||||
|
"improvements": [],
|
||||||
|
"turn_valence": [
|
||||||
|
{"seq": 2, "v": -0.3},
|
||||||
|
{"seq": 4, "v": -0.1},
|
||||||
|
{"seq": 6, "v": 0.2},
|
||||||
|
{"seq": 8, "v": 0.5},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
review = session_read_model.build_session_review(
|
||||||
|
session_read_model.SessionReviewReadInput(
|
||||||
|
session=sess,
|
||||||
|
evaluation_record=evaluation_record,
|
||||||
|
evaluation_durable=True,
|
||||||
|
now_ts=base + 200.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(review.clientValence), 4)
|
||||||
|
self.assertEqual(len(review.counselorBaseline), 4)
|
||||||
|
# payload turn_valence 를 그대로 쓴다.
|
||||||
|
self.assertAlmostEqual(review.clientValence[0].v, -0.3)
|
||||||
|
self.assertAlmostEqual(review.clientValence[-1].v, 0.5)
|
||||||
|
# x축 라벨은 3~5개.
|
||||||
|
self.assertGreaterEqual(len(review.valenceAxis), 3)
|
||||||
|
self.assertLessEqual(len(review.valenceAxis), 5)
|
||||||
|
self.assertEqual(review.valenceAxis[0], "0:00")
|
||||||
|
|
||||||
|
def test_review_without_signals_keeps_empty_arrays(self) -> None:
|
||||||
|
base = 1_000.0
|
||||||
|
sess = _session(created_at=base)
|
||||||
|
sess.ended = True
|
||||||
|
sess.ended_at = base + 60.0
|
||||||
|
sess.turns.append(_turn(1, "counselor", base))
|
||||||
|
sess.turns.append(_turn(2, "client", base + 10.0))
|
||||||
|
review = session_read_model.build_session_review(
|
||||||
|
session_read_model.SessionReviewReadInput(
|
||||||
|
session=sess,
|
||||||
|
evaluation_record={"status": "ready", "payload": {}},
|
||||||
|
evaluation_durable=True,
|
||||||
|
now_ts=base + 100.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(review.clientValence, [])
|
||||||
|
self.assertEqual(review.counselorBaseline, [])
|
||||||
|
# 포인트가 없으면 기존 2원소 axis 를 유지한다.
|
||||||
|
self.assertEqual(len(review.valenceAxis), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class _DeepValenceEngine:
|
||||||
|
"""deep-loop 구조화 응답에 turn_valence 를 포함하는 가짜 엔진."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[Any] = []
|
||||||
|
|
||||||
|
async def generate(self, req: Any) -> GenerateResponse:
|
||||||
|
self.requests.append(req)
|
||||||
|
return GenerateResponse(
|
||||||
|
text="",
|
||||||
|
model="fake-deep",
|
||||||
|
provider="fake-provider",
|
||||||
|
structured={
|
||||||
|
"strengths": ["공감 표현"],
|
||||||
|
"improvements": [],
|
||||||
|
"turn_valence": [
|
||||||
|
{"seq": 2, "v": -0.4},
|
||||||
|
{"seq": 4, "v": 1.7}, # 범위 초과 → 클램프
|
||||||
|
{"seq": 0, "v": 0.2}, # 잘못된 seq → 버림
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSchemaTurnValenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self) -> None:
|
||||||
|
evaluator.clear_evaluator_semantic_cache()
|
||||||
|
|
||||||
|
async def asyncTearDown(self) -> None:
|
||||||
|
evaluator.clear_evaluator_semantic_cache()
|
||||||
|
|
||||||
|
def test_deep_schema_declares_turn_valence(self) -> None:
|
||||||
|
schema = evaluator._deep_schema()
|
||||||
|
turn_valence = schema["properties"].get("turn_valence")
|
||||||
|
self.assertIsNotNone(turn_valence)
|
||||||
|
self.assertEqual(turn_valence["type"], "array")
|
||||||
|
item_props = turn_valence["items"]["properties"]
|
||||||
|
self.assertIn("seq", item_props)
|
||||||
|
self.assertIn("v", item_props)
|
||||||
|
# 선택 필드라 required 에는 없어야 한다.
|
||||||
|
self.assertNotIn("turn_valence", schema["required"])
|
||||||
|
|
||||||
|
async def test_evaluate_session_parses_turn_valence(self) -> None:
|
||||||
|
engine = _DeepValenceEngine()
|
||||||
|
result = await evaluator.evaluate_session(
|
||||||
|
session_id="review-valence-session",
|
||||||
|
stage="라포",
|
||||||
|
masked_turns=[
|
||||||
|
{"seq": 1, "speaker": "counselor", "text": "요즘 어떠세요?"},
|
||||||
|
{"seq": 2, "speaker": "client", "text": "잘 모르겠어요."},
|
||||||
|
],
|
||||||
|
engine=engine, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
self.assertIsNone(result.error)
|
||||||
|
self.assertEqual(len(result.turn_valence), 2)
|
||||||
|
self.assertEqual(result.turn_valence[0].seq, 2)
|
||||||
|
self.assertAlmostEqual(result.turn_valence[0].v, -0.4)
|
||||||
|
self.assertAlmostEqual(result.turn_valence[1].v, 1.0) # 클램프
|
||||||
|
# 영속 payload 경로(to_dict)에 turn_valence 가 실린다.
|
||||||
|
payload = result.to_dict()
|
||||||
|
self.assertIn("turn_valence", payload)
|
||||||
|
self.assertEqual(payload["turn_valence"][0]["seq"], 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
68
apps/api/app/test_user_onboarding_validation.py
Normal file
68
apps/api/app/test_user_onboarding_validation.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""온보딩/프로필 연락처(phone) 형식 검증 회귀 테스트.
|
||||||
|
|
||||||
|
2026-07-27 전수 순회에서 발견: 온보딩 연락처에 "전화번호아님텍스트" 같은
|
||||||
|
임의 문자열을 넣어도 클라이언트·서버 모두 통과되어 DB에 저장됐다.
|
||||||
|
OnboardingRequest / UserProfilePatch 는 숫자 9~15자리(하이픈·공백·괄호·점·
|
||||||
|
선행 + 허용)만 받아야 한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from .routes.users import OnboardingRequest, UserProfilePatch
|
||||||
|
|
||||||
|
|
||||||
|
def _onboarding_kwargs(**overrides: object) -> dict:
|
||||||
|
base: dict = {
|
||||||
|
"legal_name": "검증 사용자",
|
||||||
|
"affiliation": "한신대학교",
|
||||||
|
"department": "상담심리학과",
|
||||||
|
"grade_level": "3학년",
|
||||||
|
"phone": "010-1234-5678",
|
||||||
|
"contact_address": "경기도 오산시 한신대길 137",
|
||||||
|
"nickname": "검증",
|
||||||
|
"self_introduction": "연락처 검증 테스트 사용자입니다.",
|
||||||
|
"avatar_url": "",
|
||||||
|
"terms_accepted": True,
|
||||||
|
"privacy_accepted": True,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingPhoneValidationTest(unittest.TestCase):
|
||||||
|
def test_onboarding_rejects_non_numeric_phone_text(self) -> None:
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
OnboardingRequest(**_onboarding_kwargs(phone="전화번호아님텍스트"))
|
||||||
|
|
||||||
|
def test_onboarding_rejects_phone_with_too_few_digits(self) -> None:
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
OnboardingRequest(**_onboarding_kwargs(phone="010-12"))
|
||||||
|
|
||||||
|
def test_onboarding_accepts_common_korean_phone_formats(self) -> None:
|
||||||
|
for phone in (
|
||||||
|
"010-1234-5678",
|
||||||
|
"01012345678",
|
||||||
|
"010 1234 5678",
|
||||||
|
"02-123-4567",
|
||||||
|
"031-8000-9000",
|
||||||
|
"+82-10-1234-5678",
|
||||||
|
):
|
||||||
|
with self.subTest(phone=phone):
|
||||||
|
req = OnboardingRequest(**_onboarding_kwargs(phone=phone))
|
||||||
|
self.assertEqual(req.phone, phone.strip())
|
||||||
|
|
||||||
|
def test_profile_patch_rejects_non_numeric_phone_text(self) -> None:
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
UserProfilePatch(phone="전화번호아님텍스트")
|
||||||
|
|
||||||
|
def test_profile_patch_allows_omitted_phone(self) -> None:
|
||||||
|
patch = UserProfilePatch()
|
||||||
|
self.assertIsNone(patch.phone)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -781,6 +781,28 @@ test.describe("admin route guards", () => {
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps the boot diagnostic overlay off a rendered AI operations page", async ({ page }) => {
|
||||||
|
await mockAdminSession(page, {
|
||||||
|
user_id: "ai-watchdog-admin",
|
||||||
|
email: "ai-watchdog-admin@twentyoz.kr",
|
||||||
|
display_name: "AI Watchdog Admin",
|
||||||
|
role: "admin",
|
||||||
|
admin_access: true,
|
||||||
|
super_admin: true,
|
||||||
|
onboarding_completed_at: 1_782_900_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible();
|
||||||
|
|
||||||
|
// index.html watchdog은 로드 후 3.5초/8초에 부트 진단을 판정한다.
|
||||||
|
// 정상 렌더된 화면에서는 두 판정 창이 지난 뒤에도 진단 오버레이가 없어야 한다.
|
||||||
|
await page.waitForTimeout(8_600);
|
||||||
|
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("resets scroll when moving from long admin pages to access policy", async ({ page }) => {
|
test("resets scroll when moving from long admin pages to access policy", async ({ page }) => {
|
||||||
await mockAdminSession(page, {
|
await mockAdminSession(page, {
|
||||||
user_id: "scroll-admin",
|
user_id: "scroll-admin",
|
||||||
|
|
|
||||||
501
apps/web/e2e/full-sweep-admin-ai.spec.ts
Normal file
501
apps/web/e2e/full-sweep-admin-ai.spec.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
// 2026-07-27 E2E 전수 순회 — 관리자 AI 설정(/admin/ai) 영역의 "신규 spec 필요" 항목 검증.
|
||||||
|
// docs/ops/e2e-full-sweep-2026-07-27.md §8 (admin-ai) 체크리스트 중 기존 spec이 없는 항목을
|
||||||
|
// route fixture로 상태를 고정해 검증한다. 실제 AI 엔진 턴 생성은 하지 않는다.
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
interface AdminUsageBreakdown {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
turns: number;
|
||||||
|
tokens_in: number;
|
||||||
|
tokens_out: number;
|
||||||
|
cost_usd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminUsageDailyCost {
|
||||||
|
day: string;
|
||||||
|
turns: number;
|
||||||
|
tokens_in: number;
|
||||||
|
tokens_out: number;
|
||||||
|
cost_usd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminUsageEvaluatorCache {
|
||||||
|
enabled: boolean;
|
||||||
|
entries: number;
|
||||||
|
hits: number;
|
||||||
|
misses: number;
|
||||||
|
stores: number;
|
||||||
|
evictions: number;
|
||||||
|
requests: number;
|
||||||
|
hit_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminUsageResponse {
|
||||||
|
source: "database" | "server_session_registry";
|
||||||
|
durable: boolean;
|
||||||
|
generated_at: number;
|
||||||
|
window_days: number;
|
||||||
|
total_turns: number;
|
||||||
|
metered_turns: number;
|
||||||
|
tokens_in: number;
|
||||||
|
tokens_out: number;
|
||||||
|
cost_usd: number;
|
||||||
|
budget: {
|
||||||
|
limit_usd: number;
|
||||||
|
used_ratio: number;
|
||||||
|
remaining_usd: number | null;
|
||||||
|
status: "disabled" | "ok" | "warn" | "exceeded";
|
||||||
|
};
|
||||||
|
evaluator_cache?: AdminUsageEvaluatorCache;
|
||||||
|
by_provider: AdminUsageBreakdown[];
|
||||||
|
daily_cost?: AdminUsageDailyCost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminEngineConfigResponse {
|
||||||
|
engine_mode: string;
|
||||||
|
engine_url: string;
|
||||||
|
model: string;
|
||||||
|
source: "database" | "runtime_cache" | "runtime_default";
|
||||||
|
durable: boolean;
|
||||||
|
updated_by: string | null;
|
||||||
|
updated_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageFixture(
|
||||||
|
days: number,
|
||||||
|
overrides: Partial<AdminUsageResponse> = {},
|
||||||
|
): AdminUsageResponse {
|
||||||
|
return {
|
||||||
|
source: "database",
|
||||||
|
durable: true,
|
||||||
|
generated_at: 1_785_142_800,
|
||||||
|
window_days: days,
|
||||||
|
total_turns: 0,
|
||||||
|
metered_turns: 0,
|
||||||
|
tokens_in: 0,
|
||||||
|
tokens_out: 0,
|
||||||
|
cost_usd: 0,
|
||||||
|
budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled" },
|
||||||
|
evaluator_cache: {
|
||||||
|
enabled: false,
|
||||||
|
entries: 0,
|
||||||
|
hits: 0,
|
||||||
|
misses: 0,
|
||||||
|
stores: 0,
|
||||||
|
evictions: 0,
|
||||||
|
requests: 0,
|
||||||
|
hit_rate: 0,
|
||||||
|
},
|
||||||
|
by_provider: [],
|
||||||
|
daily_cost: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineConfigFixture(
|
||||||
|
overrides: Partial<AdminEngineConfigResponse> = {},
|
||||||
|
): AdminEngineConfigResponse {
|
||||||
|
return {
|
||||||
|
engine_mode: "openai",
|
||||||
|
engine_url: "http://127.0.0.1:9099",
|
||||||
|
model: "gateway-default",
|
||||||
|
source: "database",
|
||||||
|
durable: true,
|
||||||
|
updated_by: "admin@twentyoz.kr",
|
||||||
|
updated_at: 1_785_142_800,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminAiMockState {
|
||||||
|
counts: { usage: number; health: number; engine: number };
|
||||||
|
authUser: {
|
||||||
|
account_status?: "pending" | "approved" | "suspended";
|
||||||
|
onboarding_completed_at?: number | null;
|
||||||
|
};
|
||||||
|
usageForWindow: (days: number) => AdminUsageResponse;
|
||||||
|
/** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */
|
||||||
|
usageGate?: (days: number) => Promise<void> | undefined;
|
||||||
|
engineConfig: AdminEngineConfigResponse;
|
||||||
|
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
|
||||||
|
engineConfigStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockState(overrides: Partial<AdminAiMockState> = {}): AdminAiMockState {
|
||||||
|
return {
|
||||||
|
counts: { usage: 0, health: 0, engine: 0 },
|
||||||
|
authUser: {},
|
||||||
|
usageForWindow: (days) => usageFixture(days),
|
||||||
|
engineConfig: engineConfigFixture(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const method = request.method();
|
||||||
|
const path = url.pathname;
|
||||||
|
const fulfillJson = (body: unknown, status = 200) =>
|
||||||
|
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
if (method === "GET" && path.endsWith("/auth/me")) {
|
||||||
|
await fulfillJson({
|
||||||
|
user_id: "sweep-ai-admin",
|
||||||
|
email: "sweep-ai-admin@twentyoz.kr",
|
||||||
|
display_name: "Sweep AI Admin",
|
||||||
|
role: "admin",
|
||||||
|
admin_access: true,
|
||||||
|
super_admin: true,
|
||||||
|
account_status: state.authUser.account_status ?? "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: null,
|
||||||
|
onboarding_completed_at: state.authUser.onboarding_completed_at ?? 1_782_900_000,
|
||||||
|
nickname: "",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && path.endsWith("/admin/usage")) {
|
||||||
|
state.counts.usage += 1;
|
||||||
|
const days = Number(url.searchParams.get("window_days") ?? 30);
|
||||||
|
const gate = state.usageGate?.(days);
|
||||||
|
if (gate) await gate;
|
||||||
|
await fulfillJson(state.usageForWindow(days));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && path.endsWith("/admin/health")) {
|
||||||
|
state.counts.health += 1;
|
||||||
|
await fulfillJson({
|
||||||
|
status: "ok",
|
||||||
|
environment: "dev",
|
||||||
|
engine_mode: "openai",
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
key: "engine",
|
||||||
|
name: "응답 엔진",
|
||||||
|
status: "ok",
|
||||||
|
detail: "엔진 응답 정상",
|
||||||
|
metric: "120ms",
|
||||||
|
load: 0.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && path.endsWith("/admin/engine-config")) {
|
||||||
|
state.counts.engine += 1;
|
||||||
|
if (state.engineConfigStatus) {
|
||||||
|
await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(state.engineConfig);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "PATCH" && path.endsWith("/admin/engine-config")) {
|
||||||
|
const body = request.postDataJSON() as Partial<AdminEngineConfigResponse>;
|
||||||
|
await fulfillJson({ ...state.engineConfig, ...body });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson({ detail: `unmocked API request: ${method} ${path}` }, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("full sweep: admin ai operations", () => {
|
||||||
|
// checklist: admin-ai-guard-pending-approval
|
||||||
|
test("redirects a pending-approval admin from /admin/ai to /pending", async ({ page }) => {
|
||||||
|
const state = createMockState({
|
||||||
|
authUser: { account_status: "pending", onboarding_completed_at: 1_782_900_000 },
|
||||||
|
});
|
||||||
|
await mockAdminAiSession(page, state);
|
||||||
|
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/pending$/);
|
||||||
|
await expect(page.getByText("승인 대기")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveCount(0);
|
||||||
|
// 승인 대기 계정은 관리자 데이터 API를 호출하지 않아야 한다.
|
||||||
|
expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: admin-ai-aria-busy, admin-ai-refresh-button
|
||||||
|
test("marks the page aria-busy while loading and reloads all three data sets on refresh", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let gate: ReturnType<typeof deferred> | null = deferred();
|
||||||
|
const state = createMockState({
|
||||||
|
usageGate: () => gate?.promise,
|
||||||
|
});
|
||||||
|
await mockAdminAiSession(page, state);
|
||||||
|
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
|
||||||
|
const container = page.locator('[data-testid="admin-ai-page"]');
|
||||||
|
const refreshButton = page.getByRole("button", { name: "새로고침" });
|
||||||
|
|
||||||
|
// usage 응답이 보류된 동안 aria-busy=true, 새로고침 버튼 비활성.
|
||||||
|
await expect(container).toHaveAttribute("aria-busy", "true");
|
||||||
|
await expect(refreshButton).toBeDisabled();
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
gate = null;
|
||||||
|
await expect(container).toHaveAttribute("aria-busy", "false");
|
||||||
|
await expect(refreshButton).toBeEnabled();
|
||||||
|
// React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다.
|
||||||
|
const base = { ...state.counts };
|
||||||
|
expect(base.health).toBe(1);
|
||||||
|
expect(base.engine).toBe(1);
|
||||||
|
|
||||||
|
// 새로고침 클릭 → 세 엔드포인트 모두 재조회, 로딩 동안 다시 비활성.
|
||||||
|
gate = deferred();
|
||||||
|
await refreshButton.click();
|
||||||
|
await expect(container).toHaveAttribute("aria-busy", "true");
|
||||||
|
await expect(refreshButton).toBeDisabled();
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
gate = null;
|
||||||
|
await expect(container).toHaveAttribute("aria-busy", "false");
|
||||||
|
await expect(refreshButton).toBeEnabled();
|
||||||
|
await expect
|
||||||
|
.poll(() => state.counts)
|
||||||
|
.toEqual({ usage: base.usage + 1, health: base.health + 1, engine: base.engine + 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: admin-ai-error-alert, admin-ai-engine-loading-empty
|
||||||
|
test("shows a role=alert banner with the engine panel placeholder on load failure and clears it on refresh", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = createMockState({ engineConfigStatus: 500 });
|
||||||
|
await mockAdminAiSession(page, state);
|
||||||
|
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
|
||||||
|
// engine-config 로드 실패 → role=alert 배너에 오류 메시지 노출.
|
||||||
|
const alert = page.locator(".aic-alert[role='alert']");
|
||||||
|
await expect(alert).toBeVisible();
|
||||||
|
await expect(alert).toContainText("engine config unavailable");
|
||||||
|
// 엔진 패널은 폼 대신 로딩/빈 상태 문구를 표시한다.
|
||||||
|
await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0);
|
||||||
|
|
||||||
|
// 다음 요청이 시작되면 오류 배너가 초기화된다: 응답을 보류한 채 새로고침.
|
||||||
|
state.engineConfigStatus = undefined;
|
||||||
|
let gate: ReturnType<typeof deferred> | null = deferred();
|
||||||
|
state.usageGate = () => gate?.promise;
|
||||||
|
await page.getByRole("button", { name: "새로고침" }).click();
|
||||||
|
await expect(alert).toHaveCount(0);
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
gate = null;
|
||||||
|
// 성공 응답 이후에도 배너는 없고 엔진 설정 폼이 렌더된다.
|
||||||
|
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099");
|
||||||
|
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
|
||||||
|
await expect(alert).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: admin-ai-window-toggle
|
||||||
|
test("refetches only usage on window change and ignores a stale late response", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const slowWindow = deferred();
|
||||||
|
const state = createMockState({
|
||||||
|
usageForWindow: (days) =>
|
||||||
|
usageFixture(days, {
|
||||||
|
total_turns: 10,
|
||||||
|
metered_turns: 10,
|
||||||
|
cost_usd: days === 90 ? 9.99 : days === 7 ? 1.11 : 3.33,
|
||||||
|
}),
|
||||||
|
usageGate: (days) => (days === 90 ? slowWindow.promise : undefined),
|
||||||
|
});
|
||||||
|
await mockAdminAiSession(page, state);
|
||||||
|
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
const ledger = page.locator(".aic-ledger");
|
||||||
|
await expect(ledger).toContainText("$3.3300");
|
||||||
|
// React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다.
|
||||||
|
const base = { ...state.counts };
|
||||||
|
expect(base.health).toBe(1);
|
||||||
|
expect(base.engine).toBe(1);
|
||||||
|
|
||||||
|
// 90일 클릭(응답 보류) 후 곧바로 7일 클릭 → 7일 데이터가 반영된다.
|
||||||
|
const staleResponse = page.waitForResponse((response) =>
|
||||||
|
response.url().includes("/api/admin/usage?window_days=90"),
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "90일" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "90일" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "7일" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "7일" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(ledger).toContainText("$1.1100");
|
||||||
|
await expect(ledger).toContainText("7일 DB 집계");
|
||||||
|
|
||||||
|
// 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다.
|
||||||
|
slowWindow.resolve();
|
||||||
|
await staleResponse;
|
||||||
|
await expect(ledger).toContainText("$1.1100");
|
||||||
|
await expect(ledger).not.toContainText("$9.9900");
|
||||||
|
|
||||||
|
// 기간 변경은 usage만 재조회한다(health·engine-config는 초기 1회 그대로).
|
||||||
|
await expect.poll(() => state.counts.usage).toBe(base.usage + 2);
|
||||||
|
expect(state.counts.health).toBe(1);
|
||||||
|
expect(state.counts.engine).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel
|
||||||
|
test("renders the coverage track, daily cost chart, and evaluator cache panel from metered usage", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = createMockState({
|
||||||
|
usageForWindow: (days) =>
|
||||||
|
usageFixture(days, {
|
||||||
|
total_turns: 40,
|
||||||
|
metered_turns: 30,
|
||||||
|
tokens_in: 125_000,
|
||||||
|
tokens_out: 18_500,
|
||||||
|
cost_usd: 6.6212,
|
||||||
|
budget: { limit_usd: 20, used_ratio: 0.33106, remaining_usd: 13.3788, status: "ok" },
|
||||||
|
evaluator_cache: {
|
||||||
|
enabled: true,
|
||||||
|
entries: 12,
|
||||||
|
hits: 8,
|
||||||
|
misses: 2,
|
||||||
|
stores: 2,
|
||||||
|
evictions: 1,
|
||||||
|
requests: 10,
|
||||||
|
hit_rate: 0.8,
|
||||||
|
},
|
||||||
|
by_provider: [
|
||||||
|
{
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-5-mini",
|
||||||
|
turns: 30,
|
||||||
|
tokens_in: 125_000,
|
||||||
|
tokens_out: 18_500,
|
||||||
|
cost_usd: 6.6212,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
daily_cost: [
|
||||||
|
{ day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
|
||||||
|
{ day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
|
||||||
|
{ day: "2026-07-15", turns: 12, tokens_in: 52_000, tokens_out: 7_600, cost_usd: 3.1212 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await mockAdminAiSession(page, state);
|
||||||
|
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
|
||||||
|
// DB 계량 커버리지 트랙: metered/total 비율과 미계량 턴 안내.
|
||||||
|
const budgetPanel = page.locator(".aic-budget");
|
||||||
|
await expect(budgetPanel).toContainText("DB 계량 커버리지");
|
||||||
|
await expect(budgetPanel).toContainText("75%");
|
||||||
|
await expect(budgetPanel.locator('[aria-label="DB 계량 커버리지 75%"]')).toHaveCount(1);
|
||||||
|
await expect(budgetPanel).toContainText(
|
||||||
|
"10개 턴은 provider·model·token·cost 계량 필드가 없습니다.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 일별 비용 차트: role=img aria-label, 날짜·비용 툴팁, 막대 개수.
|
||||||
|
const chart = page.locator(".aic-chart[role='img']");
|
||||||
|
await expect(chart).toBeVisible();
|
||||||
|
await expect(chart).toHaveAttribute("aria-label", "30일 일별 AI 비용 막대 차트");
|
||||||
|
await expect(chart.locator(".aic-chart__day")).toHaveCount(3);
|
||||||
|
await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
"2026-07-13 $1.4200",
|
||||||
|
);
|
||||||
|
await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴");
|
||||||
|
|
||||||
|
// 평가 캐시 패널: 사용 중 배지, hit-rate, 6개 카운트, 원문 미저장 안내.
|
||||||
|
const cachePanel = page.locator(".aic-panel").filter({ hasText: "평가 캐시 효율" });
|
||||||
|
await expect(cachePanel).toContainText("사용 중");
|
||||||
|
await expect(cachePanel.locator(".aic-cache-score")).toContainText("80%");
|
||||||
|
await expect(cachePanel.locator(".aic-cache-score")).toContainText("hit-rate");
|
||||||
|
const miniGrid = cachePanel.locator(".aic-mini-grid");
|
||||||
|
await expect(miniGrid).toContainText("요청");
|
||||||
|
await expect(miniGrid.locator("div").filter({ hasText: "요청" }).locator("dd")).toHaveText("10");
|
||||||
|
await expect(miniGrid.locator("div").filter({ hasText: "적중" }).locator("dd")).toHaveText("8");
|
||||||
|
await expect(miniGrid.locator("div").filter({ hasText: "미스" }).locator("dd")).toHaveText("2");
|
||||||
|
await expect(miniGrid.locator("div").filter({ hasText: "저장" }).locator("dd")).toHaveText("2");
|
||||||
|
await expect(
|
||||||
|
miniGrid.locator("div").filter({ hasText: "현재 엔트리" }).locator("dd"),
|
||||||
|
).toHaveText("12");
|
||||||
|
await expect(miniGrid.locator("div").filter({ hasText: "축출" }).locator("dd")).toHaveText("1");
|
||||||
|
await expect(cachePanel).toContainText(
|
||||||
|
"캐시는 성공적으로 파싱된 평가 결과만 보관하며 원문 프롬프트와 응답은 저장하지 않습니다.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: admin-ai-daily-chart-empty, admin-ai-provider-table-empty
|
||||||
|
test("shows empty states for the daily chart and the provider ledger", async ({ page }) => {
|
||||||
|
const state = createMockState({
|
||||||
|
usageForWindow: (days) => usageFixture(days, { by_provider: [], daily_cost: [] }),
|
||||||
|
});
|
||||||
|
await mockAdminAiSession(page, state);
|
||||||
|
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
|
||||||
|
await expect(page.getByText("선택한 기간에 비용이 계량된 AI 호출이 없습니다.")).toBeVisible();
|
||||||
|
await expect(page.getByText("모델별 계량 행이 아직 없습니다.")).toBeVisible();
|
||||||
|
await expect(page.locator(".aic-chart")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".aic-table")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: admin-ai-engine-config-meta
|
||||||
|
test("shows engine config metadata for durable DB and runtime-only sources", async ({ page }) => {
|
||||||
|
const state = createMockState({
|
||||||
|
engineConfig: engineConfigFixture({
|
||||||
|
source: "database",
|
||||||
|
durable: true,
|
||||||
|
updated_by: "admin@twentyoz.kr",
|
||||||
|
updated_at: 1_785_142_800,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await mockAdminAiSession(page, state);
|
||||||
|
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
|
||||||
|
const meta = page.locator(".aic-config-meta");
|
||||||
|
await expect(meta).toBeVisible();
|
||||||
|
const rowValue = (label: string) =>
|
||||||
|
meta.locator("div").filter({ hasText: label }).locator("dd");
|
||||||
|
await expect(rowValue("저장 원천")).toHaveText("DB 영구 저장");
|
||||||
|
await expect(rowValue("현재 소스")).toHaveText("database");
|
||||||
|
await expect(rowValue("최근 변경")).not.toHaveText("기록 없음");
|
||||||
|
await expect(rowValue("변경자")).toHaveText("admin@twentyoz.kr");
|
||||||
|
|
||||||
|
// 런타임 적용 변형: durable=false·기록 없음 표시.
|
||||||
|
state.engineConfig = engineConfigFixture({
|
||||||
|
source: "runtime_default",
|
||||||
|
durable: false,
|
||||||
|
updated_by: null,
|
||||||
|
updated_at: null,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "새로고침" }).click();
|
||||||
|
await expect(rowValue("저장 원천")).toHaveText("런타임 적용");
|
||||||
|
await expect(rowValue("현재 소스")).toHaveText("runtime_default");
|
||||||
|
await expect(rowValue("최근 변경")).toHaveText("기록 없음");
|
||||||
|
await expect(rowValue("변경자")).toHaveText("기록 없음");
|
||||||
|
});
|
||||||
|
});
|
||||||
1232
apps/web/e2e/full-sweep-admin.spec.ts
Normal file
1232
apps/web/e2e/full-sweep-admin.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
433
apps/web/e2e/full-sweep-auth.spec.ts
Normal file
433
apps/web/e2e/full-sweep-auth.spec.ts
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
/* =====================================================================
|
||||||
|
full-sweep-auth.spec.ts — E2E 전수 순회(2026-07-27) auth 영역 신규 스펙.
|
||||||
|
|
||||||
|
docs/ops/e2e-full-sweep-2026-07-27.md 1장에서 "검증: 신규 spec 필요"로
|
||||||
|
표기된 항목을 검증한다:
|
||||||
|
- auth-login-generic-google-button
|
||||||
|
- auth-login-brand-domain-badges
|
||||||
|
- auth-onboarding-avatar-upload
|
||||||
|
- auth-onboarding-legal-doc-details
|
||||||
|
- auth-onboarding-error-notice
|
||||||
|
- auth-pending-status-panel
|
||||||
|
- auth-pending-refresh-button
|
||||||
|
- auth-pending-signout-button
|
||||||
|
- auth-guard-pending-approval-gate
|
||||||
|
|
||||||
|
원칙: 실 API(dev-login·온보딩·legal-docs·아바타 업로드)를 기본으로 쓰고,
|
||||||
|
실 환경에서 고정할 수 없는 상태(OAuth 로컬 redirect 구성, 미승인 계정,
|
||||||
|
서버 5xx)만 page.route fixture로 고정한다. AI 엔진 턴 생성은 없다.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { completeOnboarding, useRealApi } from "./support";
|
||||||
|
|
||||||
|
const CONFIG_PATTERN = "**/api/auth/config";
|
||||||
|
const ME_PATTERN = "**/api/auth/me";
|
||||||
|
|
||||||
|
/** 로컬 redirect_uri라서 두 OAuth 버튼이 모두 활성화되는 구성 fixture. */
|
||||||
|
const READY_AUTH_CONFIG = {
|
||||||
|
google_oauth_configured: true,
|
||||||
|
saml_configured: false,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: "google",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
login_path: "/auth/login?provider=google",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "saml",
|
||||||
|
configured: false,
|
||||||
|
enabled: false,
|
||||||
|
login_path: "/auth/login?provider=saml",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
|
||||||
|
redirect_uri: "http://127.0.0.1:8000/auth/callback",
|
||||||
|
dev_login_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sweepEmailCounter = 0;
|
||||||
|
|
||||||
|
function uniqueEmail(prefix: string) {
|
||||||
|
sweepEmailCounter += 1;
|
||||||
|
return `${prefix}.${Date.now()}.${sweepEmailCounter}@hs.ac.kr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 온보딩 미완료 learner 계정을 만들고 세션 쿠키를 심는다. */
|
||||||
|
async function loginFreshLearner(page: Page, prefix: string): Promise<string> {
|
||||||
|
const email = uniqueEmail(prefix);
|
||||||
|
const res = await page.request.post("/api/auth/dev-login", {
|
||||||
|
data: { email, role: "learner", display_name: "Full Sweep Auth" },
|
||||||
|
});
|
||||||
|
expect(res.ok(), await res.text()).toBeTruthy();
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 온보딩까지 끝낸 learner 계정을 만든다(승인 대기 화면 fixture의 원본 계정). */
|
||||||
|
async function loginOnboardedLearner(page: Page, prefix: string): Promise<string> {
|
||||||
|
const email = await loginFreshLearner(page, prefix);
|
||||||
|
await completeOnboarding(page, {
|
||||||
|
legal_name: "전수 순회 학습자",
|
||||||
|
affiliation: "한신대학교",
|
||||||
|
department: "상담심리학과",
|
||||||
|
grade_level: "3학년",
|
||||||
|
phone: "010-0000-0000",
|
||||||
|
contact_address: "경기도 오산시 한신대학교",
|
||||||
|
});
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실 세션의 /auth/me 응답 원본을 가져온다(page.request는 route fixture를 우회한다). */
|
||||||
|
async function fetchRealMe(page: Page): Promise<Record<string, unknown>> {
|
||||||
|
const res = await page.request.get("/api/auth/me");
|
||||||
|
expect(res.ok(), await res.text()).toBeTruthy();
|
||||||
|
return (await res.json()) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /auth/me만 지정한 본문으로 고정한다. 그 외 메서드/요청은 실 API로 보낸다. */
|
||||||
|
async function mockAuthMe(page: Page, body: () => Record<string, unknown>) {
|
||||||
|
await page.route(ME_PATTERN, async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillConfig(page: Page, body: unknown) {
|
||||||
|
await page.route(CONFIG_PATTERN, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("full sweep auth", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await useRealApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-login-generic-google-button
|
||||||
|
test("secondary Google button shows the second allowed domain and starts the same google OAuth flow", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await fulfillConfig(page, READY_AUTH_CONFIG);
|
||||||
|
await page.route("**/api/auth/login**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/html; charset=utf-8",
|
||||||
|
body: "<html><body>oauth-start-stub</body></html>",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
const secondary = page.locator(".lg-obtn.secondary");
|
||||||
|
await expect(secondary).toBeEnabled();
|
||||||
|
await expect(secondary).toContainText("Google 계정으로 계속");
|
||||||
|
// 서브라벨은 두 번째 허용 도메인을 표시한다.
|
||||||
|
await expect(secondary.locator(".sub")).toHaveText("@twentyoz.kr");
|
||||||
|
// primary 버튼과 동일하게 첫 번째 도메인은 primary 서브라벨에 있다.
|
||||||
|
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr");
|
||||||
|
|
||||||
|
// 클릭 시 primary와 동일한 provider=google 시작 URL로 이동한다.
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/api\/auth\/login\?provider=google&next=%2F/),
|
||||||
|
secondary.click(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-login-brand-domain-badges
|
||||||
|
test("brand panel domain badges distinguish checking, unset, and loaded states", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// 1) 설정 조회가 끝나지 않은 동안: "확인 중" 배지.
|
||||||
|
await page.route(CONFIG_PATTERN, () => {
|
||||||
|
/* 응답을 보류해 조회 중 상태를 고정한다 */
|
||||||
|
});
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
|
||||||
|
await expect(page.locator(".lg-policy b")).toHaveText("확인 중");
|
||||||
|
|
||||||
|
// 2) 설정 조회 실패: "설정 필요" 배지.
|
||||||
|
await page.unroute(CONFIG_PATTERN);
|
||||||
|
await page.route(CONFIG_PATTERN, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "config unavailable" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.locator(".lg-policy b")).toHaveText("설정 필요");
|
||||||
|
|
||||||
|
// 3) 정상 로드: allowed_email_domains를 배지로 나열.
|
||||||
|
await page.unroute(CONFIG_PATTERN);
|
||||||
|
await fulfillConfig(page, READY_AUTH_CONFIG);
|
||||||
|
await page.goto("/login");
|
||||||
|
const badges = page.locator(".lg-policy b");
|
||||||
|
await expect(badges).toHaveCount(2);
|
||||||
|
await expect(badges.nth(0)).toHaveText("hs.ac.kr");
|
||||||
|
await expect(badges.nth(1)).toHaveText("twentyoz.kr");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-onboarding-avatar-upload
|
||||||
|
test("avatar upload stores avatar_url immediately and shows the 3MB notice on failure", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await loginFreshLearner(page, "sweep-avatar");
|
||||||
|
await page.goto("/onboarding");
|
||||||
|
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
|
||||||
|
|
||||||
|
// 성공 경로(실 API): 파일 선택 즉시 업로드되어 avatar_url이 폼과 미리보기에 반영된다.
|
||||||
|
await page.getByLabel("아바타 이미지").setInputFiles({
|
||||||
|
name: "sweep-avatar.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await expect(page.getByText("다른 이미지 선택")).toBeVisible();
|
||||||
|
await expect(page.locator(".ob-avatar__preview img")).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
/\/uploads\/profile-avatars\//,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 실패 경로(fixture): 서버가 거절하면 3MB·형식 안내를 role=alert로 띄운다.
|
||||||
|
await page.route("**/api/users/me/avatar", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 413,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "file too large" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByLabel("아바타 이미지").setInputFiles({
|
||||||
|
name: "sweep-avatar-too-big.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const avatarAlert = page.locator(".ob-avatar .ob-error[role='alert']");
|
||||||
|
await expect(avatarAlert).toBeVisible();
|
||||||
|
await expect(avatarAlert).toContainText("아바타 업로드에 실패했습니다");
|
||||||
|
await expect(avatarAlert).toContainText("3MB");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-onboarding-legal-doc-details
|
||||||
|
test("legal document details expand real docs and fall back when docs fail to load", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await loginFreshLearner(page, "sweep-legal");
|
||||||
|
|
||||||
|
const docsRes = await page.request.get("/api/users/legal-docs");
|
||||||
|
expect(docsRes.ok(), await docsRes.text()).toBeTruthy();
|
||||||
|
const docs = (await docsRes.json()) as {
|
||||||
|
terms: { title: string; version: string; body: string };
|
||||||
|
privacy: { title: string; version: string; body: string };
|
||||||
|
};
|
||||||
|
const termsNeedle = docs.terms.body.trim().split(/\s+/).slice(0, 3).join(" ");
|
||||||
|
const privacyNeedle = docs.privacy.body.trim().split(/\s+/).slice(0, 3).join(" ");
|
||||||
|
|
||||||
|
await page.goto("/onboarding");
|
||||||
|
const details = page.locator(".ob-legal details");
|
||||||
|
await expect(details).toHaveCount(2);
|
||||||
|
|
||||||
|
// 제목·버전이 실 legal-docs 응답과 일치한다.
|
||||||
|
await expect(details.nth(0).locator("summary")).toContainText(docs.terms.title);
|
||||||
|
await expect(details.nth(0).locator("summary b")).toHaveText(docs.terms.version);
|
||||||
|
await expect(details.nth(1).locator("summary")).toContainText(docs.privacy.title);
|
||||||
|
await expect(details.nth(1).locator("summary b")).toHaveText(docs.privacy.version);
|
||||||
|
|
||||||
|
// 펼치면 본문이 보인다.
|
||||||
|
await details.nth(0).locator("summary").click();
|
||||||
|
await expect(details.nth(0).locator(".ob-doc-text")).toBeVisible();
|
||||||
|
await expect(details.nth(0).locator(".ob-doc-text")).toContainText(termsNeedle);
|
||||||
|
await details.nth(1).locator("summary").click();
|
||||||
|
await expect(details.nth(1).locator(".ob-doc-text")).toContainText(privacyNeedle);
|
||||||
|
|
||||||
|
// 문서 로드 실패 시 제목·버전·본문이 폴백 문구로 대체된다.
|
||||||
|
await page.route("**/api/users/legal-docs", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "legal docs unavailable" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/onboarding");
|
||||||
|
const fallbackDetails = page.locator(".ob-legal details");
|
||||||
|
await expect(fallbackDetails.nth(0).locator("summary")).toContainText("서비스 이용약관");
|
||||||
|
await expect(fallbackDetails.nth(0).locator("summary b")).toHaveText("확인 중");
|
||||||
|
await expect(fallbackDetails.nth(1).locator("summary")).toContainText("개인정보 처리방침");
|
||||||
|
await fallbackDetails.nth(0).locator("summary").click();
|
||||||
|
await expect(fallbackDetails.nth(0).locator(".ob-doc-text")).toContainText(
|
||||||
|
"문서를 불러오는 중입니다.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-onboarding-error-notice
|
||||||
|
test("onboarding shows role=alert notices for load and save failures", async ({ page }) => {
|
||||||
|
await loginFreshLearner(page, "sweep-ob-error");
|
||||||
|
|
||||||
|
// 로드 실패: GET /users/me 5xx → 폼 하단 role=alert.
|
||||||
|
await page.route("**/api/users/me", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "profile unavailable" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/onboarding");
|
||||||
|
const loadAlert = page.locator(".ob-form > .ob-error[role='alert']");
|
||||||
|
await expect(loadAlert).toBeVisible();
|
||||||
|
await expect(loadAlert).toContainText("온보딩 정보를 불러오지 못했습니다.");
|
||||||
|
|
||||||
|
// 저장 실패: 폼을 모두 채운 뒤 POST /users/me/onboarding 5xx → role=alert, 페이지 잔류.
|
||||||
|
await page.unroute("**/api/users/me");
|
||||||
|
await page.goto("/onboarding");
|
||||||
|
await page.getByLabel("닉네임").fill("전수 순회");
|
||||||
|
await page.getByLabel("자기소개").fill("온보딩 저장 오류 알림을 검증합니다.");
|
||||||
|
await page.getByLabel("이름").fill("전수 순회 학습자");
|
||||||
|
await page.getByLabel("소속").fill("한신대학교");
|
||||||
|
await page.getByLabel("학과/부서").fill("상담심리학과");
|
||||||
|
await page.getByLabel("학년/직위").fill("3학년");
|
||||||
|
await page.getByLabel("연락처").fill("010-1234-5678");
|
||||||
|
await page.getByLabel("주소/수령지").fill("경기도 오산시 한신대학교");
|
||||||
|
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
|
||||||
|
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
|
||||||
|
|
||||||
|
await page.route("**/api/users/me/onboarding", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "save failed" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: /가입 설정 완료/ }).click();
|
||||||
|
const saveAlert = page.locator(".ob-form > .ob-error[role='alert']");
|
||||||
|
await expect(saveAlert).toBeVisible();
|
||||||
|
await expect(saveAlert).toContainText("온보딩 저장에 실패했습니다.");
|
||||||
|
await expect(page).toHaveURL(/\/onboarding(?:$|[/?#])/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-pending-status-panel
|
||||||
|
test("pending panel shows waiting vs suspended kicker with the account email", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const email = await loginOnboardedLearner(page, "sweep-pending-panel");
|
||||||
|
const realMe = await fetchRealMe(page);
|
||||||
|
|
||||||
|
let accountStatus = "pending";
|
||||||
|
await mockAuthMe(page, () => ({
|
||||||
|
...realMe,
|
||||||
|
account_status: accountStatus,
|
||||||
|
approval_required: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForURL(/\/pending(?:$|[/?#])/);
|
||||||
|
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
|
||||||
|
await expect(page.locator(".pa-copy")).toContainText(email);
|
||||||
|
const status = page.locator(".pa-status[role='status']");
|
||||||
|
await expect(status).toBeVisible();
|
||||||
|
await expect(status).toContainText("접속 권한 확인 중");
|
||||||
|
await expect(status).toContainText("승인되면 새로고침 후 온보딩 또는 역할 홈으로 이동합니다.");
|
||||||
|
|
||||||
|
// suspended 계정은 '접속 보류' 키커로 구분 표시된다.
|
||||||
|
accountStatus = "suspended";
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForURL(/\/pending(?:$|[/?#])/);
|
||||||
|
await expect(page.locator(".pa-kicker")).toHaveText("접속 보류");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-pending-refresh-button
|
||||||
|
test("pending refresh button re-checks the session and routes an approved learner home", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await loginOnboardedLearner(page, "sweep-pending-refresh");
|
||||||
|
const realMe = await fetchRealMe(page);
|
||||||
|
|
||||||
|
let pendingNow = true;
|
||||||
|
await mockAuthMe(page, () =>
|
||||||
|
pendingNow
|
||||||
|
? { ...realMe, account_status: "pending", approval_required: true }
|
||||||
|
: realMe,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForURL(/\/pending(?:$|[/?#])/);
|
||||||
|
const refreshButton = page.getByRole("button", { name: "승인 상태 새로고침" });
|
||||||
|
await expect(refreshButton).toBeEnabled();
|
||||||
|
|
||||||
|
// 승인 후 새로고침 → GET /auth/me 재조회 → 역할 홈(/learn)으로 이동.
|
||||||
|
pendingNow = false;
|
||||||
|
await refreshButton.click();
|
||||||
|
await page.waitForURL(/\/learn(?:$|[/?#])/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-pending-signout-button
|
||||||
|
test("pending sign-out button ends the session and returns to the login screen", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await loginOnboardedLearner(page, "sweep-pending-signout");
|
||||||
|
const realMe = await fetchRealMe(page);
|
||||||
|
await mockAuthMe(page, () => ({
|
||||||
|
...realMe,
|
||||||
|
account_status: "pending",
|
||||||
|
approval_required: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForURL(/\/pending(?:$|[/?#])/);
|
||||||
|
await page.getByRole("button", { name: "다른 계정으로 로그인" }).click();
|
||||||
|
await page.waitForURL(/\/login(?:$|[/?#])/);
|
||||||
|
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||||
|
|
||||||
|
// 실 세션이 실제로 종료됐는지 확인한다(page.request는 fixture를 우회한다).
|
||||||
|
const me = await page.request.get("/api/auth/me");
|
||||||
|
expect(me.status(), await me.text()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검증 checklist: auth-guard-pending-approval-gate
|
||||||
|
test("pending approval gate recalls unapproved users from every route and releases approved users", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await loginOnboardedLearner(page, "sweep-pending-gate");
|
||||||
|
const realMe = await fetchRealMe(page);
|
||||||
|
|
||||||
|
let pendingNow = true;
|
||||||
|
await mockAuthMe(page, () =>
|
||||||
|
pendingNow
|
||||||
|
? { ...realMe, account_status: "pending", approval_required: true }
|
||||||
|
: realMe,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const blockedPath of ["/learn", "/learn/practice", "/settings", "/login"]) {
|
||||||
|
await page.goto(blockedPath);
|
||||||
|
await page.waitForURL(/\/pending(?:$|[/?#])/);
|
||||||
|
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 승인된 사용자가 /pending에 오면 승인 홈으로 되돌린다.
|
||||||
|
pendingNow = false;
|
||||||
|
await page.goto("/pending");
|
||||||
|
await page.waitForURL(/\/learn(?:$|[/?#])/);
|
||||||
|
});
|
||||||
|
});
|
||||||
904
apps/web/e2e/full-sweep-learner-home.spec.ts
Normal file
904
apps/web/e2e/full-sweep-learner-home.spec.ts
Normal file
|
|
@ -0,0 +1,904 @@
|
||||||
|
// 2026-07-27 E2E 전수 순회 — 학습자 홈(learner-home) 영역 신규 스펙.
|
||||||
|
// docs/ops/e2e-full-sweep-2026-07-27.md §2에서 "검증: 신규 spec 필요"로 표기된
|
||||||
|
// 체크리스트 항목을 검증한다. 실 API 로그인(dev-login) 위에 route fixture로
|
||||||
|
// 페르소나·회기·대시보드 응답을 고정해 결정론적으로 UI 상태를 검증하며,
|
||||||
|
// 실제 AI 엔진 턴 생성은 일절 수행하지 않는다.
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { signInAsLearner, useRealApi } from "./support";
|
||||||
|
|
||||||
|
const ACT_ID = "lhact001";
|
||||||
|
const REV_ID = "lhrev001";
|
||||||
|
const END_ID = "lhend001";
|
||||||
|
const ARC_ID = "lharc001";
|
||||||
|
|
||||||
|
const NOW = Date.now();
|
||||||
|
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const PERSONAS_BODY = [
|
||||||
|
{
|
||||||
|
code: "P1",
|
||||||
|
display_name: "성하늘",
|
||||||
|
difficulty: "easy",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
demographics: { age_band: "20대" },
|
||||||
|
presenting_summary: "학업 스트레스와 수면 문제를 호소",
|
||||||
|
voice_preset: null,
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "P2",
|
||||||
|
display_name: "김도윤",
|
||||||
|
difficulty: "moderate",
|
||||||
|
theory_target: ["cbt"],
|
||||||
|
demographics: { age_band: "30대" },
|
||||||
|
presenting_summary: "직장 내 대인관계 갈등을 호소",
|
||||||
|
voice_preset: null,
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const S_ACT = {
|
||||||
|
session_id: ACT_ID,
|
||||||
|
session_no: 4,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "성하늘",
|
||||||
|
status: "active",
|
||||||
|
stage: "탐색",
|
||||||
|
started_at: iso(1 * HOUR),
|
||||||
|
ended_at: null,
|
||||||
|
review_ready: false,
|
||||||
|
archived: false,
|
||||||
|
archived_at: null,
|
||||||
|
turn_count: 6,
|
||||||
|
learner_turn_count: 3,
|
||||||
|
client_turn_count: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const S_REV = {
|
||||||
|
session_id: REV_ID,
|
||||||
|
session_no: 3,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "성하늘",
|
||||||
|
status: "ended",
|
||||||
|
stage: "정리",
|
||||||
|
started_at: iso(3 * HOUR),
|
||||||
|
ended_at: iso(2.5 * HOUR),
|
||||||
|
review_ready: true,
|
||||||
|
archived: false,
|
||||||
|
archived_at: null,
|
||||||
|
turn_count: 10,
|
||||||
|
learner_turn_count: 5,
|
||||||
|
client_turn_count: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const S_END = {
|
||||||
|
session_id: END_ID,
|
||||||
|
session_no: 2,
|
||||||
|
persona_code: "P2",
|
||||||
|
persona_name: "김도윤",
|
||||||
|
status: "ended",
|
||||||
|
stage: "라포",
|
||||||
|
started_at: iso(5 * HOUR),
|
||||||
|
ended_at: iso(4.5 * HOUR),
|
||||||
|
review_ready: false,
|
||||||
|
archived: false,
|
||||||
|
archived_at: null,
|
||||||
|
turn_count: 4,
|
||||||
|
learner_turn_count: 2,
|
||||||
|
client_turn_count: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const S_ARC = {
|
||||||
|
session_id: ARC_ID,
|
||||||
|
session_no: 1,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "성하늘",
|
||||||
|
status: "ended",
|
||||||
|
stage: "개입",
|
||||||
|
started_at: iso(8 * HOUR),
|
||||||
|
ended_at: iso(7 * HOUR),
|
||||||
|
review_ready: false,
|
||||||
|
archived: true,
|
||||||
|
archived_at: iso(6 * HOUR),
|
||||||
|
turn_count: 8,
|
||||||
|
learner_turn_count: 4,
|
||||||
|
client_turn_count: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSIONS_BODY = {
|
||||||
|
sessions: [S_ACT, S_REV, S_END, S_ARC],
|
||||||
|
source: "runtime",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DASHBOARD_BODY = {
|
||||||
|
message: "학습 현황 요약",
|
||||||
|
source: "runtime",
|
||||||
|
overview: {
|
||||||
|
total_sessions: 4,
|
||||||
|
active_sessions: 1,
|
||||||
|
review_ready_sessions: 1,
|
||||||
|
completed_sessions: 3,
|
||||||
|
archived_sessions: 1,
|
||||||
|
learner_turns: 14,
|
||||||
|
client_turns: 14,
|
||||||
|
last_practiced_at: iso(1 * HOUR),
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
trend: "down",
|
||||||
|
avg_rapport: 0.2,
|
||||||
|
avg_score: 0.6,
|
||||||
|
evaluated_sessions: 3,
|
||||||
|
latest_score: 0.62,
|
||||||
|
first_score: 0.7,
|
||||||
|
score_delta: -0.08,
|
||||||
|
top_techniques: ["감정 반영"],
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
session_id: REV_ID,
|
||||||
|
session_no: 3,
|
||||||
|
persona_code: "P1",
|
||||||
|
stage: "정리",
|
||||||
|
started_at: iso(3 * HOUR),
|
||||||
|
ended_at: iso(2.5 * HOUR),
|
||||||
|
score: 0.62,
|
||||||
|
rapport: 0.2,
|
||||||
|
technique_count: 3,
|
||||||
|
watch_count: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
recent_feedback: [
|
||||||
|
{
|
||||||
|
session_id: REV_ID,
|
||||||
|
session_no: 3,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "성하늘",
|
||||||
|
stage: "정리",
|
||||||
|
score: 0.62,
|
||||||
|
rapport: 0.2,
|
||||||
|
note: "감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.",
|
||||||
|
created_at: iso(2.5 * HOUR),
|
||||||
|
turn_seq: 6,
|
||||||
|
techniques: ["감정 반영"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
persona_progress: [
|
||||||
|
{
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "성하늘",
|
||||||
|
sessions: 3,
|
||||||
|
active_sessions: 1,
|
||||||
|
completed_sessions: 2,
|
||||||
|
review_ready_sessions: 1,
|
||||||
|
rapport_percent: 64,
|
||||||
|
trend: "down",
|
||||||
|
latest_stage: "탐색",
|
||||||
|
latest_score: 0.62,
|
||||||
|
latest_at: iso(1 * HOUR),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
persona_code: "P2",
|
||||||
|
persona_name: "김도윤",
|
||||||
|
sessions: 1,
|
||||||
|
active_sessions: 0,
|
||||||
|
completed_sessions: 1,
|
||||||
|
review_ready_sessions: 0,
|
||||||
|
rapport_percent: 32,
|
||||||
|
trend: "insufficient",
|
||||||
|
latest_stage: "라포",
|
||||||
|
latest_score: null,
|
||||||
|
latest_at: iso(4.5 * HOUR),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
achievements: [
|
||||||
|
{
|
||||||
|
id: "first-session",
|
||||||
|
label: "첫 회기 완료",
|
||||||
|
detail: "첫 회기를 끝냈습니다.",
|
||||||
|
state: "done",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "five-sessions",
|
||||||
|
label: "다섯 회기 도전",
|
||||||
|
detail: "다섯 번째 회기를 끝내면 열립니다.",
|
||||||
|
state: "locked",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DETAIL_BODY = {
|
||||||
|
case_id: "case-lh",
|
||||||
|
session_id: ACT_ID,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "성하늘",
|
||||||
|
status: "active",
|
||||||
|
stage: "탐색",
|
||||||
|
started_at: iso(1 * HOUR),
|
||||||
|
ended_at: null,
|
||||||
|
review_ready: false,
|
||||||
|
theory_mode: "humanistic",
|
||||||
|
goal_stages: ["라포", "탐색"],
|
||||||
|
effective_openness: 0.4,
|
||||||
|
duration_limit_seconds: 1200,
|
||||||
|
warning_before_end_seconds: 600,
|
||||||
|
progress: null,
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
turn_seq: 1,
|
||||||
|
speaker: "learner",
|
||||||
|
stage: "라포",
|
||||||
|
text: "안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?",
|
||||||
|
created_at: iso(0.9 * HOUR),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
turn_seq: 2,
|
||||||
|
speaker: "client",
|
||||||
|
stage: "라포",
|
||||||
|
text: "요즘은 잠이 잘 안 와요.",
|
||||||
|
created_at: iso(0.8 * HOUR),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 결정 D3 — 신규 학습자 기초 우선 추천 검증용 fixture.
|
||||||
|
// P1을 고난도, P4를 기초로 두어 API 순서(P1 먼저)와 난도 오름차순(P4 먼저)이 갈리게 한다.
|
||||||
|
const STARTER_END_ID = "lhstend1";
|
||||||
|
|
||||||
|
const STARTER_PERSONAS_BODY = [
|
||||||
|
{
|
||||||
|
code: "P1",
|
||||||
|
display_name: "성하늘",
|
||||||
|
difficulty: "hard",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
demographics: { age_band: "20대" },
|
||||||
|
presenting_summary: "학업 스트레스와 수면 문제를 호소",
|
||||||
|
voice_preset: null,
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "P4",
|
||||||
|
display_name: "박새봄",
|
||||||
|
difficulty: "easy",
|
||||||
|
theory_target: ["cbt"],
|
||||||
|
demographics: { age_band: "30대" },
|
||||||
|
presenting_summary: "출근길 불안과 긴장을 호소",
|
||||||
|
voice_preset: null,
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const S_STARTER_END = {
|
||||||
|
session_id: STARTER_END_ID,
|
||||||
|
session_no: 1,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "성하늘",
|
||||||
|
status: "ended",
|
||||||
|
stage: "라포",
|
||||||
|
started_at: iso(5 * HOUR),
|
||||||
|
ended_at: iso(4.5 * HOUR),
|
||||||
|
review_ready: false,
|
||||||
|
archived: false,
|
||||||
|
archived_at: null,
|
||||||
|
turn_count: 4,
|
||||||
|
learner_turn_count: 2,
|
||||||
|
client_turn_count: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_DASHBOARD_BODY = {
|
||||||
|
message: "학습 현황 요약",
|
||||||
|
source: "runtime",
|
||||||
|
overview: {
|
||||||
|
total_sessions: 0,
|
||||||
|
active_sessions: 0,
|
||||||
|
review_ready_sessions: 0,
|
||||||
|
completed_sessions: 0,
|
||||||
|
archived_sessions: 0,
|
||||||
|
learner_turns: 0,
|
||||||
|
client_turns: 0,
|
||||||
|
last_practiced_at: null,
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
trend: "insufficient",
|
||||||
|
avg_rapport: null,
|
||||||
|
avg_score: null,
|
||||||
|
evaluated_sessions: 0,
|
||||||
|
latest_score: null,
|
||||||
|
first_score: null,
|
||||||
|
score_delta: null,
|
||||||
|
top_techniques: [],
|
||||||
|
points: [],
|
||||||
|
},
|
||||||
|
recent_feedback: [],
|
||||||
|
persona_progress: [],
|
||||||
|
achievements: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function jsonRoute(body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */
|
||||||
|
async function installLearnerHomeFixtures(
|
||||||
|
page: Page,
|
||||||
|
options: { detail?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const { detail = true } = options;
|
||||||
|
await page.route(/\/api\/personas$/, (route) =>
|
||||||
|
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/sessions$/, (route) =>
|
||||||
|
route.fulfill(jsonRoute(SESSIONS_BODY)),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||||
|
route.fulfill(jsonRoute(DASHBOARD_BODY)),
|
||||||
|
);
|
||||||
|
if (detail) {
|
||||||
|
await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) =>
|
||||||
|
route.fulfill(jsonRoute(DETAIL_BODY)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 결정 D3 검증용 — 난도 역순 페르소나 + 회기 0건(또는 P1 종료 1건)을 고정한다. */
|
||||||
|
async function installStarterFixtures(
|
||||||
|
page: Page,
|
||||||
|
options: { withRecords?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const { withRecords = false } = options;
|
||||||
|
await page.route(/\/api\/personas$/, (route) =>
|
||||||
|
route.fulfill(jsonRoute(STARTER_PERSONAS_BODY)),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/sessions$/, (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
sessions: withRecords ? [S_STARTER_END] : [],
|
||||||
|
source: "runtime",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||||
|
route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)),
|
||||||
|
);
|
||||||
|
if (withRecords) {
|
||||||
|
await page.route(new RegExp(`/api/sessions/${STARTER_END_ID}$`), (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
...DETAIL_BODY,
|
||||||
|
session_id: STARTER_END_ID,
|
||||||
|
status: "ended",
|
||||||
|
stage: "라포",
|
||||||
|
ended_at: iso(4.5 * HOUR),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("full-sweep learner home", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await useRealApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-guard-pending-approval
|
||||||
|
test("redirects a pending learner from /learn to /pending", async ({ page }) => {
|
||||||
|
await page.route("**/api/auth/me", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
user_id: "pending-learner",
|
||||||
|
email: "pending.learner@hs.ac.kr",
|
||||||
|
display_name: "Pending Learner",
|
||||||
|
role: "learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "pending",
|
||||||
|
approval_required: true,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: 1,
|
||||||
|
onboarding_completed_at: 1,
|
||||||
|
nickname: "Pending Learner",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/learn");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/pending$/);
|
||||||
|
await expect(page.getByText("승인 대기")).toBeVisible();
|
||||||
|
await expect(page.locator(".pa-status")).toHaveAttribute("role", "status");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "승인 상태 새로고침" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-head-status-live
|
||||||
|
test("announces persona and history counts in the aria-live head status", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
|
||||||
|
const status = page.locator(".lh-head__status");
|
||||||
|
await expect(status).toHaveAttribute("aria-live", "polite");
|
||||||
|
// 실 API: 사용 가능 페르소나 수 + 기록 수(신규 학습자 → 0회)가 상태 문구로 뜬다.
|
||||||
|
await expect(status).toHaveText(/\d+명 · \d+회/, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-dash-metric-cards
|
||||||
|
test("renders four dashboard metric cards and routes them to history and practice", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
|
||||||
|
const cards = page.locator(".lh-dashboard-status");
|
||||||
|
await expect(cards.getByRole("button")).toHaveCount(4);
|
||||||
|
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
|
||||||
|
"4회",
|
||||||
|
);
|
||||||
|
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
|
||||||
|
"3회 종료 · 1회 진행",
|
||||||
|
);
|
||||||
|
await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText(
|
||||||
|
"1건",
|
||||||
|
);
|
||||||
|
await expect(cards.getByRole("button", { name: /최근 평가/ })).toContainText(
|
||||||
|
"62점",
|
||||||
|
);
|
||||||
|
await expect(cards.getByRole("button", { name: /최근 평가/ })).toContainText(
|
||||||
|
"3회 평가 · 점검 필요",
|
||||||
|
);
|
||||||
|
await expect(cards.getByRole("button", { name: /라포 흐름/ })).toContainText(
|
||||||
|
"+0.20",
|
||||||
|
);
|
||||||
|
await expect(cards.getByRole("button", { name: /라포 흐름/ })).toContainText(
|
||||||
|
"성하늘 · 3회",
|
||||||
|
);
|
||||||
|
|
||||||
|
await cards.getByRole("button", { name: /진행 회기/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||||
|
|
||||||
|
await page.goto("/learn");
|
||||||
|
await page
|
||||||
|
.locator(".lh-dashboard-status")
|
||||||
|
.getByRole("button", { name: /라포 흐름/ })
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/\/learn\/practice$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta
|
||||||
|
test("shows the spotlight recap card and coach CTA for an active session", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
|
||||||
|
// 리캡 카드 — 우선순위 스포트라이트(진행 중 회기)의 아바타·단계·진행률·마지막 발화.
|
||||||
|
const focus = page.locator(".lh-session-focus");
|
||||||
|
await expect(focus).toContainText("오늘 이어갈 회기");
|
||||||
|
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
|
||||||
|
await expect(focus.locator(".lh-session-focus__status")).toHaveText(
|
||||||
|
"진행 중",
|
||||||
|
);
|
||||||
|
const recap = page.getByLabel("마지막 세션 리캡");
|
||||||
|
await expect(recap).toContainText("6턴");
|
||||||
|
await expect(recap).toContainText("탐색");
|
||||||
|
await expect(recap.locator(".lh-recap__progress")).toContainText("50%");
|
||||||
|
await expect(recap.locator(".lh-recap__quote")).toContainText(
|
||||||
|
"요즘은 잠이 잘 안 와요.",
|
||||||
|
);
|
||||||
|
await expect(recap.locator(".lh-recap__learner")).toContainText(
|
||||||
|
"안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?",
|
||||||
|
);
|
||||||
|
|
||||||
|
// AI 코치 카드 — 진행 중 회기 우선 규칙 문구 + 힌트 칩 + CTA가 세션으로 이동.
|
||||||
|
const coachCard = page.getByLabel("AI 코치");
|
||||||
|
await expect(
|
||||||
|
coachCard.getByRole("heading", {
|
||||||
|
name: "진행 중인 회기를 먼저 마무리합니다.",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(coachCard.getByLabel("코칭 힌트")).toContainText(
|
||||||
|
"마지막 반응 먼저 반영",
|
||||||
|
);
|
||||||
|
await coachCard.getByRole("button", { name: "이어하기" }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-dash-recommend-card, learner-home-dash-recent-feedback,
|
||||||
|
// learner-home-dash-review-queue
|
||||||
|
test("prioritizes review in the recommend card, recent feedback, and review queue", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
|
||||||
|
// 다음 연습 추천 — 리뷰 대기가 있으면 리뷰 우선 문구/이동.
|
||||||
|
const recommend = page.locator(".lh-dashboard-recommend");
|
||||||
|
await expect(
|
||||||
|
recommend.getByRole("heading", { name: "리뷰 확인을 우선합니다." }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(recommend).toContainText("1건의 회기 피드백 확인");
|
||||||
|
await recommend.getByRole("button", { name: /리뷰 확인하기/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||||
|
|
||||||
|
// 최근 피드백 — 첫 항목 버튼이 해당 회기 리뷰로 이동.
|
||||||
|
await page.goto("/learn");
|
||||||
|
const feedback = page.locator(".lh-dashboard-feedback");
|
||||||
|
await expect(feedback.locator(".lh-panel__badge")).toHaveText("1건");
|
||||||
|
const feedbackRow = feedback.locator(".lh-feedback-mini__row");
|
||||||
|
await expect(feedbackRow).toContainText("P1 · 정리 · 62점");
|
||||||
|
await expect(feedbackRow).toContainText(
|
||||||
|
"감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.",
|
||||||
|
);
|
||||||
|
await feedbackRow.click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
|
||||||
|
|
||||||
|
// 리뷰 대기 패널 — review_ready 상위 항목의 'AI 피드백' 버튼이 리뷰로 진입.
|
||||||
|
// 대시보드 셸은 전 폭에서 가로 탭으로 영역을 전환하므로 '기록 · 리뷰' 탭을 먼저 연다.
|
||||||
|
await page.goto("/learn");
|
||||||
|
await page.getByRole("tab", { name: "기록 · 리뷰" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "피드백 받을 기록입니다." }),
|
||||||
|
).toBeVisible();
|
||||||
|
const reviewButtons = page.getByRole("button", { name: "AI 피드백" });
|
||||||
|
await expect(reviewButtons).toHaveCount(1);
|
||||||
|
await reviewButtons.first().click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-dash-weakness-insights, learner-home-dash-rapport-progress,
|
||||||
|
// learner-home-dash-milestones
|
||||||
|
test("derives weakness insights, rapport bars, and milestone badges from dashboard data", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
// 성장 지표 패널들은 '성장 지표' 탭 아래에 배치된다(대시보드 셸 가로 탭).
|
||||||
|
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||||
|
|
||||||
|
// 보완할 부분 — growth 데이터에서 결정론으로 파생된 인사이트 최대 3건.
|
||||||
|
const weakness = page.locator(".lh-weakness");
|
||||||
|
await expect(weakness).toBeVisible();
|
||||||
|
const weaknessButtons = weakness.locator(".lh-weakness__list button");
|
||||||
|
await expect(weaknessButtons).toHaveCount(3);
|
||||||
|
await expect(weaknessButtons.nth(0)).toContainText("평가가 내려가는 흐름");
|
||||||
|
await expect(weaknessButtons.nth(1)).toContainText("라포 신호가 아직 약함");
|
||||||
|
await expect(weaknessButtons.nth(2)).toContainText("살펴볼 개입 2건");
|
||||||
|
|
||||||
|
// 라포 누적 — 회기가 있는 페르소나 상위 3명의 rapport_percent progressbar.
|
||||||
|
const rapport = page.getByLabel("페르소나별 라포 누적");
|
||||||
|
const bars = rapport.getByRole("progressbar");
|
||||||
|
await expect(bars).toHaveCount(2);
|
||||||
|
await expect(bars.nth(0)).toHaveAttribute("aria-valuenow", "64");
|
||||||
|
await expect(bars.nth(1)).toHaveAttribute("aria-valuenow", "32");
|
||||||
|
await expect(rapport).toContainText("성하늘");
|
||||||
|
await expect(rapport).toContainText("64%");
|
||||||
|
|
||||||
|
// 연습 이정표 — achievements 상태별 배지 + title 툴팁.
|
||||||
|
const milestones = page.locator(".lh-milestones");
|
||||||
|
await expect(milestones).toContainText("첫 회기 완료");
|
||||||
|
await expect(milestones.locator(".lh-milestone.is-done")).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
"첫 회기를 끝냈습니다.",
|
||||||
|
);
|
||||||
|
await expect(milestones.locator(".lh-milestone.is-locked")).toContainText(
|
||||||
|
"다섯 회기 도전",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 인사이트 버튼 클릭 시 해당 화면으로 이동.
|
||||||
|
await weaknessButtons.nth(1).click();
|
||||||
|
await expect(page).toHaveURL(/\/learn\/practice$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-dash-recap-fetch-states
|
||||||
|
test("shows recap detail loading, error fallback, and ignores stale detail responses", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page, { detail: false });
|
||||||
|
|
||||||
|
// 1) 로딩 → 성공: 상세 응답을 지연시켜 로딩 문구를 확인한 뒤 발화를 확정한다.
|
||||||
|
let releaseDetail!: () => void;
|
||||||
|
const detailGate = new Promise<void>((resolve) => {
|
||||||
|
releaseDetail = resolve;
|
||||||
|
});
|
||||||
|
const detailPattern = new RegExp(`/api/sessions/${ACT_ID}$`);
|
||||||
|
await page.route(detailPattern, async (route) => {
|
||||||
|
await detailGate;
|
||||||
|
await route.fulfill(jsonRoute(DETAIL_BODY));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/learn");
|
||||||
|
const quote = page.locator(".lh-recap__quote");
|
||||||
|
await expect(quote).toContainText("상세 발화를 확인하고 있습니다.");
|
||||||
|
releaseDetail();
|
||||||
|
await expect(quote).toContainText("요즘은 잠이 잘 안 와요.");
|
||||||
|
|
||||||
|
// 2) 오류: 상세 로드 실패 시 발화 없음 폴백 문구로 전환된다.
|
||||||
|
await page.unroute(detailPattern);
|
||||||
|
await page.route(detailPattern, (route) =>
|
||||||
|
route.fulfill(jsonRoute({ detail: "boom" }, 500)),
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await expect(quote).toContainText("아직 표시할 내담자 발화가 없습니다.");
|
||||||
|
|
||||||
|
// 3) stale 응답: session_id가 다른 상세는 무시되어 발화가 표시되지 않는다.
|
||||||
|
await page.unroute(detailPattern);
|
||||||
|
await page.route(detailPattern, (route) =>
|
||||||
|
route.fulfill(jsonRoute({ ...DETAIL_BODY, session_id: "other-session" })),
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await expect(quote).toContainText("아직 표시할 내담자 발화가 없습니다.");
|
||||||
|
await expect(quote).not.toContainText("요즘은 잠이 잘 안 와요.");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-practice-persona-skeleton
|
||||||
|
test("shows persona skeletons while loading and a role=alert notice on failure", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
|
||||||
|
let releasePersonas!: () => void;
|
||||||
|
const personasGate = new Promise<void>((resolve) => {
|
||||||
|
releasePersonas = resolve;
|
||||||
|
});
|
||||||
|
await page.route(/\/api\/personas$/, async (route) => {
|
||||||
|
await personasGate;
|
||||||
|
await route.fulfill(jsonRoute({ detail: "boom" }, 500));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
|
||||||
|
// 로딩 중 스켈레톤 3개.
|
||||||
|
await expect(page.locator(".lh-persona.is-skel")).toHaveCount(3);
|
||||||
|
|
||||||
|
// 실패 시 role=alert 안내.
|
||||||
|
releasePersonas();
|
||||||
|
const alert = page.locator(".lh-error");
|
||||||
|
await expect(alert).toHaveAttribute("role", "alert");
|
||||||
|
await expect(alert).toContainText("내담자 목록을 불러오지 못했습니다.");
|
||||||
|
await expect(alert).toContainText("잠시 뒤 다시 시도해 주세요.");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-session-list-states
|
||||||
|
test("distinguishes session list loading, error, and empty states", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
|
||||||
|
let releaseSessions!: () => void;
|
||||||
|
const sessionsGate = new Promise<void>((resolve) => {
|
||||||
|
releaseSessions = resolve;
|
||||||
|
});
|
||||||
|
const sessionsPattern = /\/api\/sessions$/;
|
||||||
|
await page.route(sessionsPattern, async (route) => {
|
||||||
|
await sessionsGate;
|
||||||
|
await route.fulfill(jsonRoute({ detail: "boom" }, 500));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
|
||||||
|
// 로딩 문구.
|
||||||
|
await expect(
|
||||||
|
page.getByText("저장된 연습 기록을 확인하고 있습니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// 오류: 인라인 경고(role=status).
|
||||||
|
releaseSessions();
|
||||||
|
const inlineAlert = page.locator(".lh-activity .lh-inline-alert");
|
||||||
|
await expect(inlineAlert).toContainText("연습 기록을 불러오지 못했습니다.");
|
||||||
|
await expect(inlineAlert).toHaveAttribute("role", "status");
|
||||||
|
|
||||||
|
// 빈 상태: 컨텍스트별 빈 메시지.
|
||||||
|
await page.unroute(sessionsPattern);
|
||||||
|
await page.route(sessionsPattern, (route) =>
|
||||||
|
route.fulfill(jsonRoute({ sessions: [], source: "runtime" })),
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
const empty = page.locator(".lh-activity__empty");
|
||||||
|
await expect(empty).toContainText("저장된 기존 회기가 없습니다.");
|
||||||
|
await expect(empty).toContainText(
|
||||||
|
"첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.",
|
||||||
|
);
|
||||||
|
await expect(empty).toHaveAttribute("role", "status");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-history-overview-cards, learner-home-history-filter-chips,
|
||||||
|
// learner-home-history-search
|
||||||
|
test("filters history via overview cards, filter chips, and search", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page);
|
||||||
|
await page.goto("/learn/history");
|
||||||
|
|
||||||
|
// 요약 필터 카드 4개 + 버킷 카운트 + aria-pressed.
|
||||||
|
const overview = page.locator(".lh-history-overview");
|
||||||
|
await expect(overview.getByRole("button")).toHaveCount(4);
|
||||||
|
await expect(overview.getByRole("button", { name: /전체/ })).toContainText(
|
||||||
|
"4",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
overview.getByRole("button", { name: /전체/ }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(
|
||||||
|
overview.getByRole("button", { name: /진행 중/ }),
|
||||||
|
).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(page.locator(".lh-history-main__head")).toContainText("4개");
|
||||||
|
|
||||||
|
// 카드 클릭으로 필터 전환.
|
||||||
|
await overview.getByRole("button", { name: /진행 중/ }).click();
|
||||||
|
await expect(
|
||||||
|
overview.getByRole("button", { name: /진행 중/ }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
|
||||||
|
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
|
||||||
|
|
||||||
|
// 칩과 카드가 같은 필터 상태를 공유한다.
|
||||||
|
const chips = page.locator(".lh-history-filter");
|
||||||
|
await expect(chips.getByRole("button", { name: "진행 중" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await chips.getByRole("button", { name: "보관됨" }).click();
|
||||||
|
await expect(
|
||||||
|
overview.getByRole("button", { name: /보관됨/ }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
|
||||||
|
await expect(page.getByRole("button", { name: "복원" })).toBeVisible();
|
||||||
|
|
||||||
|
// 검색 — 단계 문자열 부분 일치 즉시 필터링.
|
||||||
|
await chips.getByRole("button", { name: "전체" }).click();
|
||||||
|
await expect(page.locator(".lh-history-main__head")).toContainText("4개");
|
||||||
|
const search = page.getByLabel("기록 검색");
|
||||||
|
await search.fill("탐색");
|
||||||
|
await expect(page.locator(".lh-history-main__head")).toContainText("1개");
|
||||||
|
await expect(page.locator(".lh-session-list")).toContainText("탐색 단계");
|
||||||
|
await search.fill("존재하지않는검색어");
|
||||||
|
await expect(page.locator(".lh-history-main__head")).toContainText("0개");
|
||||||
|
await expect(
|
||||||
|
page.getByText("조건에 맞는 회기가 없습니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-history-archive-notice
|
||||||
|
test("surfaces archive failure and success notices with role=status", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page);
|
||||||
|
|
||||||
|
let archiveCalls = 0;
|
||||||
|
await page.route(new RegExp(`/api/sessions/${END_ID}/archive$`), (route) => {
|
||||||
|
archiveCalls += 1;
|
||||||
|
if (archiveCalls === 1) {
|
||||||
|
return route.fulfill(jsonRoute({ detail: "archive failed" }, 500));
|
||||||
|
}
|
||||||
|
return route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
session_id: END_ID,
|
||||||
|
archived: true,
|
||||||
|
archived_at: iso(0),
|
||||||
|
session: { ...S_END, archived: true, archived_at: iso(0) },
|
||||||
|
source: "runtime",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/learn/history");
|
||||||
|
|
||||||
|
const endCard = page.locator(".lh-session-card", { hasText: "김도윤" });
|
||||||
|
await expect(endCard).toBeVisible();
|
||||||
|
|
||||||
|
// 실패: role=status 배너에 실패 문구.
|
||||||
|
await endCard.getByRole("button", { name: "보관", exact: true }).click();
|
||||||
|
const notice = page.locator(".lh-inline-alert--archive");
|
||||||
|
await expect(notice).toContainText("보관 상태를 바꾸지 못했습니다.");
|
||||||
|
await expect(notice).toHaveAttribute("role", "status");
|
||||||
|
|
||||||
|
// 성공: 같은 배너 위치에 성공 문구.
|
||||||
|
await endCard.getByRole("button", { name: "보관", exact: true }).click();
|
||||||
|
await expect(notice).toContainText("회기를 보관했습니다.");
|
||||||
|
await expect(notice).toHaveAttribute("role", "status");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-history-persona-progress
|
||||||
|
test("lists persona progress rows and jumps to practice with the persona selected", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page);
|
||||||
|
await page.goto("/learn/history");
|
||||||
|
|
||||||
|
const progress = page.getByLabel("페르소나별 진행 정도");
|
||||||
|
await expect(progress).toContainText("2명");
|
||||||
|
const rows = progress.locator(".lh-persona-progress__row");
|
||||||
|
await expect(rows).toHaveCount(2);
|
||||||
|
// 회기 수 내림차순 정렬 — P1(3회)이 먼저 온다.
|
||||||
|
await expect(rows.nth(0)).toContainText("P1");
|
||||||
|
await expect(rows.nth(0)).toContainText(
|
||||||
|
"3회 · 1회 진행 · 1회 리뷰 · 평균 4턴",
|
||||||
|
);
|
||||||
|
await expect(rows.nth(1)).toContainText("P2");
|
||||||
|
|
||||||
|
// 행 클릭 시 해당 페르소나가 선택된 채 연습 화면으로 진입.
|
||||||
|
await rows.nth(1).click();
|
||||||
|
await expect(page).toHaveURL(/\/learn\/practice$/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "김도윤" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("listbox", { name: "연습 페르소나" })
|
||||||
|
.getByRole("option", { name: /P2/ }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-newbie-basic-first (결정 D3)
|
||||||
|
test("defaults a learner with no records to the easiest persona in dashboard and practice", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installStarterFixtures(page);
|
||||||
|
|
||||||
|
// (a) 대시보드 첫 회기 준비 카드가 API 순서(P1·고난도)가 아니라 기초(P4)를 보여준다.
|
||||||
|
await page.goto("/learn");
|
||||||
|
const focus = page.locator(".lh-session-focus");
|
||||||
|
await expect(focus).toContainText("첫 회기 준비");
|
||||||
|
await expect(focus.getByRole("heading", { name: "박새봄" })).toBeVisible();
|
||||||
|
await expect(focus).toContainText("기초");
|
||||||
|
await expect(page.getByLabel("마지막 세션 리캡")).toContainText("P4");
|
||||||
|
|
||||||
|
// (b) /learn/practice 기본 선택도 난도 오름차순 첫 사용 가능 페르소나(P4)다.
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||||
|
await expect(
|
||||||
|
listbox.getByRole("option", { name: /P4/ }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(
|
||||||
|
listbox.getByRole("option", { name: /P1/ }),
|
||||||
|
).toHaveAttribute("aria-selected", "false");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "박새봄" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: learner-home-newbie-basic-first (결정 D3 — 기록 보유 시 무회귀)
|
||||||
|
test("keeps the recent-session rule when the learner already has records", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installStarterFixtures(page, { withRecords: true });
|
||||||
|
|
||||||
|
// (c) 기록이 있으면 스포트라이트는 최근 회기 페르소나(P1)를 유지한다.
|
||||||
|
await page.goto("/learn");
|
||||||
|
const focus = page.locator(".lh-session-focus");
|
||||||
|
await expect(focus).toContainText("오늘 이어갈 회기");
|
||||||
|
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
|
||||||
|
|
||||||
|
// 기본 선택도 기존 규칙(첫 사용 가능 페르소나 = P1)을 유지한다.
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||||
|
await expect(
|
||||||
|
listbox.getByRole("option", { name: /P1/ }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(
|
||||||
|
listbox.getByRole("option", { name: /P4/ }),
|
||||||
|
).toHaveAttribute("aria-selected", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
812
apps/web/e2e/full-sweep-persona-studio.spec.ts
Normal file
812
apps/web/e2e/full-sweep-persona-studio.spec.ts
Normal file
|
|
@ -0,0 +1,812 @@
|
||||||
|
/**
|
||||||
|
* 전수 순회(2026-07-27) — 페르소나 스튜디오(/teach/personas) 신규 spec.
|
||||||
|
*
|
||||||
|
* docs/ops/e2e-full-sweep-2026-07-27.md 6장에서 "검증: 신규 spec 필요"로 표기된
|
||||||
|
* 체크리스트 항목을 검증한다. AI 엔진 호출 없이 route fixture로 상태를 고정하고,
|
||||||
|
* 인증만 실 dev-login API를 사용한다. 각 test() 위 주석에 검증하는 checklist id를 적는다.
|
||||||
|
*/
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoHorizontalOverflow,
|
||||||
|
signInAsLearner,
|
||||||
|
signInAsTeacher,
|
||||||
|
} from "./support";
|
||||||
|
|
||||||
|
const APPROVED_ID = "00000000-0000-0000-0000-000000000801";
|
||||||
|
const DRAFT_ID = "00000000-0000-0000-0000-000000000802";
|
||||||
|
const REVIEW_ID = "00000000-0000-0000-0000-000000000803";
|
||||||
|
|
||||||
|
const approvedP1 = {
|
||||||
|
persona_id: APPROVED_ID,
|
||||||
|
code: "P1",
|
||||||
|
version: 3,
|
||||||
|
status: "approved",
|
||||||
|
display_name: "서연(가명) · 고2 · 우울/자살사고",
|
||||||
|
difficulty: "hard",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
demographics: { age_band: "F-teen" },
|
||||||
|
presenting_summary: "최근 무기력과 관계 단절을 호소합니다.",
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
voice_preset: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewP12 = {
|
||||||
|
persona_id: REVIEW_ID,
|
||||||
|
code: "P12",
|
||||||
|
version: 1,
|
||||||
|
status: "review",
|
||||||
|
display_name: "직장 적응 훈련 페르소나",
|
||||||
|
difficulty: "moderate",
|
||||||
|
theory_target: ["cbt"],
|
||||||
|
source_provenance: "교수자 작성 초안",
|
||||||
|
is_synthetic: true,
|
||||||
|
created_at: "2026-07-15T08:00:00Z",
|
||||||
|
approved_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const draftP13 = {
|
||||||
|
persona_id: DRAFT_ID,
|
||||||
|
code: "P13",
|
||||||
|
version: 1,
|
||||||
|
status: "review",
|
||||||
|
display_name: "발표 불안 훈련 초안",
|
||||||
|
difficulty: "moderate",
|
||||||
|
theory_target: ["cbt"],
|
||||||
|
source_provenance: "교수 상담 기록 2024",
|
||||||
|
is_synthetic: false,
|
||||||
|
created_at: "2026-07-20T02:00:00Z",
|
||||||
|
approved_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function draftDetail(base: typeof draftP13) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
demographics: { age_band: "20대", sex: "여", role: "대학생", context: "실습" },
|
||||||
|
presenting: { complaint: "발표 상황에서 심한 불안을 호소합니다." },
|
||||||
|
history: { family: "성취 압박이 강한 가정" },
|
||||||
|
big5: { O: 0.5, C: 0.4, E: 0.3, A: 0.6, N: 0.8 },
|
||||||
|
resistance: {
|
||||||
|
base_resistance: 0.6,
|
||||||
|
unlock_rate: 0.1,
|
||||||
|
decay_floor: 0.05,
|
||||||
|
silence_prob: 0.15,
|
||||||
|
deflection_prob: 0.25,
|
||||||
|
},
|
||||||
|
speech_style: { register: "polite", honorific: true },
|
||||||
|
affect_baseline: {
|
||||||
|
negative_affect: 0.7,
|
||||||
|
anxiety: 0.6,
|
||||||
|
hopelessness: 0.2,
|
||||||
|
anhedonia: 0.2,
|
||||||
|
sleep: 0.2,
|
||||||
|
suicide_ideation_stage: 1,
|
||||||
|
},
|
||||||
|
ccd: { core_belief: "나는 부족하다" },
|
||||||
|
dsm5_dimensional: { note: "평가 상황에서 불안 상승" },
|
||||||
|
triggers: { sore_spots: ["평가", "비교"], forbidden: ["단정"] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboardFixture(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
source: "database",
|
||||||
|
cohort_label: "E2E cohort",
|
||||||
|
total_learners: 0,
|
||||||
|
active_sessions: 0,
|
||||||
|
ended_sessions: 0,
|
||||||
|
learner_growth: [],
|
||||||
|
safety_alerts: [],
|
||||||
|
pending_reviews: [],
|
||||||
|
recent_sessions: [],
|
||||||
|
message: "persona studio full sweep fixture",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockStudio(
|
||||||
|
page: Page,
|
||||||
|
options: {
|
||||||
|
catalog?: unknown[];
|
||||||
|
reviews?: unknown[];
|
||||||
|
dashboard?: Record<string, unknown> | null;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
await page.route("**/api/personas", (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(options.catalog ?? []),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/personas/review", (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(options.reviews ?? []),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/teacher/dashboard", (route) => {
|
||||||
|
if (options.dashboard === null) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "dashboard unavailable" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(options.dashboard ?? dashboardFixture()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function meFixture(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
user_id: "00000000-0000-0000-0000-000000000901",
|
||||||
|
email: "guard-check@hs.ac.kr",
|
||||||
|
role: "teacher",
|
||||||
|
display_name: "Guard Check Teacher",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: 1782820000,
|
||||||
|
onboarding_completed_at: 1782820001,
|
||||||
|
nickname: "Guard Check",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("persona studio full sweep", () => {
|
||||||
|
// checklist: persona-studio-guard-require-auth, persona-studio-guard-pending-approval,
|
||||||
|
// persona-studio-guard-onboarding
|
||||||
|
test("redirects unauthenticated, learner, pending, and un-onboarded users away", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// 미인증 → /login
|
||||||
|
await page.goto("/teach/personas");
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
|
||||||
|
// learner 역할 → 자기 역할 홈(/learn)
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await page.goto("/teach/personas");
|
||||||
|
await expect(page).toHaveURL(/\/learn$/);
|
||||||
|
|
||||||
|
// 미승인(teacher, pending) → /pending
|
||||||
|
await page.route("**/api/auth/me", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(
|
||||||
|
meFixture({ account_status: "pending", onboarding_completed_at: null }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto("/teach/personas");
|
||||||
|
await expect(page).toHaveURL(/\/pending$/);
|
||||||
|
|
||||||
|
// 승인됐지만 온보딩 미완료 → /onboarding
|
||||||
|
await page.unroute("**/api/auth/me");
|
||||||
|
await page.route("**/api/auth/me", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(meFixture({ onboarding_completed_at: null })),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto("/teach/personas");
|
||||||
|
await expect(page).toHaveURL(/\/onboarding$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-dash-usage-row, persona-studio-detail-learning-empty
|
||||||
|
test("opens approved detail from a dashboard usage row and shows empty learning state", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [approvedP1], reviews: [] });
|
||||||
|
|
||||||
|
await page.goto("/teach/personas");
|
||||||
|
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
|
||||||
|
|
||||||
|
const usageRow = page.locator(".ps-usage-row").filter({ hasText: "P1" });
|
||||||
|
await expect(usageRow).toBeVisible();
|
||||||
|
await usageRow.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/view=personas/);
|
||||||
|
await expect(page).toHaveURL(/persona=approved%3AP1/);
|
||||||
|
await expect(page.getByRole("heading", { name: "페르소나 상세" })).toBeVisible();
|
||||||
|
|
||||||
|
// 평가된 회기 궤적이 없으면 학습 현황 탭은 빈 상태를 표시한다.
|
||||||
|
await page.getByRole("tab", { name: "학습 현황" }).click();
|
||||||
|
await expect(page.getByText("학습 기록 없음")).toBeVisible();
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-dash-usage-empty, persona-studio-catalog-empty-distribution,
|
||||||
|
// persona-studio-review-empty
|
||||||
|
test("shows empty states on dashboard, catalog distribution, and review queue", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [] });
|
||||||
|
|
||||||
|
await page.goto("/teach/personas");
|
||||||
|
await expect(page.getByText("공개 페르소나 없음")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /카탈로그/ }).first().click();
|
||||||
|
await expect(page.getByRole("heading", { name: "페르소나 카탈로그" })).toBeVisible();
|
||||||
|
await expect(page.getByText("분포 자료 없음")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /페르소나/ }).first().click();
|
||||||
|
await page.getByRole("tab", { name: /검수 현황/ }).click();
|
||||||
|
await expect(page.getByText("검수 대기 없음")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-list-search, persona-studio-list-origin-filter,
|
||||||
|
// persona-studio-list-empty
|
||||||
|
test("filters the persona list by search text and origin select", async ({ page }) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [approvedP1], reviews: [reviewP12] });
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas");
|
||||||
|
const rows = page.locator(".ps-persona-row");
|
||||||
|
await expect(rows).toHaveCount(2);
|
||||||
|
|
||||||
|
// 이름 부분 일치 검색
|
||||||
|
await page.getByLabel("페르소나 검색").fill("직장");
|
||||||
|
await expect(rows).toHaveCount(1);
|
||||||
|
await expect(rows.first()).toContainText("P12");
|
||||||
|
|
||||||
|
// 상태 라벨로도 검색된다 (검수 대기)
|
||||||
|
await page.getByLabel("페르소나 검색").fill("검수 대기");
|
||||||
|
await expect(rows).toHaveCount(1);
|
||||||
|
await expect(rows.first()).toContainText("P12");
|
||||||
|
|
||||||
|
await page.getByLabel("페르소나 검색").fill("");
|
||||||
|
await expect(rows).toHaveCount(2);
|
||||||
|
|
||||||
|
// 구분 필터: P1~P11은 시스템, 그 외는 커스텀
|
||||||
|
await page.getByLabel("구분").selectOption("system");
|
||||||
|
await expect(rows).toHaveCount(1);
|
||||||
|
await expect(rows.first()).toContainText("P1");
|
||||||
|
|
||||||
|
await page.getByLabel("구분").selectOption("custom");
|
||||||
|
await expect(rows).toHaveCount(1);
|
||||||
|
await expect(rows.first()).toContainText("P12");
|
||||||
|
|
||||||
|
// 결과 없음 빈 상태
|
||||||
|
await page.getByLabel("구분").selectOption("all");
|
||||||
|
await page.getByLabel("페르소나 검색").fill("존재하지않는페르소나");
|
||||||
|
await expect(page.getByText("조건에 맞는 페르소나 없음")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-review-card-reject, persona-studio-review-card-edit
|
||||||
|
test("rejects a pending review card and opens the draft editor from a card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
const draftStatusPersona = { ...draftP13, status: "draft" };
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [reviewP12, draftStatusPersona] });
|
||||||
|
|
||||||
|
let rejectPayload: unknown = null;
|
||||||
|
await page.route(`**/api/personas/review/${REVIEW_ID}`, async (route) => {
|
||||||
|
expect(route.request().method()).toBe("POST");
|
||||||
|
rejectPayload = route.request().postDataJSON();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ...reviewP12, status: "draft" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/personas/drafts/${REVIEW_ID}`, (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(draftDetail({ ...draftP13, ...reviewP12 })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas&queue=review");
|
||||||
|
|
||||||
|
// status=draft 카드는 승인·반려가 비활성화된다.
|
||||||
|
const draftCard = page.locator(".ps-review-card").filter({ hasText: "P13" });
|
||||||
|
await expect(draftCard).toBeVisible();
|
||||||
|
await expect(draftCard.getByRole("button", { name: "반려" })).toBeDisabled();
|
||||||
|
await expect(draftCard.getByRole("button", { name: "승인" })).toBeDisabled();
|
||||||
|
|
||||||
|
// status=review 카드 반려 → decide API 호출과 결과 메시지
|
||||||
|
const reviewCard = page.locator(".ps-review-card").filter({ hasText: "P12" });
|
||||||
|
await reviewCard.getByRole("button", { name: "반려" }).click();
|
||||||
|
await expect(page.getByText("P12 v1 반려했습니다.")).toBeVisible();
|
||||||
|
expect(rejectPayload).toEqual({ action: "reject" });
|
||||||
|
|
||||||
|
// 검수 카드 수정 → mode=edit 편집기로 이동해 초안이 자동 로드된다.
|
||||||
|
await page
|
||||||
|
.locator(".ps-review-card")
|
||||||
|
.filter({ hasText: "P12" })
|
||||||
|
.getByRole("button", { name: "수정" })
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/mode=edit/);
|
||||||
|
await expect(page).toHaveURL(new RegExp(`draft=${REVIEW_ID}`));
|
||||||
|
await expect(page.getByLabel("표시 이름")).toHaveValue("직장 적응 훈련 페르소나");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-draft-autoload-param
|
||||||
|
test("auto-loads the draft named by the ?draft= query param into the editor", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [draftP13] });
|
||||||
|
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(draftDetail(draftP13)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/teach/personas?view=personas&mode=edit&draft=${DRAFT_ID}&step=edit`,
|
||||||
|
);
|
||||||
|
await expect(page.getByText("P13 v1 초안을 불러왔습니다.")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("표시 이름")).toHaveValue("발표 불안 훈련 초안");
|
||||||
|
await expect(page.getByLabel("코드")).toHaveValue("P13");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-author-rail-draft-load, persona-studio-author-rail-approved-actions
|
||||||
|
// 저작 모드는 4단계 스테퍼+인스펙터 중심의 포커스 화면을 쓰고, 좌측 레일(.ps-rail)
|
||||||
|
// 마크업은 렌더하지 않는다. 초안 로드는 목록 뷰·검수 큐·직접 URL로 진입한다.
|
||||||
|
// 항상 숨겨지던 죽은 DOM이었던 레일은 2026-07-27 소유자 결정으로 정리 완료
|
||||||
|
// (체크리스트 결함 로그 #14).
|
||||||
|
test("keeps the authoring screen focused without rendering the left rail", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [approvedP1], reviews: [draftP13] });
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||||
|
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
|
||||||
|
|
||||||
|
// 레일은 DOM 자체가 존재하지 않아야 한다. 초안 진입 경로(목록 뷰 행 클릭·검수
|
||||||
|
// 큐·직접 URL)는 이 파일의 다른 테스트가 검증한다.
|
||||||
|
await expect(page.locator(".ps-authoring-layout > .ps-rail")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-inspector-review-actions
|
||||||
|
test("edits and approves review drafts from the inspector queue", async ({ page }) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
// 편집 대상(P13)과 승인 대상(P12)을 분리한다. 현재 편집 중인 초안을 승인하면
|
||||||
|
// resetDraft가 성공 메시지를 지우는 별개 동작이 있어 결정 메시지 검증이 섞이지 않게 한다.
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [draftP13, reviewP12] });
|
||||||
|
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(draftDetail(draftP13)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let decidePayload: unknown = null;
|
||||||
|
await page.route(`**/api/personas/review/${REVIEW_ID}`, async (route) => {
|
||||||
|
expect(route.request().method()).toBe("POST");
|
||||||
|
decidePayload = route.request().postDataJSON();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...reviewP12,
|
||||||
|
status: "approved",
|
||||||
|
approved_at: "2026-07-27T00:00:00Z",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||||
|
const editCard = page.locator(".ps-inspector .ps-review").filter({ hasText: "P13" });
|
||||||
|
await expect(editCard).toBeVisible();
|
||||||
|
|
||||||
|
await editCard.getByRole("button", { name: "편집" }).click();
|
||||||
|
await expect(page.getByText("P13 v1 초안을 불러왔습니다.")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("표시 이름")).toHaveValue("발표 불안 훈련 초안");
|
||||||
|
|
||||||
|
const approveCard = page
|
||||||
|
.locator(".ps-inspector .ps-review")
|
||||||
|
.filter({ hasText: "P12" });
|
||||||
|
await approveCard.getByRole("button", { name: "승인" }).click();
|
||||||
|
await expect(page.getByText("P12 v1 승인했습니다.")).toBeVisible();
|
||||||
|
expect(decidePayload).toEqual({ action: "approve" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-author-empty-states, persona-studio-inspector-validation,
|
||||||
|
// persona-studio-field-provenance
|
||||||
|
test("shows inspector empty states and live validation checklist while authoring", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [] });
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||||
|
const inspector = page.locator(".ps-inspector");
|
||||||
|
|
||||||
|
// 인스펙터 빈 상태 (생성 근거 없음 / 결정 대기 없음)
|
||||||
|
await expect(
|
||||||
|
inspector.getByText("자료 기반 생성 결과가 아직 없습니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(inspector.getByText("결정 대기 없음")).toBeVisible();
|
||||||
|
|
||||||
|
// 검증 체크리스트: 필수 항목 오류가 실시간으로 나열된다.
|
||||||
|
await expect(inspector.getByText("표시 이름이 비어 있습니다.")).toBeVisible();
|
||||||
|
await expect(inspector.getByText("주호소가 비어 있습니다.")).toBeVisible();
|
||||||
|
await expect(inspector.getByText("CCD 핵심신념이 비어 있습니다.")).toBeVisible();
|
||||||
|
await expect(inspector.getByText("역린 민감 영역이 비어 있습니다.")).toBeVisible();
|
||||||
|
|
||||||
|
// 값을 채우면 해당 오류가 사라진다.
|
||||||
|
await page.getByLabel("표시 이름").fill("검증 확인 페르소나");
|
||||||
|
await expect(inspector.getByText("표시 이름이 비어 있습니다.")).toHaveCount(0);
|
||||||
|
|
||||||
|
// 출처를 비우면 경고가 나타난다.
|
||||||
|
await page.getByLabel("출처").fill("");
|
||||||
|
await expect(inspector.getByText("출처/작성 근거가 비어 있습니다.")).toBeVisible();
|
||||||
|
await page.getByLabel("출처").fill("교수 자체 작성");
|
||||||
|
await expect(inspector.getByText("출처/작성 근거가 비어 있습니다.")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-theory-toggle, persona-studio-fields-resistance-numbers
|
||||||
|
test("toggles theory targets and edits the 16 resistance numeric fields", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [] });
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||||
|
|
||||||
|
const segments = page.locator(".ps-segments");
|
||||||
|
const humanistic = segments.getByRole("button", { name: "humanistic", exact: true });
|
||||||
|
const cbt = segments.getByRole("button", { name: "cbt", exact: true });
|
||||||
|
await expect(humanistic).toHaveClass(/is-active/);
|
||||||
|
|
||||||
|
await cbt.click();
|
||||||
|
await expect(cbt).toHaveClass(/is-active/);
|
||||||
|
await expect(humanistic).toHaveClass(/is-active/);
|
||||||
|
|
||||||
|
await humanistic.click();
|
||||||
|
await expect(humanistic).not.toHaveClass(/is-active/);
|
||||||
|
|
||||||
|
// 마지막 남은 이론(cbt)을 해제하면 humanistic으로 되돌아간다.
|
||||||
|
await cbt.click();
|
||||||
|
await expect(cbt).not.toHaveClass(/is-active/);
|
||||||
|
await expect(humanistic).toHaveClass(/is-active/);
|
||||||
|
|
||||||
|
// 저항 탭: 저항 5 + Big5 5 + 정서 기저선 5 + 자살사고 단계 = 16개 숫자 입력
|
||||||
|
await page.getByRole("tab", { name: "저항" }).click();
|
||||||
|
await expect(page.locator('.ps-number-grid input[type="number"]')).toHaveCount(16);
|
||||||
|
|
||||||
|
await page.getByLabel("초기 저항").fill("0.8");
|
||||||
|
await expect(page.getByLabel("초기 저항")).toHaveValue("0.8");
|
||||||
|
await page.getByLabel("자살사고 단계").fill("3");
|
||||||
|
await expect(page.getByLabel("자살사고 단계")).toHaveValue("3");
|
||||||
|
await expect(page.getByLabel("자살사고 단계")).toHaveAttribute("max", "5");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-source-dropzone, persona-studio-source-file-picker,
|
||||||
|
// persona-studio-source-kind-select, persona-studio-source-name-input,
|
||||||
|
// persona-studio-generation-goal-input, persona-studio-source-text-area,
|
||||||
|
// persona-studio-generate-draft, persona-studio-inspector-evidence,
|
||||||
|
// persona-studio-status-message-line
|
||||||
|
test("registers pasted source as KB evidence and generates a draft", async ({ page }) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [] });
|
||||||
|
|
||||||
|
const sourceDoc = {
|
||||||
|
source_id: "src-e2e-1",
|
||||||
|
doc_id: "doc-e2e-9",
|
||||||
|
chunk_count: 3,
|
||||||
|
content_hash: "abcdef1234567890",
|
||||||
|
title: "사례-정리.md",
|
||||||
|
source_kind: "textbook_guide",
|
||||||
|
pii_entities_masked: ["이름"],
|
||||||
|
external_llm_ok: true,
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
const generatedDraft = {
|
||||||
|
code: "P5",
|
||||||
|
display_name: "생성된 페르소나",
|
||||||
|
difficulty: "moderate",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
demographics: {},
|
||||||
|
presenting: { complaint: "발표 상황 회피" },
|
||||||
|
history: {},
|
||||||
|
speech_style: {},
|
||||||
|
ccd: {},
|
||||||
|
dsm5_dimensional: {},
|
||||||
|
triggers: {},
|
||||||
|
source_provenance: "상담기록 근거",
|
||||||
|
is_synthetic: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sourcePayload: Record<string, unknown> | null = null;
|
||||||
|
let generatePayload: Record<string, unknown> | null = null;
|
||||||
|
await page.route("**/api/personas/sources", async (route) => {
|
||||||
|
sourcePayload = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(sourceDoc),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/personas/drafts/generate", async (route) => {
|
||||||
|
generatePayload = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
draft: generatedDraft,
|
||||||
|
source_summary: "근거 청크 3개로 초안을 생성했습니다.",
|
||||||
|
warnings: ["표면화 조건 근거가 부족합니다."],
|
||||||
|
pii_entities_masked: ["연락처"],
|
||||||
|
source_references: [sourceDoc],
|
||||||
|
evidence_chunks: [
|
||||||
|
{
|
||||||
|
source_id: "src-e2e-1",
|
||||||
|
chunk_id: "c-1",
|
||||||
|
score: 0.912,
|
||||||
|
heading_path: "1회기 요약",
|
||||||
|
excerpt: "내담자는 발표 상황에서 심한 긴장을 보고했다.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas&mode=create&step=generate");
|
||||||
|
|
||||||
|
// 자료 없이 생성 → role=alert 오류 문구 (상태 메시지 라인 오류 분기)
|
||||||
|
await page.getByRole("button", { name: "KB 등록 후 생성" }).click();
|
||||||
|
const errorLine = page.locator(".ps-status.is-error");
|
||||||
|
await expect(errorLine).toBeVisible();
|
||||||
|
await expect(errorLine).toHaveAttribute("role", "alert");
|
||||||
|
await expect(errorLine).toContainText("20자 이상");
|
||||||
|
|
||||||
|
// 파일 선택 → 텍스트 파일은 브라우저에서 읽어 본문에 채운다.
|
||||||
|
await page.locator(".ps-file-input").setInputFiles({
|
||||||
|
name: "상담기록-1회기.txt",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
buffer: Buffer.from("내담자는 발표를 앞두고 반복적으로 불안을 호소했다.", "utf-8"),
|
||||||
|
});
|
||||||
|
await expect(page.getByLabel("파일/자료명")).toHaveValue("상담기록-1회기.txt");
|
||||||
|
await expect(page.getByLabel("자료 본문")).toHaveValue(/반복적으로 불안을 호소/);
|
||||||
|
await expect(page.getByText(/첨부 내용을 불러왔습니다/)).toBeVisible();
|
||||||
|
|
||||||
|
// 드래그&드롭 → 첫 파일을 읽어 본문을 교체한다.
|
||||||
|
const dataTransfer = await page.evaluateHandle(() => {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(
|
||||||
|
new File(
|
||||||
|
["드롭한 사례 기록: 평가 상황에서 회피 행동이 반복적으로 관찰되었다."],
|
||||||
|
"드롭-사례.md",
|
||||||
|
{ type: "text/markdown" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return dt;
|
||||||
|
});
|
||||||
|
await page.locator(".ps-upload-card").dispatchEvent("drop", { dataTransfer });
|
||||||
|
await expect(page.getByLabel("파일/자료명")).toHaveValue("드롭-사례.md");
|
||||||
|
await expect(page.getByLabel("자료 본문")).toHaveValue(/드롭한 사례 기록/);
|
||||||
|
|
||||||
|
// 자료 종류·자료명·생성 목표 입력이 생성 요청에 함께 전달된다.
|
||||||
|
await page.getByLabel("자료 종류").selectOption("textbook_guide");
|
||||||
|
await page.getByLabel("파일/자료명").fill("사례-정리.md");
|
||||||
|
await page.getByLabel("생성 목표").fill("인간중심 초심 훈련");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "KB 등록 후 생성" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("첨부 자료를 RAG 근거 문서로 등록하고 초안을 생성했습니다.", {
|
||||||
|
exact: false,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(sourcePayload).toMatchObject({
|
||||||
|
filename: "사례-정리.md",
|
||||||
|
source_kind: "textbook_guide",
|
||||||
|
source_note: "인간중심 초심 훈련",
|
||||||
|
title: "사례-정리.md",
|
||||||
|
});
|
||||||
|
expect(String((sourcePayload as Record<string, unknown> | null)?.text)).toContain(
|
||||||
|
"드롭한 사례 기록",
|
||||||
|
);
|
||||||
|
expect(generatePayload).toMatchObject({
|
||||||
|
source_ids: ["src-e2e-1"],
|
||||||
|
source_kind: "textbook_guide",
|
||||||
|
generation_goal: "인간중심 초심 훈련",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 인스펙터 생성 근거 패널: 요약·마스킹·경고·근거 문서·근거 청크
|
||||||
|
const inspector = page.locator(".ps-inspector");
|
||||||
|
await expect(
|
||||||
|
inspector.getByText("근거 청크 3개로 초안을 생성했습니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(inspector.getByText("이름 마스킹됨")).toBeVisible();
|
||||||
|
await expect(inspector.getByText("연락처 마스킹됨")).toBeVisible();
|
||||||
|
await expect(inspector.getByText("표면화 조건 근거가 부족합니다.")).toBeVisible();
|
||||||
|
await expect(inspector.getByText("사례-정리.md")).toBeVisible();
|
||||||
|
await expect(inspector.getByText("chunk c-1 · 0.912")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
inspector.getByText(/내담자는 발표 상황에서 심한 긴장을 보고했다\./),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// 생성 결과가 편집 폼에 반영됐는지 설정 단계에서 확인
|
||||||
|
await page
|
||||||
|
.getByRole("navigation", { name: "페르소나 작성 단계" })
|
||||||
|
.getByRole("button", { name: /설정/ })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByLabel("표시 이름")).toHaveValue("생성된 페르소나");
|
||||||
|
await expect(page.getByLabel("코드")).toHaveValue("P5");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-save-progress, persona-studio-loading-busy-states
|
||||||
|
test("saves progress locally first and to the server with busy labels", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [] });
|
||||||
|
|
||||||
|
let draftPosted = false;
|
||||||
|
await page.route("**/api/personas/drafts", async (route) => {
|
||||||
|
if (route.request().method() !== "POST") return route.fallback();
|
||||||
|
draftPosted = true;
|
||||||
|
const payload = route.request().postDataJSON() as { display_name: string };
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
persona_id: "00000000-0000-0000-0000-000000000899",
|
||||||
|
code: "P1",
|
||||||
|
version: 1,
|
||||||
|
status: "draft",
|
||||||
|
display_name: payload.display_name,
|
||||||
|
difficulty: "moderate",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
source_provenance: "clinical draft",
|
||||||
|
is_synthetic: true,
|
||||||
|
created_at: "2026-07-27T00:00:00Z",
|
||||||
|
approved_at: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||||
|
|
||||||
|
// 최소 검증(표시 이름) 미달 → localStorage 임시 저장만 수행
|
||||||
|
await page.getByRole("button", { name: "중간 저장" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("작성 중 내용을 이 브라우저에 임시 저장했습니다.", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(draftPosted).toBe(false);
|
||||||
|
const stored = await page.evaluate(() =>
|
||||||
|
window.localStorage.getItem("vignette:persona-studio:progress:v1"),
|
||||||
|
);
|
||||||
|
expect(stored).not.toBeNull();
|
||||||
|
|
||||||
|
// 표시 이름을 채우면 서버 초안 생성까지 수행하고, 진행 중엔 버튼이 비활성 라벨로 바뀐다.
|
||||||
|
await page.getByLabel("표시 이름").fill("임시 저장 페르소나");
|
||||||
|
await page.getByRole("button", { name: "중간 저장" }).click();
|
||||||
|
const savingButton = page.getByRole("button", { name: "저장 중" });
|
||||||
|
await expect(savingButton).toBeVisible();
|
||||||
|
await expect(savingButton).toBeDisabled();
|
||||||
|
await expect(page.getByText("P1 v1 초안을 저장했습니다.")).toBeVisible();
|
||||||
|
expect(draftPosted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-load-error-banner, persona-studio-refresh-button,
|
||||||
|
// persona-studio-dashboard-degraded-note
|
||||||
|
test("surfaces catalog load failure and recovers via refresh with a degraded note", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
|
||||||
|
// 1) 카탈로그 로드 실패 → role=alert 오류 배너
|
||||||
|
await page.route("**/api/personas", (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "catalog unavailable" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/personas/review", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "review unavailable" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/teacher/dashboard", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "dashboard unavailable" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/teach/personas");
|
||||||
|
const errorBanner = page.locator(".ps-error");
|
||||||
|
await expect(errorBanner).toBeVisible();
|
||||||
|
await expect(errorBanner).toHaveAttribute("role", "alert");
|
||||||
|
await expect(errorBanner).toContainText("catalog unavailable");
|
||||||
|
|
||||||
|
// 2) 카탈로그·검수 큐는 복구, 대시보드만 계속 실패 → 새로고침
|
||||||
|
await page.unroute("**/api/personas");
|
||||||
|
await page.unroute("**/api/personas/review");
|
||||||
|
await page.route("**/api/personas", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/personas/review", (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshButton = page.locator(".ps-head__actions button").first();
|
||||||
|
await expect(refreshButton).toHaveText(/새로고침/);
|
||||||
|
await refreshButton.click();
|
||||||
|
|
||||||
|
// 로딩 중에는 '확인 중' 라벨로 비활성화된다.
|
||||||
|
await expect(refreshButton).toHaveText(/확인 중/);
|
||||||
|
await expect(refreshButton).toBeDisabled();
|
||||||
|
|
||||||
|
// 카탈로그가 복구되면 배너는 사라지고, 대시보드 실패는 경고 노트로만 남는다.
|
||||||
|
await expect(errorBanner).toHaveCount(0);
|
||||||
|
const degradedNote = page.locator(".ps-inline-note.is-warn");
|
||||||
|
await expect(degradedNote).toBeVisible();
|
||||||
|
await expect(degradedNote).toHaveAttribute("role", "status");
|
||||||
|
await expect(degradedNote).toContainText("학습 지표를 불러오지 못했습니다.");
|
||||||
|
await expect(degradedNote).toContainText(
|
||||||
|
"카탈로그와 저작 기능은 계속 사용할 수 있습니다.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: persona-studio-detail-draft-fetch, persona-studio-detail-notfound
|
||||||
|
test("fetches draft detail for a review draft and shows the not-found state", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [], reviews: [draftP13] });
|
||||||
|
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(draftDetail(draftP13)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/teach/personas?view=personas&persona=draft:${DRAFT_ID}`);
|
||||||
|
await expect(page.getByRole("heading", { name: "페르소나 상세" })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("발표 상황에서 심한 불안을 호소합니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// 초안 상세 자동 조회 결과: 출처·핵심 신념·민감 영역
|
||||||
|
await expect(page.getByText("교수 상담 기록 2024")).toBeVisible();
|
||||||
|
await expect(page.getByText("나는 부족하다")).toBeVisible();
|
||||||
|
await expect(page.getByText("평가, 비교")).toBeVisible();
|
||||||
|
|
||||||
|
// 목록에 없는 키로 진입하면 미존재 빈 상태를 표시한다.
|
||||||
|
await page.goto("/teach/personas?view=personas&persona=approved:NOPE");
|
||||||
|
await expect(page.getByText("페르소나를 찾지 못했습니다")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
823
apps/web/e2e/full-sweep-professor.spec.ts
Normal file
823
apps/web/e2e/full-sweep-professor.spec.ts
Normal file
|
|
@ -0,0 +1,823 @@
|
||||||
|
/**
|
||||||
|
* full-sweep-professor.spec.ts — 2026-07-27 E2E 전수 순회에서 "신규 spec 필요"로
|
||||||
|
* 표기된 교수자 콘솔(/teach, /teach/analysis) 체크리스트 항목 검증.
|
||||||
|
* (docs/ops/e2e-full-sweep-2026-07-27.md · "## 5. 교수자 콘솔 (professor)")
|
||||||
|
*
|
||||||
|
* 커버 항목:
|
||||||
|
* - professor-guard-pending-approval / professor-guard-onboarding
|
||||||
|
* - professor-dashboard-error-banner / professor-header-refresh / professor-kpi-cards
|
||||||
|
* - professor-persona-review-error-retry / professor-persona-review-empty-loading
|
||||||
|
* - professor-analysis-sort-segment / professor-analysis-table-states
|
||||||
|
* - professor-analysis-back-to-list / professor-analysis-invalid-learner-redirect
|
||||||
|
* - professor-analysis-trend-chart / professor-analysis-stage-breakdown
|
||||||
|
*
|
||||||
|
* UI 상태 고정은 page.route fixture로 처리하고, 실 API로 검증 가능한 가드류는
|
||||||
|
* dev-login 실 세션을 사용한다. AI 엔진 턴 생성은 하지 않는다.
|
||||||
|
*/
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { signInAsTeacher } from "./support";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authMeFixture(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
user_id: "00000000-0000-0000-0000-000000000901",
|
||||||
|
email: "teacher@hs.ac.kr",
|
||||||
|
role: "teacher",
|
||||||
|
display_name: "E2E Teacher",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: 1782820000,
|
||||||
|
onboarding_completed_at: 1782820001,
|
||||||
|
nickname: "E2E Teacher",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboardFixture(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
source: "database",
|
||||||
|
cohort_label: "E2E cohort",
|
||||||
|
total_learners: 0,
|
||||||
|
active_sessions: 0,
|
||||||
|
ended_sessions: 0,
|
||||||
|
learner_growth: [],
|
||||||
|
safety_alerts: [],
|
||||||
|
pending_reviews: [],
|
||||||
|
recent_sessions: [],
|
||||||
|
message: "검토할 실제 회기가 없습니다.",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingReviewFixture(index: number) {
|
||||||
|
const padded = String(index).padStart(2, "0");
|
||||||
|
return {
|
||||||
|
session_id: `professor-sweep-pending-${padded}`,
|
||||||
|
learner_id: `00000000-0000-0000-0000-0000000001${padded}`,
|
||||||
|
learner_label: `검토 학습자 ${padded}`,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "서연",
|
||||||
|
session_no: index,
|
||||||
|
status: "ended",
|
||||||
|
stage: "정리",
|
||||||
|
turn_count: 6 + index,
|
||||||
|
learner_turn_count: 3,
|
||||||
|
client_turn_count: 3,
|
||||||
|
started_at: `2026-07-2${index}T07:00:00Z`,
|
||||||
|
ended_at: `2026-07-2${index}T07:32:00Z`,
|
||||||
|
review_status: "pending",
|
||||||
|
review_note: null,
|
||||||
|
reviewed_at: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentSessionFixture(index: number) {
|
||||||
|
const padded = String(index).padStart(2, "0");
|
||||||
|
return {
|
||||||
|
session_id: `professor-sweep-recent-${padded}`,
|
||||||
|
learner_id: `00000000-0000-0000-0000-0000000002${padded}`,
|
||||||
|
learner_label: `최근 학습자 ${padded}`,
|
||||||
|
persona_code: "P2",
|
||||||
|
persona_name: "민재",
|
||||||
|
session_no: index,
|
||||||
|
status: index % 2 === 0 ? "active" : "ended",
|
||||||
|
stage: "탐색",
|
||||||
|
turn_count: 4 + index,
|
||||||
|
learner_turn_count: 2 + index,
|
||||||
|
client_turn_count: 2,
|
||||||
|
started_at: `2026-07-2${index}T08:00:00Z`,
|
||||||
|
ended_at: index % 2 === 0 ? null : `2026-07-2${index}T08:40:00Z`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safetyAlertFixture(index: number) {
|
||||||
|
return {
|
||||||
|
id: `professor-sweep-alert-${index}`,
|
||||||
|
session_id: `professor-sweep-crisis-${index}`,
|
||||||
|
learner_id: `00000000-0000-0000-0000-0000000003${String(index).padStart(2, "0")}`,
|
||||||
|
learner_label: `위기 학습자 ${index}`,
|
||||||
|
persona_code: "P1",
|
||||||
|
ko_risk_level: "높음",
|
||||||
|
created_at: "2026-07-26T10:00:00Z",
|
||||||
|
resource_title: "자살예방 상담전화",
|
||||||
|
resource_number: "109",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewPersonaFixture(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
persona_id: "00000000-0000-0000-0000-000000009911",
|
||||||
|
code: "P13",
|
||||||
|
version: 1,
|
||||||
|
status: "review",
|
||||||
|
display_name: "전수 검수 대기 페르소나",
|
||||||
|
difficulty: "moderate",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
source_provenance: "full-sweep fixture",
|
||||||
|
is_synthetic: true,
|
||||||
|
created_at: "2026-07-25T07:00:00Z",
|
||||||
|
approved_at: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function growthLearnerFixture(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
overrides: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
learner_id: id,
|
||||||
|
learner_label: label,
|
||||||
|
sessions: 1,
|
||||||
|
ended_sessions: 1,
|
||||||
|
latest_at: "2026-07-01T09:00:00Z",
|
||||||
|
first_score: 0.4,
|
||||||
|
latest_score: 0.5,
|
||||||
|
score_delta: 0.1,
|
||||||
|
avg_score: 0.45,
|
||||||
|
avg_rapport: 0.2,
|
||||||
|
trend: "flat",
|
||||||
|
top_techniques: ["reflection"],
|
||||||
|
points: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function growthPointFixture(
|
||||||
|
learnerId: string,
|
||||||
|
index: number,
|
||||||
|
score: number,
|
||||||
|
rapport: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
session_id: `${learnerId}-session-${index}`,
|
||||||
|
session_no: index,
|
||||||
|
persona_code: "P1",
|
||||||
|
stage: index > 1 ? "개입" : "탐색",
|
||||||
|
started_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
|
||||||
|
ended_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
|
||||||
|
score,
|
||||||
|
rapport,
|
||||||
|
technique_count: index + 1,
|
||||||
|
watch_count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function analysisSessionFixture(learnerId: string, label: string, index: number) {
|
||||||
|
return {
|
||||||
|
session_id: `${learnerId}-session-${index}`,
|
||||||
|
learner_id: learnerId,
|
||||||
|
learner_label: label,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "서연",
|
||||||
|
session_no: index,
|
||||||
|
status: "ended",
|
||||||
|
stage: index > 1 ? "개입" : "탐색",
|
||||||
|
turn_count: 8 + index,
|
||||||
|
learner_turn_count: 4 + index,
|
||||||
|
client_turn_count: 4,
|
||||||
|
started_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
|
||||||
|
ended_at: `2026-07-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
|
||||||
|
review_status: "pending",
|
||||||
|
review_note: null,
|
||||||
|
reviewed_at: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function learnerAnalysisFixture(learnerId: string, label: string) {
|
||||||
|
const points = [
|
||||||
|
growthPointFixture(learnerId, 1, 0.5, 0.2),
|
||||||
|
growthPointFixture(learnerId, 2, 0.8, 0.4),
|
||||||
|
];
|
||||||
|
const sessions = [
|
||||||
|
analysisSessionFixture(learnerId, label, 1),
|
||||||
|
analysisSessionFixture(learnerId, label, 2),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
source: "database",
|
||||||
|
learner_id: learnerId,
|
||||||
|
learner_label: label,
|
||||||
|
total_sessions: sessions.length,
|
||||||
|
active_sessions: 0,
|
||||||
|
ended_sessions: sessions.length,
|
||||||
|
pending_reviews: sessions.length,
|
||||||
|
closed_reviews: 0,
|
||||||
|
summary: {
|
||||||
|
learner_id: learnerId,
|
||||||
|
learner_label: label,
|
||||||
|
sessions: sessions.length,
|
||||||
|
ended_sessions: sessions.length,
|
||||||
|
latest_at: "2026-07-12T09:30:00Z",
|
||||||
|
first_score: 0.5,
|
||||||
|
latest_score: 0.8,
|
||||||
|
score_delta: 0.3,
|
||||||
|
avg_score: 0.65,
|
||||||
|
avg_rapport: 0.3,
|
||||||
|
trend: "up",
|
||||||
|
top_techniques: ["reflection", "open question"],
|
||||||
|
points,
|
||||||
|
},
|
||||||
|
points,
|
||||||
|
stage_breakdown: [
|
||||||
|
{ stage: "라포", sessions: 0, turns: 0 },
|
||||||
|
{ stage: "탐색", sessions: 1, turns: 34 },
|
||||||
|
{ stage: "개입", sessions: 1, turns: 42 },
|
||||||
|
{ stage: "정리", sessions: 0, turns: 0 },
|
||||||
|
],
|
||||||
|
sessions,
|
||||||
|
message: `${label} 전체 회기 ${sessions.length}건`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillJson(body: unknown) {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeEmptyConsole(page: Page, dashboard?: Record<string, unknown>) {
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson(dashboardFixture(dashboard))),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("professor console full-sweep", () => {
|
||||||
|
// checklist: professor-guard-pending-approval
|
||||||
|
test("redirects an unapproved teacher to /pending and returns an approved teacher from /pending", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let accountStatus = "pending";
|
||||||
|
await page.route("**/api/auth/me", async (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
await fulfillJson(authMeFixture({ account_status: accountStatus })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await routeEmptyConsole(page);
|
||||||
|
|
||||||
|
await page.goto("/teach");
|
||||||
|
await expect(page).toHaveURL(/\/pending$/);
|
||||||
|
await expect(page.getByText("승인 대기", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator(".pf-root")).toHaveCount(0);
|
||||||
|
|
||||||
|
accountStatus = "approved";
|
||||||
|
await page.goto("/pending");
|
||||||
|
await expect(page).toHaveURL(/\/teach$/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "검토할 실제 회기가 없습니다." }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-guard-onboarding
|
||||||
|
test("redirects a teacher without onboarding from /teach to /onboarding", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const res = await page.request.post("/api/auth/dev-login", {
|
||||||
|
data: {
|
||||||
|
email: `professor.sweep.onboarding.${Date.now()}@hs.ac.kr`,
|
||||||
|
role: "teacher",
|
||||||
|
display_name: "E2E Sweep Teacher",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok(), await res.text()).toBeTruthy();
|
||||||
|
|
||||||
|
await page.goto("/teach");
|
||||||
|
await expect(page).toHaveURL(/\/onboarding$/);
|
||||||
|
await expect(page.locator(".pf-root")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-dashboard-error-banner
|
||||||
|
test("shows a role=alert error banner when the dashboard load fails", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.route("**/api/teacher/dashboard", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "주입된 대시보드 오류" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/teach");
|
||||||
|
const banner = page.locator("section.pf-error");
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toHaveAttribute("role", "alert");
|
||||||
|
await expect(banner).toContainText("API 500");
|
||||||
|
await expect(banner).toContainText("주입된 대시보드 오류");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-header-refresh
|
||||||
|
test("refresh button refetches dashboard and persona review queue and disables while refreshing", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// React StrictMode(dev)는 마운트 effect를 두 번 실행하므로 초기 호출 수는
|
||||||
|
// 정확히 1이 아닐 수 있다. 절대 카운트 대신 "새로고침 클릭이 추가 호출을
|
||||||
|
// 발생시킨다"를 기준선 대비 증가로 검증한다.
|
||||||
|
let dashboardCalls = 0;
|
||||||
|
let reviewCalls = 0;
|
||||||
|
let holdDashboard = false;
|
||||||
|
const dashboardGate = deferred();
|
||||||
|
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) => {
|
||||||
|
dashboardCalls += 1;
|
||||||
|
if (holdDashboard) await dashboardGate.promise;
|
||||||
|
await route.fulfill(await fulfillJson(dashboardFixture()));
|
||||||
|
});
|
||||||
|
await page.route("**/api/personas/review", async (route) => {
|
||||||
|
reviewCalls += 1;
|
||||||
|
await route.fulfill(await fulfillJson([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach");
|
||||||
|
|
||||||
|
const refreshButton = page.getByRole("button", { name: /새로고침|확인 중/ });
|
||||||
|
await expect(refreshButton).toHaveText(/새로고침/);
|
||||||
|
await expect(refreshButton).toBeEnabled();
|
||||||
|
const baselineDashboard = dashboardCalls;
|
||||||
|
const baselineReview = reviewCalls;
|
||||||
|
|
||||||
|
holdDashboard = true;
|
||||||
|
await refreshButton.click();
|
||||||
|
await expect(refreshButton).toHaveText(/확인 중/);
|
||||||
|
await expect(refreshButton).toBeDisabled();
|
||||||
|
|
||||||
|
holdDashboard = false;
|
||||||
|
dashboardGate.resolve();
|
||||||
|
await expect(refreshButton).toHaveText(/새로고침/);
|
||||||
|
await expect(refreshButton).toBeEnabled();
|
||||||
|
await expect.poll(() => dashboardCalls).toBe(baselineDashboard + 1);
|
||||||
|
await expect.poll(() => reviewCalls).toBe(baselineReview + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-kpi-cards
|
||||||
|
test("renders six KPI cards summarizing queue, alerts, sessions, and learners", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
await fulfillJson(
|
||||||
|
dashboardFixture({
|
||||||
|
total_learners: 5,
|
||||||
|
active_sessions: 4,
|
||||||
|
ended_sessions: 6,
|
||||||
|
pending_reviews: [pendingReviewFixture(1)],
|
||||||
|
safety_alerts: [safetyAlertFixture(1), safetyAlertFixture(2)],
|
||||||
|
recent_sessions: [1, 2, 3].map(recentSessionFixture),
|
||||||
|
message: "1건의 리뷰가 대기 중입니다.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([reviewPersonaFixture()])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach");
|
||||||
|
|
||||||
|
const kpis = page.locator(".pf-kpi");
|
||||||
|
await expect(kpis).toHaveCount(6);
|
||||||
|
const expected: Array<[string, string]> = [
|
||||||
|
["검토 대기", "1"],
|
||||||
|
["위기 알림", "2"],
|
||||||
|
["페르소나 검수", "1"],
|
||||||
|
["최근 세션", "3"],
|
||||||
|
["전체 학생", "5"],
|
||||||
|
["활성 세션", "4"],
|
||||||
|
];
|
||||||
|
for (const [label, value] of expected) {
|
||||||
|
const card = kpis.filter({ has: page.getByText(label, { exact: true }) });
|
||||||
|
await expect(card).toHaveCount(1);
|
||||||
|
await expect(card.locator("b")).toHaveText(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-persona-review-error-retry
|
||||||
|
test("shows persona review error card with a retry button that reloads the queue", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// StrictMode 이중 마운트로 초기 호출이 2회일 수 있으므로 호출 횟수가 아닌
|
||||||
|
// 플래그로 실패/복구를 전환한다.
|
||||||
|
let failReviews = true;
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson(dashboardFixture())),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) => {
|
||||||
|
if (failReviews) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "review queue exploded" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill(await fulfillJson([reviewPersonaFixture()]));
|
||||||
|
});
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach");
|
||||||
|
|
||||||
|
const errorCard = page
|
||||||
|
.locator(".pf-section--persona")
|
||||||
|
.locator('[role="alert"]');
|
||||||
|
await expect(errorCard).toBeVisible();
|
||||||
|
await expect(errorCard).toContainText("검수 데이터를 불러오지 못했습니다");
|
||||||
|
|
||||||
|
failReviews = false;
|
||||||
|
await errorCard.getByRole("button", { name: "다시 시도" }).click();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-persona-review-row="true"]').filter({
|
||||||
|
hasText: "전수 검수 대기 페르소나",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(errorCard).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-persona-review-empty-loading
|
||||||
|
test("shows persona review loading state, then the empty state", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const firstReview = deferred();
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson(dashboardFixture())),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) => {
|
||||||
|
await firstReview.promise;
|
||||||
|
await route.fulfill(await fulfillJson([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach");
|
||||||
|
|
||||||
|
await expect(page.getByText("페르소나 검수 큐 확인 중")).toBeVisible();
|
||||||
|
firstReview.resolve();
|
||||||
|
await expect(page.getByText("검수 대기 페르소나 없음")).toBeVisible();
|
||||||
|
await expect(page.getByText("페르소나 검수 큐 확인 중")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-analysis-sort-segment
|
||||||
|
test("re-sorts the learner table by latest, sessions, score, and delta", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const growth = [
|
||||||
|
growthLearnerFixture("sweep-learner-a", "학습자 A", {
|
||||||
|
sessions: 5,
|
||||||
|
latest_at: "2026-07-01T09:00:00Z",
|
||||||
|
latest_score: 0.4,
|
||||||
|
score_delta: 0.1,
|
||||||
|
}),
|
||||||
|
growthLearnerFixture("sweep-learner-b", "학습자 B", {
|
||||||
|
sessions: 2,
|
||||||
|
latest_at: "2026-07-10T09:00:00Z",
|
||||||
|
latest_score: 0.9,
|
||||||
|
score_delta: -0.2,
|
||||||
|
}),
|
||||||
|
growthLearnerFixture("sweep-learner-c", "학습자 C", {
|
||||||
|
sessions: 8,
|
||||||
|
latest_at: "2026-07-05T09:00:00Z",
|
||||||
|
latest_score: 0.6,
|
||||||
|
score_delta: 0.5,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
await fulfillJson(
|
||||||
|
dashboardFixture({ total_learners: 3, learner_growth: growth }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach/analysis");
|
||||||
|
|
||||||
|
const segment = page.locator(".pf-sort-segment");
|
||||||
|
await expect(segment).toBeVisible();
|
||||||
|
const names = page.locator(
|
||||||
|
'[data-learner-overview-row="true"] .pf-learner-name-button',
|
||||||
|
);
|
||||||
|
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
|
||||||
|
|
||||||
|
await segment.getByRole("button", { name: "회기순" }).click();
|
||||||
|
await expect(names).toHaveText(["학습자 C", "학습자 A", "학습자 B"]);
|
||||||
|
|
||||||
|
await segment.getByRole("button", { name: "점수순" }).click();
|
||||||
|
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
|
||||||
|
|
||||||
|
await segment.getByRole("button", { name: "변화순" }).click();
|
||||||
|
await expect(names).toHaveText(["학습자 C", "학습자 A", "학습자 B"]);
|
||||||
|
|
||||||
|
await segment.getByRole("button", { name: "최근순" }).click();
|
||||||
|
await expect(names).toHaveText(["학습자 B", "학습자 C", "학습자 A"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-analysis-table-states
|
||||||
|
test("distinguishes learner table loading, no-search-result, and empty states", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// StrictMode 이중 마운트에서도 로딩 상태가 관측되도록, 게이트 해제 전까지
|
||||||
|
// 들어오는 모든 dashboard 요청을 잡아둔다.
|
||||||
|
const firstDashboard = deferred();
|
||||||
|
let holdDashboard = true;
|
||||||
|
let growth: unknown[] = [
|
||||||
|
growthLearnerFixture("sweep-learner-states", "상태 학습자"),
|
||||||
|
];
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) => {
|
||||||
|
if (holdDashboard) await firstDashboard.promise;
|
||||||
|
await route.fulfill(
|
||||||
|
await fulfillJson(
|
||||||
|
dashboardFixture({
|
||||||
|
total_learners: growth.length,
|
||||||
|
learner_growth: growth,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach/analysis");
|
||||||
|
|
||||||
|
// 1) 로딩 상태
|
||||||
|
await expect(page.getByText("학습자 추이 계산 중")).toBeVisible();
|
||||||
|
firstDashboard.resolve();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-learner-overview-table="true"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// 2) 검색 결과 없음
|
||||||
|
await page.getByLabel("학습자 검색").fill("존재하지-않는-학습자-zzz");
|
||||||
|
await expect(page.getByText("검색 결과 없음")).toBeVisible();
|
||||||
|
|
||||||
|
// 3) 데이터 자체가 없는 빈 상태
|
||||||
|
growth = [];
|
||||||
|
await page.getByRole("button", { name: "새로고침" }).click();
|
||||||
|
await expect(page.getByText("표시할 학습자 이력 없음")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-analysis-back-to-list
|
||||||
|
test("returns from learner detail to the full list via 전체 목록", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const learnerId = "sweep-learner-back";
|
||||||
|
const analysis = learnerAnalysisFixture(learnerId, "복귀 학습자");
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
await fulfillJson(
|
||||||
|
dashboardFixture({
|
||||||
|
total_learners: 1,
|
||||||
|
learner_growth: [
|
||||||
|
growthLearnerFixture(learnerId, "복귀 학습자", {
|
||||||
|
points: analysis.points,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson(analysis)),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto(`/teach/analysis?learner=${learnerId}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-learner-analysis-panel="true"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "전체 목록" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-learner-overview-table="true"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-learner-analysis-panel="true"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-analysis-invalid-learner-redirect
|
||||||
|
test("clears an unknown learner query and falls back to the list", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
await fulfillJson(
|
||||||
|
dashboardFixture({
|
||||||
|
total_learners: 1,
|
||||||
|
learner_growth: [
|
||||||
|
growthLearnerFixture("sweep-learner-known", "실존 학습자"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/teacher/learners/*/analysis", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "learner not found" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach/analysis?learner=sweep-learner-missing");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-learner-overview-table="true"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("실존 학습자")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-analysis-trend-chart
|
||||||
|
test("renders the trend tab bar chart with per-session tooltips", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const learnerId = "sweep-learner-trend";
|
||||||
|
const analysis = learnerAnalysisFixture(learnerId, "추이 학습자");
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
await fulfillJson(
|
||||||
|
dashboardFixture({
|
||||||
|
total_learners: 1,
|
||||||
|
learner_growth: [
|
||||||
|
growthLearnerFixture(learnerId, "추이 학습자", {
|
||||||
|
points: analysis.points,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson(analysis)),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto(`/teach/analysis?learner=${learnerId}`);
|
||||||
|
|
||||||
|
const panel = page.locator('[data-learner-analysis-panel="true"]');
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await panel.getByRole("tab", { name: /^추이/ }).click();
|
||||||
|
|
||||||
|
const bars = panel.locator(".pf-analysis-bars .pf-growth-bar");
|
||||||
|
await expect(bars).toHaveCount(2);
|
||||||
|
await expect(bars.nth(0)).toHaveAttribute("title", "P1 1회기 · 50%");
|
||||||
|
await expect(bars.nth(1)).toHaveAttribute("title", "P1 2회기 · 80%");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-analysis-virtualized-table (결정 D4 — 학생 분석 테이블 가상화)
|
||||||
|
test("virtualizes the 300-learner table while keeping scroll, search, and expand", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// 최근순 정렬이 001→300 순서가 되도록 latest_at을 1분 간격 내림차순으로 부여한다.
|
||||||
|
const growth = Array.from({ length: 300 }, (_, index) => {
|
||||||
|
const no = String(index + 1).padStart(3, "0");
|
||||||
|
const latestAt = new Date(
|
||||||
|
Date.parse("2026-07-20T09:00:00Z") - index * 60_000,
|
||||||
|
).toISOString();
|
||||||
|
return growthLearnerFixture(`sweep-virtual-${no}`, `가상화 학습자 ${no}`, {
|
||||||
|
latest_at: latestAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
await fulfillJson(
|
||||||
|
dashboardFixture({
|
||||||
|
total_learners: growth.length,
|
||||||
|
learner_growth: growth,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach/analysis");
|
||||||
|
|
||||||
|
const table = page.locator('[data-learner-overview-table="true"]');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
const rows = page.locator('[data-learner-overview-row="true"]');
|
||||||
|
await expect(
|
||||||
|
rows
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: "가상화 학습자 001", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// (a) 가상화: 300명 전부가 아니라 화면 주변 행만 DOM에 렌더된다.
|
||||||
|
const renderedRows = await rows.count();
|
||||||
|
expect(renderedRows).toBeGreaterThan(0);
|
||||||
|
expect(renderedRows).toBeLessThan(60);
|
||||||
|
|
||||||
|
// (b) 스크롤 컨테이너를 끝까지 내리면 마지막 학습자 행이 나타난다.
|
||||||
|
// 동적 행 높이 측정으로 scrollHeight가 갱신되므로 나타날 때까지 반복 스크롤한다.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
await table.evaluate((el) => {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
return rows
|
||||||
|
.getByRole("button", { name: "가상화 학습자 300", exact: true })
|
||||||
|
.count();
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await expect(
|
||||||
|
rows.getByRole("button", { name: "가상화 학습자 300", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// (c) 검색: 상위권 밖 이름을 치면 해당 행이 표시된다.
|
||||||
|
await page.getByLabel("학습자 검색").fill("가상화 학습자 250");
|
||||||
|
const filteredRow = rows.filter({ hasText: "가상화 학습자 250" });
|
||||||
|
await expect(filteredRow).toHaveCount(1);
|
||||||
|
|
||||||
|
// (d) 행 펼침 후 상세가 보인다.
|
||||||
|
await filteredRow
|
||||||
|
.getByRole("button", { name: "가상화 학습자 250 요약 펼치기" })
|
||||||
|
.click();
|
||||||
|
const expanded = page.locator('[data-learner-expanded-row="true"]');
|
||||||
|
await expect(expanded).toBeVisible();
|
||||||
|
await expect(expanded.getByText("회기별 추이")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: professor-analysis-stage-breakdown
|
||||||
|
test("renders stage breakdown cards with session and turn counts", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const learnerId = "sweep-learner-stages";
|
||||||
|
const analysis = learnerAnalysisFixture(learnerId, "단계 학습자");
|
||||||
|
await page.route("**/api/teacher/dashboard", async (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
await fulfillJson(
|
||||||
|
dashboardFixture({
|
||||||
|
total_learners: 1,
|
||||||
|
learner_growth: [
|
||||||
|
growthLearnerFixture(learnerId, "단계 학습자", {
|
||||||
|
points: analysis.points,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/teacher/learners/*/analysis", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson(analysis)),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas/review", async (route) =>
|
||||||
|
route.fulfill(await fulfillJson([])),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto(`/teach/analysis?learner=${learnerId}`);
|
||||||
|
|
||||||
|
const panel = page.locator('[data-learner-analysis-panel="true"]');
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await panel.getByRole("tab", { name: /^단계 분석/ }).click();
|
||||||
|
|
||||||
|
const stageItems = panel.locator(".pf-stage-item");
|
||||||
|
await expect(stageItems).toHaveCount(4);
|
||||||
|
const explore = stageItems.filter({ hasText: "탐색" });
|
||||||
|
await expect(explore.locator("b")).toHaveText("1");
|
||||||
|
await expect(explore.locator("small")).toHaveText("34턴");
|
||||||
|
const intervene = stageItems.filter({ hasText: "개입" });
|
||||||
|
await expect(intervene.locator("small")).toHaveText("42턴");
|
||||||
|
await expect(stageItems.filter({ hasText: "라포" })).toHaveCount(1);
|
||||||
|
await expect(stageItems.filter({ hasText: "정리" })).toHaveCount(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
555
apps/web/e2e/full-sweep-session-review.spec.ts
Normal file
555
apps/web/e2e/full-sweep-session-review.spec.ts
Normal file
|
|
@ -0,0 +1,555 @@
|
||||||
|
/**
|
||||||
|
* 전수 순회(2026-07-27) — 회기 리뷰(session-review) 영역의 "신규 spec 필요" 항목 검증.
|
||||||
|
*
|
||||||
|
* docs/ops/e2e-full-sweep-2026-07-27.md §4에서 기존 spec이 덮지 않던 기능을
|
||||||
|
* route fixture(리뷰 응답·공유·교수자 검수 PUT) + 실 dev-login 조합으로 고정해 검증한다.
|
||||||
|
* 실제 AI 엔진 턴 생성은 하지 않는다.
|
||||||
|
*
|
||||||
|
* 대상 checklist id:
|
||||||
|
* - session-review-guard-require-auth / session-review-guard-pending-onboarding
|
||||||
|
* - session-review-error-state
|
||||||
|
* - session-review-btn-audio / session-review-btn-pdf
|
||||||
|
* - session-review-btn-share / session-review-share-note
|
||||||
|
* - session-review-transcript-filter / session-review-turn-jump
|
||||||
|
* - session-review-phase-flow / session-review-rubric-display
|
||||||
|
* - session-review-good-growth-points / session-review-worksheet-evidence-jump
|
||||||
|
* - session-review-teacher-worksheet-note / session-review-teacher-worksheet-reject
|
||||||
|
*/
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
filledReviewResponse,
|
||||||
|
routeEmptySessionReview,
|
||||||
|
routeFilledSessionReview,
|
||||||
|
routePrepostMeasures,
|
||||||
|
} from "./session-review-fixture";
|
||||||
|
import { signInAsLearner, signInAsTeacher } from "./support";
|
||||||
|
|
||||||
|
/** 리뷰 상단 가로 탭 전환(기존 session-review.spec.ts 헬퍼와 동일한 계약). */
|
||||||
|
async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
|
||||||
|
const tabs = page.locator(".sr-tabs");
|
||||||
|
await tabs.waitFor({ state: "attached", timeout: 10_000 });
|
||||||
|
await tabs.locator("button", { hasText: label }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeAuthMe(
|
||||||
|
page: Page,
|
||||||
|
overrides: {
|
||||||
|
role?: "learner" | "teacher";
|
||||||
|
account_status?: string;
|
||||||
|
onboarding_completed_at?: number | null;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: "00000000-0000-0000-0000-000000000101",
|
||||||
|
email: "learner@hs.ac.kr",
|
||||||
|
role: overrides.role ?? "learner",
|
||||||
|
display_name: "E2E Learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: overrides.account_status ?? "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: 1782820000,
|
||||||
|
onboarding_completed_at:
|
||||||
|
overrides.onboarding_completed_at === undefined
|
||||||
|
? 1782820001
|
||||||
|
: overrides.onboarding_completed_at,
|
||||||
|
nickname: "E2E Learner",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("full-sweep session review", () => {
|
||||||
|
// checklist: session-review-guard-require-auth
|
||||||
|
test("redirects unauthenticated and role-mismatched users away from review routes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// 미인증 → /login (state.from 회수용 리다이렉트).
|
||||||
|
await page.goto("/learn/session/guard-check/review");
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
|
||||||
|
// learner가 교수자 리뷰 경로에 오면 자기 역할 홈(/learn)으로 replace.
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await page.goto("/teach/session/guard-check/review");
|
||||||
|
await expect(page).toHaveURL(/\/learn$/);
|
||||||
|
|
||||||
|
// teacher가 학습자 리뷰 경로에 오면 자기 역할 홈(/teach)으로 replace.
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/learn/session/guard-check/review");
|
||||||
|
await expect(page).toHaveURL(/\/teach$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-guard-pending-onboarding
|
||||||
|
test("recovers pending and onboarding-incomplete users from review routes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
|
||||||
|
// 미승인 계정은 어느 리뷰 경로에서든 /pending으로 회수된다.
|
||||||
|
await routeAuthMe(page, { account_status: "pending" });
|
||||||
|
await page.goto("/learn/session/guard-check/review");
|
||||||
|
await expect(page).toHaveURL(/\/pending$/);
|
||||||
|
|
||||||
|
// 온보딩 미완료(승인됨)는 /onboarding으로 회수된다.
|
||||||
|
await page.unroute("**/api/auth/me");
|
||||||
|
await routeAuthMe(page, { onboarding_completed_at: null });
|
||||||
|
await page.goto("/learn/session/guard-check/review");
|
||||||
|
await expect(page).toHaveURL(/\/onboarding$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-error-state
|
||||||
|
test("shows the error card with the failure message when the review load fails", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
const sessionId = "review-load-error";
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "review backend down" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
|
||||||
|
await expect(page.getByText("리뷰를 표시할 수 없습니다")).toBeVisible();
|
||||||
|
await expect(page.getByText("API 500: review backend down")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-btn-audio, session-review-btn-pdf
|
||||||
|
test("opens audio and pdf export urls in a new tab only when provided", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-audio-pdf";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
const audioButton = page.getByRole("button", { name: "오디오 다시 듣기" });
|
||||||
|
const pdfButton = page.getByRole("button", { name: "PDF 내보내기" });
|
||||||
|
await expect(audioButton).toBeEnabled();
|
||||||
|
await expect(pdfButton).toBeEnabled();
|
||||||
|
|
||||||
|
const [audioPopup] = await Promise.all([
|
||||||
|
page.context().waitForEvent("page"),
|
||||||
|
audioButton.click(),
|
||||||
|
]);
|
||||||
|
await expect
|
||||||
|
.poll(() => audioPopup.url())
|
||||||
|
.toContain("/mock/reviews/filled-review-visual.mp3");
|
||||||
|
await audioPopup.close();
|
||||||
|
|
||||||
|
const [pdfPopup] = await Promise.all([
|
||||||
|
page.context().waitForEvent("page"),
|
||||||
|
pdfButton.click(),
|
||||||
|
]);
|
||||||
|
await expect
|
||||||
|
.poll(() => pdfPopup.url())
|
||||||
|
.toContain("/mock/reviews/filled-review-visual.pdf");
|
||||||
|
await pdfPopup.close();
|
||||||
|
|
||||||
|
// URL이 없는 회기에서는 두 버튼 모두 비활성화된다.
|
||||||
|
const emptyId = "full-sweep-audio-pdf-empty";
|
||||||
|
await routeEmptySessionReview(page, emptyId);
|
||||||
|
await page.goto(`/learn/session/${emptyId}/review`);
|
||||||
|
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled();
|
||||||
|
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-btn-share, session-review-share-note
|
||||||
|
test("creates a share url, flips the button label, and shows the url note", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-share-success";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
const shareUrl = "https://vignette.example.test/share/full-sweep-share-success";
|
||||||
|
let shareRequests = 0;
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/share`, async (route) => {
|
||||||
|
shareRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
shareUrl,
|
||||||
|
title: "회기 리뷰 공유",
|
||||||
|
description: "테스트 공유 링크",
|
||||||
|
imageUrl: "https://vignette.example.test/share/full-sweep.png",
|
||||||
|
createdAt: "2026-07-27T00:00:00Z",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
const shareButton = page.getByRole("button", { name: "공유 URL 복사" });
|
||||||
|
await expect(shareButton).toBeEnabled();
|
||||||
|
await shareButton.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "공유 URL 복사됨" })).toBeVisible();
|
||||||
|
const note = page.locator(".sr-share-note");
|
||||||
|
await expect(note).toBeVisible();
|
||||||
|
await expect(note).toContainText(shareUrl);
|
||||||
|
await expect(note).not.toHaveClass(/sr-share-note--error/);
|
||||||
|
expect(shareRequests).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-btn-share, session-review-share-note
|
||||||
|
test("keeps the share button usable and shows the error note when share creation fails", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-share-error";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/share`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "share backend unavailable" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
await page.getByRole("button", { name: "공유 URL 복사" }).click();
|
||||||
|
|
||||||
|
const note = page.locator(".sr-share-note");
|
||||||
|
await expect(note).toBeVisible();
|
||||||
|
await expect(note).toHaveClass(/sr-share-note--error/);
|
||||||
|
await expect(note).toContainText("API 503: share backend unavailable");
|
||||||
|
// 실패 후 라벨은 '복사됨'으로 넘어가지 않고 재시도 가능해야 한다.
|
||||||
|
await expect(page.getByRole("button", { name: "공유 URL 복사" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-transcript-filter
|
||||||
|
test("toggles the transcript between all turns and learner-only turns", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-transcript-filter";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
const filters = page.locator(".sr-tx__filters");
|
||||||
|
const allChip = filters.getByRole("button", { name: "전체" });
|
||||||
|
const mineChip = filters.getByRole("button", { name: "내 발화만" });
|
||||||
|
|
||||||
|
await expect(allChip).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(mineChip).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(page.locator(".sr-turn")).toHaveCount(6);
|
||||||
|
await expect(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
await mineChip.click();
|
||||||
|
await expect(mineChip).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(allChip).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(page.locator(".sr-turn")).toHaveCount(3);
|
||||||
|
await expect(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeHidden();
|
||||||
|
await expect(
|
||||||
|
page.getByText("스스로를 의심하게 됐던 걸까요?", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// 축어록이 없으면 두 필터 칩 모두 비활성화된다.
|
||||||
|
const emptyId = "full-sweep-transcript-filter-empty";
|
||||||
|
await routeEmptySessionReview(page, emptyId);
|
||||||
|
await page.goto(`/learn/session/${emptyId}/review`);
|
||||||
|
await expect(page.locator(".sr-tx__filters").getByRole("button", { name: "전체" })).toBeDisabled();
|
||||||
|
await expect(
|
||||||
|
page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-turn-jump
|
||||||
|
test("jumps to the referenced turn and auto-clears the learner-only filter", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-turn-jump";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
await page.locator(".sr-tx__filters").getByRole("button", { name: "내 발화만" }).click();
|
||||||
|
await expect(page.locator(".sr-turn")).toHaveCount(3);
|
||||||
|
|
||||||
|
// 개선점 '비교 장면 좁히기'는 내담자 발화 t3(12:41)로 점프한다.
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
await page.getByRole("button", { name: "12:41 발화로 이동" }).click();
|
||||||
|
|
||||||
|
await openReviewTab(page, "축어록");
|
||||||
|
// 내담자 발화 점프이므로 '내 발화만' 필터가 자동 해제되어야 한다.
|
||||||
|
await expect(
|
||||||
|
page.locator(".sr-tx__filters").getByRole("button", { name: "전체" }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
const activeTurn = page.locator(".sr-turn--active");
|
||||||
|
await expect(activeTurn).toHaveCount(1);
|
||||||
|
await expect(activeTurn).toContainText("제가 더 나쁜 사람이 될 것 같아서");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-phase-flow
|
||||||
|
test("renders the phase flow bar with proportional tooltips and its empty state", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-phase-flow";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
|
||||||
|
const phases = page.locator(".sr-phasebar__track .sr-phase");
|
||||||
|
await expect(phases).toHaveCount(4);
|
||||||
|
await expect(phases.nth(0)).toContainText("라포");
|
||||||
|
await expect(phases.nth(0)).toHaveAttribute("title", "라포 · 22%");
|
||||||
|
await expect(phases.nth(1)).toHaveAttribute("title", "탐색 · 34%");
|
||||||
|
await expect(phases.nth(2)).toHaveAttribute("title", "개입 · 26%");
|
||||||
|
await expect(phases.nth(3)).toHaveAttribute("title", "정리 · 18%");
|
||||||
|
const axis = page.locator(".sr-phasebar__axis");
|
||||||
|
await expect(axis).toContainText("0:00");
|
||||||
|
await expect(axis).toContainText("32:14");
|
||||||
|
|
||||||
|
// 단계 데이터가 없으면 빈 상태 문구를 보여준다.
|
||||||
|
const emptyId = "full-sweep-phase-flow-empty";
|
||||||
|
await routeEmptySessionReview(page, emptyId);
|
||||||
|
await page.goto(`/learn/session/${emptyId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
await expect(page.locator(".sr-card--flow")).toContainText("흐름 데이터 없음");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 결정 D7: 감정 밸런스 타임라인 — fixture 포인트로 차트 path 렌더 확인
|
||||||
|
test("renders the valence timeline chart paths from fixture points", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-valence-chart";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
|
||||||
|
const chartCard = page.locator(".sr-card--chart");
|
||||||
|
await expect(chartCard.locator(".sr-chart")).toBeVisible();
|
||||||
|
|
||||||
|
// 실선(내담자)·점선(상담자 기준선) path 둘 다 다점 곡선(d="M… C…")으로 그려진다.
|
||||||
|
const paths = chartCard.locator(".sr-chart__svg path");
|
||||||
|
await expect(paths).toHaveCount(2);
|
||||||
|
const dValues = await paths.evaluateAll((els) =>
|
||||||
|
els.map((el) => el.getAttribute("d") ?? ""),
|
||||||
|
);
|
||||||
|
for (const d of dValues) {
|
||||||
|
expect(d).toMatch(/^M/);
|
||||||
|
expect(d).toContain("C");
|
||||||
|
}
|
||||||
|
// 시간축 라벨은 fixture valenceAxis 5개를 그대로 사용한다.
|
||||||
|
await expect(chartCard.locator(".sr-chart__xaxis span")).toHaveCount(5);
|
||||||
|
|
||||||
|
// 포인트가 없으면 차트 대신 빈 상태 카드가 노출된다.
|
||||||
|
const emptyId = "full-sweep-valence-chart-empty";
|
||||||
|
await routeEmptySessionReview(page, emptyId);
|
||||||
|
await page.goto(`/learn/session/${emptyId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
await expect(page.locator(".sr-card--chart")).toContainText("감정 타임라인 대기");
|
||||||
|
await expect(page.locator(".sr-card--chart .sr-chart")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-rubric-display
|
||||||
|
test("renders rubric frequency bars with quality badges and the pending state", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-rubric";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
|
||||||
|
const rubricCard = page.locator(".sr-card--rubric");
|
||||||
|
await expect(rubricCard.locator(".sr-rubric__row")).toHaveCount(4);
|
||||||
|
const reflectRow = rubricCard.locator(".sr-rubric__row", { hasText: "감정 반영" });
|
||||||
|
await expect(reflectRow.locator(".sr-rubric__qual")).toHaveText(/적절/);
|
||||||
|
await expect(reflectRow).toContainText("정서 확인 · 6회");
|
||||||
|
const paceRow = rubricCard.locator(".sr-rubric__row", { hasText: "개입 속도" });
|
||||||
|
await expect(paceRow.locator(".sr-rubric__qual")).toHaveText(/살펴보기/);
|
||||||
|
const reflectBar = page.getByRole("progressbar", { name: "감정 반영 사용 빈도" });
|
||||||
|
await expect(reflectBar).toBeVisible();
|
||||||
|
await expect(reflectBar).toHaveAttribute("aria-valuenow", "82");
|
||||||
|
|
||||||
|
// 평가가 없으면 대기 상태를 보여준다.
|
||||||
|
const emptyId = "full-sweep-rubric-empty";
|
||||||
|
await routeEmptySessionReview(page, emptyId);
|
||||||
|
await page.goto(`/learn/session/${emptyId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
await expect(page.locator(".sr-card--rubric")).toContainText("평가 대기");
|
||||||
|
await expect(
|
||||||
|
page.locator(".sr-card--rubric").locator(".sr-rubric__row"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-good-growth-points
|
||||||
|
test("lists good moments and growth points with jump anchors capped at three", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-good-growth";
|
||||||
|
const response = filledReviewResponse(sessionId);
|
||||||
|
response.growthPoints = [
|
||||||
|
...response.growthPoints,
|
||||||
|
{ title: "세 번째 개선점", body: "표시 한도 검증용 항목 3.", jumpTo: null },
|
||||||
|
{ title: "네 번째 개선점", body: "최대 3개 초과분은 표시되지 않아야 한다.", jumpTo: null },
|
||||||
|
];
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(response),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
|
||||||
|
const goodCard = page.locator(".sr-card--good");
|
||||||
|
await expect(goodCard).toContainText("방어를 낮춘 감정 반영");
|
||||||
|
await expect(goodCard).toContainText("정서 정상화");
|
||||||
|
await expect(goodCard.getByRole("button", { name: "05:04 발화로 이동" })).toBeVisible();
|
||||||
|
await expect(goodCard.getByRole("button", { name: "16:05 발화로 이동" })).toBeVisible();
|
||||||
|
|
||||||
|
const growthCard = page.locator(".sr-card--growth");
|
||||||
|
await expect(growthCard).toContainText("개입 전 준비도 확인");
|
||||||
|
await expect(growthCard).toContainText("비교 장면 좁히기");
|
||||||
|
await expect(growthCard).toContainText("세 번째 개선점");
|
||||||
|
await expect(growthCard.locator(".sr-point")).toHaveCount(3);
|
||||||
|
await expect(growthCard.getByText("네 번째 개선점")).toHaveCount(0);
|
||||||
|
await expect(growthCard.getByRole("button", { name: "24:18 발화로 이동" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-worksheet-evidence-jump
|
||||||
|
test("jumps from a worksheet evidence quote to the transcript turn", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
const sessionId = "full-sweep-ws-evidence";
|
||||||
|
await routeFilledSessionReview(page, sessionId);
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${sessionId}/review`);
|
||||||
|
await openReviewTab(page, "워크시트");
|
||||||
|
|
||||||
|
const evidenceButton = page
|
||||||
|
.getByRole("button", { name: "내담자 근거 발화로 이동" })
|
||||||
|
.first();
|
||||||
|
await expect(evidenceButton).toContainText("다른 친구들이랑 비교할 때마다");
|
||||||
|
await evidenceButton.click();
|
||||||
|
|
||||||
|
await openReviewTab(page, "축어록");
|
||||||
|
const activeTurn = page.locator(".sr-turn--active");
|
||||||
|
await expect(activeTurn).toHaveCount(1);
|
||||||
|
await expect(activeTurn).toContainText("계속 제가 이상한 사람 같았어요.");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: session-review-teacher-worksheet-note, session-review-teacher-worksheet-reject
|
||||||
|
test("saves the teacher worksheet rejection together with the review note", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
const sessionId = "full-sweep-ws-reject";
|
||||||
|
const response = filledReviewResponse(sessionId);
|
||||||
|
if (!response.caseWorksheet) throw new Error("fixture must include a worksheet");
|
||||||
|
response.caseWorksheet.status = "saved_by_learner";
|
||||||
|
response.teacherReview = {
|
||||||
|
status: "pending",
|
||||||
|
note: "",
|
||||||
|
reviewedAt: null,
|
||||||
|
reviewerId: null,
|
||||||
|
updatedAt: null,
|
||||||
|
worksheetStatus: "pending",
|
||||||
|
worksheetNote: "",
|
||||||
|
worksheetReviewedAt: null,
|
||||||
|
};
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(response),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const worksheetNote = "핵심 정서 근거를 보강해 주세요.";
|
||||||
|
let releaseSave: (() => void) | null = null;
|
||||||
|
const saveGate = new Promise<void>((resolve) => {
|
||||||
|
releaseSave = resolve;
|
||||||
|
});
|
||||||
|
let savedBody: Record<string, unknown> | null = null;
|
||||||
|
await page.route(
|
||||||
|
`**/api/teacher/sessions/${sessionId}/review-status`,
|
||||||
|
async (route) => {
|
||||||
|
savedBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
await saveGate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "viewed",
|
||||||
|
note: String(savedBody.note ?? ""),
|
||||||
|
reviewer_id: "00000000-0000-0000-0000-000000000202",
|
||||||
|
reviewed_at: null,
|
||||||
|
updated_at: "2026-07-27 10:00",
|
||||||
|
worksheet_status: "rejected",
|
||||||
|
worksheet_note: String(savedBody.worksheet_note ?? ""),
|
||||||
|
worksheet_reviewed_at: "2026-07-27 10:00",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/teach/session/${sessionId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
|
||||||
|
const worksheetPanel = page.locator(".sr-teacher-review__worksheet");
|
||||||
|
await expect(worksheetPanel.locator(".sr-teacher-review__head--sub b")).toHaveText(
|
||||||
|
"검수 대기",
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByPlaceholder("수정요청이나 반려 사유를 간단히 남깁니다.")
|
||||||
|
.fill(worksheetNote);
|
||||||
|
|
||||||
|
const approveButton = worksheetPanel.getByRole("button", { name: "승인" });
|
||||||
|
const changesButton = worksheetPanel.getByRole("button", { name: "수정요청" });
|
||||||
|
const rejectButton = worksheetPanel.getByRole("button", { name: "반려" });
|
||||||
|
await expect(approveButton).toBeEnabled();
|
||||||
|
await expect(rejectButton).toBeEnabled();
|
||||||
|
|
||||||
|
await rejectButton.click();
|
||||||
|
|
||||||
|
// 저장 중에는 세 결정 버튼이 모두 비활성화된다(응답은 saveGate로 붙잡아 둔 상태).
|
||||||
|
await expect(worksheetPanel.getByRole("button", { name: "저장 중" })).toBeVisible();
|
||||||
|
await expect(approveButton).toBeDisabled();
|
||||||
|
await expect(changesButton).toBeDisabled();
|
||||||
|
releaseSave?.();
|
||||||
|
|
||||||
|
await expect(worksheetPanel.locator(".sr-teacher-review__head--sub b")).toHaveText(
|
||||||
|
"반려",
|
||||||
|
);
|
||||||
|
await expect(worksheetPanel).toContainText("워크시트 검수 시각 2026-07-27 10:00");
|
||||||
|
expect(savedBody).not.toBeNull();
|
||||||
|
expect(savedBody!["worksheet_status"]).toBe("rejected");
|
||||||
|
expect(savedBody!["worksheet_note"]).toBe(worksheetNote);
|
||||||
|
expect(savedBody!["status"]).toBe("viewed");
|
||||||
|
});
|
||||||
|
});
|
||||||
854
apps/web/e2e/full-sweep-session.spec.ts
Normal file
854
apps/web/e2e/full-sweep-session.spec.ts
Normal file
|
|
@ -0,0 +1,854 @@
|
||||||
|
/* =====================================================================
|
||||||
|
full-sweep-session.spec.ts — 2026-07-27 전수 순회 체크리스트 §3(상담 세션)
|
||||||
|
"검증: 신규 spec 필요" 항목 검증 스펙.
|
||||||
|
|
||||||
|
대상 checklist id:
|
||||||
|
session-guard-pending-approval, session-route-empty-param-redirect,
|
||||||
|
session-consent-checkbox, session-consent-save,
|
||||||
|
session-time-alarms, session-timebar-end-button,
|
||||||
|
session-transcript-autoscroll, session-transcript-empty-state,
|
||||||
|
session-crisis-tel-link, session-live-signal, session-coach-nudge,
|
||||||
|
session-meters-collapse, session-voice-skip, session-pause-toggle,
|
||||||
|
session-keyboard-shortcuts, session-elapsed-live-region
|
||||||
|
|
||||||
|
session-mvp.spec.ts 의 route fixture / mock WebSocket 패턴을 그대로 따르며
|
||||||
|
실제 AI 엔진 턴 생성은 하지 않는다(스트림·음성은 전부 fixture).
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { fetchAvailablePersona, signInAsLearner } from "./support";
|
||||||
|
|
||||||
|
const fixtureSessionId = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
|
||||||
|
const clientReply = "괜찮아요. 천천히 말해볼게요.";
|
||||||
|
const voiceTranscript = "요즘 잠을 잘 못 자요.";
|
||||||
|
const voiceClientReply = "말해줘서 고마워요. 조금씩 이야기해볼게요.";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__voiceSpeakFixture?: {
|
||||||
|
sent: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionFixtureOptions {
|
||||||
|
/** /auth/me 응답 덮어쓰기 (consent_at: null, account_status: "pending" 등) */
|
||||||
|
authOverrides?: Record<string, unknown>;
|
||||||
|
/** POST /auth/consent 응답 상태 (기본 200) */
|
||||||
|
consentStatus?: number;
|
||||||
|
/** POST /sessions 시작 응답의 회기 시간 계약값 */
|
||||||
|
durationLimitSeconds?: number;
|
||||||
|
warningBeforeEndSeconds?: number;
|
||||||
|
/** GET /sessions/{id} 재개 스냅샷 덮어쓰기 (status/ended_at/turns 등) */
|
||||||
|
detailOverrides?: Record<string, unknown>;
|
||||||
|
/** 스트림을 위기 안전게이트 응답으로 대체 */
|
||||||
|
crisisStream?: boolean;
|
||||||
|
/** 스트림 done 이벤트에 병합할 추가 필드 (progress 등) */
|
||||||
|
doneExtra?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeSessionFixtureApi(page: Page, options: SessionFixtureOptions = {}) {
|
||||||
|
const liveCoachRequests: unknown[] = [];
|
||||||
|
const consentRequests: unknown[] = [];
|
||||||
|
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: "00000000-0000-0000-0000-0000000fs3e1",
|
||||||
|
email: "sweep.session@hs.ac.kr",
|
||||||
|
display_name: "Sweep Learner",
|
||||||
|
role: "learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: Math.floor(Date.now() / 1000),
|
||||||
|
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||||
|
nickname: "Sweep Learner",
|
||||||
|
self_introduction: "세션 전수 순회 검증용 학습자입니다.",
|
||||||
|
avatar_url: "",
|
||||||
|
...options.authOverrides,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/auth/consent", async (route) => {
|
||||||
|
consentRequests.push(route.request().postDataJSON());
|
||||||
|
const status = options.consentStatus ?? 200;
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body:
|
||||||
|
status >= 400
|
||||||
|
? JSON.stringify({ detail: "consent persistence unavailable" })
|
||||||
|
: JSON.stringify({ accepted: true, consent_at: Math.floor(Date.now() / 1000) }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/users/me/prepost-measures**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
pilot_id: "phase3-pilot-draft",
|
||||||
|
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
||||||
|
measures: [],
|
||||||
|
complete_pre_count: 0,
|
||||||
|
complete_post_count: 0,
|
||||||
|
updated_at: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/voice/health", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ available: true, reason: null }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 텍스트 턴 뒤 TTS 사전요청 안전망 — 이 스펙의 done 은 turn_seq 를 싣지 않아
|
||||||
|
// 정상 흐름에서는 호출되지 않지만, 호출되더라도 결정론적으로 실패시킨다.
|
||||||
|
await page.route("**/api/voice/speech", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "voice synthesis disabled in fixture" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/personas", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
code: "P1",
|
||||||
|
display_name: "민서(청소년 우울)",
|
||||||
|
difficulty: "hard",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
demographics: { age_band: "10대" },
|
||||||
|
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
|
||||||
|
voice_preset: "soft-young-fem",
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/sessions", async (route) => {
|
||||||
|
if (route.request().method() !== "POST") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: fixtureSessionId,
|
||||||
|
case_id: "sweep-case-001",
|
||||||
|
session_no: 1,
|
||||||
|
stage: "라포",
|
||||||
|
effective_openness: 0.21,
|
||||||
|
recall_summary: null,
|
||||||
|
degraded: false,
|
||||||
|
goal_stages: ["라포", "탐색"],
|
||||||
|
duration_limit_seconds: options.durationLimitSeconds ?? 3600,
|
||||||
|
warning_before_end_seconds: options.warningBeforeEndSeconds ?? 600,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(`**/api/sessions/${fixtureSessionId}`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: fixtureSessionId,
|
||||||
|
case_id: "sweep-case-001",
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "민서",
|
||||||
|
session_no: 1,
|
||||||
|
status: "active",
|
||||||
|
stage: "라포",
|
||||||
|
theory_mode: "humanistic",
|
||||||
|
effective_openness: 0.21,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
ended_at: null,
|
||||||
|
review_ready: false,
|
||||||
|
turns: [],
|
||||||
|
goal_stages: ["라포", "탐색"],
|
||||||
|
progress: null,
|
||||||
|
duration_limit_seconds: options.durationLimitSeconds ?? 3600,
|
||||||
|
warning_before_end_seconds: options.warningBeforeEndSeconds ?? 600,
|
||||||
|
...options.detailOverrides,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(`**/api/sessions/${fixtureSessionId}/stream`, async (route) => {
|
||||||
|
if (options.crisisStream) {
|
||||||
|
const crisisResource = {
|
||||||
|
title: "자살예방상담전화 109",
|
||||||
|
number: "109",
|
||||||
|
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
|
||||||
|
};
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: [
|
||||||
|
"event: safety",
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
crisis_resource: crisisResource,
|
||||||
|
conversation_stopped: true,
|
||||||
|
})}`,
|
||||||
|
"",
|
||||||
|
"event: done",
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
session_id: fixtureSessionId,
|
||||||
|
stage: "라포",
|
||||||
|
effective_openness: 0.21,
|
||||||
|
turn_seq: 1,
|
||||||
|
safety_flagged: true,
|
||||||
|
crisis_resource: crisisResource,
|
||||||
|
conversation_stopped: true,
|
||||||
|
})}`,
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: [
|
||||||
|
"event: token",
|
||||||
|
`data: ${clientReply}`,
|
||||||
|
"",
|
||||||
|
"event: done",
|
||||||
|
// turn_seq 를 싣지 않아 텍스트 턴 뒤 TTS(speakClientTurn) 경로를 결정론적으로 생략한다.
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
session_id: fixtureSessionId,
|
||||||
|
stage: "탐색",
|
||||||
|
effective_openness: 0.42,
|
||||||
|
safety_flagged: false,
|
||||||
|
...options.doneExtra,
|
||||||
|
})}`,
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(`**/api/sessions/${fixtureSessionId}/live-coach`, async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
if (request.method() === "GET") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
source: "database",
|
||||||
|
quota: { remaining: 3, max: 3 },
|
||||||
|
credit_events: [],
|
||||||
|
events: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
liveCoachRequests.push(request.postDataJSON());
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: "ready",
|
||||||
|
tone: "pos",
|
||||||
|
focus: "reflection",
|
||||||
|
title: "감정 반영이 선명합니다",
|
||||||
|
message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.",
|
||||||
|
next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?",
|
||||||
|
rationale: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.",
|
||||||
|
sources: [],
|
||||||
|
safety_note: null,
|
||||||
|
latency_ms: 12,
|
||||||
|
persistence_source: "database",
|
||||||
|
quota: { remaining: 2, max: 3 },
|
||||||
|
credit_events: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(`**/api/sessions/${fixtureSessionId}/end`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: fixtureSessionId,
|
||||||
|
session_no: 1,
|
||||||
|
digest_pending: true,
|
||||||
|
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { liveCoachRequests, consentRequests };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 음성 마이크·WebSocket mock — session-mvp.spec.ts 패턴.
|
||||||
|
* audio_end 수신 시 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
495
apps/web/e2e/full-sweep-settings.spec.ts
Normal file
495
apps/web/e2e/full-sweep-settings.spec.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
/* =====================================================================
|
||||||
|
full-sweep-settings.spec.ts — 2026-07-27 E2E 전수 순회, 설정(/settings) 영역.
|
||||||
|
docs/ops/e2e-full-sweep-2026-07-27.md §9에서 "검증: 신규 spec 필요"로 표기된
|
||||||
|
체크리스트 항목을 검증한다. 각 test() 위 주석에 담당 checklist id를 적는다.
|
||||||
|
- 실 API(dev-login, P1~P7 시드)를 기본으로 쓰고, 오류·빈 상태 고정이 필요한
|
||||||
|
경우에만 page.route() fixture를 쓴다. AI 엔진 턴 생성은 호출하지 않는다.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { expect, test, type Page, type Response, type TestInfo } from "@playwright/test";
|
||||||
|
import { completeOnboarding, useRealApi } from "./support";
|
||||||
|
|
||||||
|
/** 1x1 PNG — 아바타 업로드 e2e용 최소 픽셀. */
|
||||||
|
const PNG_1X1_BASE64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
function slugFor(testInfo: TestInfo) {
|
||||||
|
let hash = 0;
|
||||||
|
for (const char of testInfo.title) {
|
||||||
|
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||||
|
}
|
||||||
|
const project = testInfo.project.name.includes("mobile") ? "mob" : "desk";
|
||||||
|
return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${hash.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApiResponse(method: string, pathnameSuffix: string) {
|
||||||
|
return (response: Response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.request().method() === method && url.pathname.endsWith(pathnameSuffix);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function devLogin(
|
||||||
|
page: Page,
|
||||||
|
role: "learner" | "admin",
|
||||||
|
testInfo: TestInfo,
|
||||||
|
displayName: string,
|
||||||
|
options: { onboarding?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr";
|
||||||
|
const email = `sweep.settings.${role}.${slugFor(testInfo)}@${domain}`;
|
||||||
|
const res = await page.request.post("/api/auth/dev-login", {
|
||||||
|
data: { email, role, display_name: displayName },
|
||||||
|
});
|
||||||
|
expect(res.ok(), await res.text()).toBeTruthy();
|
||||||
|
if (options.onboarding !== false) {
|
||||||
|
await completeOnboarding(page, {
|
||||||
|
legal_name: displayName,
|
||||||
|
affiliation: "한신대학교",
|
||||||
|
department: role === "admin" ? "운영팀" : "상담심리학과",
|
||||||
|
grade_level: role === "admin" ? "관리자" : "3학년",
|
||||||
|
phone: "010-4444-4444",
|
||||||
|
contact_address: "경기도 오산시 한신대학교",
|
||||||
|
nickname: displayName,
|
||||||
|
self_introduction: `${displayName} 설정 전수 순회 E2E 사용자입니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /settings 진입 후 프로필·환경설정이 도착해 저장 버튼들이 렌더될 때까지 대기. */
|
||||||
|
async function openLoadedSettings(page: Page) {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.locator(".vg-set")).toBeVisible();
|
||||||
|
// preferences 도착 → 알림 저장 버튼 렌더, profile 도착 → 계정 저장 버튼 활성화
|
||||||
|
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(
|
||||||
|
page.locator("#set-account .vg-set__foot .vg-btn"),
|
||||||
|
).toBeEnabled({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingMeResponse(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
user_id: "e2e-pending-user",
|
||||||
|
email: "sweep.settings.pending@hs.ac.kr",
|
||||||
|
display_name: "승인 대기 학습자",
|
||||||
|
role: "learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "pending",
|
||||||
|
approval_required: true,
|
||||||
|
avatar_url: "",
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: null,
|
||||||
|
nickname: "승인대기",
|
||||||
|
self_introduction: "",
|
||||||
|
onboarding_completed_at: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("full-sweep settings", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await useRealApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-guard-require-auth
|
||||||
|
test("redirects an unauthenticated visitor from /settings to /login", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
// 설정 화면 본문이 렌더되지 않아야 한다.
|
||||||
|
await expect(page.locator(".vg-set")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-guard-pending-approval
|
||||||
|
test("redirects a pending-approval user from /settings to /pending", async ({ page }) => {
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({ json: pendingMeResponse() });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page).toHaveURL(/\/pending$/);
|
||||||
|
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
|
||||||
|
await expect(page.locator(".pa-status")).toContainText("접속 권한 확인 중");
|
||||||
|
await expect(page.locator(".vg-set")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-guard-onboarding
|
||||||
|
test("redirects a signed-in user without onboarding from /settings to /onboarding", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Onboarding Gate", { onboarding: false });
|
||||||
|
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page).toHaveURL(/\/onboarding$/);
|
||||||
|
await expect(page.locator(".vg-set")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-load-error-callout
|
||||||
|
test("shows a role=alert warning callout when the settings load fails", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Load Error");
|
||||||
|
await page.route("**/api/users/me/preferences", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "preferences unavailable (e2e fixture)" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/settings");
|
||||||
|
const callout = page.locator(".vg-set__callout--warn");
|
||||||
|
await expect(callout).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(callout).toHaveAttribute("role", "alert");
|
||||||
|
await expect(callout.locator(".vg-set__callout-text")).not.toHaveText("");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-support-error-state
|
||||||
|
test("keeps the page usable with an inline alert when only support tickets fail", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Ticket Error");
|
||||||
|
await page.route("**/api/users/support-tickets", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "support tickets unavailable (e2e fixture)" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
// 지원 요청 섹션 안에만 role=alert 경고가 뜨고, 페이지 전체 오류 콜아웃은 없다.
|
||||||
|
const supportAlert = page.locator("#set-support .vg-set__state--warn");
|
||||||
|
await expect(supportAlert).toBeVisible();
|
||||||
|
await expect(supportAlert).toHaveAttribute("role", "alert");
|
||||||
|
await expect(page.locator(".vg-set__callout--warn")).toHaveCount(0);
|
||||||
|
// 다른 섹션은 살아 있다: 계정 저장·알림 저장 동작 가능 상태.
|
||||||
|
await expect(page.locator("#set-account .vg-set__foot .vg-btn")).toBeEnabled();
|
||||||
|
await expect(page.getByRole("button", { name: "알림 저장" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-support-empty-state
|
||||||
|
test("shows distinct support empty states for zero tickets and a null payload", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Ticket Empty");
|
||||||
|
|
||||||
|
// 1) 실 API — 방금 만든 학습자는 접수 티켓 0건.
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
const emptyState = page.getByTestId("settings-support-empty");
|
||||||
|
await expect(emptyState).toBeVisible();
|
||||||
|
await expect(emptyState).toContainText("접수한 지원 요청이 없습니다");
|
||||||
|
|
||||||
|
// 2) fixture — 저장소가 null 페이로드를 반환하면 별도 문구를 보여준다.
|
||||||
|
await page.route("**/api/users/support-tickets", async (route) => {
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", body: "null" });
|
||||||
|
});
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
await expect(emptyState).toBeVisible();
|
||||||
|
await expect(emptyState).toContainText("지원 요청 저장소에 표시할 항목이 없습니다");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-rail-nav-buttons
|
||||||
|
test("rail nav buttons scroll to their section and expose aria-current", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Rail Nav");
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const nav = page.getByRole("navigation", { name: "설정 섹션" });
|
||||||
|
// 학습자에게는 관리자 전용 AI 운영 항목이 없다(계정·지원 요청·동의·테마·알림·음성 6개).
|
||||||
|
await expect(nav.getByRole("button")).toHaveCount(6);
|
||||||
|
await expect(nav.getByRole("button", { name: "AI 운영" })).toHaveCount(0);
|
||||||
|
|
||||||
|
const accountItem = nav.getByRole("button", { name: "계정" });
|
||||||
|
const voiceItem = nav.getByRole("button", { name: "음성" });
|
||||||
|
await expect(accountItem).toHaveAttribute("aria-current", "location");
|
||||||
|
|
||||||
|
await voiceItem.click();
|
||||||
|
await expect(page.locator("#set-voice")).toBeInViewport();
|
||||||
|
await expect(voiceItem).toHaveAttribute("aria-current", "location");
|
||||||
|
await expect(accountItem).not.toHaveAttribute("aria-current", "location");
|
||||||
|
|
||||||
|
await accountItem.click();
|
||||||
|
await expect(page.locator("#set-account")).toBeInViewport();
|
||||||
|
await expect(accountItem).toHaveAttribute("aria-current", "location");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-rail-nav-scrollspy
|
||||||
|
test("scroll spy syncs the active rail item to the section scrolled into view", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Scrollspy");
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const nav = page.getByRole("navigation", { name: "설정 섹션" });
|
||||||
|
await expect(nav.getByRole("button", { name: "계정" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"location",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 클릭 없이 스크롤만으로 IntersectionObserver가 활성 섹션을 동기화해야 한다.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.getElementById("set-voice")?.scrollIntoView({ block: "start" });
|
||||||
|
});
|
||||||
|
await expect(nav.getByRole("button", { name: "음성" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"location",
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
await expect(nav.getByRole("button", { name: "계정" })).not.toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"location",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-theme-dark-toggle
|
||||||
|
test("dark mode switch flips data-theme immediately and persists to localStorage", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Theme Toggle");
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const readTheme = () =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
applied: document.documentElement.getAttribute("data-theme"),
|
||||||
|
stored: window.localStorage.getItem("vignette.theme"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const initialDark = (await readTheme()).applied === "dark";
|
||||||
|
const darkSwitch = page.locator("#set-appearance").getByRole("switch", { name: "다크 모드" });
|
||||||
|
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
|
||||||
|
|
||||||
|
await darkSwitch.click();
|
||||||
|
const flipped = !initialDark;
|
||||||
|
await expect(darkSwitch).toHaveAttribute("aria-checked", String(flipped));
|
||||||
|
await expect.poll(readTheme).toEqual({
|
||||||
|
applied: flipped ? "dark" : "light",
|
||||||
|
stored: flipped ? "dark" : "light",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 되돌리기도 즉시 반영된다.
|
||||||
|
await darkSwitch.click();
|
||||||
|
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
|
||||||
|
await expect.poll(readTheme).toEqual({
|
||||||
|
applied: initialDark ? "dark" : "light",
|
||||||
|
stored: initialDark ? "dark" : "light",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-theme-save-button, settings-saved-flash-indicator
|
||||||
|
test("theme save button PATCHes the current theme and flashes then clears 저장됨", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Theme Save");
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const appearance = page.locator("#set-appearance");
|
||||||
|
const darkSwitch = appearance.getByRole("switch", { name: "다크 모드" });
|
||||||
|
const isDark = (await darkSwitch.getAttribute("aria-checked")) === "true";
|
||||||
|
// 한 번 토글해 저장할 값을 명시적으로 만든다.
|
||||||
|
await darkSwitch.click();
|
||||||
|
const nextTheme = !isDark ? "dark" : "light";
|
||||||
|
await expect(darkSwitch).toHaveAttribute("aria-checked", String(!isDark));
|
||||||
|
|
||||||
|
const patchPromise = page.waitForResponse(
|
||||||
|
isApiResponse("PATCH", "/users/me/preferences"),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
await appearance.locator(".vg-set__foot .vg-btn").click();
|
||||||
|
const patchResponse = await patchPromise;
|
||||||
|
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
|
||||||
|
const requestBody = patchResponse.request().postDataJSON() as { theme?: string };
|
||||||
|
expect(requestBody.theme).toBe(nextTheme);
|
||||||
|
const saved = (await patchResponse.json()) as { theme: string };
|
||||||
|
expect(saved.theme).toBe(nextTheme);
|
||||||
|
|
||||||
|
// '저장됨' 플래시: 표시됐다가 2.4초 뒤 자동으로 사라진다.
|
||||||
|
const savedFlash = appearance.locator(".vg-set__saved");
|
||||||
|
await expect(savedFlash).toBeVisible();
|
||||||
|
await expect(savedFlash).toContainText("저장됨");
|
||||||
|
await expect(savedFlash).toHaveCount(0, { timeout: 6_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-voice-empty-state
|
||||||
|
test("voice section shows an empty state and hides slider/save without presets", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Voice Empty");
|
||||||
|
await page.route("**/api/users/me/voice-presets", async (route) => {
|
||||||
|
await route.fulfill({ json: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const voiceEmpty = page.getByTestId("settings-voice-empty");
|
||||||
|
await expect(voiceEmpty).toBeVisible();
|
||||||
|
await expect(voiceEmpty).toContainText("서버에 등록된 음성 프리셋이 없습니다");
|
||||||
|
await expect(page.getByLabel("말하기 속도")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "음성 저장" })).toHaveCount(0);
|
||||||
|
await expect(page.locator("#set-voice").getByRole("radio")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-topbar-theme-toggle
|
||||||
|
test("topbar theme button drives the same global store as the settings switch", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Topbar Theme");
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const darkSwitch = page.locator("#set-appearance").getByRole("switch", { name: "다크 모드" });
|
||||||
|
const initialDark =
|
||||||
|
(await page.evaluate(() => document.documentElement.getAttribute("data-theme"))) === "dark";
|
||||||
|
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
|
||||||
|
|
||||||
|
const topbarToggle = page.getByRole("button", {
|
||||||
|
name: initialDark ? "라이트 모드로" : "다크 모드로",
|
||||||
|
});
|
||||||
|
await topbarToggle.click();
|
||||||
|
|
||||||
|
const flipped = !initialDark;
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => document.documentElement.getAttribute("data-theme")))
|
||||||
|
.toBe(flipped ? "dark" : "light");
|
||||||
|
// 설정 섹션의 스위치가 같은 스토어를 구독해 함께 뒤집힌다.
|
||||||
|
await expect(darkSwitch).toHaveAttribute("aria-checked", String(flipped));
|
||||||
|
// 버튼 라벨도 반대 방향으로 갱신된다.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: flipped ? "라이트 모드로" : "다크 모드로" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-topbar-logout
|
||||||
|
test("topbar logout ends the server session and returns to /login", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Logout");
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const logoutPromise = page.waitForResponse(isApiResponse("POST", "/auth/logout"), {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "로그아웃" }).click();
|
||||||
|
const logoutResponse = await logoutPromise;
|
||||||
|
expect(logoutResponse.ok(), await logoutResponse.text()).toBeTruthy();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
// 서버 세션이 실제로 종료됐는지 확인 — 쿠키로 /auth/me 재조회 시 401.
|
||||||
|
const meAfter = await page.request.get("/api/auth/me");
|
||||||
|
expect(meAfter.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-avatar-upload-missing
|
||||||
|
// 소유자 결정 D1: 계정 섹션에서 온보딩과 같은 업로드 컨트롤로 아바타를 교체한다.
|
||||||
|
// 업로드 성공 → PATCH /users/me 저장 → 미리보기·레일 아바타가 이미지로 전환된다.
|
||||||
|
test("settings exposes an avatar upload control (briefing requirement)", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Avatar Upload");
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
// 업로드 전에는 이니셜 폴백만 렌더된다.
|
||||||
|
const account = page.locator("#set-account");
|
||||||
|
await expect(account.locator(".vg-set__avatar img")).toHaveCount(0);
|
||||||
|
|
||||||
|
const fileInput = account.locator("input[type='file']");
|
||||||
|
await expect(fileInput).toHaveAttribute("accept", "image/png,image/jpeg,image/webp");
|
||||||
|
|
||||||
|
const uploadPromise = page.waitForResponse(isApiResponse("POST", "/users/me/avatar"), {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
const patchPromise = page.waitForResponse(isApiResponse("PATCH", "/users/me"), {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: "sweep-settings-avatar.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||||
|
});
|
||||||
|
const uploadResponse = await uploadPromise;
|
||||||
|
expect(uploadResponse.ok(), await uploadResponse.text()).toBeTruthy();
|
||||||
|
const uploaded = (await uploadResponse.json()) as { avatar_url: string };
|
||||||
|
expect(uploaded.avatar_url.length).toBeGreaterThan(0);
|
||||||
|
// 업로드 성공 직후 avatar_url 이 프로필 PATCH 로 저장된다.
|
||||||
|
const patchResponse = await patchPromise;
|
||||||
|
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
|
||||||
|
|
||||||
|
// 계정 요약 미리보기가 업로드된 이미지를 가리키고, 레일 아바타도 이미지로 전환된다.
|
||||||
|
const preview = account.locator(".vg-set__avatar img");
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
expect(await preview.getAttribute("src")).toContain(uploaded.avatar_url);
|
||||||
|
await expect(page.locator(".vg-set__rail-avatar img")).toHaveCount(1);
|
||||||
|
|
||||||
|
// 서버 프로필 재조회로 avatar_url 영속 저장을 확인한다.
|
||||||
|
const meRes = await page.request.get("/api/users/me");
|
||||||
|
expect(meRes.ok(), await meRes.text()).toBeTruthy();
|
||||||
|
const me = (await meRes.json()) as { avatar_url: string };
|
||||||
|
expect(me.avatar_url).toBe(uploaded.avatar_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: settings-consent-management-missing
|
||||||
|
// 소유자 결정 D2: 동의 섹션 — 실습 동의 상태·약관 버전 표시, learner 철회(2단계 확인)
|
||||||
|
// → 차단 안내 → 재동의 복원까지 전체 흐름을 검증한다.
|
||||||
|
test("settings exposes consent management and withdrawal UI (briefing requirement)", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
// 주의: 공유 learner@hs.ac.kr 의 동의 상태를 파괴하지 않도록 반드시 테스트가
|
||||||
|
// 새로 만든 고유 dev-login 계정으로만 철회·재동의를 수행한다.
|
||||||
|
await devLogin(page, "learner", testInfo, "Sweep Consent Mgmt");
|
||||||
|
const consentSeed = await page.request.post("/api/auth/consent", {
|
||||||
|
data: { accepted: true },
|
||||||
|
});
|
||||||
|
expect(consentSeed.ok(), await consentSeed.text()).toBeTruthy();
|
||||||
|
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
// 레일 내비에 동의 항목이 있고 클릭하면 섹션으로 스크롤된다.
|
||||||
|
const nav = page.getByRole("navigation", { name: "설정 섹션" });
|
||||||
|
await nav.getByRole("button", { name: "동의" }).click();
|
||||||
|
const consent = page.locator("#set-consent");
|
||||||
|
await expect(consent).toBeInViewport();
|
||||||
|
|
||||||
|
// 동의됨 상태: 실습 동의 시각 + 약관·개인정보 동의 버전이 표시된다.
|
||||||
|
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
|
||||||
|
await expect(consent.getByText("이용약관")).toBeVisible();
|
||||||
|
await expect(consent.getByText("개인정보 처리방침")).toBeVisible();
|
||||||
|
|
||||||
|
// 철회는 2단계: 첫 클릭은 인라인 확인만 띄우고, 확정 클릭에서만 DELETE 가 나간다.
|
||||||
|
await consent.getByRole("button", { name: "실습 동의 철회" }).click();
|
||||||
|
const confirmButton = consent.getByRole("button", { name: "철회 확정" });
|
||||||
|
await expect(confirmButton).toBeVisible();
|
||||||
|
const withdrawPromise = page.waitForResponse(isApiResponse("DELETE", "/auth/consent"), {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await confirmButton.click();
|
||||||
|
const withdrawResponse = await withdrawPromise;
|
||||||
|
expect(withdrawResponse.ok(), await withdrawResponse.text()).toBeTruthy();
|
||||||
|
|
||||||
|
// 차단 안내가 뜨고 서버 consent_at 이 null 로 비워진다.
|
||||||
|
await expect(
|
||||||
|
consent.getByText("새 회기 시작과 음성 연습이 차단됩니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
const meAfterWithdraw = await page.request.get("/api/auth/me");
|
||||||
|
expect(meAfterWithdraw.ok(), await meAfterWithdraw.text()).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
((await meAfterWithdraw.json()) as { consent_at: number | null }).consent_at,
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// 다시 동의 → consent_at 복원 + 화면도 동의 시각 표기로 되돌아간다.
|
||||||
|
const reacceptPromise = page.waitForResponse(isApiResponse("POST", "/auth/consent"), {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await consent.getByRole("button", { name: "다시 동의" }).click();
|
||||||
|
const reacceptResponse = await reacceptPromise;
|
||||||
|
expect(reacceptResponse.ok(), await reacceptResponse.text()).toBeTruthy();
|
||||||
|
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
|
||||||
|
const meAfterAccept = await page.request.get("/api/auth/me");
|
||||||
|
expect(meAfterAccept.ok(), await meAfterAccept.text()).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
((await meAfterAccept.json()) as { consent_at: number | null }).consent_at,
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
269
apps/web/e2e/full-sweep-shell.spec.ts
Normal file
269
apps/web/e2e/full-sweep-shell.spec.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
/* =====================================================================
|
||||||
|
full-sweep-shell.spec.ts — 2026-07-27 전수 순회 체크리스트 §10(shell) 중
|
||||||
|
"신규 spec 필요" 항목 검증.
|
||||||
|
대상: 톱바 브랜드 링크·로그아웃, body[data-role] 역할 테마,
|
||||||
|
인증 부트스트랩 BootScreen, lazy 라우트 Suspense fallback,
|
||||||
|
PendingApprovalGate, 미정의 경로 리다이렉트, /dev/avatar-preview.
|
||||||
|
실 API(dev-login)를 기본으로 쓰고, 상태 고정이 필요한 곳만
|
||||||
|
page.route fixture(/auth/me·/auth/logout·lazy chunk)를 쓴다.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { signInAsAdmin, signInAsLearner, signInAsTeacher, useRealApi } from "./support";
|
||||||
|
|
||||||
|
const BOOT_SCREEN_TEXT = "불러오는 중…";
|
||||||
|
|
||||||
|
function meResponse(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
user_id: "shell-e2e-user",
|
||||||
|
email: "shell.e2e@hs.ac.kr",
|
||||||
|
display_name: "Shell E2E",
|
||||||
|
role: "learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: 1,
|
||||||
|
onboarding_completed_at: 1,
|
||||||
|
nickname: "Shell E2E",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockMe(page: Page, overrides: Record<string, unknown> = {}) {
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(meResponse(overrides)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("full sweep — shared shell, GNB, and routing guards", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await useRealApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-topbar-brand-link
|
||||||
|
// 로그인 상태에서 워드마크는 루트(/)로 링크되고, 클릭 시 RootRedirect가
|
||||||
|
// 역할 홈(/learn)으로 재분기한다. (미로그인 상태의 톱바는 정상 UI 흐름상
|
||||||
|
// 도달 불가 — 모든 셸 페이지가 RequireAuth 뒤에 있음 — 링크 목적지 규칙만 검증.)
|
||||||
|
test("routes the topbar brand link back to the role home through the root redirect", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const brand = page.locator(".vg-topbar__brand");
|
||||||
|
await expect(brand).toBeVisible();
|
||||||
|
await expect(brand).toHaveAttribute("href", "/");
|
||||||
|
await expect(brand).toContainText("Vignette");
|
||||||
|
|
||||||
|
await brand.click();
|
||||||
|
|
||||||
|
// "/" 진입 → RootRedirect가 학습자 홈으로 replace 이동
|
||||||
|
await expect(page).toHaveURL(/\/learn$/);
|
||||||
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-topbar-logout
|
||||||
|
// 로그아웃 버튼이 서버 세션을 실제로 무효화하고 /login으로 이동시킨다.
|
||||||
|
test("logs out from the topbar and invalidates the server session", async ({ page }) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "로그아웃" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
|
||||||
|
|
||||||
|
// 서버 세션도 무효화되었는지 실 API로 확인
|
||||||
|
const me = await page.request.get("/api/auth/me");
|
||||||
|
expect(me.status()).toBe(401);
|
||||||
|
|
||||||
|
// 보호 라우트 재진입 시 로그인으로 회수
|
||||||
|
await page.goto("/learn");
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-topbar-logout
|
||||||
|
// 로그아웃 API가 실패해도 로컬 사용자 상태를 비워 /login으로 빠져나온다.
|
||||||
|
test("still exits to the login screen when the logout API fails", async ({ page }) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
let logoutCalled = false;
|
||||||
|
await page.route("**/api/auth/logout", async (route) => {
|
||||||
|
logoutCalled = true;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "logout unavailable" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "로그아웃" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
|
||||||
|
expect(logoutCalled).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-appshell-role-theming
|
||||||
|
// 역할별 셸 진입 시 body[data-role]이 learner/instructor/admin으로 설정된다.
|
||||||
|
test("applies body[data-role] theming for each role shell", async ({ page }) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await page.goto("/learn");
|
||||||
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach");
|
||||||
|
await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator("body")).toHaveAttribute("data-role", "instructor");
|
||||||
|
|
||||||
|
await signInAsAdmin(page);
|
||||||
|
await page.goto("/admin");
|
||||||
|
await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator("body")).toHaveAttribute("data-role", "admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-auth-bootstrap-loading
|
||||||
|
// 세션 부트스트랩(/auth/me)이 끝나기 전에는 가드가 중립 BootScreen을 표시하고,
|
||||||
|
// 401 확정 후에야 /login으로 이동한다.
|
||||||
|
test("shows the neutral boot screen while the session bootstrap is pending", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let releaseMe!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
releaseMe = resolve;
|
||||||
|
});
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "not authenticated" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/learn");
|
||||||
|
|
||||||
|
await expect(page.getByText(BOOT_SCREEN_TEXT)).toBeVisible();
|
||||||
|
// 부트스트랩이 끝나지 않는 동안은 로그인으로 이동하지 않는다
|
||||||
|
await expect(page).toHaveURL(/\/learn$/);
|
||||||
|
|
||||||
|
releaseMe();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-suspense-fallback
|
||||||
|
// 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안
|
||||||
|
// Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현.
|
||||||
|
test("shows the suspense fallback while a lazy route chunk is loading", async ({ page }) => {
|
||||||
|
let releaseChunk!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
releaseChunk = resolve;
|
||||||
|
});
|
||||||
|
await page.route("**/src/pages/Login.tsx*", async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
const meResolved = page.waitForResponse(
|
||||||
|
(res) => res.url().includes("/api/auth/me"),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await page.goto("/login", { waitUntil: "domcontentloaded" });
|
||||||
|
// 인증 부트스트랩이 끝난 뒤에도(가드 BootScreen 종료) 청크가 지연되는 동안
|
||||||
|
// Suspense fallback이 같은 중립 화면을 유지해야 한다.
|
||||||
|
await meResolved;
|
||||||
|
// React 18 Suspense는 재-서스펜드 시 기존 콘텐츠를 display:none으로 숨긴 채
|
||||||
|
// fallback을 추가하므로 '보이는' BootScreen 하나를 기준으로 검증한다.
|
||||||
|
await expect(page.getByText(BOOT_SCREEN_TEXT).filter({ visible: true })).toHaveCount(1);
|
||||||
|
|
||||||
|
releaseChunk();
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-guard-pending-approval
|
||||||
|
// 미승인(account_status=pending) 사용자는 어떤 보호 경로에서든 /pending으로 회수된다.
|
||||||
|
test("redirects a pending-approval user to /pending from any protected route", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockMe(page, { account_status: "pending" });
|
||||||
|
|
||||||
|
await page.goto("/learn");
|
||||||
|
await expect(page).toHaveURL(/\/pending$/);
|
||||||
|
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
|
||||||
|
await expect(page.getByText("shell.e2e@hs.ac.kr")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "승인 상태 새로고침" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page).toHaveURL(/\/pending$/);
|
||||||
|
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-guard-pending-approval
|
||||||
|
// 승인된 사용자가 /pending에 직접 오면 초기 경로(역할 홈)로 되돌린다.
|
||||||
|
test("returns an approved user visiting /pending to their role home", async ({ page }) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
|
||||||
|
await page.goto("/pending");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/learn$/);
|
||||||
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-unknown-route-redirect
|
||||||
|
// 미정의 경로는 404 화면 없이 /로 replace되어 미인증은 /login,
|
||||||
|
// 인증 학습자는 /learn으로 재분기된다.
|
||||||
|
test("redirects unknown routes to the role-aware root destination", async ({ page }) => {
|
||||||
|
await page.goto("/definitely/not-a-route");
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
|
||||||
|
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await page.goto("/no-such-page");
|
||||||
|
await expect(page).toHaveURL(/\/learn$/);
|
||||||
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// checklist: shell-avatar-preview-dev-page
|
||||||
|
// /dev/avatar-preview는 인증 없이 접근 가능한 dev 페이지로
|
||||||
|
// 4개 상태 애니메이션 히어로와 대표 표정 12종 그리드를 표시한다.
|
||||||
|
test("serves the seoyeon avatar dev preview without authentication", async ({ page }) => {
|
||||||
|
await page.goto("/dev/avatar-preview");
|
||||||
|
|
||||||
|
// 인증 가드에 걸리지 않고 그대로 렌더된다
|
||||||
|
await expect(page).toHaveURL(/\/dev\/avatar-preview$/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "서연 아바타 미리보기 (SVG 파라미터 리그)" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator(".ap")).toHaveAttribute("data-avatar-rig", "svg-parameter");
|
||||||
|
|
||||||
|
// 4개 상태(speaking/idle/listening/thinking) 히어로
|
||||||
|
await expect(page.locator(".ap__hero > *")).toHaveCount(4);
|
||||||
|
|
||||||
|
// 대표 표정 12종 그리드
|
||||||
|
await expect(page.locator(".ap__grid .ap__cell")).toHaveCount(12);
|
||||||
|
// ClientAvatar 내부 figcaption(vg-avatar__label)과 구분하기 위해 직계 자식만 조회
|
||||||
|
await expect(page.locator(".ap__cell > figcaption").first()).toHaveText("neutral");
|
||||||
|
await expect(page.locator(".ap__cell > figcaption").last()).toHaveText("determined");
|
||||||
|
|
||||||
|
await expect(page.locator(".ap__count")).toContainText("총 표정 수:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -59,6 +59,10 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<!-- 본문/UI 기본 폰트: Pretendard Variable (DESIGN_CONCEPT §3.6) -->
|
<!-- 본문/UI 기본 폰트: Pretendard Variable (DESIGN_CONCEPT §3.6) -->
|
||||||
|
<!-- 렌더 블로킹 CDN 스타일시트다. 연결 수립(DNS+TCP+TLS)을 미리 열어 LCP를 줄인다.
|
||||||
|
근본 해결은 Pretendard self-host 이며 별도 과제로 남긴다. -->
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||||
|
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css"
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css"
|
||||||
|
|
@ -169,7 +173,11 @@
|
||||||
var visible = visibleCount();
|
var visible = visibleCount();
|
||||||
var hasAdminContent =
|
var hasAdminContent =
|
||||||
!isAdmin ||
|
!isAdmin ||
|
||||||
Boolean(document.querySelector(".ad-head,.ad-diagnostic,.ad-status,.ad-section,.ad-panel"));
|
Boolean(
|
||||||
|
document.querySelector(
|
||||||
|
".ad-head,.ad-diagnostic,.ad-status,.ad-section,.ad-panel,.aic,[data-testid='admin-ai-page']",
|
||||||
|
),
|
||||||
|
);
|
||||||
if (reason === "watchdog" && text.length > 24 && visible > 0 && hasAdminContent) {
|
if (reason === "watchdog" && text.length > 24 && visible > 0 && hasAdminContent) {
|
||||||
var old = document.getElementById("vignette-boot-diagnostic");
|
var old = document.getElementById("vignette-boot-diagnostic");
|
||||||
if (old) old.remove();
|
if (old) old.remove();
|
||||||
|
|
|
||||||
28
apps/web/package-lock.json
generated
28
apps/web/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.8",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1"
|
"react-router-dom": "^6.30.1"
|
||||||
|
|
@ -1995,6 +1996,23 @@
|
||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.14.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.8.tgz",
|
||||||
|
"integrity": "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.17.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/table-core": {
|
"node_modules/@tanstack/table-core": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
|
@ -2008,6 +2026,16 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.17.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz",
|
||||||
|
"integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.8",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1"
|
"react-router-dom": "^6.30.1"
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--accent);
|
color: var(--text-accent);
|
||||||
padding-left: var(--sp-4);
|
padding-left: var(--sp-4);
|
||||||
margin-left: var(--sp-2);
|
margin-left: var(--sp-2);
|
||||||
border-left: 1px solid var(--hair);
|
border-left: 1px solid var(--hair);
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ export type IconName =
|
||||||
| "google"
|
| "google"
|
||||||
| "school"
|
| "school"
|
||||||
| "sun"
|
| "sun"
|
||||||
| "moon";
|
| "moon"
|
||||||
|
| "refresh";
|
||||||
|
|
||||||
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||||
name: IconName;
|
name: IconName;
|
||||||
|
|
@ -49,6 +50,12 @@ export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||||
|
|
||||||
// 각 아이콘의 path/children (24x24 viewBox 기준). google 만 멀티컬러 예외.
|
// 각 아이콘의 path/children (24x24 viewBox 기준). google 만 멀티컬러 예외.
|
||||||
const PATHS: Record<IconName, ReactNode> = {
|
const PATHS: Record<IconName, ReactNode> = {
|
||||||
|
refresh: (
|
||||||
|
<>
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||||
|
<path d="M21 3v5h-5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
home: (
|
home: (
|
||||||
<>
|
<>
|
||||||
<path d="M3 9.5 12 4l9 5.5" />
|
<path d="M3 9.5 12 4l9 5.5" />
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
font-size: var(--fs-kicker);
|
font-size: var(--fs-kicker);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
color: var(--accent);
|
color: var(--text-accent);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
}
|
}
|
||||||
.vg-btn--secondary {
|
.vg-btn--secondary {
|
||||||
background: var(--surface-gradient);
|
background: var(--surface-gradient);
|
||||||
color: var(--accent);
|
color: var(--text-accent);
|
||||||
border-color: color-mix(in srgb, var(--accent) 62%, var(--botanical-hair));
|
border-color: color-mix(in srgb, var(--accent) 62%, var(--botanical-hair));
|
||||||
}
|
}
|
||||||
.vg-btn--secondary:hover:not(:disabled) {
|
.vg-btn--secondary:hover:not(:disabled) {
|
||||||
|
|
|
||||||
|
|
@ -1090,8 +1090,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const services = health?.services ?? [];
|
// 렌더마다 새 배열을 만들면 TanStack core row model이 재계산되며
|
||||||
const users = usersData?.users ?? [];
|
// _autoResetPageIndex 상태 갱신 루프(무한 동기 렌더 프리즈)를 유발한다 — 반드시 메모화.
|
||||||
|
const services = useMemo(() => health?.services ?? [], [health]);
|
||||||
|
const users = useMemo(() => usersData?.users ?? [], [usersData]);
|
||||||
const counts = useMemo(
|
const counts = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
ok: services.filter((service) => service.status === "ok").length,
|
ok: services.filter((service) => service.status === "ok").length,
|
||||||
|
|
@ -1447,6 +1449,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getRowId: (user) => user.user_id,
|
getRowId: (user) => user.user_id,
|
||||||
|
// 페이지네이션 미사용(수동 40행 슬라이스). row model 재계산이 pageIndex 리셋
|
||||||
|
// setState를 큐잉해 무한 렌더 루프를 만들던 경로를 차단한다(결함 로그 #19).
|
||||||
|
autoResetPageIndex: false,
|
||||||
});
|
});
|
||||||
const visibleUserRows = userTable
|
const visibleUserRows = userTable
|
||||||
.getRowModel()
|
.getRowModel()
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
sessionSortTime,
|
sessionSortTime,
|
||||||
sessionStatusLabel,
|
sessionStatusLabel,
|
||||||
stageProgressValue,
|
stageProgressValue,
|
||||||
|
starterPersona,
|
||||||
theoryLine,
|
theoryLine,
|
||||||
trendLabel,
|
trendLabel,
|
||||||
truncateText,
|
truncateText,
|
||||||
|
|
@ -98,7 +99,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
|
|
||||||
if (personaResult.status === "fulfilled") {
|
if (personaResult.status === "fulfilled") {
|
||||||
const personaRows = personaResult.value;
|
const personaRows = personaResult.value;
|
||||||
const firstUsableCode = personaRows.find(isUsablePersona)?.code ?? "";
|
// 결정 D3: 회기 기록이 0건인 신규 학습자는 난도 오름차순(기초 우선) 첫
|
||||||
|
// 사용 가능 페르소나를 기본 선택한다. 기록이 있거나 기록 조회에 실패하면
|
||||||
|
// 기존 규칙(API 순서 첫 사용 가능 페르소나)을 유지한다.
|
||||||
|
const hasNoSessionRecords =
|
||||||
|
sessionResult.status === "fulfilled" &&
|
||||||
|
(sessionResult.value.sessions ?? []).length === 0;
|
||||||
|
const firstUsableCode = hasNoSessionRecords
|
||||||
|
? (starterPersona(personaRows)?.code ?? "")
|
||||||
|
: (personaRows.find(isUsablePersona)?.code ?? "");
|
||||||
setPersonas(personaRows);
|
setPersonas(personaRows);
|
||||||
setSelectedCode((cur) =>
|
setSelectedCode((cur) =>
|
||||||
personaRows.some(
|
personaRows.some(
|
||||||
|
|
@ -1285,7 +1294,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="lh-dashboard-side" aria-label="최근 기록 패널">
|
<aside
|
||||||
|
className="lh-dashboard-side"
|
||||||
|
aria-label={
|
||||||
|
dashTab === "growth" ? "성장 지표 패널" : "최근 기록 패널"
|
||||||
|
}
|
||||||
|
>
|
||||||
<article
|
<article
|
||||||
className={surfaceClassName("lh-panel lh-panel--records")}
|
className={surfaceClassName("lh-panel lh-panel--records")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ const EMPTY_FORM: OnboardingForm = {
|
||||||
privacy_accepted: false,
|
privacy_accepted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 서버 OnboardingRequest 검증과 동일 규칙: 숫자·하이픈·공백·괄호·점, 선행 +, 숫자 9~15자리.
|
||||||
|
function isValidPhone(raw: string): boolean {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const digits = trimmed.replace(/\D/g, "");
|
||||||
|
return /^\+?[0-9()\-\s.]+$/.test(trimmed) && digits.length >= 9 && digits.length <= 15;
|
||||||
|
}
|
||||||
|
|
||||||
function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
|
function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
|
||||||
return {
|
return {
|
||||||
...EMPTY_FORM,
|
...EMPTY_FORM,
|
||||||
|
|
@ -121,6 +128,10 @@ export default function Onboarding() {
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!canSubmit || submitting) return;
|
if (!canSubmit || submitting) return;
|
||||||
|
if (!isValidPhone(form.phone)) {
|
||||||
|
setError("연락처는 숫자 9~15자리의 전화번호 형식으로 입력해 주세요 (예: 010-1234-5678).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ export default function PendingApproval() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout, refresh } = useAuth();
|
const { user, logout, refresh } = useAuth();
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [checkResult, setCheckResult] = useState<string | null>(null);
|
||||||
|
|
||||||
const checkAgain = async () => {
|
const checkAgain = async () => {
|
||||||
if (checking) return;
|
if (checking) return;
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
|
setCheckResult(null);
|
||||||
try {
|
try {
|
||||||
const nextUser = await refresh();
|
const nextUser = await refresh();
|
||||||
if (nextUser?.accountStatus === "approved") {
|
if (nextUser?.accountStatus === "approved") {
|
||||||
|
|
@ -21,6 +23,16 @@ export default function PendingApproval() {
|
||||||
nextUser.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(nextUser.role),
|
nextUser.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(nextUser.role),
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const at = new Date().toLocaleTimeString("ko-KR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
if (nextUser) {
|
||||||
|
setCheckResult(`${at} 기준 아직 승인 전입니다. 잠시 후 다시 확인해 주세요.`);
|
||||||
|
} else {
|
||||||
|
setCheckResult(`${at} 상태 확인에 실패했습니다. 네트워크 연결 후 다시 시도해 주세요.`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
|
|
@ -51,14 +63,17 @@ export default function PendingApproval() {
|
||||||
<span aria-hidden="true" />
|
<span aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<b>접속 권한 확인 중</b>
|
<b>접속 권한 확인 중</b>
|
||||||
<small>승인되면 새로고침 후 온보딩 또는 역할 홈으로 이동합니다.</small>
|
<small>
|
||||||
|
{checkResult ??
|
||||||
|
"승인되면 새로고침 후 온보딩 또는 역할 홈으로 이동합니다."}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pa-actions">
|
<div className="pa-actions">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
leading={<Icon name="settings" size={16} />}
|
leading={<Icon name="refresh" size={16} />}
|
||||||
onClick={() => void checkAgain()}
|
onClick={() => void checkAgain()}
|
||||||
disabled={checking}
|
disabled={checking}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2595,120 +2595,6 @@ export default function PersonaStudio() {
|
||||||
className="ps-layout ps-authoring-layout"
|
className="ps-layout ps-authoring-layout"
|
||||||
data-authoring-step={authoringStep}
|
data-authoring-step={authoringStep}
|
||||||
>
|
>
|
||||||
<aside className="ps-rail" aria-label="페르소나 목록">
|
|
||||||
<div className={surfaceClassName("ps-rail__head")}>
|
|
||||||
<div>
|
|
||||||
<Kicker>카탈로그</Kicker>
|
|
||||||
<b>{catalog.length + reviews.length}개</b>
|
|
||||||
</div>
|
|
||||||
<Badge tone={loadState === "ready" ? "accent" : "neutral"}>
|
|
||||||
{loadState === "ready" ? suggestedCode : "..."}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={surfaceClassName("ps-workflow")}
|
|
||||||
aria-label="저작 단계"
|
|
||||||
>
|
|
||||||
<Kicker>저작 흐름</Kicker>
|
|
||||||
{AUTHORING_STEPS.map((step, index) => (
|
|
||||||
<div
|
|
||||||
className={`ps-workflow-step ${
|
|
||||||
index < authoringStepIndex
|
|
||||||
? "is-done"
|
|
||||||
: index === authoringStepIndex
|
|
||||||
? "is-current"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
key={step.label}
|
|
||||||
>
|
|
||||||
<span>{index + 1}</span>
|
|
||||||
<div>
|
|
||||||
<b>{step.label}</b>
|
|
||||||
<small>{step.desc}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ps-list">
|
|
||||||
{reviews.length > 0 ? (
|
|
||||||
reviews.map((persona) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={surfaceClassName(
|
|
||||||
`ps-list-row ${editingId === persona.persona_id ? "is-active" : ""}`,
|
|
||||||
{ variant: "interactive", flat: true },
|
|
||||||
)}
|
|
||||||
key={`${persona.persona_id}:${persona.version}`}
|
|
||||||
onClick={() => void loadDraft(persona.persona_id)}
|
|
||||||
disabled={busy !== null}
|
|
||||||
>
|
|
||||||
<span className="ps-list-row__code">{persona.code}</span>
|
|
||||||
<span className="ps-list-row__body">
|
|
||||||
<b>{persona.display_name}</b>
|
|
||||||
<small>
|
|
||||||
v{persona.version} ·{" "}
|
|
||||||
{personaReviewStatusLabel(persona.status)}
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
title="검수 큐 없음"
|
|
||||||
desc="저장된 초안이 여기에 표시됩니다."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={surfaceClassName("ps-approved")}>
|
|
||||||
<Kicker>공개 페르소나</Kicker>
|
|
||||||
{catalog.length > 0 ? (
|
|
||||||
catalog.map((persona) => (
|
|
||||||
<article
|
|
||||||
className="ps-approved-row"
|
|
||||||
key={`${persona.code}:${persona.version ?? "approved"}`}
|
|
||||||
data-approved-persona-row="true"
|
|
||||||
>
|
|
||||||
<span className="ps-approved-row__code">
|
|
||||||
{persona.code}
|
|
||||||
</span>
|
|
||||||
<div className="ps-approved-row__body">
|
|
||||||
<b>{persona.display_name}</b>
|
|
||||||
<small>
|
|
||||||
v{persona.version ?? 1} ·{" "}
|
|
||||||
{persona.source === "database"
|
|
||||||
? "DB 공개본"
|
|
||||||
: persona.source}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div className="ps-approved-row__actions">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => void reviseApprovedPersona(persona)}
|
|
||||||
disabled={busy !== null || !persona.persona_id}
|
|
||||||
>
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => void archiveApprovedPersona(persona)}
|
|
||||||
disabled={busy !== null || !persona.persona_id}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="ps-muted">공개 목록 없음</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="ps-editor">
|
<section className="ps-editor">
|
||||||
<div className={surfaceClassName("ps-editor__top")}>
|
<div className={surfaceClassName("ps-editor__top")}>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { AppShell } from "../components/shell/AppShell";
|
import { AppShell } from "../components/shell/AppShell";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
|
|
@ -1003,6 +1004,16 @@ function LearnerOverviewTable({
|
||||||
const [expandedLearnerId, setExpandedLearnerId] = useState<string | null>(
|
const [expandedLearnerId, setExpandedLearnerId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
// 세로 가상화 스크롤 컨테이너(.pf-learner-table) 참조.
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: learners.length,
|
||||||
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
|
// 접힌 행 기준 추정 높이. 실제 높이(펼침 포함)는 measureElement가 보정한다.
|
||||||
|
estimateSize: () => 62,
|
||||||
|
overscan: 8,
|
||||||
|
getItemKey: (index) => learners[index]?.learner_id ?? index,
|
||||||
|
});
|
||||||
const sortOptions: Array<{ key: LearnerSort; label: string }> = [
|
const sortOptions: Array<{ key: LearnerSort; label: string }> = [
|
||||||
{ key: "latest", label: "최근순" },
|
{ key: "latest", label: "최근순" },
|
||||||
{ key: "sessions", label: "회기순" },
|
{ key: "sessions", label: "회기순" },
|
||||||
|
|
@ -1019,6 +1030,12 @@ function LearnerOverviewTable({
|
||||||
}
|
}
|
||||||
}, [expandedLearnerId, learners]);
|
}, [expandedLearnerId, learners]);
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback((learnerId: string) => {
|
||||||
|
setExpandedLearnerId((current) =>
|
||||||
|
current === learnerId ? null : learnerId,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="pf-section pf-section--learner-overview">
|
<section className="pf-section pf-section--learner-overview">
|
||||||
<div className="pf-section__head">
|
<div className="pf-section__head">
|
||||||
|
|
@ -1071,7 +1088,11 @@ function LearnerOverviewTable({
|
||||||
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다."
|
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="pf-learner-table" data-learner-overview-table="true">
|
<div
|
||||||
|
className="pf-learner-table"
|
||||||
|
data-learner-overview-table="true"
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
>
|
||||||
<div className="pf-learner-table__head" aria-hidden="true">
|
<div className="pf-learner-table__head" aria-hidden="true">
|
||||||
<span>학습자</span>
|
<span>학습자</span>
|
||||||
<span>회기</span>
|
<span>회기</span>
|
||||||
|
|
@ -1083,19 +1104,57 @@ function LearnerOverviewTable({
|
||||||
<span>펼침</span>
|
<span>펼침</span>
|
||||||
<span>상세</span>
|
<span>상세</span>
|
||||||
</div>
|
</div>
|
||||||
{learners.map((learner) => {
|
{/* 세로 가상화 본문 — 전체 높이를 확보하고 화면 주변 행만 절대 배치로 렌더한다. */}
|
||||||
|
<div
|
||||||
|
className="pf-learner-table__body"
|
||||||
|
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const learner = learners[virtualRow.index];
|
||||||
const isExpanded = expandedLearnerId === learner.learner_id;
|
const isExpanded = expandedLearnerId === learner.learner_id;
|
||||||
const expandId = `learner-expanded-${learner.learner_id}`;
|
|
||||||
const topTechniques = learner.top_techniques ?? [];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`}
|
className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`}
|
||||||
key={learner.learner_id}
|
key={learner.learner_id}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||||
>
|
>
|
||||||
<article
|
<LearnerOverviewRecord
|
||||||
className="pf-learner-row"
|
learner={learner}
|
||||||
data-learner-overview-row="true"
|
isExpanded={isExpanded}
|
||||||
>
|
onToggleExpand={toggleExpanded}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 학습자 개요 테이블의 한 행(요약 article + 펼침 상세). 가상화 래퍼(.pf-learner-record)
|
||||||
|
// 안에서 렌더되며, 펼침 시 높이 변화는 measureElement의 ResizeObserver가 재측정한다.
|
||||||
|
function LearnerOverviewRecord({
|
||||||
|
learner,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
learner: TeacherLearnerGrowth;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: (learnerId: string) => void;
|
||||||
|
onOpen: (learnerId: string) => void;
|
||||||
|
}) {
|
||||||
|
const expandId = `learner-expanded-${learner.learner_id}`;
|
||||||
|
const topTechniques = learner.top_techniques ?? [];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<article className="pf-learner-row" data-learner-overview-row="true">
|
||||||
<div className="pf-learner-row__id">
|
<div className="pf-learner-row__id">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1112,19 +1171,13 @@ function LearnerOverviewTable({
|
||||||
</b>
|
</b>
|
||||||
<span>{formatDateTime(learner.latest_at)}</span>
|
<span>{formatDateTime(learner.latest_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="pf-learner-row__metric" data-label="최근 적절성">
|
||||||
className="pf-learner-row__metric"
|
|
||||||
data-label="최근 적절성"
|
|
||||||
>
|
|
||||||
<b>{formatScore(learner.latest_score)}</b>
|
<b>{formatScore(learner.latest_score)}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-learner-row__metric" data-label="변화">
|
<div className="pf-learner-row__metric" data-label="변화">
|
||||||
<b>{formatDelta(learner.score_delta)}</b>
|
<b>{formatDelta(learner.score_delta)}</b>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="pf-learner-row__metric" data-label="평균 라포">
|
||||||
className="pf-learner-row__metric"
|
|
||||||
data-label="평균 라포"
|
|
||||||
>
|
|
||||||
<b>{formatRapport(learner.avg_rapport)}</b>
|
<b>{formatRapport(learner.avg_rapport)}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-learner-row__trend" data-label="추이">
|
<div className="pf-learner-row__trend" data-label="추이">
|
||||||
|
|
@ -1140,10 +1193,7 @@ function LearnerOverviewTable({
|
||||||
{trendLabel(learner.trend)}
|
{trendLabel(learner.trend)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="pf-learner-row__tag-summary" data-label="기법 요약">
|
||||||
className="pf-learner-row__tag-summary"
|
|
||||||
data-label="기법 요약"
|
|
||||||
>
|
|
||||||
<b>{topTechniques[0] ?? "기법 부족"}</b>
|
<b>{topTechniques[0] ?? "기법 부족"}</b>
|
||||||
{topTechniques.length > 1 ? (
|
{topTechniques.length > 1 ? (
|
||||||
<span>외 {topTechniques.length - 1}개</span>
|
<span>외 {topTechniques.length - 1}개</span>
|
||||||
|
|
@ -1156,16 +1206,9 @@ function LearnerOverviewTable({
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-controls={expandId}
|
aria-controls={expandId}
|
||||||
aria-label={`${learner.learner_label} 요약 ${isExpanded ? "접기" : "펼치기"}`}
|
aria-label={`${learner.learner_label} 요약 ${isExpanded ? "접기" : "펼치기"}`}
|
||||||
onClick={() =>
|
onClick={() => onToggleExpand(learner.learner_id)}
|
||||||
setExpandedLearnerId(
|
|
||||||
isExpanded ? null : learner.learner_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon name={isExpanded ? "arrow-up" : "arrow-down"} size={14} />
|
||||||
name={isExpanded ? "arrow-up" : "arrow-down"}
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-learner-row__action">
|
<div className="pf-learner-row__action">
|
||||||
|
|
@ -1213,8 +1256,7 @@ function LearnerOverviewTable({
|
||||||
<span>최근 기록</span>
|
<span>최근 기록</span>
|
||||||
<b>{formatDateTime(learner.latest_at)}</b>
|
<b>{formatDateTime(learner.latest_at)}</b>
|
||||||
<small>
|
<small>
|
||||||
완료 {learner.ended_sessions} / 전체{" "}
|
완료 {learner.ended_sessions} / 전체 {learner.sessions}
|
||||||
{learner.sessions}
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-learner-detail-item pf-learner-detail-item--tags">
|
<div className="pf-learner-detail-item pf-learner-detail-item--tags">
|
||||||
|
|
@ -1223,20 +1265,12 @@ function LearnerOverviewTable({
|
||||||
{topTechniques.slice(0, 5).map((tag) => (
|
{topTechniques.slice(0, 5).map((tag) => (
|
||||||
<span key={tag}>{tag}</span>
|
<span key={tag}>{tag}</span>
|
||||||
))}
|
))}
|
||||||
{topTechniques.length === 0 ? (
|
{topTechniques.length === 0 ? <span>기법 부족</span> : null}
|
||||||
<span>기법 부족</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3403,12 +3403,12 @@ export default function Session() {
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="sx-end-dialog-title">
|
<h2 id="sx-end-dialog-title">
|
||||||
{timeUp ? "회기 시간이 끝났어요" : "회기를 종료할까?"}
|
{timeUp ? "회기 시간이 끝났어요" : "회기를 종료할까요?"}
|
||||||
</h2>
|
</h2>
|
||||||
<p id="sx-end-dialog-desc">
|
<p id="sx-end-dialog-desc">
|
||||||
{timeUp
|
{timeUp
|
||||||
? "실제 상담처럼 시간이 회기를 마무리합니다. 마지막 인사를 나누고 싶다면 잠시 계속할 수 있어요. 종료하면 축어록을 저장하고 리뷰로 이동합니다."
|
? "실제 상담처럼 시간이 회기를 마무리합니다. 마지막 인사를 나누고 싶다면 잠시 계속할 수 있어요. 종료하면 축어록을 저장하고 리뷰로 이동합니다."
|
||||||
: "종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동한다."}
|
: "종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동합니다."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { roleLabel, useAuth } from "../lib/auth";
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
adminEngineApi,
|
adminEngineApi,
|
||||||
|
apiUrl,
|
||||||
userApi,
|
userApi,
|
||||||
type AdminHealthResponse,
|
type AdminHealthResponse,
|
||||||
type AdminEngineConfigResponse,
|
type AdminEngineConfigResponse,
|
||||||
|
|
@ -108,6 +109,18 @@ function formatTicketDate(value?: number | null): string {
|
||||||
}).format(new Date(value * 1000));
|
}).format(new Date(value * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 동의 시각 표기 — 감사 추적을 위해 연도까지 포함한다. */
|
||||||
|
function formatConsentDate(value?: number | null): string {
|
||||||
|
if (!value) return "-";
|
||||||
|
return new Intl.DateTimeFormat("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(value * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsLoadingState({
|
function SettingsLoadingState({
|
||||||
children,
|
children,
|
||||||
testId,
|
testId,
|
||||||
|
|
@ -189,11 +202,13 @@ function completeNotificationPreferences(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { user } = useAuth();
|
const { user, refresh, acceptConsent, withdrawConsent } = useAuth();
|
||||||
const role = user?.role ?? "learner";
|
const role = user?.role ?? "learner";
|
||||||
const isAdmin = role === "admin";
|
const isAdmin = role === "admin";
|
||||||
const [dark, setDark] = useTheme();
|
const [dark, setDark] = useTheme();
|
||||||
const [activeSection, setActiveSection] = useState("account");
|
const [activeSection, setActiveSection] = useState("account");
|
||||||
|
// goSection의 스무스 스크롤 동안 IntersectionObserver가 활성 섹션을 되돌리지 않게 억제한다.
|
||||||
|
const spySuppressUntilRef = useRef(0);
|
||||||
|
|
||||||
const [profile, setProfile] = useState<UserProfileResponse | null>(null);
|
const [profile, setProfile] = useState<UserProfileResponse | null>(null);
|
||||||
const [displayName, setDisplayName] = useState(user?.name ?? "");
|
const [displayName, setDisplayName] = useState(user?.name ?? "");
|
||||||
|
|
@ -221,6 +236,15 @@ export default function Settings() {
|
||||||
const affiliationDirtyRef = useRef(false);
|
const affiliationDirtyRef = useRef(false);
|
||||||
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
|
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
|
||||||
const loadSettingsRequestRef = useRef(0);
|
const loadSettingsRequestRef = useRef(0);
|
||||||
|
// 아바타 업로드 — 온보딩과 같은 계약(3MB, PNG/JPEG/WebP)을 설정에서도 쓴다.
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
|
const [avatarError, setAvatarError] = useState("");
|
||||||
|
// 실습 동의 관리 — 철회는 인라인 2단계 확인을 거친다.
|
||||||
|
const [consentConfirming, setConsentConfirming] = useState(false);
|
||||||
|
const [consentBusy, setConsentBusy] = useState(false);
|
||||||
|
const [consentWithdrawn, setConsentWithdrawn] = useState(false);
|
||||||
|
const [consentError, setConsentError] = useState("");
|
||||||
|
|
||||||
const flashSaved = (key: string) => {
|
const flashSaved = (key: string) => {
|
||||||
setSavedKey(key);
|
setSavedKey(key);
|
||||||
|
|
@ -312,6 +336,7 @@ export default function Settings() {
|
||||||
const base: NavItem[] = [
|
const base: NavItem[] = [
|
||||||
{ id: "account", label: "계정", icon: "users" },
|
{ id: "account", label: "계정", icon: "users" },
|
||||||
{ id: "support", label: "지원 요청", icon: "review" },
|
{ id: "support", label: "지원 요청", icon: "review" },
|
||||||
|
{ id: "consent", label: "동의", icon: "check" },
|
||||||
{ id: "appearance", label: "테마", icon: "settings" },
|
{ id: "appearance", label: "테마", icon: "settings" },
|
||||||
{ id: "notify", label: "알림", icon: "info" },
|
{ id: "notify", label: "알림", icon: "info" },
|
||||||
{ id: "voice", label: "음성", icon: "mic" },
|
{ id: "voice", label: "음성", icon: "mic" },
|
||||||
|
|
@ -323,18 +348,34 @@ export default function Settings() {
|
||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
// 활성 규칙: 뷰포트 상단 18~38% 밴드에 걸친 섹션 중 가장 아래(=방금 지나친 헤딩).
|
||||||
(entries) => {
|
// 비율 우세 방식은 scroll-margin 정렬 지점이 밴드 하단 근처인 모바일에서
|
||||||
const visible = entries
|
// 직전 섹션이 계속 이겨 마지막 섹션이 활성화되지 않는다(결함 로그 #17).
|
||||||
.filter((entry) => entry.isIntersecting)
|
const pickActiveSection = () => {
|
||||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
|
const bandTop = window.innerHeight * 0.18;
|
||||||
if (visible?.target.id) {
|
const bandBottom = window.innerHeight * 0.38;
|
||||||
setActiveSection(visible.target.id.replace(/^set-/, ""));
|
let winner: { id: string; top: number } | null = null;
|
||||||
|
for (const item of navItems) {
|
||||||
|
const section = document.getElementById(`set-${item.id}`);
|
||||||
|
if (!section) continue;
|
||||||
|
const rect = section.getBoundingClientRect();
|
||||||
|
if (rect.top > bandBottom || rect.bottom < bandTop) continue;
|
||||||
|
if (!winner || rect.top > winner.top) {
|
||||||
|
winner = { id: item.id, top: rect.top };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (winner) setActiveSection(winner.id);
|
||||||
|
};
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
() => {
|
||||||
|
if (Date.now() < spySuppressUntilRef.current) return;
|
||||||
|
pickActiveSection();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rootMargin: "-18% 0px -62% 0px",
|
rootMargin: "-18% 0px -62% 0px",
|
||||||
threshold: [0.2, 0.55],
|
// 0 임계값이 없으면 긴 섹션(모바일)은 밴드 대비 비율이 0.2에 못 미쳐
|
||||||
|
// 콜백이 아예 오지 않는다(결함 로그 #17).
|
||||||
|
threshold: [0, 0.2, 0.55],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -343,7 +384,22 @@ export default function Settings() {
|
||||||
if (section) observer.observe(section);
|
if (section) observer.observe(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => observer.disconnect();
|
// 마지막 섹션은 짧은 뷰포트에서 활성 밴드(18~38%)에 도달하지 못한다.
|
||||||
|
// 스크롤 컨테이너가 바닥에 닿으면 마지막 섹션을 활성으로 처리한다(결함 로그 #17).
|
||||||
|
const scroller = document.querySelector(".vg-main");
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!scroller || Date.now() < spySuppressUntilRef.current) return;
|
||||||
|
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 4) {
|
||||||
|
const last = navItems[navItems.length - 1];
|
||||||
|
if (last) setActiveSection(last.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scroller?.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
scroller?.removeEventListener("scroll", onScroll);
|
||||||
|
};
|
||||||
}, [navItems]);
|
}, [navItems]);
|
||||||
|
|
||||||
const visibleNotifications = useMemo(
|
const visibleNotifications = useMemo(
|
||||||
|
|
@ -380,6 +436,7 @@ export default function Settings() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const goSection = (id: string) => {
|
const goSection = (id: string) => {
|
||||||
|
spySuppressUntilRef.current = Date.now() + 900;
|
||||||
setActiveSection(id);
|
setActiveSection(id);
|
||||||
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
};
|
};
|
||||||
|
|
@ -406,6 +463,58 @@ export default function Settings() {
|
||||||
flashSaved("account");
|
flashSaved("account");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 아바타 업로드 → PATCH /users/me 저장 → 톱바·레일 아바타 갱신. */
|
||||||
|
const uploadAvatar = async (file: File | null) => {
|
||||||
|
if (!file || uploadingAvatar) return;
|
||||||
|
setUploadingAvatar(true);
|
||||||
|
setAvatarError("");
|
||||||
|
try {
|
||||||
|
const uploaded = await userApi.uploadAvatar(file);
|
||||||
|
const next = await userApi.updateMe({ avatar_url: uploaded.avatar_url });
|
||||||
|
setProfile(next);
|
||||||
|
await refresh();
|
||||||
|
flashSaved("account");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[settings] avatar upload failed", err);
|
||||||
|
setAvatarError("아바타 업로드에 실패했습니다. PNG, JPG, WebP 파일을 3MB 이하로 올려 주세요.");
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 실습 동의 철회 확정 — DELETE /auth/consent, learner 전용. */
|
||||||
|
const confirmWithdrawConsent = async () => {
|
||||||
|
if (consentBusy) return;
|
||||||
|
setConsentBusy(true);
|
||||||
|
setConsentError("");
|
||||||
|
try {
|
||||||
|
await withdrawConsent();
|
||||||
|
setConsentWithdrawn(true);
|
||||||
|
setConsentConfirming(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[settings] consent withdraw failed", err);
|
||||||
|
setConsentError(err instanceof Error ? err.message : "실습 동의 철회에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setConsentBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 실습 동의 재동의 — POST /auth/consent, 회기 시작 화면과 같은 계약. */
|
||||||
|
const reacceptConsent = async () => {
|
||||||
|
if (consentBusy) return;
|
||||||
|
setConsentBusy(true);
|
||||||
|
setConsentError("");
|
||||||
|
try {
|
||||||
|
await acceptConsent();
|
||||||
|
setConsentWithdrawn(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[settings] consent accept failed", err);
|
||||||
|
setConsentError(err instanceof Error ? err.message : "실습 동의 저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setConsentBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const savePreferences = async (key: string) => {
|
const savePreferences = async (key: string) => {
|
||||||
if (!preferencesReady || !preferences) return;
|
if (!preferencesReady || !preferences) return;
|
||||||
const notifications = completeNotificationPreferences(preferences.notifications);
|
const notifications = completeNotificationPreferences(preferences.notifications);
|
||||||
|
|
@ -453,6 +562,10 @@ export default function Settings() {
|
||||||
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
|
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
|
||||||
const accountEmail = profile?.email ?? user?.email ?? "";
|
const accountEmail = profile?.email ?? user?.email ?? "";
|
||||||
const initials = accountName.trim().slice(0, 1).toUpperCase();
|
const initials = accountName.trim().slice(0, 1).toUpperCase();
|
||||||
|
// 프로필이 먼저 도착하면 프로필 값을, 아니면 auth 스토어의 절대화된 URL을 쓴다.
|
||||||
|
const avatarSrc = profile?.avatar_url ? apiUrl(profile.avatar_url) : user?.avatarUrl ?? "";
|
||||||
|
const isLearner = role === "learner";
|
||||||
|
const consentAt = user?.consentAt ?? null;
|
||||||
const engineHasRequiredFields = engineConfig
|
const engineHasRequiredFields = engineConfig
|
||||||
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
|
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
|
||||||
: false;
|
: false;
|
||||||
|
|
@ -490,7 +603,7 @@ export default function Settings() {
|
||||||
|
|
||||||
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
|
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
|
||||||
<div className="vg-set__rail-avatar" aria-hidden="true">
|
<div className="vg-set__rail-avatar" aria-hidden="true">
|
||||||
{initials}
|
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
|
||||||
</div>
|
</div>
|
||||||
<div className="vg-set__rail-copy">
|
<div className="vg-set__rail-copy">
|
||||||
<div className="vg-set__rail-name">{accountName}</div>
|
<div className="vg-set__rail-name">{accountName}</div>
|
||||||
|
|
@ -541,7 +654,7 @@ export default function Settings() {
|
||||||
|
|
||||||
<div className={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
|
<div className={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
|
||||||
<div className="vg-set__avatar" aria-hidden="true">
|
<div className="vg-set__avatar" aria-hidden="true">
|
||||||
{initials}
|
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
|
||||||
</div>
|
</div>
|
||||||
<div className="vg-set__profile-meta">
|
<div className="vg-set__profile-meta">
|
||||||
<div className="n">{accountName}</div>
|
<div className="n">{accountName}</div>
|
||||||
|
|
@ -551,7 +664,24 @@ export default function Settings() {
|
||||||
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
|
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="vg-set__avatar-actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
disabled={loading || uploadingAvatar}
|
||||||
|
>
|
||||||
|
{uploadingAvatar ? "업로드 중" : avatarSrc ? "아바타 변경" : "아바타 올리기"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{avatarError ? (
|
||||||
|
<div className="vg-set__state vg-set__state--warn" role="alert">
|
||||||
|
<span className="vg-set__state-dot" aria-hidden="true" />
|
||||||
|
<span>{avatarError}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="vg-set__field-grid vg-set__field-grid--account">
|
<div className="vg-set__field-grid vg-set__field-grid--account">
|
||||||
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
|
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
|
||||||
|
|
@ -626,6 +756,21 @@ export default function Settings() {
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 숨은 아바타 file input — 계정 텍스트 입력들의 DOM 순서(0=표시 이름)를
|
||||||
|
흔들지 않도록 섹션 마지막에 둔다. 버튼이 ref 로 클릭을 위임한다. */}
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="vg-set__avatar-input"
|
||||||
|
aria-label="아바타 이미지"
|
||||||
|
disabled={loading || uploadingAvatar}
|
||||||
|
onChange={(event) => {
|
||||||
|
void uploadAvatar(event.currentTarget.files?.[0] ?? null);
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel id="set-support" className="vg-set__group vg-set__group--wide">
|
<Panel id="set-support" className="vg-set__group vg-set__group--wide">
|
||||||
|
|
@ -819,6 +964,118 @@ export default function Settings() {
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Panel id="set-consent" className="vg-set__group vg-set__group--compact">
|
||||||
|
<div className="vg-set__group-head">
|
||||||
|
<div>
|
||||||
|
<div className="vg-set__group-title">동의</div>
|
||||||
|
<div className="vg-set__group-desc">실습 동의와 약관 동의 이력을 확인하고 관리합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vg-set__opt-grid">
|
||||||
|
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
|
||||||
|
<div className="vg-set__opt-text">
|
||||||
|
<div className="l">실습 동의</div>
|
||||||
|
<div className="h" data-testid="settings-consent-practice">
|
||||||
|
{!isLearner
|
||||||
|
? "학습자 전용 항목입니다. 실습 동의는 학습자 계정에서만 관리합니다."
|
||||||
|
: consentAt != null
|
||||||
|
? `동의 시각 ${formatConsentDate(consentAt)}`
|
||||||
|
: "미동의 상태입니다."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isLearner ? (
|
||||||
|
<Badge tone="neutral">학습자 전용</Badge>
|
||||||
|
) : consentAt != null && !consentConfirming ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setConsentConfirming(true)}
|
||||||
|
disabled={consentBusy}
|
||||||
|
>
|
||||||
|
실습 동의 철회
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
|
||||||
|
<div className="vg-set__opt-text">
|
||||||
|
<div className="l">이용약관</div>
|
||||||
|
<div className="h">
|
||||||
|
{profileReady && profile
|
||||||
|
? `버전 ${profile.terms_version || "-"} · 동의 ${formatConsentDate(profile.terms_agreed_at)}`
|
||||||
|
: "동의 이력을 확인하는 중입니다."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
|
||||||
|
<div className="vg-set__opt-text">
|
||||||
|
<div className="l">개인정보 처리방침</div>
|
||||||
|
<div className="h">
|
||||||
|
{profileReady && profile
|
||||||
|
? `버전 ${profile.privacy_version || "-"} · 동의 ${formatConsentDate(profile.privacy_agreed_at)}`
|
||||||
|
: "동의 이력을 확인하는 중입니다."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{consentError ? (
|
||||||
|
<div className="vg-set__state vg-set__state--warn" role="alert">
|
||||||
|
<span className="vg-set__state-dot" aria-hidden="true" />
|
||||||
|
<span>{consentError}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLearner && consentAt != null && consentConfirming ? (
|
||||||
|
<div className={surfaceClassName("vg-set__consent-block", { variant: "inset", flat: true })}>
|
||||||
|
<span>정말 철회할까요? 철회하면 새 회기 시작과 음성 연습이 즉시 차단됩니다.</span>
|
||||||
|
<div className="vg-set__consent-actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => void confirmWithdrawConsent()}
|
||||||
|
disabled={consentBusy}
|
||||||
|
>
|
||||||
|
{consentBusy ? "철회 중" : "철회 확정"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConsentConfirming(false)}
|
||||||
|
disabled={consentBusy}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLearner && consentAt == null ? (
|
||||||
|
<div className={surfaceClassName("vg-set__consent-block", { variant: "inset", flat: true })}>
|
||||||
|
{consentWithdrawn ? (
|
||||||
|
<span role="status">
|
||||||
|
새 회기 시작과 음성 연습이 차단됩니다. 아래 버튼 또는 회기 시작 화면에서
|
||||||
|
다시 동의할 수 있습니다.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>실습 동의가 없어 새 회기 시작과 음성 연습이 차단된 상태입니다.</span>
|
||||||
|
)}
|
||||||
|
<div className="vg-set__consent-actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leading={<Icon name="check" size={14} />}
|
||||||
|
onClick={() => void reacceptConsent()}
|
||||||
|
disabled={consentBusy}
|
||||||
|
>
|
||||||
|
{consentBusy ? "저장 중" : "다시 동의"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
|
<Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
|
||||||
<div className="vg-set__group-head">
|
<div className="vg-set__group-head">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -838,7 +838,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.ad-user-sort__indicator {
|
.ad-user-sort__indicator {
|
||||||
color: var(--accent);
|
color: var(--text-accent);
|
||||||
font-family: var(--font-num);
|
font-family: var(--font-num);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
@ -980,7 +980,7 @@
|
||||||
.ad-approval-head h2 {
|
.ad-approval-head h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: var(--fs-lg);
|
font-size: var(--fs-h3);
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1278,14 +1278,31 @@
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 티켓 필터 툴바.
|
||||||
|
기존에는 기본값이 7트랙 한 줄(minmax(220px,...) + 132px*3 + 140px + 버튼 2개)이라
|
||||||
|
최소 954px를 요구했다. 그런데 폭 1180~1257px 구간은 셸 사이드바(262px)가 펼쳐지면서
|
||||||
|
이 툴바의 실제 컨테이너가 876~950px밖에 안 된다. 그래서 1180px에서 마지막
|
||||||
|
"초기화" 버튼이 left=1142 / right=1216 으로 뷰포트(1180) 밖 36px까지 밀려나 잘렸다.
|
||||||
|
(1179px 이하에서는 max-width:1179px 규칙이 3트랙으로 접어줘서 문제가 없었다.)
|
||||||
|
해결: 기본값을 4트랙 2줄로 낮추고 모든 유연 트랙을 minmax(0, ...)로 두어
|
||||||
|
컨테이너가 좁아져도 트랙이 끝까지 줄어들 수 있게 한다(구조적으로 넘칠 수 없음). */
|
||||||
.ad-ticket-filter {
|
.ad-ticket-filter {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(132px, 0.45fr)) minmax(140px, 0.5fr) max-content max-content;
|
grid-template-columns: minmax(0, 1.4fr) repeat(3, minmax(0, 1fr));
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 컨테이너가 954px 이상 확보되는 폭(뷰포트 1320px~, 컨테이너 1016px~)에서만
|
||||||
|
7트랙 한 줄로 편다. 최소치를 0으로 둬서 이 구간에서도 넘침이 불가능하다. */
|
||||||
|
@media (min-width: 1320px) {
|
||||||
|
.ad-ticket-filter {
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(0, 1.6fr) repeat(3, minmax(0, 1fr)) minmax(0, 1.1fr) max-content max-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ad-ticket-filter input,
|
.ad-ticket-filter input,
|
||||||
.ad-ticket-filter select {
|
.ad-ticket-filter select {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -1469,13 +1486,10 @@
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1179px) {
|
/* max-width:1179px 의 .ad-ticket-filter 3트랙 규칙을 제거했다.
|
||||||
.ad-ticket-filter {
|
1179px(컨테이너 875px)와 1180px(컨테이너 876px)은 실제 사용 가능한 폭이 1px 차이인데
|
||||||
grid-template-columns: minmax(180px, 1fr) minmax(132px, 0.6fr) minmax(132px, 0.6fr);
|
트랙 수만 3 -> 7 로 튀면서 1180px에서만 넘침이 생겼다. 이제 981~1319px 전 구간이
|
||||||
}
|
위의 기본 4트랙을 공유하므로 경계에서 레이아웃이 튀지 않는다. */
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.ad-kpis,
|
.ad-kpis,
|
||||||
.ad-flow-grid,
|
.ad-flow-grid,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
gap: var(--sp-5);
|
gap: var(--sp-5);
|
||||||
align-items: end;
|
align-items: end;
|
||||||
padding-bottom: var(--sp-5);
|
padding-bottom: var(--sp-5);
|
||||||
border-bottom: 1px solid var(--line-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__head h1,
|
.axl__head h1,
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
.axl__stats span {
|
.axl__stats span {
|
||||||
min-width: 104px;
|
min-width: 104px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--line-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-5);
|
gap: var(--sp-5);
|
||||||
padding-bottom: var(--sp-7);
|
padding-bottom: var(--sp-7);
|
||||||
border-bottom: 1px solid var(--line-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__persona-head {
|
.axl__persona-head {
|
||||||
|
|
@ -111,7 +111,7 @@
|
||||||
.axl__persona-title dl > div {
|
.axl__persona-title dl > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--line-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: color-mix(in srgb, var(--surface) 82%, transparent);
|
background: color-mix(in srgb, var(--surface) 82%, transparent);
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--line-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
|
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
|
||||||
.ap__head h1{font-size:var(--fs-xl);margin:0 0 6px;}
|
.ap__head h1{font-size:var(--fs-h1);margin:0 0 6px;}
|
||||||
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
|
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
|
||||||
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;}
|
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;}
|
||||||
.ap__hero .vg-avatar__persona{color:#1c2a2a;}
|
.ap__hero .vg-avatar__persona{color:#1c2a2a;}
|
||||||
|
|
|
||||||
|
|
@ -1748,6 +1748,20 @@
|
||||||
padding:4px 7px;
|
padding:4px 7px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* 초소형 폭(320~420px, iPhone SE급) 전용 밴드.
|
||||||
|
기존 최소 브레이크포인트가 460px이라 이 구간이 비어 있었다.
|
||||||
|
320px에서 페르소나 카드는 78vw(=249.6px)이고 마크 38px + 좌우 패딩 48px + 갭 11px를
|
||||||
|
빼면 이름 칸이 150.6px밖에 안 남는다. 그래서
|
||||||
|
"하린(가명) · 고3 · 진로갈등(부모기대 vs 본인욕구)"가 3줄(57px)을 요구하는데
|
||||||
|
기본 -webkit-line-clamp:2(38px)에 걸려 마지막 줄 19px이 통째로 사라졌다
|
||||||
|
(실측: clientHeight 38 / scrollHeight 57).
|
||||||
|
→ 이 구간만 3줄까지 허용해 이름이 온전히 보이게 한다. 그보다 더 길어져도
|
||||||
|
-webkit-box + line-clamp를 유지하므로 세로로 깎이지 않고 말줄임(…)으로만 잘린다. */
|
||||||
|
@media (max-width:420px){
|
||||||
|
.lh-persona__name{
|
||||||
|
-webkit-line-clamp:3;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion:reduce){
|
@media (prefers-reduced-motion:reduce){
|
||||||
.lh-skel__box,
|
.lh-skel__box,
|
||||||
.lh-skel__line{animation:none;}
|
.lh-skel__line{animation:none;}
|
||||||
|
|
@ -1764,8 +1778,8 @@
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 9px 11px;
|
padding: 9px 11px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.09));
|
border: 1px solid var(--border-subtle);
|
||||||
background: var(--bg-inset, rgba(0, 0, 0, 0.12));
|
background: var(--bg-surface-2);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
@ -1781,7 +1795,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text-dim);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.lh-rapport-list {
|
.lh-rapport-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -1806,13 +1820,13 @@
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
color: var(--text-dim);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.lh-rapport-row__bar {
|
.lh-rapport-row__bar {
|
||||||
display: block;
|
display: block;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--bg-inset, rgba(0, 0, 0, 0.16));
|
background: var(--neutral-100);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.lh-rapport-row__bar i {
|
.lh-rapport-row__bar i {
|
||||||
|
|
@ -1840,10 +1854,10 @@
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.1));
|
border: 1px solid var(--border-subtle);
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 680;
|
font-weight: 680;
|
||||||
color: var(--text-dim);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.lh-milestone.is-done {
|
.lh-milestone.is-done {
|
||||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,36 @@ import {
|
||||||
DIFFICULTY_LABEL,
|
DIFFICULTY_LABEL,
|
||||||
PENDING_PERSONA_AVATAR_APPEARANCE,
|
PENDING_PERSONA_AVATAR_APPEARANCE,
|
||||||
demographicText,
|
demographicText,
|
||||||
|
isUsablePersona,
|
||||||
personaAvatarAppearance,
|
personaAvatarAppearance,
|
||||||
personaBaselineExpression,
|
personaBaselineExpression,
|
||||||
personaMeta,
|
personaMeta,
|
||||||
shortPersonaName,
|
shortPersonaName,
|
||||||
} from "../../lib/personaViewModel";
|
} from "../../lib/personaViewModel";
|
||||||
|
|
||||||
|
// 결정 D3: 난도 오름차순 정렬 순서 — 알 수 없는 값은 뒤로 보낸다.
|
||||||
|
const DIFFICULTY_ORDER: Record<string, number> = {
|
||||||
|
easy: 0,
|
||||||
|
moderate: 1,
|
||||||
|
hard: 2,
|
||||||
|
};
|
||||||
|
const UNKNOWN_DIFFICULTY_ORDER = 99;
|
||||||
|
|
||||||
|
/** 회기 기록이 없는 신규 학습자에게 권하는 기초 우선 페르소나(난도 오름차순, 동률이면 code 순). */
|
||||||
|
export function starterPersona(
|
||||||
|
personas: PersonaSummary[],
|
||||||
|
): PersonaSummary | null {
|
||||||
|
const usable = personas.filter(isUsablePersona);
|
||||||
|
usable.sort((a, b) => {
|
||||||
|
const rank =
|
||||||
|
(DIFFICULTY_ORDER[a.difficulty] ?? UNKNOWN_DIFFICULTY_ORDER) -
|
||||||
|
(DIFFICULTY_ORDER[b.difficulty] ?? UNKNOWN_DIFFICULTY_ORDER);
|
||||||
|
if (rank !== 0) return rank;
|
||||||
|
return a.code.localeCompare(b.code);
|
||||||
|
});
|
||||||
|
return usable[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export type HistoryFilter = "all" | "active" | "review" | "archived";
|
export type HistoryFilter = "all" | "active" | "review" | "archived";
|
||||||
|
|
||||||
export const HISTORY_FILTERS: Array<{
|
export const HISTORY_FILTERS: Array<{
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,8 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--accent-bright);
|
/* --accent-bright 는 dot/진행바 fill 용 밝은 값이라 12px 글자로 쓰면 3.29:1 이다. */
|
||||||
|
color: var(--text-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg-kicker .d {
|
.lg-kicker .d {
|
||||||
|
|
@ -285,9 +286,12 @@
|
||||||
margin-top: var(--sp-3);
|
margin-top: var(--sp-3);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: rgba(154, 94, 20, 0.28);
|
/* 2026-07-27: 기존 rgba(154,94,20,.28) + #f2b75f 조합은 다크 패널 전제였다.
|
||||||
border: 1px solid rgba(236, 180, 91, 0.16);
|
라이트 패널 위에서는 탄색 배경에 밝은 앰버 글자가 얹혀 1.19:1까지 떨어져
|
||||||
color: #f2b75f;
|
사실상 읽을 수 없었다. 테마별로 스왑되는 warn 의미 토큰으로 교체한다. */
|
||||||
|
background: var(--warn-tint);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
|
||||||
|
color: var(--warn-text);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,10 +166,14 @@
|
||||||
border-radius:var(--radius);
|
border-radius:var(--radius);
|
||||||
background:var(--bg-surface-2);
|
background:var(--bg-surface-2);
|
||||||
}
|
}
|
||||||
|
/* 약관·개인정보 동의는 법적 확인 조작이다. 라벨 박스가 실제 클릭 타깃인데
|
||||||
|
높이가 19px 라 WCAG 2.2 SC 2.5.8(24px)에 미달했다. 최소 높이를 올린다. */
|
||||||
.ob-checks label{
|
.ob-checks label{
|
||||||
grid-template-columns:auto minmax(0,1fr);
|
grid-template-columns:auto minmax(0,1fr);
|
||||||
align-items:center;
|
align-items:center;
|
||||||
gap:10px;
|
gap:10px;
|
||||||
|
min-height:28px;
|
||||||
|
cursor:pointer;
|
||||||
}
|
}
|
||||||
.ob-checks input{
|
.ob-checks input{
|
||||||
width:18px;
|
width:18px;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@
|
||||||
gap:16px;
|
gap:16px;
|
||||||
align-items:start;
|
align-items:start;
|
||||||
}
|
}
|
||||||
.ps-rail,
|
|
||||||
.ps-inspector{
|
.ps-inspector{
|
||||||
position:sticky;
|
position:sticky;
|
||||||
top:76px;
|
top:76px;
|
||||||
|
|
@ -45,9 +44,7 @@
|
||||||
gap:14px;
|
gap:14px;
|
||||||
min-width:0;
|
min-width:0;
|
||||||
}
|
}
|
||||||
.ps-rail__head,
|
|
||||||
.ps-editor__top,
|
.ps-editor__top,
|
||||||
.ps-workflow,
|
|
||||||
.ps-inspector__block{
|
.ps-inspector__block{
|
||||||
border:1px solid var(--border-subtle);
|
border:1px solid var(--border-subtle);
|
||||||
border-radius:var(--radius);
|
border-radius:var(--radius);
|
||||||
|
|
@ -55,14 +52,12 @@
|
||||||
padding:16px;
|
padding:16px;
|
||||||
box-shadow:var(--shadow-sm);
|
box-shadow:var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.ps-rail__head,
|
|
||||||
.ps-editor__top{
|
.ps-editor__top{
|
||||||
display:flex;
|
display:flex;
|
||||||
align-items:flex-start;
|
align-items:flex-start;
|
||||||
justify-content:space-between;
|
justify-content:space-between;
|
||||||
gap:12px;
|
gap:12px;
|
||||||
}
|
}
|
||||||
.ps-rail__head b,
|
|
||||||
.ps-editor__top h2{
|
.ps-editor__top h2{
|
||||||
display:block;
|
display:block;
|
||||||
margin:4px 0 0;
|
margin:4px 0 0;
|
||||||
|
|
@ -73,167 +68,15 @@
|
||||||
line-height:1.25;
|
line-height:1.25;
|
||||||
letter-spacing:0;
|
letter-spacing:0;
|
||||||
}
|
}
|
||||||
.ps-list,
|
|
||||||
.ps-approved,
|
|
||||||
.ps-review-cards,
|
.ps-review-cards,
|
||||||
.ps-check{
|
.ps-check{
|
||||||
display:grid;
|
display:grid;
|
||||||
gap:8px;
|
gap:8px;
|
||||||
}
|
}
|
||||||
.ps-list-row{
|
|
||||||
width:100%;
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:42px minmax(0,1fr);
|
|
||||||
gap:10px;
|
|
||||||
align-items:center;
|
|
||||||
padding:10px;
|
|
||||||
border:1px solid var(--border-subtle);
|
|
||||||
border-radius:var(--radius);
|
|
||||||
background:var(--bg-surface);
|
|
||||||
color:inherit;
|
|
||||||
text-align:left;
|
|
||||||
cursor:pointer;
|
|
||||||
}
|
|
||||||
.ps-list-row:hover,
|
|
||||||
.ps-list-row.is-active{
|
|
||||||
border-color:var(--accent);
|
|
||||||
background:var(--accent-tint);
|
|
||||||
}
|
|
||||||
.ps-list-row:disabled{
|
|
||||||
opacity:.55;
|
|
||||||
cursor:not-allowed;
|
|
||||||
}
|
|
||||||
.ps-list-row__code{
|
|
||||||
display:grid;
|
|
||||||
place-items:center;
|
|
||||||
min-width:38px;
|
|
||||||
min-height:34px;
|
|
||||||
border:1px solid var(--hair);
|
|
||||||
border-radius:var(--radius-sm);
|
|
||||||
color:var(--accent-deep);
|
|
||||||
background:var(--bg-surface-2);
|
|
||||||
font-weight:760;
|
|
||||||
font-family:var(--font-num);
|
|
||||||
}
|
|
||||||
.ps-list-row__body{
|
|
||||||
min-width:0;
|
|
||||||
}
|
|
||||||
.ps-list-row__body b,
|
|
||||||
.ps-list-row__body small{
|
|
||||||
display:block;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
}
|
|
||||||
.ps-list-row__body small,
|
|
||||||
.ps-muted,
|
|
||||||
.ps-approved-row span,
|
|
||||||
.ps-review span{
|
.ps-review span{
|
||||||
color:var(--text-muted);
|
color:var(--text-muted);
|
||||||
font-size:var(--fs-xs);
|
font-size:var(--fs-xs);
|
||||||
}
|
}
|
||||||
.ps-approved{
|
|
||||||
padding:14px;
|
|
||||||
border:1px solid var(--border-subtle);
|
|
||||||
border-radius:var(--radius);
|
|
||||||
background:var(--bg-surface);
|
|
||||||
}
|
|
||||||
.ps-workflow{
|
|
||||||
display:grid;
|
|
||||||
gap:10px;
|
|
||||||
}
|
|
||||||
.ps-workflow-step{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:30px minmax(0,1fr);
|
|
||||||
align-items:center;
|
|
||||||
gap:10px;
|
|
||||||
padding:10px;
|
|
||||||
border:1px solid var(--hair);
|
|
||||||
border-radius:var(--radius-sm);
|
|
||||||
background:var(--bg-surface-2);
|
|
||||||
color:var(--text-muted);
|
|
||||||
}
|
|
||||||
.ps-workflow-step > span{
|
|
||||||
display:grid;
|
|
||||||
place-items:center;
|
|
||||||
width:30px;
|
|
||||||
height:30px;
|
|
||||||
border:1px solid var(--border-subtle);
|
|
||||||
border-radius:50%;
|
|
||||||
background:var(--bg-surface);
|
|
||||||
color:var(--text-muted);
|
|
||||||
font-family:var(--font-num);
|
|
||||||
font-weight:760;
|
|
||||||
}
|
|
||||||
.ps-workflow-step b,
|
|
||||||
.ps-workflow-step small{
|
|
||||||
display:block;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
}
|
|
||||||
.ps-workflow-step b{
|
|
||||||
color:var(--text-strong);
|
|
||||||
font-size:var(--fs-sm);
|
|
||||||
}
|
|
||||||
.ps-workflow-step small{
|
|
||||||
margin-top:2px;
|
|
||||||
font-size:11px;
|
|
||||||
}
|
|
||||||
.ps-workflow-step.is-current{
|
|
||||||
border-color:var(--accent);
|
|
||||||
background:var(--accent-tint);
|
|
||||||
color:var(--accent-deep);
|
|
||||||
}
|
|
||||||
.ps-workflow-step.is-current > span,
|
|
||||||
.ps-workflow-step.is-done > span{
|
|
||||||
border-color:var(--accent);
|
|
||||||
background:var(--accent);
|
|
||||||
color:var(--text-on-accent);
|
|
||||||
}
|
|
||||||
.ps-approved-row{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:38px minmax(0,1fr);
|
|
||||||
gap:8px 10px;
|
|
||||||
align-items:center;
|
|
||||||
padding:8px 0;
|
|
||||||
border-top:1px solid var(--hair);
|
|
||||||
}
|
|
||||||
.ps-approved-row:first-of-type{border-top:0;}
|
|
||||||
.ps-approved-row__code{
|
|
||||||
font-family:var(--font-num);
|
|
||||||
font-size:var(--fs-xs);
|
|
||||||
color:var(--text-muted);
|
|
||||||
}
|
|
||||||
.ps-approved-row__body{
|
|
||||||
min-width:0;
|
|
||||||
display:grid;
|
|
||||||
gap:2px;
|
|
||||||
}
|
|
||||||
.ps-approved-row b{
|
|
||||||
min-width:0;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
font-size:var(--fs-xs);
|
|
||||||
}
|
|
||||||
.ps-approved-row small{
|
|
||||||
min-width:0;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
color:var(--text-muted);
|
|
||||||
font-size:11px;
|
|
||||||
}
|
|
||||||
.ps-approved-row__actions{
|
|
||||||
grid-column:1 / -1;
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
||||||
gap:6px;
|
|
||||||
}
|
|
||||||
.ps-approved-row__actions .vg-btn{
|
|
||||||
width:100%;
|
|
||||||
}
|
|
||||||
.ps-editor{
|
.ps-editor{
|
||||||
display:grid;
|
display:grid;
|
||||||
gap:14px;
|
gap:14px;
|
||||||
|
|
@ -493,8 +336,8 @@
|
||||||
}
|
}
|
||||||
.ps-prompt-section__head h3{
|
.ps-prompt-section__head h3{
|
||||||
margin:0;
|
margin:0;
|
||||||
color:var(--text-heading);
|
color:var(--text-strong);
|
||||||
font-size:var(--fs-md);
|
font-size:var(--fs-body);
|
||||||
line-height:1.25;
|
line-height:1.25;
|
||||||
}
|
}
|
||||||
.ps-prompt-section__head p,
|
.ps-prompt-section__head p,
|
||||||
|
|
@ -516,7 +359,7 @@
|
||||||
gap:10px;
|
gap:10px;
|
||||||
align-items:start;
|
align-items:start;
|
||||||
padding:8px 0;
|
padding:8px 0;
|
||||||
border-top:1px solid var(--border-faint);
|
border-top:1px solid var(--hair);
|
||||||
}
|
}
|
||||||
.ps-prompt-row dt{
|
.ps-prompt-row dt{
|
||||||
color:var(--text-muted);
|
color:var(--text-muted);
|
||||||
|
|
@ -671,9 +514,6 @@
|
||||||
.ps-layout{
|
.ps-layout{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
}
|
}
|
||||||
.ps-rail{
|
|
||||||
position:static;
|
|
||||||
}
|
|
||||||
.ps-inspector{
|
.ps-inspector{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
}
|
}
|
||||||
|
|
@ -931,7 +771,7 @@
|
||||||
min-height:58px;
|
min-height:58px;
|
||||||
padding:9px 16px;
|
padding:9px 16px;
|
||||||
border:0;
|
border:0;
|
||||||
border-top:1px solid var(--border-faint);
|
border-top:1px solid var(--hair);
|
||||||
background:transparent;
|
background:transparent;
|
||||||
color:var(--text-body);
|
color:var(--text-body);
|
||||||
text-align:left;
|
text-align:left;
|
||||||
|
|
@ -959,7 +799,7 @@
|
||||||
.ps-catalog-hero__count b{color:var(--text-strong);font-family:var(--font-num);font-size:54px;line-height:1;letter-spacing:-.06em;}
|
.ps-catalog-hero__count b{color:var(--text-strong);font-family:var(--font-num);font-size:54px;line-height:1;letter-spacing:-.06em;}
|
||||||
.ps-catalog-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
|
.ps-catalog-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
|
||||||
.ps-settings-list{display:grid;margin:18px 0 0;}
|
.ps-settings-list{display:grid;margin:18px 0 0;}
|
||||||
.ps-settings-list > div{display:grid;grid-template-columns:minmax(130px,.4fr) minmax(0,1fr);gap:18px;padding:11px 0;border-top:1px solid var(--border-faint);}
|
.ps-settings-list > div{display:grid;grid-template-columns:minmax(130px,.4fr) minmax(0,1fr);gap:18px;padding:11px 0;border-top:1px solid var(--hair);}
|
||||||
.ps-settings-list dt{color:var(--text-muted);font-size:var(--fs-xs);font-weight:720;}
|
.ps-settings-list dt{color:var(--text-muted);font-size:var(--fs-xs);font-weight:720;}
|
||||||
.ps-settings-list dd{margin:0;color:var(--text-strong);font-size:var(--fs-sm);text-align:right;overflow-wrap:anywhere;}
|
.ps-settings-list dd{margin:0;color:var(--text-strong);font-size:var(--fs-sm);text-align:right;overflow-wrap:anywhere;}
|
||||||
.ps-distribution{display:grid;gap:14px;margin-top:18px;}
|
.ps-distribution{display:grid;gap:14px;margin-top:18px;}
|
||||||
|
|
@ -967,7 +807,7 @@
|
||||||
.ps-distribution span{color:var(--text-body);font-size:var(--fs-xs);}
|
.ps-distribution span{color:var(--text-body);font-size:var(--fs-xs);}
|
||||||
.ps-distribution b{color:var(--text-strong);font-family:var(--font-num);font-size:12px;}
|
.ps-distribution b{color:var(--text-strong);font-family:var(--font-num);font-size:12px;}
|
||||||
.ps-catalog-list{margin-top:16px;border-top:1px solid var(--border-subtle);}
|
.ps-catalog-list{margin-top:16px;border-top:1px solid var(--border-subtle);}
|
||||||
.ps-catalog-list button{width:100%;display:grid;grid-template-columns:48px minmax(0,1fr) minmax(70px,.25fr) 46px 20px;gap:12px;align-items:center;padding:12px 4px;border:0;border-bottom:1px solid var(--border-faint);background:transparent;color:var(--text-body);text-align:left;cursor:pointer;}
|
.ps-catalog-list button{width:100%;display:grid;grid-template-columns:48px minmax(0,1fr) minmax(70px,.25fr) 46px 20px;gap:12px;align-items:center;padding:12px 4px;border:0;border-bottom:1px solid var(--hair);background:transparent;color:var(--text-body);text-align:left;cursor:pointer;}
|
||||||
.ps-catalog-list button:hover{background:var(--bg-surface-2);}
|
.ps-catalog-list button:hover{background:var(--bg-surface-2);}
|
||||||
.ps-catalog-list button > span:nth-child(2){min-width:0;display:grid;gap:3px;}
|
.ps-catalog-list button > span:nth-child(2){min-width:0;display:grid;gap:3px;}
|
||||||
.ps-catalog-list button b,.ps-catalog-list button small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
.ps-catalog-list button b,.ps-catalog-list button small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
|
@ -987,7 +827,7 @@
|
||||||
.ps-persona-table{overflow:hidden;border:1px solid var(--border-subtle);border-radius:var(--radius);background:color-mix(in srgb,var(--bg-surface) 88%,transparent);}
|
.ps-persona-table{overflow:hidden;border:1px solid var(--border-subtle);border-radius:var(--radius);background:color-mix(in srgb,var(--bg-surface) 88%,transparent);}
|
||||||
.ps-persona-table__head,.ps-persona-row{display:grid;grid-template-columns:minmax(300px,1.5fr) minmax(135px,.55fr) minmax(100px,.4fr) minmax(110px,.42fr) 54px minmax(190px,.72fr);gap:12px;align-items:center;}
|
.ps-persona-table__head,.ps-persona-row{display:grid;grid-template-columns:minmax(300px,1.5fr) minmax(135px,.55fr) minmax(100px,.4fr) minmax(110px,.42fr) 54px minmax(190px,.72fr);gap:12px;align-items:center;}
|
||||||
.ps-persona-table__head{padding:10px 14px;background:var(--bg-surface-2);color:var(--text-muted);font-size:10px;font-weight:780;}
|
.ps-persona-table__head{padding:10px 14px;background:var(--bg-surface-2);color:var(--text-muted);font-size:10px;font-weight:780;}
|
||||||
.ps-persona-row{min-height:70px;padding:10px 14px;border-top:1px solid var(--border-faint);}
|
.ps-persona-row{min-height:70px;padding:10px 14px;border-top:1px solid var(--hair);}
|
||||||
.ps-persona-row:hover{background:color-mix(in srgb,var(--bg-surface-2) 72%,transparent);}
|
.ps-persona-row:hover{background:color-mix(in srgb,var(--bg-surface-2) 72%,transparent);}
|
||||||
.ps-persona-row__identity{min-width:0;display:grid;grid-template-columns:44px minmax(0,1fr);gap:11px;align-items:center;padding:0;border:0;background:transparent;color:inherit;text-align:left;cursor:pointer;}
|
.ps-persona-row__identity{min-width:0;display:grid;grid-template-columns:44px minmax(0,1fr);gap:11px;align-items:center;padding:0;border:0;background:transparent;color:inherit;text-align:left;cursor:pointer;}
|
||||||
.ps-persona-row__identity > span:last-child,.ps-persona-row__usage{min-width:0;display:grid;gap:3px;}
|
.ps-persona-row__identity > span:last-child,.ps-persona-row__usage{min-width:0;display:grid;gap:3px;}
|
||||||
|
|
@ -1008,7 +848,7 @@
|
||||||
.ps-review-card__top{display:flex;align-items:center;justify-content:space-between;gap:10px;}
|
.ps-review-card__top{display:flex;align-items:center;justify-content:space-between;gap:10px;}
|
||||||
.ps-review-card h3{margin:0;color:var(--text-strong);font-size:17px;}
|
.ps-review-card h3{margin:0;color:var(--text-strong);font-size:17px;}
|
||||||
.ps-review-card > p{margin:0;color:var(--text-muted);font-size:var(--fs-xs);line-height:1.5;}
|
.ps-review-card > p{margin:0;color:var(--text-muted);font-size:var(--fs-xs);line-height:1.5;}
|
||||||
.ps-review-card dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0;border-top:1px solid var(--border-faint);}
|
.ps-review-card dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0;border-top:1px solid var(--hair);}
|
||||||
.ps-review-card dl > div{display:grid;gap:4px;padding:10px 8px 0;}
|
.ps-review-card dl > div{display:grid;gap:4px;padding:10px 8px 0;}
|
||||||
.ps-review-card dt{color:var(--text-muted);font-size:10px;}
|
.ps-review-card dt{color:var(--text-muted);font-size:10px;}
|
||||||
.ps-review-card dd{margin:0;color:var(--text-body);font-size:11px;overflow-wrap:anywhere;}
|
.ps-review-card dd{margin:0;color:var(--text-body);font-size:11px;overflow-wrap:anywhere;}
|
||||||
|
|
@ -1024,13 +864,13 @@
|
||||||
.ps-chip-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:20px;}
|
.ps-chip-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:20px;}
|
||||||
.ps-chip-row span{padding:5px 8px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);color:var(--text-muted);font-size:11px;}
|
.ps-chip-row span{padding:5px 8px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);color:var(--text-muted);font-size:11px;}
|
||||||
.ps-detail-facts dl{display:grid;margin:0;}
|
.ps-detail-facts dl{display:grid;margin:0;}
|
||||||
.ps-detail-facts dl > div{display:flex;justify-content:space-between;gap:16px;padding:10px 0;border-top:1px solid var(--border-faint);}
|
.ps-detail-facts dl > div{display:flex;justify-content:space-between;gap:16px;padding:10px 0;border-top:1px solid var(--hair);}
|
||||||
.ps-detail-facts dl > div:first-child{border-top:0;}
|
.ps-detail-facts dl > div:first-child{border-top:0;}
|
||||||
.ps-detail-facts dt{color:var(--text-muted);font-size:var(--fs-xs);}
|
.ps-detail-facts dt{color:var(--text-muted);font-size:var(--fs-xs);}
|
||||||
.ps-detail-facts dd{margin:0;color:var(--text-strong);font-size:var(--fs-xs);font-weight:720;text-align:right;}
|
.ps-detail-facts dd{margin:0;color:var(--text-strong);font-size:var(--fs-xs);font-weight:720;text-align:right;}
|
||||||
.ps-learning-detail{display:grid;gap:14px;}
|
.ps-learning-detail{display:grid;gap:14px;}
|
||||||
.ps-point-list{display:grid;margin-top:14px;border-top:1px solid var(--border-subtle);}
|
.ps-point-list{display:grid;margin-top:14px;border-top:1px solid var(--border-subtle);}
|
||||||
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--border-faint);color:var(--text-body);font-size:var(--fs-xs);}
|
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
|
||||||
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
|
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
|
||||||
.ps-point-list small{color:var(--text-muted);text-align:right;}
|
.ps-point-list small{color:var(--text-muted);text-align:right;}
|
||||||
.ps-stepper{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden;background:color-mix(in srgb,var(--bg-surface) 78%,transparent);}
|
.ps-stepper{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden;background:color-mix(in srgb,var(--bg-surface) 78%,transparent);}
|
||||||
|
|
@ -1044,7 +884,6 @@
|
||||||
.ps-stepper button.is-current{background:var(--accent-tint);color:var(--accent-deep);}
|
.ps-stepper button.is-current{background:var(--accent-tint);color:var(--accent-deep);}
|
||||||
.ps-stepper button.is-current > span:first-child,.ps-stepper button.is-done > span:first-child{border-color:var(--accent);background:var(--accent);color:var(--text-on-accent);}
|
.ps-stepper button.is-current > span:first-child,.ps-stepper button.is-done > span:first-child{border-color:var(--accent);background:var(--accent);color:var(--text-on-accent);}
|
||||||
.ps-authoring-layout{grid-template-columns:minmax(0,1fr) minmax(260px,320px);}
|
.ps-authoring-layout{grid-template-columns:minmax(0,1fr) minmax(260px,320px);}
|
||||||
.ps-authoring-layout > .ps-rail{display:none;}
|
|
||||||
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs,
|
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs,
|
||||||
.ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel,
|
.ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel,
|
||||||
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs,
|
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs,
|
||||||
|
|
@ -1074,7 +913,7 @@
|
||||||
.ps-kpi > b{font-size:30px;}
|
.ps-kpi > b{font-size:30px;}
|
||||||
.ps-performance__metrics{grid-template-columns:1fr;gap:18px;}
|
.ps-performance__metrics{grid-template-columns:1fr;gap:18px;}
|
||||||
.ps-review-summary__grid{grid-template-columns:1fr;}
|
.ps-review-summary__grid{grid-template-columns:1fr;}
|
||||||
.ps-review-summary__grid > div{padding:12px 0;border-right:0;border-bottom:1px solid var(--border-faint);}
|
.ps-review-summary__grid > div{padding:12px 0;border-right:0;border-bottom:1px solid var(--hair);}
|
||||||
.ps-usage-table__head{display:none;}
|
.ps-usage-table__head{display:none;}
|
||||||
.ps-usage-row{grid-template-columns:minmax(0,1fr) repeat(2,58px);gap:8px;}
|
.ps-usage-row{grid-template-columns:minmax(0,1fr) repeat(2,58px);gap:8px;}
|
||||||
.ps-usage-row strong:nth-last-child(-n+2){display:none;}
|
.ps-usage-row strong:nth-last-child(-n+2){display:none;}
|
||||||
|
|
@ -1094,14 +933,14 @@
|
||||||
padding:14px;
|
padding:14px;
|
||||||
}
|
}
|
||||||
.ps-persona-row__identity{grid-column:1 / -1;}
|
.ps-persona-row__identity{grid-column:1 / -1;}
|
||||||
.ps-persona-row__usage{grid-column:1 / -1;padding:9px 0;border-top:1px solid var(--border-faint);border-bottom:1px solid var(--border-faint);}
|
.ps-persona-row__usage{grid-column:1 / -1;padding:9px 0;border-top:1px solid var(--hair);border-bottom:1px solid var(--hair);}
|
||||||
.ps-persona-row > span:nth-child(3),
|
.ps-persona-row > span:nth-child(3),
|
||||||
.ps-persona-row > span:nth-child(4){justify-self:start;}
|
.ps-persona-row > span:nth-child(4){justify-self:start;}
|
||||||
.ps-version{justify-self:end;}
|
.ps-version{justify-self:end;}
|
||||||
.ps-persona-row__actions{grid-column:1 / -1;justify-content:stretch;}
|
.ps-persona-row__actions{grid-column:1 / -1;justify-content:stretch;}
|
||||||
.ps-persona-row__actions .vg-btn{flex:1 1 0;}
|
.ps-persona-row__actions .vg-btn{flex:1 1 0;}
|
||||||
.ps-review-page__summary{grid-template-columns:1fr;}
|
.ps-review-page__summary{grid-template-columns:1fr;}
|
||||||
.ps-review-page__summary > div{border-right:0;border-bottom:1px solid var(--border-faint);}
|
.ps-review-page__summary > div{border-right:0;border-bottom:1px solid var(--hair);}
|
||||||
.ps-review-board{grid-template-columns:1fr;}
|
.ps-review-board{grid-template-columns:1fr;}
|
||||||
.ps-drill-head{grid-template-columns:1fr;}
|
.ps-drill-head{grid-template-columns:1fr;}
|
||||||
.ps-drill-head__actions{justify-content:flex-start;}
|
.ps-drill-head__actions{justify-content:flex-start;}
|
||||||
|
|
|
||||||
|
|
@ -405,9 +405,26 @@
|
||||||
min-width:0;
|
min-width:0;
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
|
/* 세로 가상화 스크롤 컨테이너. height:auto + max-height라 행이 적으면
|
||||||
|
내용 높이만 차지해 데드스페이스가 생기지 않는다. */
|
||||||
|
max-height:64vh;
|
||||||
overflow-x:auto;
|
overflow-x:auto;
|
||||||
|
overflow-y:auto;
|
||||||
scrollbar-gutter:stable;
|
scrollbar-gutter:stable;
|
||||||
}
|
}
|
||||||
|
/* 가상화 본문 — 전체 목록 높이를 확보하는 relative 캔버스.
|
||||||
|
column flex 컨테이너가 인라인 height를 축소하지 않도록 flex:none이 필수다. */
|
||||||
|
.pf-learner-table__body{
|
||||||
|
position:relative;
|
||||||
|
flex:none;
|
||||||
|
min-width:1000px;
|
||||||
|
}
|
||||||
|
.pf-learner-table__body .pf-learner-record{
|
||||||
|
position:absolute;
|
||||||
|
top:0;
|
||||||
|
left:0;
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
.pf-learner-table__head,
|
.pf-learner-table__head,
|
||||||
.pf-learner-row{
|
.pf-learner-row{
|
||||||
display:grid;
|
display:grid;
|
||||||
|
|
@ -441,7 +458,8 @@
|
||||||
min-width:1000px;
|
min-width:1000px;
|
||||||
border-top:1px solid var(--paper-2);
|
border-top:1px solid var(--paper-2);
|
||||||
}
|
}
|
||||||
.pf-learner-table__head + .pf-learner-record{
|
/* 가상화로 첫 행이 헤더의 형제가 아니게 되어 data-index 기준으로 상단 이중선을 막는다. */
|
||||||
|
.pf-learner-record[data-index="0"]{
|
||||||
border-top:0;
|
border-top:0;
|
||||||
}
|
}
|
||||||
.pf-learner-row{
|
.pf-learner-row{
|
||||||
|
|
@ -458,6 +476,15 @@
|
||||||
.pf-learner-row__tags{
|
.pf-learner-row__tags{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
}
|
}
|
||||||
|
/* 암시적 컬럼(auto = minmax(auto,max-content))은 자식의 width:max-content 요청을
|
||||||
|
그대로 수용해 셀 폭(261px)을 넘겨 288px까지 늘어났고, 그 결과 max-width:100%가
|
||||||
|
확장된 트랙 기준으로 풀려 ellipsis가 걸리지 않고 옆 컬럼을 침범했다.
|
||||||
|
트랙을 minmax(0,1fr)로 고정해야 셀 안에서 잘린다. (2026-07-27 겹침 회귀 수정) */
|
||||||
|
.pf-learner-row__id,
|
||||||
|
.pf-learner-row__metric,
|
||||||
|
.pf-learner-row__tag-summary{
|
||||||
|
grid-template-columns:minmax(0,1fr);
|
||||||
|
}
|
||||||
.pf-learner-row__id{
|
.pf-learner-row__id{
|
||||||
display:grid;
|
display:grid;
|
||||||
gap:3px;
|
gap:3px;
|
||||||
|
|
@ -465,7 +492,10 @@
|
||||||
.pf-learner-name-button{
|
.pf-learner-name-button{
|
||||||
width:max-content;
|
width:max-content;
|
||||||
max-width:100%;
|
max-width:100%;
|
||||||
padding:0;
|
/* 상세 진입의 주 조작이다. 줄높이만큼(19px)이면 WCAG 2.2 SC 2.5.8(24px)에
|
||||||
|
미달해 세로 여백으로 타깃을 채운다. */
|
||||||
|
min-height:24px;
|
||||||
|
padding:3px 0;
|
||||||
border:0;
|
border:0;
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
background:transparent;
|
background:transparent;
|
||||||
|
|
|
||||||
|
|
@ -946,10 +946,10 @@
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
.sr-nonverbal--paralinguistic .sr-nonverbal__dot {
|
.sr-nonverbal--paralinguistic .sr-nonverbal__dot {
|
||||||
background: var(--good-solid);
|
background: var(--pos-solid);
|
||||||
}
|
}
|
||||||
.sr-nonverbal--prosody .sr-nonverbal__dot {
|
.sr-nonverbal--prosody .sr-nonverbal__dot {
|
||||||
background: var(--ink-soft);
|
background: var(--ink-2);
|
||||||
}
|
}
|
||||||
.sr-nonverbal--audio_quality .sr-nonverbal__dot {
|
.sr-nonverbal--audio_quality .sr-nonverbal__dot {
|
||||||
background: var(--text-muted);
|
background: var(--text-muted);
|
||||||
|
|
@ -1389,7 +1389,7 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--accent);
|
color: var(--text-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-num);
|
font-family: var(--font-num);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
|
||||||
|
|
@ -2637,7 +2637,13 @@
|
||||||
.sx-prestart {
|
.sx-prestart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 0.82fr) minmax(360px, 1.72fr) minmax(260px, 0.98fr);
|
/* 트랙 최소치를 0으로 둬 컨테이너가 좁아져도 트랙이 끝까지 축소되게 한다(구조적 넘침 불가).
|
||||||
|
이전 minmax(220px,…) minmax(360px,…) minmax(260px,…) 는 최소 합 840px+gap 48px=888px 를
|
||||||
|
요구했는데, 뷰포트 1041~1240px 구간은 셸 사이드바를 뺀 실제 컨테이너가 735~886px 뿐이라
|
||||||
|
3열이 축소되지 못하고 우측 "진행 초점" 패널이 최대 195px 잘려 안 보였다.
|
||||||
|
overflow-x:hidden 이라 스크롤도 안 되고 문서 오버플로도 0 이라 기존 게이트가 못 잡았다.
|
||||||
|
아래 2열 전환(≤1260px)과 이 최소치 0 이 이중 안전망이다. (2026-07-27) */
|
||||||
|
grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.72fr) minmax(0, 0.98fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-5);
|
gap: var(--sp-5);
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|
@ -2794,13 +2800,16 @@
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
max-width: 58ch;
|
max-width: 58ch;
|
||||||
}
|
}
|
||||||
|
/* 사실 목록. repeat(3, 1fr) 고정이면 항목이 1개뿐인 상태(페르소나 로딩/폴백)에서도
|
||||||
|
칸이 1/3 로 쪼개져 값이 세로로 길게 잘렸다(결함 #2). auto-fit 으로 바꿔 남는 트랙을
|
||||||
|
접고, 항목 수·폭에 따라 1~3열로 스스로 접히게 한다. */
|
||||||
.sx-prestart__facts {
|
.sx-prestart__facts {
|
||||||
margin: var(--sp-4) 0 0;
|
margin: var(--sp-4) 0 0;
|
||||||
padding: var(--sp-4) 0;
|
padding: var(--sp-3) 0;
|
||||||
border-top: 1px solid var(--hair);
|
border-top: 1px solid var(--hair);
|
||||||
border-bottom: 1px solid var(--hair);
|
border-bottom: 1px solid var(--hair);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 96px), 1fr));
|
||||||
gap: var(--sp-4);
|
gap: var(--sp-4);
|
||||||
}
|
}
|
||||||
.sx-page--prestart .sx-prestart__facts {
|
.sx-page--prestart .sx-prestart__facts {
|
||||||
|
|
@ -2814,16 +2823,27 @@
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
/* 값은 2줄 클램프로 잘라 쓰던 자리다. 폴백 문구("실제 연습 대상 정보를 확인하고
|
||||||
|
있습니다.")가 320~421 등에서 113px 잘려 나가 클램프를 걷고 전문을 흘린다.
|
||||||
|
대신 데스크톱 한 화면 제약은 아래 min-width:1181px 블록에서 사실 블록 자체의
|
||||||
|
최대 높이 + 내부 스크롤로 지킨다. */
|
||||||
.sx-prestart__facts dd {
|
.sx-prestart__facts dd {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
overflow: hidden;
|
display: block;
|
||||||
display: -webkit-box;
|
overflow: visible;
|
||||||
-webkit-line-clamp: 2;
|
overflow-wrap: anywhere;
|
||||||
-webkit-box-orient: vertical;
|
}
|
||||||
|
/* 데스크톱은 문서 스크롤 없이 한 화면에 들어가야 하므로, 페르소나 요약이 아주 길 때만
|
||||||
|
사실 블록 안에서 스크롤되게 상한을 둔다(잘라내지 않는다). */
|
||||||
|
@media (min-width: 1181px) {
|
||||||
|
.sx-prestart__facts {
|
||||||
|
max-height: 152px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sx-prestart__chips {
|
.sx-prestart__chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -2847,9 +2867,12 @@
|
||||||
background: var(--clay-tint);
|
background: var(--clay-tint);
|
||||||
color: var(--clay-deep);
|
color: var(--clay-deep);
|
||||||
}
|
}
|
||||||
|
/* 아래 두 여백(이론모드 위 / 시작 버튼 위)은 dd 클램프를 걷어내면서 늘어난 세로
|
||||||
|
12px 을 되돌려 받는 자리다. 프리스타트는 1280x900 에서 문서 스크롤 없이 들어가야
|
||||||
|
한다(이 파일 상단 .vg-main:has(.sx-prestart--...) 규칙과 같은 제약). */
|
||||||
.sx-theory {
|
.sx-theory {
|
||||||
width: min(100%, 520px);
|
width: min(100%, 520px);
|
||||||
margin: var(--sp-4) 0 0;
|
margin: var(--sp-3) 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -2913,12 +2936,16 @@
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
/* 상태 안내문. 예전에는 목표 fieldset 과의 간격을 줄이려고 margin-top 을 -12px 로
|
||||||
|
당겨 썼는데, .sx-goals 는 마진이 0 으로 끝나는 블록이라 그 -12px 이 그대로
|
||||||
|
fieldset 마지막 줄(목표 힌트) 위로 파고들었다. 320~1536 전 폭에서 88x12 겹침이
|
||||||
|
재현돼(프리스타트 결함 #1) 음수 마진을 걷어내고 양수 간격으로 되돌린다. */
|
||||||
.sx-prestart__note {
|
.sx-prestart__note {
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin-top: calc(var(--sp-3) * -1);
|
margin-top: var(--sp-2);
|
||||||
}
|
}
|
||||||
.sx-prestart__err {
|
.sx-prestart__err {
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
|
|
@ -2956,7 +2983,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-4);
|
gap: var(--sp-4);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: var(--sp-5);
|
margin-top: var(--sp-4);
|
||||||
}
|
}
|
||||||
.sx-prestart__actions span {
|
.sx-prestart__actions span {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -3036,7 +3063,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 반응형 ── */
|
/* ── 반응형 ── */
|
||||||
@media (max-width: 1040px) {
|
/* 3열이 실제로 들어가는 최소 컨테이너는 888px 이고, 그 폭은 뷰포트 1241px 부터 확보된다.
|
||||||
|
기존 전환점 1040px 은 200px 짜리 사각지대(1041~1240)를 남겨 우측 패널이 잘렸다.
|
||||||
|
안전 마진을 두고 1260px 까지 2열(사이드 패널은 하단 전체 폭)로 내린다. (2026-07-27) */
|
||||||
|
@media (max-width: 1260px) {
|
||||||
.sx-prestart {
|
.sx-prestart {
|
||||||
grid-template-columns: minmax(180px, 230px) minmax(0, 1fr);
|
grid-template-columns: minmax(180px, 230px) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
@ -3448,6 +3478,14 @@
|
||||||
.sx-page--active .sx-seg-block__label {
|
.sx-page--active .sx-seg-block__label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
/* 절대배치 넛지가 좁은 폭에서 음성 건너뛰기 등 위쪽 컨트롤을 덮어 클릭을
|
||||||
|
가로채던 문제(결함 로그 #18) — 모바일에서는 플로우 배치로 전환한다. */
|
||||||
|
.sx-page--active .sx-coach-nudge {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
}
|
||||||
.sx-page--active .sx-segmented button {
|
.sx-page--active .sx-segmented button {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
|
|
@ -3590,23 +3628,22 @@
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
/* 2줄 클램프를 쓰던 자리. 본문 열이 좁아지는 320~421 에서 이 문장이 73px 잘려
|
||||||
|
나갔다(결함 #3). 1180 이하는 어차피 세로 스크롤이 있는 구간이라 전문을 흘린다. */
|
||||||
.sx-page--prestart .sx-prestart__desc {
|
.sx-page--prestart .sx-prestart__desc {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
overflow: hidden;
|
display: block;
|
||||||
display: -webkit-box;
|
overflow: visible;
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
}
|
||||||
.sx-page--prestart .sx-prestart__facts {
|
.sx-page--prestart .sx-prestart__facts {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 132px), 1fr));
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
.sx-page--prestart .sx-prestart__facts dd {
|
.sx-page--prestart .sx-prestart__facts dd {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
}
|
}
|
||||||
.sx-page--prestart .sx-prestart__chips {
|
.sx-page--prestart .sx-prestart__chips {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -3766,6 +3803,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 프리스타트 아주 좁은 폭: 1180 이하 규칙이 아바타 열을 140px 로 고정해 두는데,
|
||||||
|
460px 아래에서는 본문 열에 128px 밖에 남지 않아 설명문·목표 버튼·사실값이 전부
|
||||||
|
짜부러졌다(결함 #2·#3 의 실제 원인). 이 구간만 세로 1열로 쌓는다. */
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.sx-page--prestart .sx-prestart {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) and (max-height: 820px) {
|
@media (max-width: 1180px) and (max-height: 820px) {
|
||||||
.sx-sessionbar {
|
.sx-sessionbar {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
|
|
@ -4090,6 +4140,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 시간 알람 바(10분 전 경고 · 시간 만료) — 강제 노출 배너 ── */
|
/* ── 시간 알람 바(10분 전 경고 · 시간 만료) — 강제 노출 배너 ── */
|
||||||
|
/* 시간 알람 바가 있으면 페이지 그리드가 4자식이 된다. 3행 템플릿 그대로면
|
||||||
|
타임바가 1fr 행을 차지하고 본 그리드가 min-content로 밀려 모바일에서
|
||||||
|
컨텍스트 패널이 타임바를 덮는 겹침이 생긴다(결함 로그 #18). */
|
||||||
|
.sx-page--active:has(> .sx-timebar) {
|
||||||
|
grid-template-rows: min-content min-content minmax(0, 1fr) min-content;
|
||||||
|
}
|
||||||
.sx-timebar {
|
.sx-timebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -591,6 +591,55 @@
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
/* 업로드된 아바타 이미지 — 원형 프레임을 그대로 채운다(이니셜 폴백 대체). */
|
||||||
|
.vg-set__avatar img,
|
||||||
|
.vg-set__rail-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* 아바타 업로드 컨트롤 — 숨은 file input + 버튼(온보딩과 같은 계약). */
|
||||||
|
.vg-set__avatar-actions {
|
||||||
|
position: relative;
|
||||||
|
margin-left: auto;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.vg-set__avatar-input {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 1px;
|
||||||
|
block-size: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 동의 섹션 — 철회 확인·재동의 안내 블록 ── */
|
||||||
|
.vg-set__consent-block {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-surface-2);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--text-body);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.vg-set__consent-block > span {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 240px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.vg-set__consent-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 모델/엔진 메타 한 줄(읽기전용 상태 표기) ── */
|
/* ── 모델/엔진 메타 한 줄(읽기전용 상태 표기) ── */
|
||||||
.vg-set__meta {
|
.vg-set__meta {
|
||||||
|
|
@ -1032,9 +1081,48 @@
|
||||||
.vg-set__range-control {
|
.vg-set__range-control {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* WCAG 2.2 SC 2.5.8(타깃 최소 24px): 브라우저 기본 range 는 높이 16px 라 미달이다.
|
||||||
|
thumb 을 24px 로 잡되 투명 테두리 + background-clip:content-box 로 실제로 칠하는
|
||||||
|
원은 16px 로 남긴다. 시각 크기는 그대로, 포인터 적중 영역만 24px 로 넓어진다. */
|
||||||
.vg-set__range-control input[type="range"] {
|
.vg-set__range-control input[type="range"] {
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
display: block;
|
display: block;
|
||||||
|
height: 24px;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.vg-set__range-control input[type="range"]::-webkit-slider-runnable-track {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
}
|
||||||
|
.vg-set__range-control input[type="range"]::-moz-range-track {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
}
|
||||||
|
.vg-set__range-control input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: -10px;
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
.vg-set__range-control input[type="range"]::-moz-range-thumb {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
.vg-set__meta {
|
.vg-set__meta {
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
|
|
@ -1281,15 +1369,18 @@ body[data-page="settings"] #set-support {
|
||||||
body[data-page="settings"] #set-engine {
|
body[data-page="settings"] #set-engine {
|
||||||
order: 3;
|
order: 3;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] #set-appearance {
|
body[data-page="settings"] #set-consent {
|
||||||
order: 4;
|
order: 4;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] #set-voice {
|
body[data-page="settings"] #set-appearance {
|
||||||
order: 5;
|
order: 5;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] #set-notify {
|
body[data-page="settings"] #set-voice {
|
||||||
order: 6;
|
order: 6;
|
||||||
}
|
}
|
||||||
|
body[data-page="settings"] #set-notify {
|
||||||
|
order: 7;
|
||||||
|
}
|
||||||
body[data-page="settings"] .vg-set__group {
|
body[data-page="settings"] .vg-set__group {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|
@ -1404,48 +1495,92 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
background: var(--glass-surface-inset);
|
background: var(--glass-surface-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
/* 721~1080px 구간을 상단 가로 탭으로 접는다.
|
||||||
body[data-page="settings"] .vg-set {
|
이 폭에서 좌측 레일 배치가 두 갈래로 깨져 서브네비 라벨 7개가 모두 폭 0 이 됐다.
|
||||||
grid-template-columns: 86px minmax(0, 1fr);
|
(1) 981~1080px: 공용 `@media (max-width:1080px) .vg-set__rail{display:grid;
|
||||||
gap: 18px;
|
grid-template-columns:minmax(220px,280px) minmax(0,1fr)}` 가 이 페이지 레일까지
|
||||||
max-width: none;
|
덮어썼다. 레일 트랙은 244px 뿐이라 실측상 `220px 0px` 로 풀려 nav 폭 0,
|
||||||
|
nav-item 폭 28px 로 찌그러졌다(981·1023~1025·1079~1081 재현).
|
||||||
|
(2) 721~980px: 이전 980 미디어쿼리가 레일을 86px 아이콘 전용으로 만들며
|
||||||
|
`.vg-set__nav-item span{display:none}` 으로 라벨을 통째로 숨겼다.
|
||||||
|
두 경우 모두 라벨이 사라지므로, 이 프로젝트가 선호하는 가로 탭 전환으로 통일한다.
|
||||||
|
244px+28px+820px = 1092px 를 요구하는 2열 배치는 1081px 이상에서만 쓴다. */
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
body[data-page="settings"] .vg-set__callout {
|
||||||
|
margin: var(--sp-4) 24px 0;
|
||||||
}
|
}
|
||||||
|
body[data-page="settings"] .vg-set {
|
||||||
|
display: block;
|
||||||
|
min-height: 100dvh;
|
||||||
|
max-width: none;
|
||||||
|
padding: 0 24px 28px;
|
||||||
|
}
|
||||||
|
/* 공용 1080 미디어쿼리의 2열 grid 를 무효화해 레일 전체를 가로 탭 바로 되돌린다.
|
||||||
|
이 구간에서는 아래 폼 카드와 같은 폭·라운드로 맞춰 탭이 카드 밖으로 삐져나오지 않게 한다. */
|
||||||
body[data-page="settings"] .vg-set__rail {
|
body[data-page="settings"] .vg-set__rail {
|
||||||
padding: 18px 10px;
|
display: block;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
min-height: auto;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__rail-title {
|
body[data-page="settings"] .vg-set__rail-title {
|
||||||
justify-content: center;
|
gap: 10px;
|
||||||
padding: 0 0 16px;
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set__rail-title h1 {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set__rail-title p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set__rail-mark {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__rail-title div,
|
|
||||||
body[data-page="settings"] .vg-set__nav-item span,
|
|
||||||
body[data-page="settings"] .vg-set__rail-status {
|
body[data-page="settings"] .vg-set__rail-status {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
/* 탭이 많아 폭이 모자라면 가로 스크롤로 흘린다(라벨은 계속 노출). */
|
||||||
|
body[data-page="settings"] .vg-set__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0 0 2px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
body[data-page="settings"] .vg-set__nav-item {
|
body[data-page="settings"] .vg-set__nav-item {
|
||||||
justify-content: center;
|
flex: 0 0 auto;
|
||||||
min-height: 48px;
|
min-height: 38px;
|
||||||
padding: 0;
|
gap: 7px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set__nav-item span {
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__forms {
|
body[data-page="settings"] .vg-set__forms {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ≤720px 는 위 1080 가로 탭 배치를 그대로 이어받고 여백·타이포만 더 좁힌다.
|
||||||
|
display/position/overflow 같은 배치 선언은 1080 블록에 한 번만 두고 여기선 중복 제거. */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
body[data-page="settings"] .vg-set__callout {
|
body[data-page="settings"] .vg-set__callout {
|
||||||
margin: 12px 12px 0;
|
margin: 12px 12px 0;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set {
|
body[data-page="settings"] .vg-set {
|
||||||
display: block;
|
|
||||||
min-height: 100dvh;
|
|
||||||
padding: 0 16px 28px;
|
padding: 0 16px 28px;
|
||||||
}
|
}
|
||||||
|
/* ≤720px 만 full-bleed 상단 바(모바일 헤더 패턴)로 되돌린다. */
|
||||||
body[data-page="settings"] .vg-set__rail {
|
body[data-page="settings"] .vg-set__rail {
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 20;
|
|
||||||
min-height: auto;
|
|
||||||
margin: 0 -16px 12px;
|
margin: 0 -16px 12px;
|
||||||
padding: 13px 16px 10px;
|
padding: 13px 16px 10px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|
@ -1456,44 +1591,27 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 0 10px;
|
padding: 0 0 10px;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__rail-title div {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
body[data-page="settings"] .vg-set__rail-title h1 {
|
body[data-page="settings"] .vg-set__rail-title h1 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__rail-title p {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
body[data-page="settings"] .vg-set__rail-mark {
|
body[data-page="settings"] .vg-set__rail-mark {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__nav {
|
body[data-page="settings"] .vg-set__nav {
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__nav-item {
|
body[data-page="settings"] .vg-set__nav-item {
|
||||||
flex: 0 0 auto;
|
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 0 7px;
|
padding: 0 7px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__nav-item svg {
|
body[data-page="settings"] .vg-set__nav-item svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__nav-item span {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
body[data-page="settings"] .vg-set__forms {
|
body[data-page="settings"] .vg-set__forms {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ body {
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
/* 한국어 조판: 기본값(normal)은 한글을 음절 단위로 끊어 "오전"이 "오/전",
|
||||||
|
"검토 대기"가 "검/토 대기"로 쪼개진다. 어절 단위로 끊고, 끊을 수 없는 긴
|
||||||
|
토큰(URL·ID)만 강제 줄바꿈한다. 세션 화면이 쓰던 규칙을 전역으로 올렸다. */
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 링크 기본: accent 텍스트, 밑줄은 hover에서만 (양산형 회피) */
|
/* 링크 기본: accent 텍스트, 밑줄은 hover에서만 (양산형 회피) */
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,14 @@
|
||||||
--neutral-100: #f0ede6; /* 표면 hover / subtle fill */
|
--neutral-100: #f0ede6; /* 표면 hover / subtle fill */
|
||||||
--neutral-150: #e4dccf; /* 금빛이 아주 옅게 섞인 hairline */
|
--neutral-150: #e4dccf; /* 금빛이 아주 옅게 섞인 hairline */
|
||||||
--neutral-200: #d7cdbd; /* divider 진함 */
|
--neutral-200: #d7cdbd; /* divider 진함 */
|
||||||
--neutral-400: #9b9a92; /* placeholder / disabled */
|
--neutral-400: #6f6e68; /* placeholder / disabled (기존 #9b9a92 — 2.4:1 AA 미달) */
|
||||||
|
|
||||||
--ink: #17231f; /* 시안의 거의 검정인 웜 포레스트 잉크 */
|
--ink: #17231f; /* 시안의 거의 검정인 웜 포레스트 잉크 */
|
||||||
--ink-2: #5f635d; /* 보조 텍스트 */
|
/* 2026-07-27 접근성 보정: 라이트 3단 위계를 유지하면서 전부 WCAG AA(4.5:1)를
|
||||||
--ink-3: #8f928a; /* 캡션/메타 */
|
넘긴다. 최악 조건은 가장 어두운 표면 --glass-canvas(#f2eee5) 기준으로 잰다.
|
||||||
|
강도 13.9:1 · 본문 6.9:1 · 캡션 4.6:1 (색상은 원래 웜 그레이 색조 유지). */
|
||||||
|
--ink-2: #4d514b; /* 보조 텍스트 (기존 #5f635d — 위계 간격 확보용 보정) */
|
||||||
|
--ink-3: #6a6c65; /* 캡션/메타 (기존 #8f928a — 2.7:1 AA 미달이라 보정) */
|
||||||
--hair: #e4dccf; /* 1px 헤어라인 */
|
--hair: #e4dccf; /* 1px 헤어라인 */
|
||||||
--botanical-gold: #b49a67;
|
--botanical-gold: #b49a67;
|
||||||
--botanical-hair: rgba(180, 154, 103, 0.28);
|
--botanical-hair: rgba(180, 154, 103, 0.28);
|
||||||
|
|
@ -80,6 +83,10 @@
|
||||||
--text-body: var(--ink-2); /* 본문 보조 */
|
--text-body: var(--ink-2); /* 본문 보조 */
|
||||||
--text-muted: var(--ink-3); /* 캡션 */
|
--text-muted: var(--ink-3); /* 캡션 */
|
||||||
--text-on-accent: #fbfaf8; /* accent 버튼 위 텍스트 */
|
--text-on-accent: #fbfaf8; /* accent 버튼 위 텍스트 */
|
||||||
|
/* accent를 "글자색"으로 쓸 때 전용 별칭.
|
||||||
|
--accent(#3e7a6e)는 채움색 기준으로 고른 값이라 어두운 표면(--glass-canvas)
|
||||||
|
위 본문 크기에서 4.3:1로 AA에 미달한다. 텍스트는 항상 이 별칭을 쓴다. */
|
||||||
|
--text-accent: var(--accent-deep);
|
||||||
|
|
||||||
--border-subtle: var(--neutral-150);
|
--border-subtle: var(--neutral-150);
|
||||||
--border-strong: var(--neutral-200);
|
--border-strong: var(--neutral-200);
|
||||||
|
|
@ -155,11 +162,13 @@
|
||||||
--neutral-100: #1b2725;
|
--neutral-100: #1b2725;
|
||||||
--neutral-150: #2b332f;
|
--neutral-150: #2b332f;
|
||||||
--neutral-200: #3b4039;
|
--neutral-200: #3b4039;
|
||||||
--neutral-400: #747970;
|
/* 2026-07-27 접근성 보정: 다크 표면 중 가장 밝은 카드(#223330) 기준으로도
|
||||||
|
AA(4.5:1)를 넘도록 올렸다. 기존 값은 placeholder 2.97:1, 캡션 4.08:1 이었다. */
|
||||||
|
--neutral-400: #949b8f; /* placeholder / disabled (기존 #747970) */
|
||||||
|
|
||||||
--ink: #eee7d9;
|
--ink: #eee7d9;
|
||||||
--ink-2: #c4bfb3;
|
--ink-2: #c4bfb3;
|
||||||
--ink-3: #8d9086;
|
--ink-3: #979a8f; /* 캡션/메타 (기존 #8d9086) */
|
||||||
--hair: #30372f;
|
--hair: #30372f;
|
||||||
--botanical-gold: #b9a275;
|
--botanical-gold: #b9a275;
|
||||||
--botanical-hair: rgba(185, 162, 117, 0.26);
|
--botanical-hair: rgba(185, 162, 117, 0.26);
|
||||||
|
|
@ -183,9 +192,11 @@
|
||||||
--bg-surface-2: #111c1c;
|
--bg-surface-2: #111c1c;
|
||||||
--bg-tint: #192522;
|
--bg-tint: #192522;
|
||||||
--bg-stage: #091315;
|
--bg-stage: #091315;
|
||||||
--text-strong: #eee7d9;
|
/* 라이트와 동일하게 primitive 별칭으로 잇는다. 이전엔 여기서 값을 다시 하드코딩해
|
||||||
--text-body: #c4bfb3;
|
위쪽 --ink-* 를 고쳐도 다크에는 반영되지 않는 이중 진실원본이었다. */
|
||||||
--text-muted: #8d9086;
|
--text-strong: var(--ink);
|
||||||
|
--text-body: var(--ink-2);
|
||||||
|
--text-muted: var(--ink-3);
|
||||||
--text-on-accent: #0f1a17;
|
--text-on-accent: #0f1a17;
|
||||||
--border-subtle: #30372f;
|
--border-subtle: #30372f;
|
||||||
--border-strong: #45483e;
|
--border-strong: #45483e;
|
||||||
|
|
|
||||||
14
docs/TODO.md
14
docs/TODO.md
|
|
@ -55,6 +55,20 @@
|
||||||
검증 수치는 SSOT 대시보드만 소유한다. 남은 후속은 저장형 fallback/synthetic 계약, 지연을 줄이는 partial stream buffer,
|
검증 수치는 SSOT 대시보드만 소유한다. 남은 후속은 저장형 fallback/synthetic 계약, 지연을 줄이는 partial stream buffer,
|
||||||
fast/deep reconciliation, resident session namespace, 임상팀 루브릭/CBT/약물·평가 문구 확정.
|
fast/deep reconciliation, resident session namespace, 임상팀 루브릭/CBT/약물·평가 문구 확정.
|
||||||
|
|
||||||
|
## D-2. 전수 E2E 순회 소유자 결정 — **전건 결정 완료(2026-07-27)** → 구현 후속 [구현]
|
||||||
|
> 출처: `ops/e2e-full-sweep-2026-07-27.md` 발견 결함 로그 · `ops/backlog-2026-06-26.md` B3-신규.
|
||||||
|
> 결함 12건은 RED→GREEN으로 수정 완료(대시보드 143차). 소유자 결정 7건은 질문 도구로 전건 확정.
|
||||||
|
|
||||||
|
- [x] **설정 아바타 변경 UI 구현** — RED→GREEN 완료(full-sweep-settings 4/4, 무회귀 44/44)
|
||||||
|
- [x] **동의 철회 전체 흐름 구현** — 설정 동의 섹션+철회 2단계 확인+재동의, 실 API round-trip GREEN
|
||||||
|
- [x] **신규 학습자 기초 우선 추천 구현** — `starterPersona()` 난도 오름차순, 무회귀 42/42
|
||||||
|
- [x] **학생 분석 테이블 가상화 구현** — @tanstack/react-virtual, 300행 RED→GREEN, 무회귀 40/40+게이트 4/4
|
||||||
|
- [x] **저작 모드 좌측 레일 마크업 정리** — 레일 JSX·CSS 삭제, dead-code 게이트 통과
|
||||||
|
- [x] dev-login 재로그인 자동 재승인 — 결정: 현행 유지(코드 변경 없음, 종결)
|
||||||
|
- [x] **감정 밸런스 타임라인 차트 구현** — deep `turn_valence`+결정론 파생 폴백, 백엔드 421/421, 라이브 5pt 실측
|
||||||
|
|
||||||
|
> 전체 검증 수치·상세는 SSOT 대시보드 144차 노트와 `ops/e2e-full-sweep-2026-07-27.md`가 소유한다.
|
||||||
|
|
||||||
## E. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시)
|
## E. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시)
|
||||||
> 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates.
|
> 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates.
|
||||||
> **2026-07-02 실측**: 로드맵 상당수는 이미 해결(turn_runtime·tier 제거·eval_hook 주입 등). 아래는 코드 재확인 기준 현행 상태.
|
> **2026-07-02 실측**: 로드맵 상당수는 이미 해결(turn_runtime·tier 제거·eval_hook 주입 등). 아래는 코드 재확인 기준 현행 상태.
|
||||||
|
|
|
||||||
|
|
@ -565,6 +565,10 @@
|
||||||
<p class="dg-note">128차 적용(2026-07-03): 127차 후속 후보였던 live coach LLM audit 실패 은폐를 닫았다. <code>record_llm_call_audit()</code>가 DB pool 또는 <code>audit.llm_call_log</code> insert 실패로 <code>False</code>를 반환해도 기존에는 <code>generate_live_coaching()</code>이 이를 무시해 정상 <code>status=ready</code> AI 코칭처럼 보일 수 있었다. 이제 live coach는 audit hook의 <code>False</code> 또는 예외를 감사 저장 미확인으로 보고, LLM structured 응답을 버린 뒤 규칙 기반 <code>status=degraded</code> 코칭으로 내려간다. 상담 루프는 막지 않되 사용자에게 정상 AI 코칭처럼 보이지 않게 하는 조치다. 검증: <code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m py_compile apps/api/app/services/live_coach.py apps/api/app/test_session_turn_persistence.py</code>, <code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_session_turn_persistence.py -k "live_coach" -q</code> 7 passed. 로컬 작업트리 기준이며 배포 전이다.</p>
|
<p class="dg-note">128차 적용(2026-07-03): 127차 후속 후보였던 live coach LLM audit 실패 은폐를 닫았다. <code>record_llm_call_audit()</code>가 DB pool 또는 <code>audit.llm_call_log</code> insert 실패로 <code>False</code>를 반환해도 기존에는 <code>generate_live_coaching()</code>이 이를 무시해 정상 <code>status=ready</code> AI 코칭처럼 보일 수 있었다. 이제 live coach는 audit hook의 <code>False</code> 또는 예외를 감사 저장 미확인으로 보고, LLM structured 응답을 버린 뒤 규칙 기반 <code>status=degraded</code> 코칭으로 내려간다. 상담 루프는 막지 않되 사용자에게 정상 AI 코칭처럼 보이지 않게 하는 조치다. 검증: <code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m py_compile apps/api/app/services/live_coach.py apps/api/app/test_session_turn_persistence.py</code>, <code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_session_turn_persistence.py -k "live_coach" -q</code> 7 passed. 로컬 작업트리 기준이며 배포 전이다.</p>
|
||||||
<p class="dg-note">129차 적용(2026-07-13): 재부팅 뒤 Chrome이 기존 <code>/admin</code> 탭의 문서 스크롤을 복원하면서 sticky topbar/sidebar만 남고 관리자 본문은 화면 위로 밀리던 silent blank를 재현·수정했다. RED 재현은 길이 2200px 문서에서 <code>scrollY=900</code>을 만든 뒤 persisted <code>pageshow</code>를 보내 수정 전 <code>scrollY=1300</code>과 빈 main을 확인했다. <code>App.tsx</code>는 <code>history.scrollRestoration=manual</code>, <code>pageshow</code> reset, pathname layout-effect와 다음 frame reset을 함께 적용한다. 로컬 focused E2E는 desktop/mobile route 이동·복원 탭 4 passed, typecheck/build passed. Cloudflare Pages production <code>0daabb1b</code> 배포 뒤 custom domain은 <code>assets/index-BhyOF3rL.js</code>를 서빙하며, 15분 일회성 관리자 세션으로 운영 홈·사용자·권한·티켓과 복원 탭을 공개 사이트에서 확인한 <code>public-admin-visual</code> 3 passed 후 세션을 즉시 삭제했다. 이후 concurrent avatar 변경이 섞인 배포는 Cloudflare Pages rollback API로 <code>0daabb1b</code>에 되돌렸다.</p>
|
<p class="dg-note">129차 적용(2026-07-13): 재부팅 뒤 Chrome이 기존 <code>/admin</code> 탭의 문서 스크롤을 복원하면서 sticky topbar/sidebar만 남고 관리자 본문은 화면 위로 밀리던 silent blank를 재현·수정했다. RED 재현은 길이 2200px 문서에서 <code>scrollY=900</code>을 만든 뒤 persisted <code>pageshow</code>를 보내 수정 전 <code>scrollY=1300</code>과 빈 main을 확인했다. <code>App.tsx</code>는 <code>history.scrollRestoration=manual</code>, <code>pageshow</code> reset, pathname layout-effect와 다음 frame reset을 함께 적용한다. 로컬 focused E2E는 desktop/mobile route 이동·복원 탭 4 passed, typecheck/build passed. Cloudflare Pages production <code>0daabb1b</code> 배포 뒤 custom domain은 <code>assets/index-BhyOF3rL.js</code>를 서빙하며, 15분 일회성 관리자 세션으로 운영 홈·사용자·권한·티켓과 복원 탭을 공개 사이트에서 확인한 <code>public-admin-visual</code> 3 passed 후 세션을 즉시 삭제했다. 이후 concurrent avatar 변경이 섞인 배포는 Cloudflare Pages rollback API로 <code>0daabb1b</code>에 되돌렸다.</p>
|
||||||
<p class="dg-note">130차 적용(2026-07-13): 운영 <code>/voice/health</code>는 OpenAI 설정을 정상으로 보고했고 실제 운영 키 직접 smoke도 98,133-byte/11.68초 MP3를 반환했지만, 운영 로그에는 사용자의 voice WebSocket 연결이 없어 텍스트 턴 AI 응답이 TTS 경로에 연결되지 않은 공백으로 좁혔다. 인증된 <code>POST /voice/speech</code>는 <code>session_id</code>/<code>turn_seq</code>로 소유 회기와 이미 저장된 client-visible 내담자 응답을 다시 검증한 뒤 기존 voice map/OpenAI TTS로 MP3를 반환한다. Session은 텍스트 전송 user gesture에서 AudioContext를 먼저 unlock하고, 합성 Blob을 기존 Web Audio/Analyser 재생기로 보내며 새 턴이 시작되면 이전 요청을 무효화한다. 검증: backend voice 32 passed, API types check, web typecheck, <code>voice-success</code> text TTS+별도 mic 회기 UI focused 1 passed.</p>
|
<p class="dg-note">130차 적용(2026-07-13): 운영 <code>/voice/health</code>는 OpenAI 설정을 정상으로 보고했고 실제 운영 키 직접 smoke도 98,133-byte/11.68초 MP3를 반환했지만, 운영 로그에는 사용자의 voice WebSocket 연결이 없어 텍스트 턴 AI 응답이 TTS 경로에 연결되지 않은 공백으로 좁혔다. 인증된 <code>POST /voice/speech</code>는 <code>session_id</code>/<code>turn_seq</code>로 소유 회기와 이미 저장된 client-visible 내담자 응답을 다시 검증한 뒤 기존 voice map/OpenAI TTS로 MP3를 반환한다. Session은 텍스트 전송 user gesture에서 AudioContext를 먼저 unlock하고, 합성 Blob을 기존 Web Audio/Analyser 재생기로 보내며 새 턴이 시작되면 이전 요청을 무효화한다. 검증: backend voice 32 passed, API types check, web typecheck, <code>voice-success</code> text TTS+별도 mic 회기 UI focused 1 passed.</p>
|
||||||
|
<p class="dg-note">144차 적용(2026-07-27): 143차 전수 순회에서 소유자 결정 대기로 남긴 7건을 <b>질문 도구로 전건 결정받아 6건 구현 완료</b>(RED→GREEN, 5개 병렬 구현 에이전트). ① <b>설정 아바타 변경</b> — 계정 섹션에 온보딩과 같은 계약(3MB·PNG/JPEG/WebP)의 업로드 컨트롤+미리보기, PATCH /users/me avatar_url 저장 후 refresh로 톱바·레일까지 갱신. ② <b>동의 관리·철회 전체 흐름</b> — 새 <code>#set-consent</code> 섹션(실습 동의 시각·약관/개인정보 버전 표시), learner 철회 버튼(2단계 확인)→차단 안내→"다시 동의" 재동의, 세션 프리스타트의 기존 동의 게이트와 연결(철회 함정 상태 해소, dead code였던 <code>withdrawConsent</code> 실사용화), e2e는 고유 계정으로만 철회 수행. ③ <b>신규 학습자 기초 우선 추천</b> — 회기 0건 학습자의 기본 선택·첫 회기 준비 카드를 난도 오름차순 첫 페르소나로(<code>starterPersona()</code>), 기록 보유·조회 실패 시 기존 규칙 유지. ④ <b>학생 분석 테이블 가상화</b> — <code>@tanstack/react-virtual</code>(신규 의존성) 세로 가상화, 동적 행 높이 measureElement·펼침 재측정, 300행 fixture에서 렌더 행 <60 검증, 데드스페이스 없게 max-height는 내용 이하로. ⑤ <b>저작 모드 좌측 레일 마크업 정리</b> — 어떤 경로에서도 보이지 않던 죽은 DOM(ps-rail)과 전용 CSS 삭제, 특성화 테스트 toHaveCount(0) 강화. ⑥ <b>감정 밸런스 타임라인 차트 구현</b>(143차 미구현 항목) — 혼합 접근: deep 평가 스키마에 <code>turn_valence</code>(seq·v −1~+1, 극단값 회피 지시) 신설 + 저장 데이터 결정론 파생 폴백(counselorBaseline=학습자 턴 rapport_signal 누적 이동평균, clientValence=deep 값 우선/client_state 극성+appropriateness 결합 폴백), 2점 미만 빈 배열·10점 초과 균등 리샘플·축 라벨 4개, hidden_turns 시 비산출로 프라이버시 계약 유지. dev-login 재승인은 현행 유지로 종결. <b>검증</b>: 각 과제 RED 관측→GREEN(에이전트별 증거 로그), 백엔드 pytest <code>421 passed</code>(신규 <code>test_review_valence.py</code> 9 포함), gateway 29, typecheck/build/design-ssot/dead-code/duplication(0.03%) 통과, focused 무회귀 44/42/40/20/14, professor·persona 레이아웃 게이트 통과, 감정 차트는 실DB 공감 5턴 세션에서 baseline·client 각 5포인트/축 4라벨 라이브 실측. 전 스위트 재완주 수치는 체크리스트 문서 §0에 기록. 부수 관찰: <code>smoke-resistance-openness-db.py</code>의 advice_jump turn_client_state ≥5 단언이 엔진 변동성에 민감(결함 로그 #23, 코드 회귀 아님). 체크리스트 415항목 미체크 0 도달. 로컬 작업트리 기준이며 배포 전이다.</p>
|
||||||
|
<p class="dg-note">144차 적용(2026-07-27): <b>브레이크포인트 경계 전수 스윕</b>. 142차 접근성 감사가 1440·390 두 폭만 봤고 기존 <code>layout-visual-gate</code>도 7개 폭(390/720/861/900/1024/1280/1440)만 검사한다는 소유자 지적에서 출발했다. CSS 전체에서 고유 width 브레이크포인트 <b>25개</b>(340~1420)를 추출해 파일→페이지로 매핑하고, 페이지마다 자기에게 영향을 주는 브레이크포인트의 <b>B-1 / B / B+1</b> 과 실기기 대표 폭을 조합해(페이지당 17~36폭 × 높이 2종) 순회하며 <b>요소 겹침 · 텍스트 잘림 · 폭 붕괴 · 화면 밖 이탈 · 문서 오버플로</b>를 계산으로 검출했다. 1차 스윕에서 <b>156개 문제 뷰포트 / 11종 결함</b>이 나왔다. <b>수정한 결함</b>: ① <b>설정 서브네비 라벨 전면 소멸</b>(721~1080px 전 구간) — 공용 <code>@media (max-width:1080px)</code>의 <code>.vg-set__rail{display:grid; grid-template-columns:minmax(220px,280px) minmax(0,1fr)}</code>가 <code>body[data-page="settings"]</code>까지 덮어써 244px 레일 안이 다시 2열로 쪼개졌고 둘째 트랙이 0px로 붕괴, nav 폭 0 · nav-item 28px · 라벨 span 7개(계정/AI 운영/지원 요청/동의/테마/알림/음성) 폭 0이 됐다. 721~980px 는 별개로 <code>.vg-set__nav-item span{display:none}</code>이 라벨을 통째로 숨기고 있었다. 721~1080px 를 상단 가로 탭으로 통일해 라벨을 항상 노출한다. ② <b>세션 프리스타트 목표 fieldset 겹침</b>(전 폭 72뷰포트) — <code>.sx-prestart__note</code>의 <code>margin-top:-12px</code> 음수 마진이 앞 형제 <code>fieldset.sx-goals</code> 마지막 줄을 88x12px 파고들었다. ③ <b>프리스타트 dd·desc 세로 잘림</b> — <code>-webkit-line-clamp:2</code>가 좁은 폭에서 문장을 잘라먹던 것을 해제하고, 데스크톱 한 화면 제약은 <code>max-height + overflow-y:auto</code>로 대체. ④ <b>프리스타트 우측 "진행 초점" 패널 195px 잘림</b>(1041~1240px, 200개 폭) — 3열 <code>minmax(220px,…) minmax(360px,…) minmax(260px,…)</code>는 최소 888px를 요구하는데 이 구간의 실제 컨테이너는 735~886px뿐이라 트랙이 축소되지 못하고 우측 패널이 통째로 잘렸다. <code>overflow-x:hidden</code>이라 스크롤도 안 되고 <b>문서 오버플로는 0</b>이라 기존 "contained" 게이트가 구조적으로 못 잡던 유형이다. 트랙 최소치를 0으로 낮추고 2열 전환점을 1040→1260px로 올려 이중 안전망을 뒀다. ⑤ <b>티켓 필터 "초기화" 버튼 화면 밖 이탈</b>(1180~1257px) — 7트랙 grid가 최소 954px를 요구하는데 셸 사이드바를 뺀 컨테이너가 876~950px라 마지막 트랙이 밀려났다. 4트랙 2줄로 낮추고 유연 트랙 최소치를 0으로. ⑥ <b>페르소나 이름 세로 잘림</b>(320px) — 이름 칸이 150.6px뿐이라 3줄이 필요한데 <code>line-clamp:2</code>에 걸려 3번째 줄이 사라졌다. ≤420px 밴드에서 3줄 허용. <b>오탐으로 판정해 고치지 않은 것</b>: <code>.aic-ledger b</code>·<code>.lh-metric-card b</code>의 2px 세로 초과는 <code>line-height</code>가 Pretendard content-area(ascent+descent≈1.09em)보다 작아 생기는 폰트메트릭 아티팩트다. overflow hidden/visible 6x DPR 캡처를 20개 폭에서 바이트 비교해 <code>pixelDiff=0</code>(잘린 픽셀 0)을 확인했고, 1180/1280px에서는 같은 렌더링인데 반올림 방향만 바뀌어 탐지에서 빠지는 것도 증거다. <code>.vg-set__voice-info .vd</code>(의도된 <code>line-clamp:2</code>)와 <code>.vg-set__skel-sr</code>(visually-hidden), <code>.vg-avatar__stage</code>(<code>border-radius:50%</code> 원형 마스크)도 오탐이다. <b>2차 스윕</b>으로 1차가 못 본 사각지대(높이 브레이크포인트 620/820 경계, 탭 전환 등 인터랙션 후 상태, 와이드 폭 1600~2560, 초협소 320)를 14개 라우트에서 추가 순회했고 <b>새 결함 0</b>. <b>결과</b>: 진짜 결함 0(잔여 66뷰포트는 위 오탐 3종). <b>검증</b>: typecheck · check:design-ssot 통과, session.css raw color 216/216 유지, <code>layout-visual-gate</code> 15/15, session-layout+session-mvp+learner+settings <b>56/56</b>, 앞선 병렬 수리 검증에서 session-layout+settings+admin <b>55/55</b>. <b>미결(소유자 결정)</b>: 프리스타트에서 호소·대상 정보가 <code>.sx-prestart__case</code>(좌측 아바타 카드)와 <code>.sx-prestart__facts</code>(본문)에 <b>중복 렌더</b>된다(3열·2열 양쪽 모두, 이번 변경과 무관한 기존 IA 문제). 로컬 작업트리 기준이며 배포 전이다.</p>
|
||||||
|
<p class="dg-note">143차 적용(2026-07-27): 전 페이지·버튼 단위 <b>전수 E2E 순회 캠페인</b>. IA 전 라우트(로그인/온보딩/승인대기/학습자 3뷰·세션·리뷰/교수자 콘솔·분석·페르소나/관리자 5섹션/설정/아바타 랩)의 기능 412개를 소스 기준 인벤토리로 만들어 <code>docs/ops/e2e-full-sweep-2026-07-27.md</code> 체크리스트 백로그(- [ ]/- [x])로 관리하고, RED→GREEN으로 결함을 수정했다. 신규 스펙 10파일 <code>e2e/full-sweep-*.spec.ts</code>(수집 424의 절반가량)를 추가했고, 소유자 결정 대기 항목은 test.fixme + 백로그 B3-신규 6건으로 분리했다. <b>수정한 결함(RED 재현→GREEN)</b>: ① <code>/admin/ai</code> 부트 watchdog 오탐 오버레이(index.html 셀렉터에 <code>.aic</code> 추가), ② 한국어 PII 마스킹이 "유의점은·방치되는·마무리했을" 같은 일반 단어를 <code>[NAME]</code>으로 치환해 평가 문장을 훼손하던 과잉 마스킹(guardrail 꼬리 글자 가드), ③ 온보딩 연락처 무검증(서버 pydantic 검증+클라이언트 안내), ④ 리뷰 요약 조사 오류("하늘와의"→받침 인식 와/과)와 난도 원문 노출(easy→기초), ⑤ /pending 새로고침 무피드백+아이콘 오용(refresh 아이콘 신설), ⑥ 학습 대시보드 탭 패널 aria-label 고정, ⑦ 회기 종료 다이얼로그 반말 문체, ⑧ 설정 스크롤 스파이 마지막 섹션 미활성(0 임계값+밴드 최하단 규칙+바닥 폴백), ⑨ 모바일 세션 겹침 2건(타임바 <code>:has</code> 4행 템플릿, 코치 넛지 플로우 배치), ⑩ <b>관리자 콘솔 무한 렌더 루프 프리즈</b>(TanStack <code>autoResetPageIndex</code>↔setState — users/services 메모화+<code>autoResetPageIndex:false</code>, 결정적 재현 스펙 2건을 회귀 가드로 유지), ⑪ <b>복수 코호트 조용한 유실</b>(POST/PATCH <code>/admin/users</code>가 cohort_ids 첫 항목만 저장 → 콤마 직렬화+알림 SQL 배열 겹침 비교), ⑫ <b>공개 페르소나 보관 전면 불능</b>(DELETE <code>/personas/{id}</code>가 실DB에서 항상 503 — <code>archive_persona_family</code> UPDATE…FROM RETURNING의 무한정 <code>code</code> 모호성 → card. 한정, 실API RED 503→GREEN archived 실측). 마무리 탐색 순회(실브라우저 6에이전트, 68항목)는 67 GREEN·감정 밸런스 차트 데이터 소스 미구현(서버가 항상 빈 배열 — 사문 렌더 경로, 로드맵 기록) 1건을 추가로 확인. <b>검증</b>: 백엔드 pytest <code>412 passed</code>(신규 회귀 테스트 8개 포함), gateway 29 passed, typecheck/build/check:api-types/design-ssot/dead-code/duplication(0.03%) 통과, layout-visual-gate 15/15, session-layout 8/8, 최종 <code>npm run e2e</code> 재완주 — 병렬 424 수집 그린(fixme 4·모바일 의도 숨김 2 스킵), 직렬 <code>49/49 passed</code>(14.4m), exit 0. 열린 항목: B3-신규 6건(설정 아바타 변경/동의 철회·재동의 흐름/첫 추천 난도/분석 페이지네이션/저작 레일 잔존 마크업/dev-login 재승인 정책), 공개 OAuth 스모크는 기존 B2 유지. 로컬 작업트리 기준이며 배포 전이다.</p>
|
||||||
|
<p class="dg-note">142차 적용(2026-07-27): <code>design-taste-frontend</code> 스킬의 보편 규칙(§4.5 대비 검사, §6.B/6.C 모션·다크모드, §9.F/9.G AI tell)을 기준으로 <b>전 20개 라우트를 3역할·라이트/다크·데스크톱/모바일 54개 조합</b>으로 순회 감사하고 결함을 수정했다. 감사는 눈대중이 아니라 페이지마다 WCAG 대비비를 계산하는 Playwright 스크립트로 측정했다. <b>(A) 라이트 팔레트 AA 미달</b> — <code>--ink-3</code>(캡션·메타)가 모든 라이트 표면에서 2.7~3.1:1, <code>--neutral-400</code>(placeholder)이 2.4:1이었다. 색조를 유지한 채 <code>#6a6c65</code>/<code>#6f6e68</code>로 낮추고 위계 유지를 위해 <code>--ink-2</code>도 <code>#4d514b</code>로 조정했다(강도 13.9 · 본문 6.9 · 캡션 4.6:1). 다크는 <code>--ink-3</code> <code>#979a8f</code>, <code>--neutral-400</code> <code>#949b8f</code>. <b>(B) 다크 토큰 이중 진실원본</b> — 다크 블록이 <code>--text-muted</code> 등을 <code>var(--ink-3)</code> 별칭 대신 값으로 하드코딩해 primitive 수정이 다크에 반영되지 않았다. 라이트와 동일하게 별칭으로 통일했다. <b>(C) 채움색을 글자색으로 전용</b> — <code>--accent</code>는 채움 기준값이라 어두운 표면 위 본문 크기에서 4.3:1이다. <code>--text-accent</code>(= <code>--accent-deep</code>) 별칭을 신설해 kicker·secondary 버튼·역할 라벨 등 텍스트 용도를 옮겼다. <b>(D) 로그인 안내 배너 1.19:1</b> — <code>.lg-config</code>가 다크 패널 전제(반투명 갈색 + 밝은 앰버)라 라이트에서 사실상 읽을 수 없었다. 테마별로 스왑되는 warn 의미 토큰으로 교체(5.3:1). <b>(E) 미정의 CSS 변수 12종</b> — <code>--text-dim</code>·<code>--good-solid</code>·<code>--border-faint</code>·<code>--fs-xl</code> 등이 정의 없이 참조돼 색/배경이 조용히 무효화되고 있었고, <code>learner-home</code>의 <code>--bg-inset</code>/<code>--border-soft</code> 폴백은 다크 전제 값이 라이트 페이지에 적용되고 있었다. 전부 기존 의미 토큰으로 매핑했다. <b>(F) 학습자 분석 테이블 컬럼 침범</b> — <code>.pf-learner-row__id</code>의 암시적 그리드 트랙이 자식의 <code>width:max-content</code>를 수용해 261px 셀이 288px로 늘어나 <code>max-width:100%</code>가 무력화됐고, 긴 이름 28행이 옆 컬럼을 27px 침범했다. <code>minmax(0,1fr)</code>로 고정해 0건. <b>(G) 한글 조판</b> — <code>word-break</code> 기본값 때문에 "오전"이 "오/전", "검토 대기"가 "검/토 대기"로 쪼개졌다(5건). 세션 화면이 쓰던 <code>keep-all</code> + <code>overflow-wrap:break-word</code>를 전역으로 올려 0건. <b>(H) 탭 타깃</b> — WCAG 2.2 SC 2.5.8(24px) 기준으로 약관 동의 라벨 19px→28px, 학습자 이름 버튼 19px→25px, 설정 range 슬라이더 16px→24px(투명 테두리 + <code>background-clip:content-box</code>로 시각 크기는 유지). <b>(I) LCP</b> — 렌더 블로킹 CDN 폰트에 <code>preconnect</code>/<code>dns-prefetch</code> 추가(Pretendard self-host는 별도 과제). <b>결과</b>: 54개 조합 전부 대비 위반 0 · 탭 타깃 위반 0 · 라벨/alt 누락 0 · 가로 오버플로 0(감사 전: 대비 2,043건, 탭 타깃 355건). <b>검증</b>: <code>npm run typecheck</code>, <code>check:design-ssot</code> 통과, <code>layout-visual-gate</code> 15/15, <code>session-layout</code> 8/8, auth-visual·teacher·admin·session-review·settings·learner 포함 E2E 87 passed + 14 passed. <b>미결(소유자 결정 필요)</b>: kicker의 accent dot이 기본 <code>true</code>라 회기 화면 한 곳에 11개 이상 찍히는 점(§9.F는 장식 dot 0을 요구, 다만 DESIGN_CONCEPT가 명시한 강조 장치라 임의 변경하지 않음), 영문 대문자 eyebrow(<code>SPEND CONTROL</code>·<code>RUNTIME ROUTE</code>·<code>DAILY JOURNAL</code>·<code>SESSION</code>·<code>SUPERVISOR</code>)의 한글화, 섹션당 eyebrow 밀도(§4.7은 섹션 3개당 1개), 중점(·) 라인당 다중 사용(<code>/teach</code> 33행), <code>prefers-color-scheme</code> 무시하고 다크 고정인 기본 테마(§8.C), 관리자 AI 비용 표시 정밀도(<code>$37.6560</code>·<code>$0.102885</code>). 로컬 작업트리 기준이며 배포 전이다.</p>
|
||||||
<p class="dg-note">141차 적용(2026-07-15): 공통 <code>Surface inset</code>는 중첩 카드처럼 보이던 border와 radius를 제거하고 반투명 광원·blur만 유지한다. 관리자 사용자 목록은 사용자별 <code>article</code> grid와 겹치던 <code>ad-user__meta</code>를 제거하고 <code>@tanstack/react-table</code> 기반 semantic table로 전환했다. 사용자/역할/관리 권한/승인 상태/소속/코호트/활성 회기/최근 접속/작업 열을 고정하고, 헤더 클릭 정렬과 sticky header, 1440px 최소 폭을 제공한다. 좁은 화면에서는 문서가 넓어지지 않고 표 region만 가로 스크롤된다. <b>검증</b>: typecheck, 관리자 사용자 생성·승인·편집·비활성화 및 모바일 스크롤 focused E2E 4/4, layout visual gate 15/15. 로컬 작업트리 기준이며 배포 전이다.</p>
|
<p class="dg-note">141차 적용(2026-07-15): 공통 <code>Surface inset</code>는 중첩 카드처럼 보이던 border와 radius를 제거하고 반투명 광원·blur만 유지한다. 관리자 사용자 목록은 사용자별 <code>article</code> grid와 겹치던 <code>ad-user__meta</code>를 제거하고 <code>@tanstack/react-table</code> 기반 semantic table로 전환했다. 사용자/역할/관리 권한/승인 상태/소속/코호트/활성 회기/최근 접속/작업 열을 고정하고, 헤더 클릭 정렬과 sticky header, 1440px 최소 폭을 제공한다. 좁은 화면에서는 문서가 넓어지지 않고 표 region만 가로 스크롤된다. <b>검증</b>: typecheck, 관리자 사용자 생성·승인·편집·비활성화 및 모바일 스크롤 focused E2E 4/4, layout visual gate 15/15. 로컬 작업트리 기준이며 배포 전이다.</p>
|
||||||
<p class="dg-note">136차 적용(2026-07-15): 135차의 학습 대시보드 한정 보태니컬 스타일을 전 페이지 공통 디자인 시스템으로 승격했다. <code>AppShell</code>은 역할·라우트와 무관하게 단일 <code>vg-shell--botanical</code> 크롬을 사용하며, 과거 관리자·교수자·대시보드 전용 톱바/GNB override를 삭제했다. GNB는 라이트/다크 고해상도 투명 원본(846×1858, 922×1706)을 사용하고 이미지 시작점을 메뉴 아래로 내려 텍스트 침범을 막는다. 데스크톱 GNB는 shell body 안에서 <code>top:0</code>, 모바일 drawer에서만 <code>top:var(--topbar-h)</code>를 사용해 상단 빈 공간을 없앴다. 공통 캔버스는 우상단 보태니컬 배경과 같은 이미지를 180° 회전한 좌하단 배경을 함께 사용하며, main은 투명하게 둔다. 표면은 셸 SSOT에서 큰 패널용 glass와 내부 행·필드·요약용 inset glass 두 단계로 통합해 학습자·교수자·관리자·설정·페르소나·회기 리뷰·진행 세션의 모바일 재배치까지 같은 반투명 그라데이션, 저채도 황금 hairline, blur/saturation을 사용한다. 로그인·온보딩·승인대기도 같은 글래스 패널을 쓴다. 라이트 테마는 흰 카드와 흰 캔버스가 겹쳐 유리 효과가 사라지던 토큰을 수정해, 반투명 본체+스페큘러 광원 2층, 안쪽 굴절선, 샴페인 hairline과 세이지/골드 캔버스 광원을 사용한다. 모바일에서도 보태니컬 캔버스를 제거하지 않는다. 학습 대시보드의 <code>오늘의 회기</code>/<code>기록 · 리뷰</code>/<code>성장 지표</code> 탭과 회기 리뷰의 <code>축어록</code>/<code>피드백</code>/<code>워크시트</code> 탭은 모든 폭에서 유지한다. Live2D 주변의 이중 외곽 컨테이너는 제거하고 투명 clipping 영역에서 캐릭터·잎 장식만 우하단으로 잘리게 했다. <code>object-separate --check</code>로 로컬 GPU 분리 런타임을 확인했고, 제공 원본이 정상 RGBA alpha(0–255)라 재분리 없이 원본을 사용했다. <b>검증</b>: <code>npm run typecheck</code>, <code>npm run build</code>, layout-visual-gate <code>15/15</code>, learner/session-layout/session-review desktop+mobile <code>46/46</code>, 라이트·다크·관리자·교수자·설정·리뷰·진행 세션 1440px 및 390px 스크린샷 직접 확인. 로컬 작업트리 기준이며 배포 전이다.</p>
|
<p class="dg-note">136차 적용(2026-07-15): 135차의 학습 대시보드 한정 보태니컬 스타일을 전 페이지 공통 디자인 시스템으로 승격했다. <code>AppShell</code>은 역할·라우트와 무관하게 단일 <code>vg-shell--botanical</code> 크롬을 사용하며, 과거 관리자·교수자·대시보드 전용 톱바/GNB override를 삭제했다. GNB는 라이트/다크 고해상도 투명 원본(846×1858, 922×1706)을 사용하고 이미지 시작점을 메뉴 아래로 내려 텍스트 침범을 막는다. 데스크톱 GNB는 shell body 안에서 <code>top:0</code>, 모바일 drawer에서만 <code>top:var(--topbar-h)</code>를 사용해 상단 빈 공간을 없앴다. 공통 캔버스는 우상단 보태니컬 배경과 같은 이미지를 180° 회전한 좌하단 배경을 함께 사용하며, main은 투명하게 둔다. 표면은 셸 SSOT에서 큰 패널용 glass와 내부 행·필드·요약용 inset glass 두 단계로 통합해 학습자·교수자·관리자·설정·페르소나·회기 리뷰·진행 세션의 모바일 재배치까지 같은 반투명 그라데이션, 저채도 황금 hairline, blur/saturation을 사용한다. 로그인·온보딩·승인대기도 같은 글래스 패널을 쓴다. 라이트 테마는 흰 카드와 흰 캔버스가 겹쳐 유리 효과가 사라지던 토큰을 수정해, 반투명 본체+스페큘러 광원 2층, 안쪽 굴절선, 샴페인 hairline과 세이지/골드 캔버스 광원을 사용한다. 모바일에서도 보태니컬 캔버스를 제거하지 않는다. 학습 대시보드의 <code>오늘의 회기</code>/<code>기록 · 리뷰</code>/<code>성장 지표</code> 탭과 회기 리뷰의 <code>축어록</code>/<code>피드백</code>/<code>워크시트</code> 탭은 모든 폭에서 유지한다. Live2D 주변의 이중 외곽 컨테이너는 제거하고 투명 clipping 영역에서 캐릭터·잎 장식만 우하단으로 잘리게 했다. <code>object-separate --check</code>로 로컬 GPU 분리 런타임을 확인했고, 제공 원본이 정상 RGBA alpha(0–255)라 재분리 없이 원본을 사용했다. <b>검증</b>: <code>npm run typecheck</code>, <code>npm run build</code>, layout-visual-gate <code>15/15</code>, learner/session-layout/session-review desktop+mobile <code>46/46</code>, 라이트·다크·관리자·교수자·설정·리뷰·진행 세션 1440px 및 390px 스크린샷 직접 확인. 로컬 작업트리 기준이며 배포 전이다.</p>
|
||||||
<p class="dg-note">135차 적용(2026-07-15): 제공된 <code>Vignette-Asset-Set</code>의 512px 최적화 PNG 14종을 <code>public/design-elements/learner-dashboard/</code>로 자산화하고 학습 대시보드(<code>/learn</code>)에만 보태니컬 시안을 적용했다. 라이트/다크 각각의 사이드바·우상단 배경과 메트릭 카드 4종·AI 코치 장식을 테마별로 교체하며, 카드 표면은 크림/차콜의 비대칭 광원 그라데이션과 저채도 황금 헤어라인을 사용한다. 이 범위 제한은 136차에서 전 페이지 공통 글래스 시스템으로 대체됐다. <code>layout-visual-gate</code>는 라이트 테마 자산 연결까지 computed style로 확인하고 테마를 다크로 복귀한다. <b>검증</b>: <code>npm run typecheck</code>, <code>npm run build</code>, layout-visual-gate <code>12/12</code>, session-layout <code>8/8</code>, learner desktop/mobile <code>14/14</code>, 라이트·다크 1440px 및 다크 390px 스크린샷 직접 확인. 로컬 작업트리 기준이며 배포 전이다.</p>
|
<p class="dg-note">135차 적용(2026-07-15): 제공된 <code>Vignette-Asset-Set</code>의 512px 최적화 PNG 14종을 <code>public/design-elements/learner-dashboard/</code>로 자산화하고 학습 대시보드(<code>/learn</code>)에만 보태니컬 시안을 적용했다. 라이트/다크 각각의 사이드바·우상단 배경과 메트릭 카드 4종·AI 코치 장식을 테마별로 교체하며, 카드 표면은 크림/차콜의 비대칭 광원 그라데이션과 저채도 황금 헤어라인을 사용한다. 이 범위 제한은 136차에서 전 페이지 공통 글래스 시스템으로 대체됐다. <code>layout-visual-gate</code>는 라이트 테마 자산 연결까지 computed style로 확인하고 테마를 다크로 복귀한다. <b>검증</b>: <code>npm run typecheck</code>, <code>npm run build</code>, layout-visual-gate <code>12/12</code>, session-layout <code>8/8</code>, learner desktop/mobile <code>14/14</code>, 라이트·다크 1440px 및 다크 390px 스크린샷 직접 확인. 로컬 작업트리 기준이며 배포 전이다.</p>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,23 @@
|
||||||
> 결정 상세는 대시보드 "윤찬 결정 상세" 탭, ADR은 `docs/decisions/`(`voice-s2s-poc.md`,
|
> 결정 상세는 대시보드 "윤찬 결정 상세" 탭, ADR은 `docs/decisions/`(`voice-s2s-poc.md`,
|
||||||
> `backend-node-transition.md`)에 있다. 각 결정의 구현·실측은 후속 build/GATE로 남는다.
|
> `backend-node-transition.md`)에 있다. 각 결정의 구현·실측은 후속 build/GATE로 남는다.
|
||||||
|
|
||||||
|
### B3-신규 (2026-07-27 전수 E2E 순회에서 제기 → **2026-07-27 소유자 전건 결정 완료**)
|
||||||
|
|
||||||
|
> 소유자(윤찬)가 질문 도구로 7건 전건 결정(2026-07-27). 결정 내용과 구현 상태:
|
||||||
|
|
||||||
|
- [x] **설정 화면 아바타 변경 UI** — 결정: **설정에 추가**. **구현 완료(RED→GREEN, 대시보드 144차)**.
|
||||||
|
- [x] **동의 관리·철회 UI와 재동의 흐름** — 결정: **전체 흐름 구현**(설정 동의 섹션 + 철회 +
|
||||||
|
재동의 경로). **구현 완료(RED→GREEN, 대시보드 144차)**.
|
||||||
|
- [x] **신규 학습자 첫 추천 난도** — 결정: **기초 우선 추천**(회기 기록 없는 신규 학습자는
|
||||||
|
기초 난도 페르소나를 첫 추천으로). **구현 완료(RED→GREEN, 대시보드 144차)**.
|
||||||
|
- [x] **학생 분석 테이블** — 결정: **지금 가상화 도입**. **구현 완료(RED→GREEN, 대시보드 144차)**.
|
||||||
|
- [x] **저작 모드 좌측 레일 잔존 마크업** — 결정: **정리**(저작 모드에서 레일 미렌더). **구현 완료(RED→GREEN, 대시보드 144차)**.
|
||||||
|
- [x] **dev-login 재로그인 자동 재승인** — 결정: **현행 유지**(dev/E2E 편의 의도 확정). 코드 변경 없음.
|
||||||
|
- [x] **감정 밸런스 타임라인 차트**(결함 로그 #21) — 결정: **지금 구현**(평가 데이터 생산→저장→
|
||||||
|
리드모델→차트 연결). **구현 완료(RED→GREEN, 대시보드 144차)**.
|
||||||
|
|
||||||
|
> 근거·상세: `docs/ops/e2e-full-sweep-2026-07-27.md` 발견 결함 로그 #2·#4·#6·#14·#15·#16·#21.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B4. 외부 거버넌스 — 한신대/데이터 steward 서면 증거
|
## B4. 외부 거버넌스 — 한신대/데이터 steward 서면 증거
|
||||||
|
|
|
||||||
694
docs/ops/e2e-full-sweep-2026-07-27.md
Normal file
694
docs/ops/e2e-full-sweep-2026-07-27.md
Normal file
|
|
@ -0,0 +1,694 @@
|
||||||
|
# E2E 전수 순회 체크리스트 — 2026-07-27
|
||||||
|
|
||||||
|
> **목적**: IA·버튼 단위로 온보딩→가입→학습자→교수자→관리자→설정 전 페이지의 모든 기능을 순회하며
|
||||||
|
> (1) 의도와 다른 동작, (2) 오류, (3) 나쁜 UI/UX를 잡아낸다.
|
||||||
|
> **방식**: RED → GREEN. 각 항목은 검증 수단(기존 spec / 신규 spec / 탐색 순회)을 명시하고,
|
||||||
|
> 실제 실행해 GREEN이 된 것만 `- [x]`로 바꾼다. 가짜 DONE 금지(실행 명령+관측 결과 필수).
|
||||||
|
> **SSOT**: 상태 결론은 `docs/dev_dashboard.html`에 동기화한다.
|
||||||
|
>
|
||||||
|
> 실행 스택: dev-up.ps1 (DB 55432 + API 8000 + web 5173 + engine 9099, `db:true engine:true` 확인됨).
|
||||||
|
|
||||||
|
## 전체 항목 수 요약
|
||||||
|
|
||||||
|
11개 에이전트가 수합한 기능 인벤토리 기준. 총 **412개 feature**, 그중 **139건**이 의심 결함(risk)으로 보고됐다.
|
||||||
|
|
||||||
|
| # | 영역 | 라우트 | 항목 수 | 의심 결함 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | 인증·온보딩 (auth) | `/login`, `/onboarding`, `/pending` | 34 | 13 |
|
||||||
|
| 2 | 학습자 홈 (learner-home) | `/learn`, `/learn/practice`, `/learn/history` | 37 | 12 |
|
||||||
|
| 3 | 상담 세션 (session) | `/learn/session/:sessionId` | 53 | 11 |
|
||||||
|
| 4 | 회기 리뷰 (session-review) | `/learn/session/:id/review`, `/teach/session/:id/review` | 37 | 18 |
|
||||||
|
| 5 | 교수자 콘솔 (professor) | `/teach`, `/teach/analysis` | 36 | 12 |
|
||||||
|
| 6 | 페르소나 스튜디오 (persona-studio) | `/teach/personas` | 71 | 21 |
|
||||||
|
| 7 | 관리자 콘솔 (admin) | `/admin`, `/admin/users`, `/admin/access`, `/admin/tickets` | 57 | 15 |
|
||||||
|
| 8 | 관리자 AI 설정 (admin-ai) | `/admin/ai` | 27 | 9 |
|
||||||
|
| 9 | 설정 (settings) | `/settings` | 35 | 18 |
|
||||||
|
| 10 | 공통 셸·GNB·라우팅 가드 (shell) | 전역 GNB, `/`, `*`, dev 라우트 | 25 | 10 |
|
||||||
|
| | **합계** | | **412** | **139** |
|
||||||
|
|
||||||
|
검증 수단 표기 규칙:
|
||||||
|
|
||||||
|
- `기존 e2e/{파일}` — 해당 spec 시나리오가 그 기능을 명확히 검증함. 베이스라인 실행으로 GREEN 확인.
|
||||||
|
- `기존(부분) e2e/{파일} + 탐색 순회` — spec이 일부(레이아웃·인접 흐름)만 덮음. 나머지는 브라우저 순회로 확인.
|
||||||
|
- `신규 spec 필요` — 이 기능을 검증하는 spec이 없음. RED 스펙부터 작성.
|
||||||
|
- `탐색 순회` — 자동화 가치가 낮은 순수 표시·이동 항목. 브라우저 순회로만 확인.
|
||||||
|
|
||||||
|
## 0. 베이스라인 — 기존 E2E 215개 전체 실행
|
||||||
|
|
||||||
|
- [x] `npm run e2e` parallel 단계 (166 tests) 전체 GREEN — 2026-07-27 실행, **166 passed (2.6m)**
|
||||||
|
- [x] `npm run e2e` single-run 단계 (49 tests) 전체 GREEN — 2026-07-27 실행, **49 passed (15.1m)**
|
||||||
|
- [x] 실패 항목 → 아래 해당 영역에 RED로 등재 후 수정 — 베이스라인 실패 0건(등재할 항목 없음)
|
||||||
|
|
||||||
|
> 베이스라인 근거: dev-up 스택(`db:true engine:true`)에서 `npm run e2e` 1회 완주, 215/215 passed.
|
||||||
|
> 아래 각 영역에서 `검증: 기존 e2e/...`(부분 아님) 항목은 이 실행을 GREEN 증거로 삼아 체크한다.
|
||||||
|
>
|
||||||
|
> **최종 게이트(2026-07-27, 결함 수정 반영 후)**: 신규 `full-sweep-*.spec.ts` 10파일 포함
|
||||||
|
> `npm run e2e` 재완주 — 병렬 단계 424 수집(fixme 4·모바일 의도 숨김 스킵 2 제외 전 GREEN),
|
||||||
|
> 직렬 단계 **49/49 passed (14.4m)**, exit 0. 부가 게이트: 백엔드 pytest **412/412**, gateway 29/29,
|
||||||
|
> typecheck·build·check:api-types·design-ssot·dead-code·duplication(0.03%) 통과,
|
||||||
|
> layout-visual-gate **15/15**, session-layout **8/8**.
|
||||||
|
>
|
||||||
|
> **소유자 결정 구현 후 재완주(2026-07-27, 대시보드 144차)**: 아바타 변경·동의 철회/재동의·기초 우선
|
||||||
|
> 추천·분석 테이블 가상화·저작 레일 정리·감정 밸런스 차트 반영 후 `npm run e2e` 3차 완주 —
|
||||||
|
> 병렬 **432 수집**(구 fixme 2건은 실 계약 테스트로 전환 GREEN, 모바일 의도 숨김 스킵 2 제외 전 GREEN),
|
||||||
|
> 직렬 **49/49 passed (15.9m)**, exit 0. 백엔드 pytest **421/421**(`test_review_valence.py` 9 신규),
|
||||||
|
> 감정 차트는 실DB 공감 5턴 세션에서 baseline·client 각 5포인트/축 4라벨 라이브 실측.
|
||||||
|
|
||||||
|
## 1. 인증·온보딩 (auth)
|
||||||
|
|
||||||
|
- [x] `auth-login-config-load` 로그인 설정 조회 (OAuth 구성/허용 도메인) — `GET /auth/config`로 OAuth 구성·dev 로그인 가능 여부·허용 도메인을 받아 버튼 활성/라벨/배지를 결정한다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `auth-login-school-google-button` 학교 Google 계정으로 계속 버튼 (primary) — 클릭 시 `/auth/login?provider=google&next=`로 이동해 Google OAuth를 시작하고 oauthReady가 아니면 비활성화된다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `auth-login-generic-google-button` Google 계정으로 계속 버튼 (secondary) — primary와 동일한 startOAuth를 호출하며 서브라벨에 두 번째 허용 도메인을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-login-oauth-status-notice` OAuth 비활성 사유 status 안내 — oauthReady가 아닐 때 role=status로 설정 확인 중/미연결/로컬 테스트 계정 사유를 아이콘과 함께 표시한다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-login-oauth-error-notice` OAuth 콜백 오류 메시지 (?oauth= 쿼리) — 쿼리 사유별(not_configured·domain_not_allowed·access_denied 등) 한국어 안내문과 원본 코드를 표시한다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-login-dev-role-radiogroup` 로컬 테스트 역할 선택 라디오그룹 (학습자/교수자/관리자) — DEV이고 dev_login_enabled일 때만 노출되며 세 역할 버튼으로 dev 로그인 역할을 고른다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `auth-login-dev-enter-button` 로컬 테스트 계정으로 계속 버튼 — 선택 역할의 고정 dev 계정으로 `POST /auth/dev-login` 후 initialPathForUser 규칙대로 이동한다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-login-brand-domain-badges` 브랜드 패널 허용 도메인 배지 — allowed_email_domains를 배지로 나열하고 조회 중/미설정 상태를 구분 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-login-dev-oauth-unavailable-detection` 로컬 개발에서 OAuth 차단 감지 — redirect_uri가 localhost가 아니면 OAuth 버튼을 비활성화하고 테스트 계정 안내로 대체한다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `auth-onboarding-initial-load` 온보딩 초기 데이터 로드 (프로필+약관) — `/users/me`와 `/users/legal-docs`를 병렬 호출해 폼을 프리필하고 약관 본문을 채운다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `auth-onboarding-avatar-upload` 상징 아바타 이미지 업로드 — 파일 선택 즉시 multipart로 업로드해 avatar_url을 폼에 반영하고 실패 시 3MB 안내를 띄운다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-onboarding-avatar-preview` 아바타 미리보기 (이미지/이니셜 폴백) — avatar_url이 있으면 이미지를, 없으면 닉네임 첫 글자를 원 안에 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `auth-onboarding-nickname-input` 닉네임 입력 — 필수 닉네임 텍스트 입력으로 폼 상태를 갱신한다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-self-introduction-textarea` 자기소개 textarea (600자 제한) — maxLength 600의 필수 textarea로 상담 훈련 관련 자기소개를 받는다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-legal-name-input` 이름(법정 이름) 입력 — 필수 입력이며 프로필의 legal_name 또는 display_name으로 프리필된다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-affiliation-input` 소속 입력 (기본값 한신대학교) — 소속 필수 입력으로 프로필 값이 없으면 기본값이 채워진다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-department-input` 학과/부서 입력 — department 필수 텍스트 입력으로 폼 상태를 갱신한다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-grade-level-input` 학년/직위 입력 — grade_level 필수 텍스트 입력으로 폼 상태를 갱신한다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-phone-input` 연락처 입력 — autoComplete=tel 필수 입력으로 연락처를 받는다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `auth-onboarding-contact-address-input` 주소/수령지 입력 — autoComplete=street-address 필수 입력으로 주소를 받는다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-terms-checkbox` 서비스 이용약관 동의 체크박스 — terms_accepted를 토글하며 미체크 시 제출이 비활성화된다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-privacy-checkbox` 개인정보 수집·이용 동의 체크박스 — privacy_accepted를 토글하며 미체크 시 제출이 막힌다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-legal-doc-details` 약관/개인정보 문서 펼침 (details/summary) — 두 문서를 details로 펼쳐 제목·버전·본문을 확인하고 미로드 시 폴백을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-onboarding-submit` 가입 설정 완료 제출 버튼 (form submit) — 필수 필드와 두 동의가 모두 충족될 때만 활성화되어 온보딩을 저장하고 역할 홈으로 이동한다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-onboarding-error-notice` 온보딩 로드/저장 오류 알림 — 로드·저장 실패 시 role=alert 문단을 폼 하단에 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-pending-status-panel` 승인 대기/접속 보류 상태 표시 — suspended면 '접속 보류', 아니면 '승인 대기' 키커와 이메일·안내를 role=status로 보여준다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-pending-refresh-button` 승인 상태 새로고침 버튼 — `GET /auth/me`로 세션을 재조회해 승인되면 온보딩 또는 역할 홈으로 이동시킨다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-pending-signout-button` 다른 계정으로 로그인 버튼 (로그아웃) — 세션을 종료하고 /login으로 이동해 다른 계정 로그인을 허용한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-provider-bootstrap` AuthProvider 세션 부트스트랩 — 앱 시작 시 `/auth/me`로 쿠키 세션을 복원하고 auth-expired 이벤트 수신 시 user를 비운다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `auth-guard-require-auth` RequireAuth 라우트 가드 — 미인증이면 state.from을 담아 /login으로, 역할 불일치면 자기 역할 홈으로 리다이렉트한다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `auth-guard-onboarding-gate` OnboardingGate 리다이렉트 — 온보딩 미완료자를 /onboarding으로 강제 이동시키고 완료자가 /login·/onboarding에 오면 되돌린다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `auth-guard-pending-approval-gate` PendingApprovalGate 리다이렉트 — 미승인 로그인 사용자를 어느 경로에서든 /pending으로 회수한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `auth-root-redirect` 루트(/) 및 미정의 경로 리다이렉트 — / 진입 시 승인·온보딩 상태에 따라 분기하고 미정의 경로(*)는 /로 회수한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `auth-boot-screen` 인증 부트스트랩 로딩 화면 (BootScreen) — 인증 확인·lazy 라우트 로딩 동안 중앙 정렬 '불러오는 중…' 중립 화면을 띄운다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `auth-login-config-load`: config 로드 실패 시 별도 오류 배너 없이 버튼이 영구 비활성 상태로만 남고, statusMessage는 '연결되지 않았습니다' 문구라서 네트워크 오류와 미설정을 구분해 주지 않는다.
|
||||||
|
- `auth-login-school-google-button`: '학교 Google 계정' 버튼과 일반 'Google 계정' 버튼이 완전히 동일한 provider=google 흐름을 호출한다(학교 SSO/SAML 별도 흐름 없음). 또한 startOAuth 내부의 !oauthReady 오류 세팅 분기는 버튼이 이미 disabled라 실제로 도달 불가한 사문(死文) 코드다.
|
||||||
|
- `auth-login-generic-google-button`: 두 버튼이 기능적으로 중복이라 사용자가 다른 인증 경로로 오해할 수 있다.
|
||||||
|
- `auth-login-oauth-error-notice`: 오류 문단에 role=alert/aria-live가 없어 스크린리더에 즉시 공지되지 않는다.
|
||||||
|
- `auth-login-dev-role-radiogroup`: role=radio 버튼 그룹이지만 방향키 이동 등 라디오그룹 키보드 시맨틱은 구현되어 있지 않다(Tab으로만 이동).
|
||||||
|
- `auth-login-dev-enter-button`: 실패 시 err.message를 그대로 노출하는데 ApiError 메시지가 'API 500: ...' 같은 원문이라 사용자 친화적이지 않을 수 있다.
|
||||||
|
- `auth-onboarding-initial-load`: 로드 실패 시 재시도 버튼이 없어 새로고침 외 복구 수단이 없고, 약관 로드 실패여도 동의 체크는 가능해 문서 미확인 동의가 성립한다.
|
||||||
|
- `auth-onboarding-avatar-upload`: 클라이언트 측 파일 크기/타입 사전 검증이 없어 3MB 제한을 서버 거절 후에야 알게 되고, 서버 오류 상세는 콘솔에만 남는다.
|
||||||
|
- `auth-onboarding-phone-input`: type=text 자유 입력이라 전화번호 형식 검증이 전혀 없다.
|
||||||
|
- `auth-onboarding-terms-checkbox`: 약관 본문을 펼쳐 보지 않아도 체크 가능해 열람 강제가 없다.
|
||||||
|
- `auth-onboarding-submit`: 실패 시 '온보딩 저장에 실패했습니다' 일반 문구만 표시하고 서버의 필드별 검증 상세(ApiError.detail)는 콘솔에만 버려진다. 또한 성공 후 initialPathForUser가 아닌 roleHomePath로 이동해 admin_access만 있는 비-admin 역할 사용자의 도착지가 로그인 직후 규칙(/admin)과 어긋난다.
|
||||||
|
- `auth-pending-refresh-button`: refresh가 네트워크 오류로 실패하면 AuthProvider가 user를 null로 만들어 RequireAuth가 아무 안내 없이 /login으로 튕긴다(여전히 pending인 경우에도 '아직 승인 전'이라는 피드백이 전혀 없음). 버튼 아이콘도 refresh가 아닌 'settings'라 의미가 어긋난다.
|
||||||
|
- `auth-guard-pending-approval-gate`: pending 사용자는 /login에도 접근할 수 없어(항상 /pending으로 회수) 계정 전환 경로가 PendingApproval의 로그아웃 버튼 하나뿐이다.
|
||||||
|
|
||||||
|
## 2. 학습자 홈 (learner-home)
|
||||||
|
|
||||||
|
- [x] `learner-home-guard-require-auth` RequireAuth(roles=[learner]) 라우트 가드 — 미인증은 /login, learner 권한 없는 역할은 자기 역할 홈으로 보낸다(admin·superAdmin은 통과) · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `learner-home-guard-pending-approval` PendingApprovalGate 리다이렉트 — 미승인 사용자가 /learn 계열에 오면 state.from을 담아 /pending으로 보낸다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-guard-onboarding` OnboardingGate 리다이렉트 — onboardingCompletedAt이 null이면 /onboarding으로 보낸다 · 검증: 기존 e2e/auth.spec.ts
|
||||||
|
- [x] `learner-home-nav-sidebar` 좌측 사이드바 학습자 내비게이션 — 대시보드·학습·기록·설정 NavLink를 제공하고 활성 항목을 알약으로 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `learner-home-initial-load` 마운트 시 페르소나·회기·대시보드 병렬 로드 — allSettled로 3개 API를 병렬 요청하고 각 섹션이 독립된 로딩/오류 상태를 유지한다 · 검증: 기존 e2e/learner.spec.ts + e2e/readiness.spec.ts
|
||||||
|
- [x] `learner-home-head-status-live` 헤더 상태 배지(aria-live) — 사용 가능 페르소나 수와 기록 상태를 aria-live=polite로 헤더 우측에 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-metric-cards` 학습 현황 지표 카드 4개 — 진행 회기·리뷰 대기·최근 평가·라포 흐름 카드를 버튼으로 렌더하고 각각 기록/연습 화면으로 이동시킨다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-midwidth-tabs` 중간 폭(≤1180px) 대시보드 가로 탭 3개 — 오늘의 회기·기록 리뷰·성장 지표 탭으로 그리드를 전환하고 데스크톱에서는 2컬럼을 유지한다 · 검증: 기존(부분) e2e/layout-visual-gate.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `learner-home-dash-new-session-shortcut` 오늘의 회기 작업 헤더 '새 회기 선택' 버튼 — 클릭 시 /learn/practice로 이동해 연습 대상 선택 화면을 연다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `learner-home-dash-recap-card` 스포트라이트 회기 리캡 카드 — 우선순위로 고른 회기의 상세를 불러와 아바타·단계·진행률·마지막 발화를 보여준다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-coach-cta` AI 코치 카드와 기본 행동 CTA 버튼 — 규칙 기반 코치 문구와 힌트 칩을 만들고 CTA로 세션·리뷰·연습 중 적절한 곳으로 보낸다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-recommend-card` 다음 연습 추천 카드 버튼 — 리뷰 대기 유무와 최근 평균 턴 수에 따라 문구를 바꿔 기록 또는 연습 화면으로 이동시킨다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-recent-feedback` 최근 피드백 패널(최신 1건 리뷰 이동) — 대시보드 recent_feedback 첫 항목을 버튼으로 보여주고 해당 회기 리뷰로 이동시킨다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-recent-records` 최근 기록 패널(최근 4건 + 전체 보기) — 최근 회기 4건을 컴팩트 행으로 보여주고 상태에 따라 세션 또는 리뷰로 이동시킨다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `learner-home-dash-review-queue` 리뷰 대기 패널(review_ready 상위 3건) — review_ready 상위 3건에서 'AI 피드백' 버튼으로 리뷰 화면에 진입시킨다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-weakness-insights` 보완할 부분 패널(파생 인사이트 최대 3건) — growth 데이터에서 하락 추세·낮은 라포 등을 결정론적으로 도출해 버튼으로 제시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-rapport-progress` 내담자와 쌓은 라포 누적 바(상위 3명) — 회기가 있는 페르소나 상위 3명의 rapport_percent를 progressbar로 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-milestones` 연습 이정표 배지(achievements) — achievements를 상태별 배지로 나열하고 상세는 title 툴팁으로만 제공한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-dash-recap-fetch-states` 리캡 상세 로딩·오류 상태 — 스포트라이트 회기가 바뀔 때마다 상세를 다시 불러오고 stale 응답은 session_id 비교로 무시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-practice-persona-listbox` 연습 페르소나 선택 리스트(role=listbox/option) — 페르소나별 옵션 버튼으로 선택 상태를 바꾸고 degraded·비DB 항목은 비활성 처리한다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `learner-home-practice-persona-skeleton` 페르소나 목록 스켈레톤·오류 상태 — 로딩 중 스켈레톤 3개를, 실패 시 role=alert 안내를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-practice-preview-pane` 선택 페르소나 프리뷰 — 선택 페르소나의 인적사항·주호소·난도/접근/음성을 보여주고 사용 가능 항목이 없으면 카탈로그 안내로 전환한다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `learner-home-practice-start-session` '새 회기 시작' 버튼과 회기 시작 흐름 — 사용 가능 여부를 재검증한 뒤 세션 프리스타트로 이동시킨다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `learner-home-practice-activity-stats` 연습 기록 통계 3칸 — 진행 중·리뷰 가능·누적 회기 수를 표시하고 로딩·오류 시 대시로 대체한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `learner-home-practice-existing-sessions` 선택 페르소나의 기존 회기 목록(최근 5건) — 같은 페르소나의 최근 5건을 상태·단계·턴·진행률과 함께 카드로 나열한다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `learner-home-session-card-continue` 회기 카드 '이어하기' 버튼(진행 중 회기) — active 회기에서 세션 화면으로 이동해 대화를 재개한다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `learner-home-session-card-review` 회기 카드 '리뷰'/'기록' 버튼(종료 회기) — review_ready·보관 여부에 따라 라벨을 바꿔 리뷰 화면으로 이동한다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `learner-home-session-card-repeat` 회기 카드 '다시 연습' 버튼 — 같은 페르소나 코드로 새 회기 준비 화면을 연다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `learner-home-session-card-archive-toggle` 회기 카드 '보관'/'복원' 토글 버튼 — 종료 회기에서만 보관/복원을 호출하고 성공 시 회기·대시보드를 재조회한다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `learner-home-history-overview-cards` 기록 상태 요약 필터 카드 4개 — 전체·진행 중·리뷰 필요·보관됨 버킷 카드(aria-pressed)로 historyFilter를 전환한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-history-search` 기록 검색 입력 — 코드·이름·단계·상태·날짜 라벨을 이어붙인 문자열에 부분 일치로 즉시 필터링한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-history-filter-chips` 기록 상태 필터 칩(도구 행) — 요약 카드와 상태를 공유하는 칩 버튼으로 동일 필터를 전환한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-history-list` 필터·검색 적용된 회기 카드 목록 — 정렬된 회기를 필터·검색으로 걸러 액션 버튼·진행률 바와 함께 카드로 나열한다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `learner-home-history-archive-notice` 보관/복원 결과 안내 배너(role=status) — 보관·복원 성공/실패 문구를 목록 위에 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-history-archive-guide` 아카이브 기준 안내 패널(읽기 전용) — 진행 중·리뷰 필요·보관됨 각각의 의미를 정적 텍스트로 안내한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `learner-home-history-persona-progress` 페르소나별 진행 행 버튼 — 페르소나별 회기·진행·리뷰·평균 턴 요약을 행 버튼으로 보여주고 클릭 시 해당 페르소나로 연습 화면에 진입한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `learner-home-session-list-states` 회기 목록 공용 로딩·오류·빈 상태 — 로딩 문구·인라인 경고·컨텍스트별 빈 메시지를 구분 표시한다 · 검증: 신규 spec 필요
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `learner-home-initial-load`: 실패 시 재시도 버튼이 어디에도 없어 사용자는 새로고침 외에 복구 수단이 없음
|
||||||
|
- `learner-home-head-status-live`: loadState가 error일 때도 삼항 조건이 '확인 중'을 영구 표시해 페르소나 로드 실패가 헤더에서는 드러나지 않음
|
||||||
|
- `learner-home-dash-midwidth-tabs`: role=tab인데 화살표 키 이동·aria-controls/tabpanel 연결이 없어 WAI-ARIA 탭 패턴 미준수
|
||||||
|
- `learner-home-dash-recap-card`: 아바타 rapport 값이 실제 데이터가 아닌 하드코딩(정리 단계 0.72, 그 외 0.38)이고, 상세 로드 실패(error)와 발화 없음(empty)이 같은 문구로 표시되어 구분 불가
|
||||||
|
- `learner-home-dash-coach-cta`: sessionLoadState=error일 때 CTA 라벨이 '기록 다시 확인'이지만 실제 동작은 재요청이 아니라 /learn/practice 이동이라 라벨과 동작이 불일치
|
||||||
|
- `learner-home-dash-recent-feedback`: 배지에는 recentFeedback.length건으로 표시하면서 목록에는 1건만 노출해 나머지 피드백에 접근할 UI가 없음
|
||||||
|
- `learner-home-dash-milestones`: 상세 설명이 title 툴팁뿐이라 터치 기기·스크린리더에서 접근 불가
|
||||||
|
- `learner-home-practice-persona-listbox`: role=listbox/option 구조인데 화살표 키 탐색·aria-activedescendant가 없어 커스텀 리스트박스 키보드 패턴 미구현(버튼 Tab 이동만 가능)
|
||||||
|
- `learner-home-practice-persona-skeleton`: '다시 시도해 주세요' 안내만 있고 실제 재시도 버튼은 제공하지 않음
|
||||||
|
- `learner-home-session-card-repeat`: 해당 페르소나가 현재 사용 불가(degraded/비DB)로 바뀐 경우에도 비활성화 없이 이동을 허용해 Session 페이지에서야 실패를 만나게 됨
|
||||||
|
- `learner-home-session-card-archive-toggle`: archiveNotice 표시 블록이 history 뷰에만 렌더링되어, practice 뷰의 기존 회기 목록에서 보관/복원(특히 실패)을 눌러도 결과 안내가 화면에 전혀 나타나지 않음
|
||||||
|
- `learner-home-history-archive-notice`: 자동 해제 타이머가 없어 다음 토글 전까지 이전 안내가 계속 남고, 실패 원인(HTTP 상태 등)은 console.warn으로만 남아 사용자에게 전달되지 않음
|
||||||
|
|
||||||
|
## 3. 상담 세션 (session)
|
||||||
|
|
||||||
|
- [x] `session-guard-require-auth` RequireAuth(learner) 라우트 가드 — 미인증은 /login(state.from 보존), learner 권한 없으면 자기 역할 홈으로 보낸다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `session-guard-pending-approval` PendingApprovalGate 가드 — 미승인 계정은 /pending으로 리다이렉트한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-guard-onboarding` OnboardingGate 가드 — 온보딩 미완료 사용자는 /onboarding으로 리다이렉트한다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-route-empty-param-redirect` 빈 라우트 파라미터 리다이렉트 — 파라미터가 비면 /learn으로 replace 이동하고, 페르소나 코드면 세션 상태를 초기화한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-persona-load` 페르소나 정보 로드(로딩/누락/오류 상태) — 코드를 대조해 아바타·컨텍스트를 구성하고 상태별 안내를 프리스타트에 표시한다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `session-resume-load` 기존 회기 재개(resume) 로드 — 세션 ID면 서버 스냅샷으로 턴·단계·경과·진행률을 복원하고 방금 시작한 회기는 재로딩을 건너뛴다 · 검증: 기존 e2e/session-layout.spec.ts
|
||||||
|
- [x] `session-theory-mode-select` 이론모드 세그먼트 선택(인간중심/CBT/통합) — 시작 전 이론모드를 고르며 페르소나 theory_target 첫 항목이 기본 선택된다 · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-goal-toggle` 이번 회기 목표 단계 토글(라포/탐색/개입/정리) — 목표 단계를 1~4개 토글하며 0개면 시작 버튼이 비활성화된다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-consent-checkbox` 연습 기록 처리 동의 체크박스 — 동의 이력이 없는 learner에게만 노출되며 체크해야 저장 버튼이 활성화된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-consent-save` 동의 저장 버튼 — 동의를 서버에 기록하고 성공 시 pos 신호, 실패 시 오류 문구를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-start-button` 회기 시작 버튼 — 준비·동의·목표 조건이 충족될 때만 활성화되어 세션을 생성하고 URL을 실제 세션 ID로 교체한 뒤 음성 가용성을 사전 확인한다 · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-prestart-status-messages` 프리스타트 상태/오류 문구 표시 — 페르소나 로딩·없음·실패·사용 불가와 시작 오류를 구분된 문구로 표시한다 · 검증: 기존(부분) e2e/learner.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-nav-back-history` 세션바 '기록으로' 버튼 — 회기 시작 후 상단 세션바에서 /learn/history로 이동한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-nav-learn-home` 세션바 '학습 홈' 버튼 — /learn 학습자 홈으로 이동한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-nav-review-sessionbar` 세션바 리뷰 이동 버튼 — 종료 후에만 노출되어 리뷰로 이동하며 reviewReady에 따라 라벨이 바뀐다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-time-alarms` 시간 알람(10분 전 경고·시간 만료) — 경고 구간 진입 시 warn 신호를 1회 띄우고 만료 시 timeUp으로 전환해 종료 다이얼로그를 자동으로 연다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-timebar-end-button` 시간 만료 바 '종료하고 리뷰 보기' 버튼 — role=alert 시간 바에서 만료 시 노출되어 종료 확인 다이얼로그를 연다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-phase-tracker` 회기 단계 트래커(헤더 가로 + 좌측 세로) — 4단계의 완료·진행·예정 상태와 목표 배지, 서버 progress 기반 누적 게이지를 표시한다 · 검증: 기존(부분) e2e/session-layout.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `session-ctx-collapse` 내담자 컨텍스트 패널 접기/펼치기 — aria-expanded 토글로 내담자 이름·메타·호소/대상/난도 행을 접고 펼친다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-progress-panel` 세션 진행 패널(경과/남은 시간/발화 수) — 경과·남은 시간·저장 발화 수를 표시하고 목표 달성 시 축하 문구를 띄운다 · 검증: 기존(부분) e2e/session-layout.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `session-avatar-display` 내담자 아바타 + 음성 오브 표시 — 상태·개방도·단계에서 파생한 표정을 렌더하고 TTS 재생 중 Analyser로 입모양을 구동한다 · 검증: 기존 e2e/avatar-expression.spec.ts
|
||||||
|
- [x] `session-transcript-autoscroll` 자막 자동 스크롤 + '최신으로' 점프 버튼 — 새 발화 시 하단으로 따라가되 위로 스크롤하면 해제하고 복귀 버튼을 띄운다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-transcript-utterances` 발화 목록(부분 스트리밍/저장 실패/응답 대기 표시) — 화자·타임코드와 함께 스트리밍 캐럿·저장 실패 노트·응답 대기 인디케이터를 표시한다 · 검증: 기존 e2e/session-mvp.spec.ts · 베이스라인 215/215 GREEN
|
||||||
|
- [x] `session-transcript-empty-state` 자막 빈 상태 안내 — 발화가 없으면 종료 여부에 따라 첫 질문 대기 또는 종료 기록 문구를 보여준다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-transcript-coach-mark` 학습자 발화의 코칭 마크(C) 버튼 — 코칭 이력이 있는 턴에 C 배지를 붙이고 클릭 시 해당 턴 필터로 이력 모달을 연다 · 검증: 기존 e2e/session-mvp.spec.ts
|
||||||
|
- [x] `session-compose-input` 학습자 발화 텍스트 입력(Enter 전송) — Enter 전송·Shift+Enter 줄바꿈이며 종료·일시정지·전송 중에는 비활성화된다 · 검증: 기존 e2e/session-mvp.spec.ts
|
||||||
|
- [x] `session-send-button` '보내기' 버튼(텍스트 턴 SSE 스트림) — 낙관적 발화 추가 후 SSE로 응답 토큰을 흘리고 done에서 단계·개방도·진행률을 반영한다 · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-stream-error-handling` 스트림 오류 처리(롤백·문구 매핑) — 실패 시 낙관적 발화를 제거하고 입력을 복원하며 오류 코드를 사용자 문구로 매핑한다 · 검증: 기존 e2e/session-layout.spec.ts
|
||||||
|
- [x] `session-turn-error-display` 턴 오류 알림 섹션 — turnError를 role=alert 섹션에 '엔진 연결/회기 종료' 제목과 함께 표시한다 · 검증: 기존(부분) e2e/session-layout.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-crisis-gate` 위기 안전게이트 — 위기 신호 수신 시 회기를 일시정지하고 마이크를 끄며 109 등 위기 자원과 안전 패널을 강제로 연다 · 검증: 기존 e2e/session-mvp.spec.ts + e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-crisis-tel-link` 위기 자원 전화 링크 — 위기 자원 번호를 tel: 링크로 제공해 즉시 전화를 걸 수 있게 한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-safety-panel-collapse` 안전 점검 패널 접기/펼치기 — 토글로 접되 위기 신호가 있으면 접힘을 무시하고 항상 펼친다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-live-signal` 라이브 신호(앰비언트 도트 + 최근 흐름) — 이벤트마다 도트를 6초 페이드로 띄우고 최근 5개 시퀀스를 누적하며 모드별로 노출을 달리한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-feedback-mode-segmented` 화면 모드 세그먼트(몰입/상태 신호/코칭) — 피드백 강도 3모드를 전환하고 코칭 버튼에 남은 기회 배지를 붙인다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `session-coach-auto-request` 턴 완료 후 라이브 코칭 자동 요청 — 코칭 모드에서 남은 기회를 확인해 코칭을 요청하고 409면 소진 문구를, 비코칭 모드면 넛지로 보관한다 · 검증: 기존 e2e/session-mvp.spec.ts + e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-coach-card` AI 코치 카드(기회 도트·크레딧 펄스·degraded) — 남은 기회 도트와 크레딧 펄스, 제안 문구, degraded·runtime 경고를 표시한다 · 검증: 기존 e2e/session-mvp.spec.ts
|
||||||
|
- [x] `session-coach-nudge` 코칭 넛지 버튼 — 코칭 화면이 닫힌 채 기회가 대기 중이면 넛지를 띄우고 클릭 시 코칭 모드로 전환해 즉시 요청한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-coach-evidence-modal` 코칭 근거 모달('근거 보기') — 판단·다음 문장·RAG 근거 자료를 다이얼로그로 보여주고 Escape·오버레이·닫기로 닫는다 · 검증: 기존 e2e/session-mvp.spec.ts
|
||||||
|
- [x] `session-coach-history-modal` 코칭 이력 모달('이력 보기'/턴 마크) — 턴별 코칭 이벤트를 시간순으로 보여주고 로딩/오류/빈 상태와 runtime 경고를 구분한다 · 검증: 기존 e2e/session-mvp.spec.ts
|
||||||
|
- [x] `session-meters-collapse` 관찰 신호 패널 접기 + 게이지 3종 — 방어·유효 개방도·라포 게이지를 서버 progress 또는 휴리스틱으로 표시하고 접으면 라포 %를 요약한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-mic-toggle` 마이크 버튼(음성 녹음 시작/발화 전송) — 권한 요청 후 음성 WS를 열어 PCM 청크를 보내고 다시 누르면 audio_end로 발화를 확정한다 · 검증: 기존 e2e/voice-success.spec.ts
|
||||||
|
- [x] `session-voice-ws-events` 음성 WebSocket 이벤트 처리 — ready/state/transcript/reply/tts_end/degraded/error 이벤트를 상태·자막·신호에 반영하고 바이너리는 TTS 버퍼로 쌓는다 · 검증: 기존 e2e/voice-success.spec.ts
|
||||||
|
- [x] `session-voice-error-recovery` 음성 오류/연결 종료 시 발화 실패 처리 — reply 확정 전 WS가 끊기면 대기 발화를 '저장 실패'로 표시하고 마이크를 안전 상태로 되돌린다 · 검증: 기존 e2e/session-mvp.spec.ts
|
||||||
|
- [x] `session-tts-playback` 내담자 TTS 재생(WebAudio+Analyser, Audio 폴백) — 합성 mp3를 WebAudio로 재생하며 입모양을 구동하고 실패 시 Audio 폴백·degraded 안내로 넘어간다 · 검증: 기존 e2e/voice-success.spec.ts
|
||||||
|
- [x] `session-voice-skip` 음성 '건너뛰기' 버튼 — TTS 재생 중에만 노출되어 재생·버퍼를 끊고 소켓을 닫은 뒤 idle로 복귀한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-pause-toggle` 일시정지/이어가기 버튼 — 일시정지 시 타이머·음성 캡처·재생을 모두 멈추고 이어가기 시 listening으로 복귀한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-end-open` '회기 종료' 버튼 — 종료 확인 다이얼로그를 열며 종료 처리 중에는 비활성화된다 · 검증: 기존 e2e/session-mvp.spec.ts
|
||||||
|
- [x] `session-end-dialog` 회기 종료 확인 다이얼로그 — 취소/종료 버튼 사이 Tab 포커스 트랩과 Escape·오버레이 닫기, 포커스 복원을 제공한다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `session-end-confirm` '종료하고 리뷰 보기' 확정 버튼 — 종료 API 성공 시 리뷰로 replace 이동하고 실패 시 리뷰로 넘어가지 않고 오류를 표시한다 · 검증: 기존 e2e/session-mvp.spec.ts
|
||||||
|
- [x] `session-review-button-controlbar` 컨트롤바 리뷰 이동 버튼(종료 후) — 종료 후 일시정지·종료 자리를 대체해 리뷰로 이동하며 준비 상태에 따라 라벨이 바뀐다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-keyboard-shortcuts` 키보드 단축키(Space=마이크, P=일시정지) — 입력창 포커스가 아닐 때 Space로 마이크를, P로 일시정지를 토글한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-elapsed-live-region` 경과 시간 스크린리더 라이브 리전 — sr-only aria-live 요소로 경과 시간을 보조기기에 알린다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-mobile-context-strip` 모바일 컨텍스트 요약 스트립 — 모바일 폭에서 회기 상태·내담자·마이크·단계·안전 상태를 한 줄로 요약한다 · 검증: 기존(부분) e2e/session-layout.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `session-start-button`: consent_required 외 모든 시작 실패가 단일 일반 문구로 뭉개져 원인(권한/404 등) 구분이 안 됨
|
||||||
|
- `session-nav-back-history`: 진행 중 회기에서 확인 없이 즉시 이탈 가능(회기는 서버에 남지만 이탈 경고 없음)
|
||||||
|
- `session-nav-learn-home`: 진행 중 회기 이탈 확인 다이얼로그 없음
|
||||||
|
- `session-timebar-end-button`: timeUp 이후에도 텍스트 입력이 클라이언트에서 막히지 않고 서버 session_time_over 오류에 의존함(의도된 정리 유예일 수 있으나 안내 부족)
|
||||||
|
- `session-stream-error-handling`: 오류 롤백 시 서버에 이미 저장됐을 수 있는 턴과 클라이언트 상태가 어긋나 재전송하면 중복 턴 가능
|
||||||
|
- `session-feedback-mode-segmented`: 세그먼트 3개 버튼에 type="button" 누락, 선택 상태가 aria-pressed 없이 클래스로만 표현됨
|
||||||
|
- `session-coach-evidence-modal`: 초기 포커스와 Escape만 있고 Tab 포커스 트랩이 없어 모달 밖으로 포커스 이탈 가능(종료 다이얼로그는 트랩 있음)
|
||||||
|
- `session-coach-history-modal`: 근거 모달과 동일하게 Tab 포커스 트랩 없음
|
||||||
|
- `session-mic-toggle`: WS onmessage가 소켓 생성 시점의 elapsed를 클로저로 고정해 음성 발화 타임코드가 마이크 연결 시각으로 표시됨
|
||||||
|
- `session-keyboard-shortcuts`: 한글 IME 등 contentEditable은 typing 예외에서 빠져 있고, P 단축키는 다이얼로그가 열린 상태에서도 동작해 모달 뒤 상태를 바꿀 수 있음
|
||||||
|
- `session-elapsed-live-region`: 매초 갱신되는 aria-live=polite라 스크린리더가 1초마다 경과 시간을 읽어 소음이 될 수 있음
|
||||||
|
|
||||||
|
## 4. 회기 리뷰 (session-review)
|
||||||
|
|
||||||
|
- [x] `session-review-guard-require-auth` RequireAuth 라우트 가드 — 미인증은 /login, 역할 불일치(학습자/교수자 경로)면 자기 역할 홈으로 replace 리다이렉트한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-guard-pending-onboarding` PendingApprovalGate·OnboardingGate 전역 가드 — 미승인은 /pending, 온보딩 미완료는 /onboarding으로 보내고 로딩 중 BootScreen을 띄운다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-initial-load` 리뷰 데이터 초기 로드와 로딩 상태 — 축어록·평가·워크시트·교수자 검토를 한 번에 불러오고 로딩 중 빈 카드를 표시한다 · 검증: 기존 e2e/session-review.spec.ts
|
||||||
|
- [x] `session-review-error-state` 리뷰 로드 오류 상태 — sessionId 누락·로드 실패 시 '리뷰를 표시할 수 없습니다' 카드에 오류 메시지를 보여준다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-teacher-auto-redirect` 교수자 자동 리다이렉트 — 학습자 경로에서 teacher 권한자가 teacherReview 응답이나 403을 만나면 교수자 리뷰 경로로 옮긴다 · 검증: 기존 e2e/session-review.spec.ts
|
||||||
|
- [x] `session-review-ready-polling` 평가 준비 상태 폴링 — 평가 대기 조건이면 1.5초 간격 최대 60회까지 리뷰를 재조회한다 · 검증: 기존 e2e/session-review.spec.ts
|
||||||
|
- [x] `session-review-tabs` 리뷰 영역 가로 탭(축어록/피드백/워크시트) — role=tablist 탭 3개로 영역을 전환하며 aria-selected로 활성 탭을 표시한다 · 검증: 기존(부분) e2e/session-review.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `session-review-btn-teach-console` 교수 콘솔로 이동 버튼 — 교수자 화면에서만 노출되며 클릭 시 /teach로 이동한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-review-btn-retry-evaluation` AI 평가 재시도 버튼 — 평가 실패 상태의 종료 회기에서만 노출되어 session_end 범위 재평가를 요청하고 리뷰를 다시 로드한다 · 검증: 기존 e2e/session-review.spec.ts + e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-review-btn-audio` 오디오 다시 듣기 버튼 — audioUrl이 있을 때만 활성화되어 새 탭으로 오디오 URL을 연다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-btn-pdf` PDF 내보내기 버튼 — pdfExportUrl이 있을 때만 활성화되어 새 탭으로 내보내기 URL을 연다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-btn-share` 공유 URL 생성·복사 버튼 — 종료된 회기의 학습자 화면에서 공유 링크를 만들어 클립보드에 복사하고 라벨을 바꾼다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-share-note` 공유 URL·오류 노트 표시 — 생성된 URL 또는 실패 메시지를 요약 카드 하단 노트로 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-transcript-filter` 축어록 전체/내 발화만 필터 토글 — aria-pressed 칩 2개로 전체·학습자 발화 보기를 전환하며 발화가 없으면 비활성화된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-turn-jump` 코멘트 타임스탬프로 발화 점프 — jumpTo 버튼으로 해당 발화를 강조·중앙 정렬하고 필요하면 필터를 자동 해제한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-transcript-display` 축어록 타임라인 표시(칩·PII 마스킹·빈 상태) — 타임스탬프·기법/비언어 칩·콜아웃을 표시하고 PII 플레이스홀더를 한국어 라벨로 치환한다 · 검증: 기존 e2e/session-review.spec.ts
|
||||||
|
- [x] `session-review-summary-band` 리뷰 요약 밴드와 준비 상태 표시 — 강조 태그 포함 요약과 근거 발화 수·평가 상태·도달 단계를 보여주고 엔진 오류 흔적은 대체 문구로 마스킹한다 · 검증: 기존(부분) e2e/session-review.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `session-review-valence-chart` 감정 밸런스 타임라인 차트 — 내담자 정서가와 상담자 기준선을 스무딩 SVG로 그리고 데이터가 없으면 빈 상태를 보여준다 · 검증: 기존(부분) e2e/session-review.spec.ts + 탐색 순회 · 빈 상태 GREEN — 차트 데이터 경로는 서버 미구현(결함 로그 #21, 로드맵)
|
||||||
|
- [x] `session-review-phase-flow` 회기 흐름 단계 바 — 단계별 weight 비율 가로 바를 그리고 구간마다 단계명·비율 툴팁을 붙인다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-rubric-display` 기법 사용 분포 카드 — 기법별 사용 빈도 바와 적절/살펴보기 품질 배지를 표시하고 평가가 없으면 대기 상태를 보여준다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-good-growth-points` 좋았던 순간·다음 회기 개선점 카드 — 근거 있는 강점과 개선점(최대 3개)을 점프 버튼과 함께 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-client-feedback` 마지막 내담자 반응 표시 — AI 내담자가 생성한 마지막 피드백 인용문을 보여주고 없으면 빈 문구를 표시한다 · 검증: 기존(부분) e2e/session-mvp.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-review-worksheet-edit` 사례개념화 워크시트 항목 편집 — 학습자 화면에서 섹션별 textarea 수정 시 confidence를 자동 조정하고 dirty 상태로 전환한다(교수자는 읽기 전용) · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-review-worksheet-save` 워크시트 저장 버튼 — 변경이 있을 때만 활성화되어 초안과 limitations를 PUT 저장하고 라벨을 저장→저장됨으로 바꾼다 · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-review-worksheet-evidence-jump` 워크시트 근거 발화 이동 버튼 — 워크시트 항목의 첫 근거 인용 버튼으로 해당 발화에 점프한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-prepost-load` 사전/사후 점수 원장 로드 — 마운트 시 phase3-pilot-draft 파일럿 측정값을 불러와 초안·기준선과 완료쌍 배지를 채운다 · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-review-prepost-input` 사전/사후 점수 입력(3척도×2시점) — 자기효능감·기술숙련도·수련만족도 사전/사후 값을 1~5로 편집하고 범위 밖 입력은 오류로 막는다 · 검증: 기존 e2e/session-review.spec.ts
|
||||||
|
- [x] `session-review-prepost-save` 사전/사후 점수 저장 버튼 — 유효 변경만 있을 때 활성화되어 키별 순차 PUT 후 원장을 재조회한다 · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-review-teacher-note-input` 교수자 검토 메모 입력 — 교수자 전용 textarea(2000자)에 다음 지도 메모를 작성한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `session-review-teacher-save-viewed` 메모 저장(검토 중) 버튼 — 검토 상태를 viewed로 두고 메모·워크시트 메모를 PUT 저장한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `session-review-teacher-close` 검토 완료 버튼 — 종료·평가 준비된 회기에서만 활성화되어 검토 상태를 closed로 저장하고 완료 시각을 표시한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `session-review-teacher-worksheet-note` 워크시트 검수 메모 입력 — 수정요청·반려 사유를 적는 textarea로 검수 결정·메모 저장 시 함께 PUT된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-teacher-worksheet-approve` 워크시트 승인 버튼 — 학습자 저장본이 있는 종료 회기에서 worksheet_status=approved를 저장한다 · 검증: 기존(부분) e2e/session-persistence.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `session-review-teacher-worksheet-request-changes` 워크시트 수정요청 버튼 — 같은 조건에서 worksheet_status=changes_requested를 저장하고 검수 배지를 갱신한다 · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `session-review-teacher-worksheet-reject` 워크시트 반려 버튼 — 같은 조건에서 worksheet_status=rejected를 저장하며 저장 중에는 세 결정 버튼이 모두 비활성화된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `session-review-eval-scope-notice` AI 평가 범위 안내 박스 — fast-loop/deep-loop 구현 범위와 임상팀 확정 필요 항목을 정적으로 안내한다 · 검증: 기존 e2e/session-review.spec.ts
|
||||||
|
- [x] `session-review-head-meta` 회기 헤더·상태 스탯 표시 — 내담자·날짜·소요시간·페르소나와 화면 배지, SESSION/SUPERVISOR 상태와 세션 ID를 표시한다 · 검증: 기존(부분) e2e/session-review.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `session-review-error-state`: 오류 화면에 재시도/뒤로가기 액션이 없어 새로고침 외 복구 수단이 없다.
|
||||||
|
- `session-review-teacher-auto-redirect`: 403 detail 문자열('session does not belong to user') 완전 일치에 의존해 백엔드 문구가 바뀌면 리다이렉트가 조용히 깨진다.
|
||||||
|
- `session-review-ready-polling`: 60회 한도 도달 시 아무 안내 없이 폴링이 멈춰 사용자는 '대기' 표시가 영구 상태인지 알 수 없다. 또한 폴링마다 setData로 워크시트 초안 상태가 리셋되어 입력 중이던 학습자 텍스트가 날아갈 수 있다.
|
||||||
|
- `session-review-tabs`: role=tab을 쓰지만 화살표 키 이동·aria-controls·tabpanel 연결이 없어 ARIA 탭 패턴 접근성이 불완전하다.
|
||||||
|
- `session-review-btn-retry-evaluation`: displayEvaluationRetryError가 실제 오류 detail을 버리고 항상 동일한 일반 문구만 보여줘 실패 원인 파악이 불가능하다. 노출 조건이 '평가 실패'·'종료됨' 같은 한국어 표시 문자열 일치에 결합돼 있어 깨지기 쉽다.
|
||||||
|
- `session-review-btn-share`: clipboard.writeText 실패 시에도 catch에서 'copied' 상태로 표시해 실제 복사 실패를 숨긴다. api에 있는 공유 해지(DELETE /share)는 UI에 노출되지 않아 만든 링크를 화면에서 되돌릴 수 없다. 활성 조건이 표시 문자열 '종료됨' 일치에 의존한다.
|
||||||
|
- `session-review-share-note`: 공유 URL이 일반 텍스트로만 표시되고 앵커 링크나 재복사 버튼이 없다.
|
||||||
|
- `session-review-turn-jump`: 필터 해제 직후 requestAnimationFrame 1회로 scrollIntoView를 호출해 React 리렌더 전이면 대상 ref가 아직 없어 스크롤이 무시될 수 있는 경합이 있다.
|
||||||
|
- `session-review-transcript-display`: 비언어 칩의 상세 내용이 title 속성 툴팁에 의존해 키보드·터치 사용자에게 전달되지 않는다.
|
||||||
|
- `session-review-worksheet-edit`: worksheet prop이 바뀔 때마다 초안·dirty가 무조건 리셋되므로 백그라운드 폴링/재로딩과 겹치면 저장 전 입력이 소실된다. 이탈 시 미저장 경고(beforeunload/라우트 블록)도 없다.
|
||||||
|
- `session-review-worksheet-save`: 저장 실패 시 '워크시트를 저장하지 못했습니다' 고정 문구만 표시하고 서버 오류 상세를 버린다.
|
||||||
|
- `session-review-worksheet-evidence-jump`: evidence 배열의 첫 항목만 노출되어 두 번째 이후 근거는 접근 불가다.
|
||||||
|
- `session-review-prepost-input`: 저장된 값을 비우는 것은 영구히 불가능하며(빈칸이 invalid 처리), invalid 칸이 하나라도 있으면 다른 유효한 변경까지 통째로 저장이 막힌다.
|
||||||
|
- `session-review-prepost-save`: 키별 순차 PUT 중 하나가 실패하면 일부만 저장된 채 일반 오류 문구만 표시되고 어떤 항목이 저장됐는지 재동기화되지 않는다.
|
||||||
|
- `session-review-teacher-save-viewed`: 이미 closed인 검토에 메모 저장을 누르면 status가 viewed로 덮여 검토 완료 상태가 되돌려질 수 있다(닫힘 여부를 보존하지 않음).
|
||||||
|
- `session-review-teacher-close`: 한 번 closed로 저장하면 화면에서 다시 여는(viewed로 되돌리는) 명시적 수단이 없다(메모 저장 부작용으로만 가능).
|
||||||
|
- `session-review-teacher-worksheet-note`: 이 textarea만 name·autoComplete 속성이 없어 다른 입력과 마크업 일관성이 없다.
|
||||||
|
- `session-review-teacher-worksheet-reject`: 승인/수정요청/반려에 확인 다이얼로그가 없어 오클릭 한 번으로 결정이 즉시 저장된다.
|
||||||
|
|
||||||
|
## 5. 교수자 콘솔 (professor)
|
||||||
|
|
||||||
|
- [x] `professor-guard-require-auth-teacher` RequireAuth(teacher) 라우트 가드 — 미인증은 /login, teacher 권한이 없으면 자기 역할 홈으로 리다이렉트한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-guard-pending-approval` PendingApprovalGate 리다이렉트 — 미승인 계정이 /teach 계열에 오면 /pending으로 보내고 승인 계정이 /pending에 오면 되돌린다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-guard-onboarding` OnboardingGate 리다이렉트 — 온보딩 미완료 사용자가 /teach 계열에 오면 /onboarding으로 보낸다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-dashboard-initial-load` 교수자 대시보드 초기 로드 — 검토 대기·최근 세션·안전 알림·학습자 성장 데이터를 불러와 로딩/오류/갱신 시각을 관리한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-dashboard-error-banner` 대시보드 오류 배너 — 로드 실패 시 role=alert 배너에 오류 메시지를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-persona-review-initial-load` 페르소나 검수 큐 초기 로드 — 검수 대기 페르소나를 불러오며 404는 빈 큐로, 5xx만 오류 카드로 처리한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-header-refresh` 새로고침 버튼 — 대시보드와 뷰별 데이터(검수 큐 또는 학습자 분석)를 함께 재조회하고 진행 중에는 비활성화된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-open-first-pending-review` 첫 검토 열기 버튼 — 검토 대기가 있을 때만 헤더에 노출되어 첫 번째 회기의 교수자 리뷰로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `professor-nav-to-analysis` 학생 분석 이동 버튼 — 콘솔 헤더에서 /teach/analysis로 이동한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-nav-to-console` 콘솔로 이동 버튼 — 분석 뷰 헤더에서 /teach 콘솔로 되돌아간다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `professor-triage-status-strip` 검토 큐 상태 스트립 — 코호트 라벨·마지막 갱신 시각·리뷰 대기/누적 회기 수를 role=status로 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `professor-kpi-cards` KPI 카드 6종 — 검토 대기·위기 알림·페르소나 검수·최근 세션·전체 학생·활성 세션 수를 표시 전용 카드로 요약한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-pending-review-list` 검토 대기 회기 목록 — 종료됐지만 닫지 않은 회기를 행 버튼으로 나열하고 클릭 시 교수자 리뷰로 이동한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-safety-alert-list` 위기 알림(109) 목록 — safety_alerts를 학습자·페르소나·위험도·발생시각·안내 자원과 함께 나열한다 · 검증: 기존 e2e/session-persistence.spec.ts
|
||||||
|
- [x] `professor-persona-review-approve` 페르소나 검수 승인 버튼 — review 상태 페르소나에만 노출되어 approve 결정을 저장하고 결과에 따라 행 갱신 또는 큐 제거를 한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-persona-review-open-studio` 스튜디오에서 검수 버튼 — `/teach/personas?draft={id}`로 이동해 스튜디오에서 상세 검수를 잇는다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `professor-persona-review-error-retry` 검수 큐 오류 카드 + 다시 시도 — 로드·결정 실패 시 role=alert 카드와 재조회 버튼을 제공한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-persona-review-empty-loading` 검수 큐 로딩/빈 상태 — 로딩 중 '검수 큐 확인 중', 비면 '검수 대기 페르소나 없음'을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-open-persona-studio` 페르소나 스튜디오 열기 버튼 — 콘텐츠 저작실 카드에서 초안 건수 메타와 함께 /teach/personas로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `professor-recent-session-table` 최근 회기 기록 테이블 — 최근 세션을 행 버튼으로 나열하고 종료 여부에 따라 상세 리뷰/진행 기록 라벨로 리뷰 화면에 진입시킨다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-analysis-learner-search` 학습자 검색 입력 — 학습자 라벨·ID·추이·기법 태그를 합친 문자열에 부분 일치로 실시간 필터링한다 · 검증: 기존 e2e/teacher.spec.ts · 베이스라인 215/215 GREEN
|
||||||
|
- [x] `professor-analysis-sort-segment` 학습자 정렬 세그먼트 (최근순/회기순/점수순/변화순) — 네 기준 중 하나로 학습자 목록을 재정렬한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-analysis-table-states` 학습자 테이블 로딩/빈/검색결과없음 상태 — 데이터 없음·로딩·검색 결과 없음을 각각 다른 문구로 구분한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-analysis-row-expand` 학습자 행 펼침 토글 — aria-expanded 버튼으로 회기별 스파크바·점수 변화·기법 태그 요약을 한 행씩 펼친다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-analysis-open-learner-detail` 학습자 상세 진입 (이름 버튼 · 상세 보기 버튼) — `?learner={id}` 쿼리를 설정해 드릴다운 화면으로 전환한다(URL 공유·뒤로가기 가능) · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-analysis-detail-load` 학습자 상세 분석 로드 — learner 파라미터 변경마다 상세를 조회하고 로딩·오류·미선택 상태를 구분하며 낡은 응답을 무시한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `professor-analysis-back-to-list` 전체 목록 복귀 버튼 — learner 쿼리를 지워 검색 테이블로 돌아간다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-analysis-invalid-learner-redirect` 존재하지 않는 학습자 파라미터 자동 복귀 — learner_growth에 없는 ID면 쿼리를 지워 전체 목록으로 되돌린다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-analysis-depth-strip` 분석 깊이 표시 스트립 — 현재 드릴다운 깊이(1 목록 → 2 상세 → 3 리뷰)를 표시 전용으로 안내한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `professor-analysis-summary-metrics` 학습자 상세 요약 메트릭 — 전체 회기·완료/진행·검토 대기·최근 적절성·변화·평균 라포 6개 지표를 상단에 요약한다 · 검증: 기존(부분) e2e/layout-visual-gate.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `professor-analysis-tabs` 상세 분석 탭 (페르소나별 회기/추이/전체 회기/단계 분석) — 4개 탭으로 콘텐츠를 전환하고 각 탭에 건수 배지를 표시한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `professor-analysis-persona-group-drill` 페르소나별 회기 그룹 아코디언 — 페르소나 요약 그룹 버튼을 눌러 그룹 내 회기 목록을 한 번에 하나씩 펼친다 · 검증: 기존 e2e/layout-visual-gate.spec.ts
|
||||||
|
- [x] `professor-analysis-session-row-open` 회기 행 열기 (페르소나 탭·전체 회기 탭 공용) — 회기 요약 행 버튼 클릭 시 교수자 리뷰 화면으로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `professor-analysis-trend-chart` 추이 탭 회기별 막대 차트 — 회기별 점수를 막대로 그리고 title 툴팁에 페르소나·회기·점수를 담는다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-analysis-stage-breakdown` 단계 분석 탭 — 상담 단계별 회기 수와 턴 수를 카드 목록으로 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `professor-analysis-list-reuses-dashboard` 학습자 목록 데이터 소스 — 분석 뷰 검색 테이블이 별도 API 없이 teacher/dashboard의 learner_growth를 재사용한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `professor-dashboard-error-banner`: 배너 자체에 재시도 버튼이 없음 — 헤더의 새로고침 버튼까지 시선 이동 필요. 오류 상태에서도 아래 큐 섹션들이 빈 상태 문구로 그대로 렌더되어 '데이터 없음'과 '로드 실패'가 혼동될 수 있음.
|
||||||
|
- `professor-kpi-cards`: KPI 카드가 해당 섹션으로 스크롤/이동하는 링크가 아니어서 숫자만 보이는 죽은 UI — 특히 '위기 알림' 같은 긴급 지표도 클릭 동작이 없음.
|
||||||
|
- `professor-safety-alert-list`: 표시 전용이라 알림 확인(acknowledge)·해결 처리 액션이 전혀 없고, session_id를 보여주면서도 해당 회기 리뷰로 이동하는 링크가 없어 위기 대응 동선이 끊김.
|
||||||
|
- `professor-persona-review-approve`: 승인 실패 시 setPersonaReviewError로 패널 전체가 에러 카드로 대체되어 검수 목록이 통째로 사라짐(단건 실패가 전역 오류로 승격). 또한 busy 비교가 정확히 '해당 persona:action'만이라 다른 행의 승인 버튼은 여전히 눌러져 동시 다발 요청 가능. 확인(confirm) 단계 없이 즉시 승인됨.
|
||||||
|
- `professor-recent-session-table`: 테이블이 div+button 구조이고 헤더 행이 aria-hidden이라 스크린리더는 열 제목 없이 셀 텍스트만 듣게 됨 (data-label 속성은 시각용). 진짜 table/grid 시맨틱 부재.
|
||||||
|
- `professor-analysis-sort-segment`: 활성 표시가 CSS 클래스(is-active)뿐이고 aria-pressed/aria-current가 없어 보조기기에서 현재 정렬 기준을 알 수 없음.
|
||||||
|
- `professor-analysis-detail-load`: 상세 로드 오류 카드에 재시도 버튼이 없음 — 헤더 새로고침으로만 복구 가능.
|
||||||
|
- `professor-analysis-invalid-learner-redirect`: learner_growth가 아직 로드되지 않은 짧은 순간에는 판정을 보류하지만, 대시보드 로드가 실패하면 learnerGrowth가 빈 배열로 남아 유효한 딥링크도 상세 제목이 '학습자 이력' 폴백으로 표시됨.
|
||||||
|
- `professor-analysis-depth-strip`: nav 요소지만 각 단계가 클릭 불가한 span이라 브레드크럼처럼 보이면서 동작하지 않음 — 1단계 클릭으로 목록 복귀를 기대하는 사용자에게 혼란.
|
||||||
|
- `professor-analysis-tabs`: role=tab/aria-selected는 있으나 tabpanel 연결(aria-controls/id)과 화살표 키 로빙 탭인덱스가 없어 WAI-ARIA 탭 패턴 미완성.
|
||||||
|
- `professor-analysis-trend-chart`: 차트 정보가 title 속성 툴팁에만 있어 키보드/터치/스크린리더로는 개별 막대 값을 알 수 없음.
|
||||||
|
- `professor-analysis-stage-breakdown`: 다른 탭과 달리 stage_breakdown이 빈 배열일 때 EmptyState가 없어 제목만 있고 내용이 없는 화면이 됨.
|
||||||
|
|
||||||
|
## 6. 페르소나 스튜디오 (persona-studio)
|
||||||
|
|
||||||
|
- [x] `persona-studio-guard-require-auth` RequireAuth 라우트 가드 (teacher/admin) — 미인증은 /login, teacher/admin이 아니면 자기 역할 홈으로 보낸다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-guard-pending-approval` PendingApprovalGate 가드 — 미승인 사용자는 /pending으로 리다이렉트한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-guard-onboarding` OnboardingGate 가드 — 온보딩 미완료 사용자는 /onboarding으로 리다이렉트한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-initial-load` 초기 데이터 로드 (카탈로그·검수 큐·교수 대시보드) — 3개 API를 병렬 호출하며 카탈로그 실패만 전체 오류, 검수 큐 404는 빈 목록, 대시보드 실패는 경고로 처리한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-load-error-banner` 로드 오류 배너 — 카탈로그 로드 실패 시 role=alert 섹션에 오류 메시지를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-dashboard-degraded-note` 학습 지표 실패 시 부분 동작 안내 — 대시보드 호출 실패 시 경고 노트만 띄우고 카탈로그·저작 기능은 계속 쓰게 한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-status-message-line` 폼 오류/성공 메시지 라인 — formError가 있으면 role=alert 오류를, 없으면 성공/안내 문장을 헤더 아래에 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-refresh-button` 새로고침 버튼 — 카탈로그·검수 큐·대시보드를 다시 호출하며 로딩·busy 중에는 비활성화된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-workspace-tabs` 워크스페이스 탭 (대시보드/카탈로그/페르소나) — view 쿼리 기반으로 탭을 전환하며 나머지 쿼리 파라미터를 모두 제거한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-create-open` 새로운 페르소나 만들기 버튼 — 초안 상태를 초기화하고 localStorage 임시본이 있으면 복원 메시지와 함께 되살린 뒤 작성 단계로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-dash-review-more` 검수 현황 더보기 버튼 — `view=personas&queue=review`로 이동해 검수 대기 목록을 연다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-dash-usage-row` 페르소나별 학습 현황 행 클릭 — 공개 페르소나 상위 8개 행을 클릭하면 승인본 상세로 이동한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-dash-usage-empty` 공개 페르소나 없음 빈 상태 — 카탈로그가 비면 '공개 페르소나 없음' 빈 상태를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-catalog-view-all` 전체 페르소나 보기 버튼 — 카탈로그 화면에서 `view=personas` 목록으로 이동한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-catalog-row` 공개본 상태 목록 행 클릭 — 공개 페르소나 행을 클릭하면 승인본 상세로 이동한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `persona-studio-catalog-empty-distribution` 이론 분포 빈 상태 — 공개 페르소나가 없으면 이론 분포 대신 '분포 자료 없음'을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-list-subtabs` 목록 서브탭 (전체 페르소나/검수 현황) — queue 파라미터를 all/review로 바꾸고 열린 상세를 닫으며 건수를 표시한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-list-search` 페르소나 검색 입력 — 코드·이름·이론·상태 라벨 문자열에 ko-KR 소문자 부분일치로 필터링한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-list-origin-filter` 구분 필터 셀렉트 (전체/시스템/새로 만든) — P1~P11·seed 출처는 시스템, 나머지는 커스텀으로 분류해 필터링한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-list-row-open-detail` 목록 행 식별자/상세 버튼 — 행의 코드·이름 또는 상세 버튼으로 `persona={key}` 상세 화면을 연다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-list-approved-revise` 공개본 수정 버튼 (새 버전 초안 생성) — 승인본에서 revision 초안을 만들어 편집기로 불러오고 승인 전까지 공개본을 유지한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-list-approved-archive` 공개본 삭제(보관) 버튼 — confirm 후 DELETE로 보관 처리하고 같은 대상 편집 중이면 상태를 초기화한 뒤 목록을 새로고침한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-list-draft-edit` 초안 행 수정 버튼 — `mode=edit&draft={id}`로 이동해 초안 상세를 편집 폼에 자동 로드한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-list-empty` 목록 필터 결과 빈 상태 — 검색·필터 결과가 없으면 '조건에 맞는 페르소나 없음'을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-review-card-detail` 검수 카드 상세 버튼 — `persona=draft:{id}`로 이동해 해당 초안 상세를 연다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-review-card-edit` 검수 카드 수정 버튼 — 해당 초안을 mode=edit 편집기로 연다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-review-card-approve` 검수 카드 승인 버튼 — approve 결정으로 공개본으로 승격하고 편집 상태를 초기화한 뒤 목록을 새로고침한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-review-card-reject` 검수 카드 반려 버튼 — reject 결정을 저장하며 status가 review가 아니거나 busy면 비활성화된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-review-empty` 검수 대기 없음 빈 상태 — review/draft 항목이 없으면 '검수 대기 없음'을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-detail-back` 상세 → 목록으로 버튼 — persona·detailTab 쿼리를 제거해 목록으로 돌아간다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-detail-tabs` 상세 서브탭 (소개·정보/학습 현황/설정 내용) — detailTab 쿼리로 상세 화면 3개 탭을 전환한다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-detail-draft-fetch` 초안 상세 자동 조회 — persona 키가 draft:면 초안 상세를 조회해 주호소·핵심 신념·민감 영역·출처를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-detail-revise-button` 상세 헤더 수정 버튼 — 승인본이면 revision 초안을 만들어, 초안이면 바로 편집기로 이동한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-detail-learning-empty` 학습 기록 없음 빈 상태 — 평가된 회기 궤적이 없으면 빈 상태를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-detail-notfound` 상세 대상 미존재 빈 상태 — persona 키가 목록에 없으면 '페르소나를 찾지 못했습니다'를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-author-stepper` 저작 4단계 스테퍼 (자료/초안/설정/검토) — step 쿼리로 단계를 이동하며 검토 단계 진입 시 프롬프트 미리보기로 자동 전환한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-author-back-to-list` 저작 화면 목록으로 버튼 — `view=personas`로 돌아가 저작 모드를 종료하며 busy 중에는 비활성화된다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-author-rail-draft-load` 좌측 레일 초안 목록 클릭 로드 — 검수 큐 초안을 클릭하면 상세를 불러와 현재 편집 폼을 교체한다 · 검증: 신규 spec 필요 · 현행 설계 특성화 GREEN(저작 모드 레일 숨김 — 결함 로그 #14 소유자 확인)
|
||||||
|
- [x] `persona-studio-author-rail-approved-actions` 좌측 레일 공개본 수정/삭제 버튼 — 공개본별로 revision 초안 생성 또는 confirm 후 보관 처리를 수행한다 · 검증: 신규 spec 필요 · 현행 설계 특성화 GREEN(저작 모드 레일 숨김 — 결함 로그 #14 소유자 확인)
|
||||||
|
- [x] `persona-studio-author-empty-states` 저작 레일/인스펙터 빈 상태 — 초안·공개본·결정 대상이 없을 때 각각의 빈 문구를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-source-dropzone` 자료 파일 드래그&드롭 업로드 — 드롭된 첫 파일만 처리해 엑셀은 서버 변환, 텍스트는 브라우저에서 읽어 본문에 채운다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-source-file-picker` 파일 선택 버튼 + 숨김 file input — 엑셀은 multipart 업로드로 표→텍스트 변환·KB 등록하고 그 외는 본문에 읽어 넣는다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-source-kind-select` 자료 종류 셀렉트 — client_record/textbook_guide/mixed_notes 중 근거 자료 종류를 선택해 요청에 함께 보낸다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-source-name-input` 파일/자료명 입력 — 등록될 근거 문서의 파일명·제목으로 쓰이며 비우면 기본값이 적용된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-generation-goal-input` 생성 목표 입력 — 초안 생성 요청의 generation_goal과 근거 문서 source_note로 전달된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-source-text-area` 자료 본문 붙여넣기 textarea — 내담자 기록·교재 기준을 붙여넣으면 생성 시 KB 근거 문서로 먼저 등록된다(20자 이상) · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-generate-draft` KB 등록 후 생성 버튼 — 붙여넣은 텍스트를 근거 문서로 등록한 뒤 RAG 초안 생성을 호출하고 결과·마스킹·근거 청크를 인스펙터에 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-studio-tabs` 저작 섹션 탭 7종 (개요/임상/저항/회기/말투/안전/프롬프트) — role=tablist로 편집 섹션을 전환하며 섹션별 작성 가이드를 함께 보여준다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-field-code` 코드 입력 (자동 제안·중복 검사) — 대문자로 정규화하고 비면 다음 P{n}을 제안하며 카탈로그·검수 큐와 중복이면 검증 오류를 낸다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-field-display-name` 표시 이름 입력 — 표시 이름을 입력하며 비면 저장·검수 요청 모두 검증 오류가 된다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-field-difficulty` 난이도 셀렉트 (easy/moderate/hard) — 페르소나 난이도를 선택한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-field-synthetic` 합성 여부 셀렉트 (synthetic/clinical source) — 합성 초안인지 실제 임상 근거 기반인지 is_synthetic 플래그를 설정한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-theory-toggle` 대상 이론 토글 (humanistic/cbt/integrative) — 세그먼트 버튼으로 이론을 다중 선택·해제하며 마지막 하나를 해제하면 humanistic으로 되돌린다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-fields-demographics` 인구통계 입력 4종 (연령대/성별·젠더/역할·학년/맥락) — demographics 객체의 네 키를 자유 텍스트로 편집한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-fields-presenting` 주호소·첫 회기 입장 발화 textarea — presenting.complaint와 first_session_opening을 편집하며 주호소는 검수 요청 시 필수다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-field-provenance` 출처(source_provenance) 입력 — 작성 근거·출처를 기록하며 비면 경고로 표시된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-fields-clinical` 임상 탭 입력 (가족/학교·직장/관계/촉발 사건/강점 + CCD 5종 + 증상 메모) — history 5개와 CCD·dsm5 메모를 편집하며 핵심신념은 검수 요청 시 필수다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-fields-resistance-numbers` 저항 탭 숫자 입력 16종 (저항 5·Big5 5·정서 기저선 5·자살사고 단계) — 0~1 파라미터와 자살사고 단계를 number 입력으로 편집한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-fields-scenario` 회기 탭 입력 (회기별 시나리오 리스트/표면화 조건/추가 프롬프트 계약) — session_scenarios 리스트와 surface·prompt_contract 장문을 편집한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-fields-speech` 말투 탭 입력 (어조/문장 길이/존댓말/군말·말버릇·비언어 단서) — speech_style의 셀렉트와 세 리스트를 편집한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-fields-safety` 안전 탭 입력 (역린 민감 영역/상담자 금기/역린 반응/내담자 금기어) — triggers의 리스트와 reaction 장문을 편집하며 sore_spots는 검수 요청 시 필수다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-listfield-add-remove` 리스트 필드 공통 항목 추가/삭제 — 모든 ListField에서 빈 행 추가와 X 버튼 삭제를 제공하고 저장 시 빈 항목을 거른다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-prompt-preview` 프롬프트 미리보기 패널 (읽기 전용) — 구조화 데이터를 8개 섹션 런타임 계약으로 렌더하고 빈 섹션은 '작성된 항목 없음'으로 표시한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-author-prev-next` 이전/다음 단계 버튼 — step 쿼리를 앞뒤로 옮기며 마지막 단계에서는 검수 요청 버튼으로 대체된다 · 검증: 기존(부분) e2e/teacher.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `persona-studio-save-progress` 중간 저장 버튼 — 작성 내용을 localStorage에 항상 저장하고 최소 검증을 통과하면 서버 초안으로도 생성·갱신한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-submit-review` 검수 요청 버튼 — 전체 검증 통과 시 submit_for_review로 저장해 상태를 review로 올리고 localStorage 임시본을 삭제한다 · 검증: 기존 e2e/teacher.spec.ts
|
||||||
|
- [x] `persona-studio-inspector-validation` 인스펙터 검증 체크리스트 — 필수 오류·경고를 실시간으로 나열하고 모두 통과하면 완료 배지를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-inspector-evidence` 인스펙터 생성 근거 패널 — 생성 요약·PII 마스킹·경고·등록 근거 문서·상위 3개 근거 청크를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-inspector-review-actions` 인스펙터 검수 큐 편집/반려/승인 버튼 — 저작 화면 우측에서 초안을 편집기로 로드하거나 즉시 승인·반려 결정을 보낸다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-draft-autoload-param` `?draft=` 파라미터 초안 자동 로드 — draft 파라미터로 진입하면 로드 완료 후 해당 초안을 한 번 자동으로 편집 폼에 채운다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `persona-studio-loading-busy-states` busy 상태별 버튼 라벨/비활성화 — load/save/submit/decide/generate/archive 작업 중 관련 버튼을 진행 라벨로 바꾸고 비활성화한다 · 검증: 신규 spec 필요
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `persona-studio-load-error-banner`: 오류 배너에 재시도 버튼이 없어 헤더의 새로고침 버튼을 찾아야 재시도 가능
|
||||||
|
- `persona-studio-status-message-line`: formError가 message를 항상 가리므로 직전 성공 안내가 오류 발생 시 소실됨
|
||||||
|
- `persona-studio-workspace-tabs`: 저작(mode=create/edit) 중 탭 클릭 시 확인 없이 편집 화면을 이탈해 미저장 서버 반영분이 사라질 수 있음(localStorage 임시본만 남음)
|
||||||
|
- `persona-studio-create-open`: 복원된 임시본은 검수 요청 성공 때만 삭제되므로 오래된 초안이 새 작성 세션에 계속 되살아날 수 있음
|
||||||
|
- `persona-studio-dash-usage-row`: role=table/row 구조인데 행 전체가 button이고 cell 역할이 없어 스크린리더 표 탐색이 어긋남
|
||||||
|
- `persona-studio-list-origin-filter`: 검색어·필터가 쿼리스트링에 저장되지 않아 상세 진입 후 돌아오면 초기화됨
|
||||||
|
- `persona-studio-list-approved-revise`: persona_id가 없는 시드 공개본은 버튼이 disabled이나 이유 툴팁이 없어 사용자가 원인을 알 수 없음
|
||||||
|
- `persona-studio-list-approved-archive`: 네이티브 window.confirm 사용으로 앱 디자인·접근성과 불일치하고 E2E에서 다이얼로그 처리가 필요
|
||||||
|
- `persona-studio-detail-draft-fetch`: 조회 실패를 catch에서 조용히 무시(null 처리)해 사용자는 작성 정보 섹션이 왜 안 보이는지 알 수 없음
|
||||||
|
- `persona-studio-detail-notfound`: 빈 상태에 목록으로 돌아가는 버튼이 없어 사용자가 직접 URL/탭을 조작해야 함
|
||||||
|
- `persona-studio-author-stepper`: 단계 이동에 완료 조건 검증이 없어 자료 없이도 검토 단계로 건너뛸 수 있음(의도된 자유 이동일 수 있음)
|
||||||
|
- `persona-studio-author-back-to-list`: 미저장 변경이 있어도 확인 없이 이탈함(localStorage 임시본은 중간 저장을 눌렀을 때만 존재)
|
||||||
|
- `persona-studio-author-rail-draft-load`: 현재 작성 중이던 내용을 확인 없이 덮어씀
|
||||||
|
- `persona-studio-source-dropzone`: 여러 파일을 드롭해도 첫 파일만 조용히 처리되고 나머지는 무시됨
|
||||||
|
- `persona-studio-source-file-picker`: accept에 .xls가 빠져 있는데 코드 분기는 .xls도 처리함(파일 선택 대화상자에서 .xls가 걸러짐); 텍스트 분기는 확장자 검증 없이 아무 파일이나 text()로 읽음
|
||||||
|
- `persona-studio-theory-toggle`: 마지막 항목 해제 시 조용히 humanistic으로 바뀌어 사용자가 의도치 않은 값을 가질 수 있음(안내 없음)
|
||||||
|
- `persona-studio-fields-resistance-numbers`: min/max가 HTML 속성뿐이라 직접 타이핑하면 범위 밖 값(예: 7, -3)이 상태에 그대로 저장되고 JS 클램핑·검증이 없음
|
||||||
|
- `persona-studio-generate-draft`: 재클릭 시 같은 붙여넣기 텍스트가 매번 새 근거 문서로 중복 등록됨; 생성 성공 시 기존 편집 내용을 확인 없이 덮어씀
|
||||||
|
- `persona-studio-save-progress`: localStorage에는 sourceText 등 원문 자료가 평문 저장됨(내담자 실제 기록일 경우 브라우저에 PII 잔존)
|
||||||
|
- `persona-studio-inspector-review-actions`: 검수 보드와 달리 status!=='review'(작성 중 draft)여도 승인/반려 버튼이 활성화되어 UI 가드가 불일치함(서버 거부에 의존)
|
||||||
|
- `persona-studio-draft-autoload-param`: Professor 페이지는 mode 없이 `?draft=`만 붙여 이동하는데 자동 로드는 isAuthoring(mode=create/edit)일 때만 동작해 해당 진입 경로에서 초안이 로드되지 않음
|
||||||
|
|
||||||
|
## 7. 관리자 콘솔 (admin)
|
||||||
|
|
||||||
|
- [x] `admin-guard-require-auth` RequireAuth admin 가드 — 미인증은 /login, admin·superAdmin·adminAccess가 없으면 자기 역할 홈으로 리다이렉트한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-guard-pending-approval` PendingApprovalGate 승인 대기 가드 — 미승인 사용자가 /admin 계열에 오면 /pending으로 보내고 승인 사용자가 /pending에 있으면 되돌린다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-guard-onboarding-bypass` OnboardingGate admin 우회 — 온보딩 미완료여도 admin 접근 권한이 있으면 /admin 경로는 그대로 통과시킨다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-error-boundary` AdminErrorBoundary 렌더 예외 진단 — 렌더 예외 시 빈 화면 대신 section·path·error·componentStack 진단 패널을 띄우고 섹션 변경 시 리셋한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-blank-content-probe` 빈 본문 감지 프로브 — 마운트 800ms 후 본문 텍스트·높이·가시 노드를 검사해 비어 있으면 진단 패널을 표시한다 · 검증: 기존 e2e/public-admin-visual.spec.ts
|
||||||
|
- [x] `admin-data-diagnostics-panel` 관리자 데이터 계약 진단 패널 — users/tickets 응답 정규화 중 생긴 경고를 최대 8건까지 나열하고 초과분은 개수로 요약한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-unknown-section-panel` 알 수 없는 섹션 폴백 패널 — 정의되지 않은 섹션이 전달되면 섹션·경로·asset 진단 패널을 표시한다 · 검증: 신규 spec 필요 · 도달 불가 방어 코드(라우터가 섹션을 하드코딩, 미정의 경로는 루트 회수) — E2E 불가, 코드 확인으로 종결
|
||||||
|
- [x] `admin-overview-refresh-button` 운영 홈 새로고침 버튼 — 헬스·사용자·사용량·업타임·티켓 5개 API를 병렬 재조회하고 로딩 중 비활성화한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-overview-status-banner` 서비스 상태 배너 — health 상태에 따라 색상·문구를 바꾸고 환경·엔진 모드·갱신 시각을 role=status로 표시한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-overview-kpi-cards` 운영 요약 KPI 4종 — 활성 회기·최근 15분 온라인 사용자·등록 사용자 수·헬스 ok/total을 카드로 표시한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-overview-flow-panel` 실시간 교육 흐름 패널 — 학습자 회기 수·온라인 교수자/관리자 수·최근 7일 AI 계량 턴 수를 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-overview-availability-panel` 가용성 패널 — 현재 헬스, 24시간 샘플 정상률(샘플 없음/미연결 구분), 최근 중단 시각을 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-overview-cost-panel` AI 비용 패널 — 7일 비용·예산 상태·사용률 바·평가 캐시 hit-rate·일별 비용 추이를 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-overview-ticket-mini-panel` 운영 티켓 미니 패널 — 미해결·높은 우선순위·24시간 정체 요약과 상위 활성 티켓 2건을 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-overview-services-list` 서비스 리소스 목록 — health.services를 상태 Dot·Badge·detail·load 바로 렌더하고 로딩 스켈레톤과 빈 문구를 갖는다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-overview-health-error` 헬스 조회 실패 InlineError — health 호출 실패 시 오류 메시지를 role=alert 섹션으로 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-tabs` 사용자 관리 탭바(승인/목록/등록/활동) — 대기 건수를 표시한 4개 탭을 role=tablist + aria-selected로 전환한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-users-approval-queue` 가입 승인 대기 큐 — pending 사용자를 접수일 내림차순으로 나열하고 집계 배지·스켈레톤·빈 상태를 제공한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-users-approve-button` 가입 승인 버튼 — 대기 사용자의 account_status를 approved로 PATCH하고 목록·드래프트를 갱신한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-users-suspend-button` 가입 보류 버튼 — 대기 사용자의 account_status를 suspended로 PATCH해 보류 처리한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-readonly-guard` DB 미연결 읽기 전용 경고 — durable이 false면 경고를 띄우고 승인·등록·편집·저장·비활성화 등 쓰기 UI를 모두 비활성화한다 · 검증: 기존(부분) e2e/readiness.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-users-search-input` 사용자 검색 입력 — 이메일·이름·역할·권한·상태·소속·코호트 문자열에 부분 일치로 필터링하고 카운트를 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-table-sort` 사용자 표 컬럼 정렬 — TanStack Table로 8개 헤더를 클릭 정렬(기본 last_seen_at 내림차순)하고 aria-sort를 갱신한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-table-scroll-region` 사용자 표 가로 스크롤 영역 — 작은 화면에서 표를 tabIndex=0, role=region 가로 스크롤 컨테이너로 감싼다 · 검증: 기존 e2e/layout-visual-gate.spec.ts + e2e/admin.spec.ts
|
||||||
|
- [x] `admin-users-row-cap` 표 40행 렌더 제한 안내 — 상위 40행만 렌더하고 초과분은 검색으로 좁히라는 안내문을 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-edit-name-input` 행 내 표시 이름 편집 — 각 행의 표시 이름을 드래프트로 수정하며 DB 미연결·저장 중에는 비활성화한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-users-edit-role-select` 행 내 역할 셀렉트 — 역할을 드래프트로 변경하되 관리자 옵션은 superAdmin만 선택 가능하다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-users-admin-access-toggle` 관리자 페이지 권한 체크박스 — admin_access를 토글하며 superAdmin이 아니거나 대상이 super_admin이면 비활성화한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-status-select` 행 내 승인 상태 셀렉트 — 승인 대기/승인됨/보류를 드래프트로 바꾸고 현재 서버 값은 옆 배지로 구분한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-users-affiliation-input` 행 내 소속 편집 — 소속 문자열을 드래프트로 수정한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-cohort-input` 행 내 코호트 편집 — 콤마 구분 문자열을 trim·빈값 제거해 cohort_ids 배열로 반영한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-save-button` 행 저장 버튼 — 드래프트가 서버 값과 다를 때만 활성화되어 PATCH하고 응답으로 로컬 상태를 교체한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-users-deactivate-button` 사용자 비활성화 버튼 — DELETE 후 목록·드래프트에서 즉시 제거하며 처리 중 라벨로 중복 클릭을 막는다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-users-register-form` 새 사용자 등록 폼 — 이메일·이름·역할·권한·상태·코호트·소속을 POST하고 성공 시 폼 초기화 후 목록 탭으로 이동한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-users-activity-tab` 활동 요약 탭 — 역할 분포를 ProgressBar로, last_seen 상위 8명의 최근 활동을 표로 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-users-states` 사용자 로딩/빈/오류 상태 — 스켈레톤, InlineError와 재시도 안내, 검색 0건·등록 0건 문구를 구분해 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-access-tabs` 접근 권한 탭바(역할/그룹/매트릭스) — 3개 탭을 role=tablist로 전환한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `admin-access-roles-cards` 역할 정책 카드 — 관리자/교수자/학습자 3개 역할의 범위·권한 칩·위험 문구를 하드코딩 상수로 표시한다 · 검증: 기존(부분) e2e/public-admin-visual.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `admin-access-groups-cards` 그룹 정책 카드 — 운영자/학과 관리자/슈퍼바이저 그룹의 범위와 접근 칩을 '정책 초안' 배지와 함께 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-access-matrix-table` 권한 매트릭스 표 — 리소스별 역할 접근 수준을 정적 5행 표로 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-tickets-refresh-button` 티켓 새로고침 버튼 — 현재 필터 조합으로 티켓 목록을 재조회하고 로딩 중 비활성화한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-search-input` 티켓 검색 입력 — 제목·본문·신고자·경로를 서버 search 파라미터로 필터링하며 값 변경 시 자동 재조회한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-status-filter` 상태 필터 셀렉트 — 전체/미해결/분류됨/처리 중/해결/종결을 status 파라미터로 서버 필터링한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-category-filter` 카테고리 필터 셀렉트 — 계정·세션·음성·콘텐츠·안전·기타를 category 파라미터로 서버 필터링한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-priority-filter` 우선순위 필터 셀렉트 — 긴급/높음/보통/낮음을 priority 파라미터로 서버 필터링한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-assigned-group-input` 담당 그룹 필터 입력 — assigned_group 파라미터로 담당 그룹을 서버 필터링한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-stale-toggle` 정체만 체크박스 — stale_only=true로 24시간 이상 미변경 티켓만 조회한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-clear-filters-button` 필터 초기화 버튼 — 모든 필터를 기본값으로 되돌리며 필터가 없으면 비활성화된다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-category-queue-chips` 카테고리 큐 칩 버튼 — 건수가 있는 카테고리를 칩으로 나열하고 클릭 시 해당 필터를 적용하며 활성 칩을 강조한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-kpi-cards` 티켓 요약 KPI 4종 — 전체 접수·미해결·높은 우선순위·정체 건수를 summary 기반 카드로 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-tickets-active-list` 미해결 티켓 큐 목록 — 활성 티켓 최대 24건을 카테고리·신고자·중복 관계·처리 이력·배지와 함께 렌더한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-ticket-inprogress-button` 티켓 '처리 중' 버튼 — 상태를 in_progress로 PATCH하고 pendingRef로 중복 클릭을 즉시 차단한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-ticket-resolve-button` 티켓 '해결' 버튼 — 상태를 resolved로 PATCH하고 성공 시 큐에서 이력으로 내린다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-ticket-link-duplicate-button` 중복 후보 '연결' 버튼 — 중복 후보가 있을 때만 노출되어 parent_ticket_id를 후보로 PATCH해 연결한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-ticket-unlink-duplicate-button` 중복 연결 '해제' 버튼 — parent_ticket_id를 빈 값으로 PATCH해 중복 연결을 해제한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
- [x] `admin-tickets-resolved-history` 최근 해결 이력 섹션 — 해결·종결 티켓 최대 6건을 읽기 전용 '이력' 카드로 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-tickets-states` 티켓 로딩/빈/오류 상태 — 최초 로딩·활성 0건 빈 상태·실패 InlineError를 구분해 표시한다 · 검증: 신규 spec 필요 · 신규 spec GREEN(full-sweep-admin)
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `admin-data-diagnostics-panel`: users는 필드 단위 정규화가 있지만 tickets 배열은 as AdminSupportTicket[] 캐스트만 함 — reporter 등 누락 시 ticket.reporter.email 접근에서 렌더 크래시(에러 바운더리로만 방어)
|
||||||
|
- `admin-overview-refresh-button`: disabled 조건이 loading||usersLoading||usageLoading뿐이라 uptime·tickets 로딩 중에는 중복 클릭 가능
|
||||||
|
- `admin-users-tabs`: role=tab이지만 화살표 키 내비게이션·aria-controls·tabpanel 연결이 없어 ARIA 탭 패턴 미완성
|
||||||
|
- `admin-users-row-cap`: 페이지네이션이 없어 41번째 이후 사용자는 검색 없이는 도달 불가
|
||||||
|
- `admin-users-status-select`: super_admin 계정의 승인 상태도 일반 관리자가 보류로 바꿀 수 있게 UI가 열려 있음(서버 검증 의존)
|
||||||
|
- `admin-users-deactivate-button`: 확인 대화상자 없이 한 번의 클릭으로 계정이 비활성화됨(파괴적 작업 컨펌 누락)
|
||||||
|
- `admin-users-register-form`: 이메일 형식 검증 없이 비어 있지만 않으면 제출됨; 오류는 상단 usersError 배너로만 표시되어 폼 필드와 연결 안 됨
|
||||||
|
- `admin-users-states`: '새로고침을 눌러 다시 시도하세요' 문구가 있지만 users 탭 화면에는 재시도 버튼이 없음(새로고침 버튼은 overview에만 존재)
|
||||||
|
- `admin-access-tabs`: users 탭바와 동일하게 키보드 화살표 내비게이션 미구현
|
||||||
|
- `admin-access-roles-cards`: 화면 스스로 '역할·그룹 저장 API는 아직 분리되지 않았다'고 명시한 정적 화면 — 조작 요소가 전혀 없어 실데이터와 어긋날 수 있음
|
||||||
|
- `admin-tickets-search-input`: 디바운스가 없어 키 입력마다 GET /admin/tickets가 발사됨(담당 그룹 입력도 동일)
|
||||||
|
- `admin-tickets-assigned-group-input`: 키 입력마다 즉시 재조회(디바운스 없음)
|
||||||
|
- `admin-tickets-category-queue-chips`: 활성 칩을 다시 클릭해도 해제(전체로 복귀)되지 않음 — 해제하려면 셀렉트나 초기화 버튼을 써야 함
|
||||||
|
- `admin-ticket-resolve-button`: 성공 시 로컬 상태 교체와 loadTickets 재조회를 둘 다 수행해 요청이 중복됨(경미)
|
||||||
|
- `admin-tickets-states`: loadTickets가 시작·성공 시 ticketsError를 초기화하지 않아 한 번 실패하면 이후 조회가 성공해도 오류 배너가 계속 남음(티켓 상태 변경 성공 시에만 지워짐)
|
||||||
|
|
||||||
|
## 8. 관리자 AI 설정 (admin-ai)
|
||||||
|
|
||||||
|
- [x] `admin-ai-guard-require-auth` RequireAuth(admin) 라우트 가드 — 미인증은 /login, admin 권한이 없으면 자기 역할 홈으로 리다이렉트한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-ai-guard-pending-approval` PendingApprovalGate 리다이렉트 — 미승인 사용자가 /admin/ai에 오면 /pending으로 리다이렉트한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-guard-onboarding-bypass` OnboardingGate 관리자 경로 우회 — 온보딩 미완료여도 admin 권한 보유 시 /admin/* 경로를 통과시킨다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-ai-shell-sidebar-nav` AppShell 관리자 사이드바 내비게이션 — navRole=admin 셸로 관리자 메뉴 링크를 제공하고 현재 페이지를 활성 표시한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-ai-initial-load` 초기 데이터 병렬 로드 — usage·health·engine-config를 allSettled로 호출해 성공분만 반영하고 실패 메시지는 중복 제거해 배너에 잇는다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-ai-refresh-button` 새로고침 버튼 — 세 가지 데이터를 모두 다시 불러오며 로딩 중 비활성화된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-error-alert` 오류 배너(role=alert) — 로드·저장 실패 시 오류 메시지를 role=alert 배너로 띄우고 다음 요청 시작 시 초기화한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-aria-busy` 로딩 중 aria-busy 표시 — 페이지 컨테이너에 로딩 동안 aria-busy=true를 설정한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-window-toggle` 계량 기간 토글(7/30/90일) — 기간을 바꾸면 usage만 재조회하고 늦게 도착한 응답은 active 플래그로 무시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-source-indicator` 계량 데이터 원천 표시 — durable+database면 '운영 DB 원장'을, 아니면 런타임 임시 집계와 소실 경고를 표시한다 · 검증: 기존 e2e/readiness.spec.ts
|
||||||
|
- [x] `admin-ai-ledger-cards` 핵심 지표 카드 5종 — 누적 비용·계량 호출·입출력 토큰·호출당 비용을 usage에서 계산해 표시한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-ai-budget-panel` 예산 상태·사용률 트랙 — budget.status를 배지로, used_ratio를 100% 캡 진행 바로 표시하고 disabled면 설정 안내를 보여준다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `admin-ai-coverage-track` DB 계량 커버리지 트랙 — metered_turns/total_turns 비율을 진행 바로 표시하고 미계량 턴 수를 안내한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-daily-chart` 일별 비용 막대 차트 — daily_cost를 상대 높이 막대로 렌더하고 날짜·비용 툴팁과 role=img aria-label을 제공한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-daily-chart-empty` 일별 차트 빈 상태 — 선택 기간에 계량된 호출이 없으면 안내 문구를 보여준다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-provider-table` Provider·모델별 사용 원장 테이블 — by_provider 행을 비용 내림차순으로 정렬해 호출·토큰·비용·비중을 표로 보여준다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `admin-ai-provider-table-empty` 모델 원장 빈 상태 — 계량 행이 없으면 '모델별 계량 행이 아직 없습니다.'를 보여준다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-engine-status-pill` 엔진 런타임 상태 배지 — health.services의 engine 서비스를 정상/중단/제한 운영으로 표시하고 미확인 시 '확인 불가'를 보여준다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `admin-ai-engine-mode-radiogroup` AI 운영 방식 라디오그룹(4종) — claude_cli·claude_api·openai·solar를 role=radio 버튼으로 제공하며 저장 버튼을 눌러야 서버에 반영된다 · 검증: 기존 e2e/admin.spec.ts + e2e/db-persistence.spec.ts
|
||||||
|
- [x] `admin-ai-engine-url-input` 연결 주소 입력 — engine_url을 제어 컴포넌트로 편집하며 게이트웨이·API base URL 힌트를 제공한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `admin-ai-engine-model-input` 기본 모델 입력 — 기본 model 문자열을 편집하며 평가 모델 override가 없을 때 적용된다고 안내한다 · 검증: 기존 e2e/db-persistence.spec.ts
|
||||||
|
- [x] `admin-ai-engine-config-meta` 엔진 설정 메타 정보 — 저장 원천(DB 영구/런타임)·현재 소스·최근 변경 시각·변경자를 dl로 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-engine-save-button` 운영 설정 저장 버튼 — 모드·URL·모델을 PATCH 저장하고 health를 재조회하며 2.4초간 '저장됨' 배지를 띄운다 · 검증: 기존 e2e/admin.spec.ts + e2e/db-persistence.spec.ts
|
||||||
|
- [x] `admin-ai-engine-loading-empty` 엔진 설정 로딩/실패 빈 상태 — engine-config가 없으면 '불러오는 중입니다' 문구를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-cache-panel` 평가 캐시 효율 패널 — enabled 배지·hit-rate·요청/적중/미스/저장/엔트리/축출 카운트와 원문 미저장 안내를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `admin-ai-integrity-panel` DB 계량 상태(무결성) 패널 — 계량 원천·durable 여부·미계량 턴 수와 '감사 필요/누락 없음' 판정을 표시한다 · 검증: 기존(부분) e2e/readiness.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `admin-ai-note-no-source-pack-kb` [조사 노트] source pack sync·KB 관리 부재 — 이 페이지에는 source pack sync·KB 관리 UI가 없고 관련 코드는 PersonaStudio·Session에만 존재한다 · 검증: 기존 e2e/kb-source-packs.spec.ts
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `admin-ai-initial-load`: 부분 실패 시 성공 데이터와 오류 배너가 공존해 어떤 패널이 실패했는지 구분하기 어렵다. engine-config 실패 시 엔진 패널은 '불러오는 중' 문구가 영구히 남는다(재시도 UI 없음).
|
||||||
|
- `admin-ai-refresh-button`: 엔진 설정 폼을 수정하고 저장하기 전에 새로고침을 누르면 편집 중이던 engine_url·model·mode가 서버 값으로 조용히 덮어써진다(변경사항 유실 경고 없음).
|
||||||
|
- `admin-ai-aria-busy`: aria-busy 외에 시각적 로딩 스피너/스켈레톤이 없어 데이터 미도착 시 지표가 '—'로만 보인다.
|
||||||
|
- `admin-ai-window-toggle`: 기간 변경 실패 시 이전 기간의 usage 데이터가 화면에 그대로 남은 채 오류 배너만 떠서 기간 라벨(예: '90일 DB 집계')과 실제 표시 데이터가 불일치할 수 있다.
|
||||||
|
- `admin-ai-daily-chart`: 막대별 상세가 title 속성에만 있어 키보드·스크린리더로는 개별 일자 값에 접근할 수 없다(전체 aria-label만 존재).
|
||||||
|
- `admin-ai-engine-mode-radiogroup`: ENGINE_MODE_LABEL에는 messages_api 라벨이 있지만 선택지(ENGINE_MODES)에는 없어, 서버가 engine_mode='messages_api'를 반환하면 어떤 라디오도 선택 표시되지 않는다. 또 role=radio이지만 방향키 이동 등 라디오 키보드 패턴이 구현되어 있지 않다.
|
||||||
|
- `admin-ai-engine-url-input`: URL 형식 검증이 없어(공백만 아니면 저장 가능) 잘못된 주소도 그대로 PATCH된다.
|
||||||
|
- `admin-ai-engine-save-button`: 저장 성공 후 health 재조회 실패는 catch(() => health)로 조용히 무시되며 이때 스테일 클로저의 이전 health를 쓴다. window.setTimeout(2400ms)을 언마운트 시 정리하지 않아 페이지 이탈 후 setState가 호출될 수 있다. 저장 확인(confirm) 없이 즉시 운영 엔진이 교체된다.
|
||||||
|
- `admin-ai-engine-loading-empty`: GET 실패 시에도 같은 '불러오는 중' 문구가 영구 표시되어 실패 상태를 로딩으로 오인하게 한다(패널 내 재시도 수단 없음).
|
||||||
|
|
||||||
|
## 9. 설정 (settings)
|
||||||
|
|
||||||
|
- [x] `settings-guard-require-auth` RequireAuth 가드 (미인증 → /login) — 로딩 중 BootScreen을 띄우고 미인증이면 state.from을 담아 /login으로 보낸다(역할 제한 없음) · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-guard-pending-approval` PendingApprovalGate (미승인 → /pending) — 미승인 사용자가 /settings에 접근하면 /pending으로 리다이렉트한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-guard-onboarding` OnboardingGate (온보딩 미완료 → /onboarding) — onboardingCompletedAt이 null이면 /onboarding으로 보낸다(/settings는 관리자 예외 경로가 아님) · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-initial-load` 설정 데이터 일괄 로드 — 프로필·환경설정·지원 티켓·음성 프리셋을 병렬 조회하고 관리자면 엔진 설정·헬스도 추가 조회한다 · 검증: 기존 e2e/settings.spec.ts
|
||||||
|
- [x] `settings-load-error-callout` 로드 실패 경고 콜아웃 — 로드 실패 시 화면 상단에 role=alert 경고 콜아웃으로 오류 메시지를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-section-skeletons` 섹션별 로딩 스켈레톤 — 데이터 도착 전 role=status 스켈레톤을 띄우고 스크린리더용 텍스트를 숨김 제공한다 · 검증: 기존 e2e/settings.spec.ts
|
||||||
|
- [x] `settings-rail-nav-buttons` 좌측 레일 섹션 내비게이션 버튼 — 계정·지원·(관리자 AI 운영)·테마·알림·음성 버튼으로 해당 섹션에 스크롤하고 aria-current를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-rail-nav-scrollspy` 스크롤 스파이 (IntersectionObserver) — 화면에 가장 많이 보이는 섹션을 감지해 레일 활성 항목을 자동 동기화한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-rail-account-summary` 레일 계정 요약 카드·역할 pill — 이니셜 아바타·표시 이름·이메일·역할·소속 배지를 요약하고 관리자에게는 AI 헬스 pill을 더한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `settings-account-display-name-input` 표시 이름 입력 — 입력 시 dirty ref를 켜서 이후 도착하는 서버 로드값이 입력을 덮어쓰지 않게 한다 · 검증: 기존 e2e/settings.spec.ts
|
||||||
|
- [x] `settings-account-email-readonly` 이메일 (읽기 전용) — 로그인으로 확인된 이메일을 disabled 입력으로 표시만 한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `settings-account-affiliation-input` 소속 입력 — 강의·연구 운영에서 표시할 소속 문자열을 입력받는다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `settings-account-save-button` 계정 저장 버튼 — 표시 이름·소속을 PATCH 저장하고 응답으로 폼을 동기화한 뒤 '저장됨'을 2.4초 띄운다 · 검증: 기존 e2e/settings.spec.ts + e2e/db-persistence.spec.ts
|
||||||
|
- [x] `settings-support-ticket-list` 내 지원 요청 티켓 목록 — 내가 접수한 티켓을 상태·카테고리·우선순위 배지, 담당 그룹, 해결 메모와 함께 나열한다 · 검증: 기존 e2e/settings.spec.ts
|
||||||
|
- [x] `settings-support-error-state` 지원 요청 로드 실패 상태 — 티켓 조회만 실패하면 페이지를 깨뜨리지 않고 섹션 안에 role=alert 경고를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-support-empty-state` 지원 요청 빈 상태 — 0건이면 '접수한 지원 요청이 없습니다', null이면 '저장소에 표시할 항목이 없습니다'를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-engine-mode-radiogroup` AI 운영 방식 세그먼트 라디오 (관리자) — 4가지 연결 방식 버튼 중 하나를 골라 로컬 상태에 반영한다 · 검증: 기존 e2e/settings.spec.ts
|
||||||
|
- [x] `settings-engine-url-input` AI 연결 주소 입력 (관리자) — 응답 생성 서비스 연결 URL을 로컬 상태와 ref에 반영한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `settings-engine-model-input` 기본 모델 입력 (관리자) — 기본 모델 이름을 입력받고 최근 변경자·저장 상태 메타를 함께 표시한다 · 검증: 기존 e2e/settings.spec.ts
|
||||||
|
- [x] `settings-engine-save-button` 운영 설정 저장 버튼 (관리자) — 운영 방식·주소·모델을 PATCH 저장한 뒤 헬스를 재조회해 상태 칩을 갱신한다 · 검증: 기존 e2e/settings.spec.ts + e2e/db-persistence.spec.ts
|
||||||
|
- [x] `settings-engine-health-chip` 응답 생성 헬스 상태 칩 (관리자) — engine 서비스 상태를 정상/제한 운영/중단/확인 중 텍스트와 detail·metric으로 표시한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `settings-theme-dark-toggle` 다크 모드 토글 — role=switch 토글로 즉시 data-theme을 바꾸고 localStorage에 저장해 전역 구독자와 동기화한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-theme-save-button` 테마 저장 버튼 — 현재 다크 여부를 theme으로 서버 환경설정에 PATCH 저장하고 '저장됨'을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-voice-preset-radiogroup` 음성 프리셋 선택 라디오 카드 — 서버 프리셋 목록을 role=radio 카드로 나열하고 클릭 시 voice_preset_id를 로컬 상태에 반영한다 · 검증: 기존 e2e/voice.spec.ts
|
||||||
|
- [x] `settings-voice-rate-slider` 말하기 속도 슬라이더 — 0.8x~1.2x range 입력으로 voice_rate를 조절하고 현재값을 실시간 표시한다 · 검증: 기존(부분) e2e/settings.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `settings-voice-save-button` 음성 저장 버튼 — 프리셋 ID와 말하기 속도를 서버 환경설정에 PATCH 저장하고 '저장됨'을 표시한다 · 검증: 기존 e2e/settings.spec.ts
|
||||||
|
- [x] `settings-voice-empty-state` 음성 프리셋 빈 상태 — 등록 프리셋이 없으면 안내를 표시하고 슬라이더·저장 버튼을 숨긴다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-notify-toggles` 알림 항목 토글 (역할 필터) — 공통 3종에 더해 담당 학습자 진행은 teacher/admin, 가입 승인은 admin에게만 role=switch로 표시한다 · 검증: 기존 e2e/settings.spec.ts
|
||||||
|
- [x] `settings-notify-save-button` 알림 저장 버튼 — 토글 상태를 theme·voice 값과 함께 환경설정에 PATCH 저장하고 '저장됨'을 표시한다 · 검증: 기존 e2e/settings.spec.ts + e2e/db-persistence.spec.ts
|
||||||
|
- [x] `settings-saved-flash-indicator` '저장됨' 플래시 피드백 — 각 섹션 저장 성공 시 체크 아이콘과 문구를 2.4초 표시했다 자동으로 지운다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-topbar-theme-toggle` 톱바 테마 전환 버튼 (공용 셸) — 상단 Topbar의 해/달 버튼이 설정 섹션의 다크 모드 토글과 같은 전역 스토어를 전환한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-topbar-logout` 로그아웃 버튼 (공용 Topbar) — 설정 화면의 로그아웃은 공용 Topbar 아이콘에 있으며 서버 로그아웃 후 /login으로 이동한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `settings-topbar-role-switch` 역할 공간 전환 링크 (공용 Topbar) — 복수 역할 권한자에게 학습자/교수자/관리자 홈 이동 링크를 표시한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `settings-avatar-upload-missing` 아바타 업로드 UI (부재) — 브리핑상 설정에 있어야 할 아바타 업로드가 없고 업로드 API는 온보딩에서만 쓰인다 · 검증: 신규 spec 필요 · **구현 완료(2026-07-27 소유자 결정: 설정에 추가)** — 신규 spec GREEN(full-sweep-settings 4/4 desktop+mobile)
|
||||||
|
- [x] `settings-consent-management-missing` 동의 관리·철회 UI (부재) — 브리핑상 설정에 있어야 할 동의 관리·철회가 없고 withdrawConsent는 어떤 UI에서도 호출되지 않는다 · 검증: 신규 spec 필요 · **구현 완료(2026-07-27 소유자 결정: 전체 흐름)** — 철회·재동의 실 API round-trip GREEN(full-sweep-settings 44/44 무회귀)
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `settings-initial-load`: supportTickets만 개별 catch되고 나머지는 하나라도 실패하면 페이지 전체 오류로 전환됨(부분 성공 데이터 폐기). 서버 theme 값이 로드 시 setDark를 호출해 사용자가 Topbar에서 방금 바꾼 로컬 테마를 되돌릴 수 있음.
|
||||||
|
- `settings-load-error-callout`: 재시도 버튼이 없어 사용자는 새로고침 외 복구 수단이 없음.
|
||||||
|
- `settings-rail-account-summary`: 서버 profile의 avatar_url을 무시하고 항상 이니셜만 렌더 — Topbar는 업로드된 아바타 이미지를 보여줘 화면 간 불일치.
|
||||||
|
- `settings-account-save-button`: saveProfile에 try/catch가 없어 실패 시 unhandled rejection — 사용자에게 오류 표시가 전혀 없고 저장 실패를 알 수 없음. 저장 중 busy 상태도 없어 연타 가능.
|
||||||
|
- `settings-support-ticket-list`: 읽기 전용 목록만 있고 이 화면에서 새 지원 요청을 접수하는 UI가 없음.
|
||||||
|
- `settings-support-empty-state`: 빈 상태가 로딩용 SettingsLoadingState(점멸 dot, role=status) 컴포넌트를 재사용해 영구 로딩 중처럼 오인될 수 있음. 두 빈 상태가 같은 testId(settings-support-empty)를 공유.
|
||||||
|
- `settings-engine-mode-radiogroup`: role=radio 버튼 그룹에 화살표 키 이동(roving tabindex)이 없어 키보드 접근성 미흡. 라벨 맵에는 messages_api가 있으나 선택지에는 없어 서버가 그 값을 반환하면 아무 라디오도 체크되지 않음.
|
||||||
|
- `settings-engine-url-input`: URL 형식 검증이 전혀 없음(공백만 아니면 저장 가능).
|
||||||
|
- `settings-engine-save-button`: 선택 모드를 React 상태 대신 document.querySelector('[data-engine-mode][aria-checked=true]') DOM 조회로 읽는 안티패턴 — 마크업 변경에 취약. PATCH 실패 시 try/catch 없음(unhandled rejection, 사용자 피드백 없음).
|
||||||
|
- `settings-engine-health-chip`: 헬스 조회 실패를 조용히 삼키고 null 처리 — 실패해도 '확인 중'으로만 남아 장애와 로딩을 구분할 수 없음. 기본 status fallback이 'degraded'라 로딩 중에도 경고색 칩으로 렌더됨.
|
||||||
|
- `settings-theme-save-button`: savePreferences에 try/catch 없음(실패 무통보). 저장 안 하면 서버 theme이 남아 다음 /settings 방문 로드 시 로컬 토글값을 되돌림 — 토글 즉시 반영과 서버 저장이 이중 상태.
|
||||||
|
- `settings-voice-preset-radiogroup`: 카드 우측 SpeakerIcon(재생 삼각형)이 role=presentation·aria-hidden 장식일 뿐 미리듣기 기능이 없음 — 재생 버튼처럼 보이는 죽은 어포던스. 화살표 키 라디오 이동도 없음.
|
||||||
|
- `settings-voice-save-button`: try/catch 없음(실패 무통보). savePreferences가 theme·notifications까지 통째로 전송해 다른 섹션의 미저장 변경도 함께 확정됨.
|
||||||
|
- `settings-notify-save-button`: try/catch 없음(실패 무통보). 알림 알림 자체는 저장만 되고 실제 알림 전달 채널이 이 화면에서 확인 불가.
|
||||||
|
- `settings-saved-flash-indicator`: role=status/aria-live가 없어 스크린리더는 저장 완료를 인지하지 못함.
|
||||||
|
- `settings-topbar-logout`: auth.tsx의 logout이 API 실패를 무시하고 로컬 세션만 지우는 구조인지 확인 필요 — Settings 본문에는 로그아웃·계정 삭제 등 세션 관리 UI가 전무.
|
||||||
|
- `settings-avatar-upload-missing`: 온보딩 이후 아바타를 바꿀 UI가 앱 어디에도 없음.
|
||||||
|
- `settings-consent-management-missing`: 동의 철회 경로가 UI에 전혀 노출되지 않아 규정(동의 철회권) 관점에서 공백 — dead code 상태의 withdrawConsent.
|
||||||
|
|
||||||
|
## 10. 공통 셸·GNB·라우팅 가드 (shell)
|
||||||
|
|
||||||
|
- [x] `shell-topbar-brand-link` 톱바 브랜드 로고 링크 — 워드마크 클릭 시 로그인 상태면 루트로, 미로그인이면 /login으로 이동한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `shell-topbar-role-context-label` 역할 컨텍스트 라벨 — 톱바에 현재 역할 라벨 또는 페이지가 넘긴 contextLabel을 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `shell-topbar-space-switcher` 공간 전환 링크 그룹 — 권한에 따라 학습자/교수자/관리자 공간 링크를 노출하고 현재 경로 접두 일치 시 활성 표시한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `shell-topbar-theme-toggle` 다크/라이트 테마 토글 — 아이콘 버튼으로 data-theme을 전환하고 localStorage에 저장하며 Settings와 같은 스토어를 공유한다 · 검증: 기존(부분) e2e/auth-visual.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `shell-topbar-user-chip` 사용자 아바타·이름 표시 — avatarUrl 이미지(없으면 이니셜)와 이름을 톱바 우측에 표시한다(클릭 동작 없음) · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `shell-topbar-logout` 로그아웃 버튼 — 로그아웃 API 호출 후(실패해도) 로컬 사용자 상태를 비우고 /login으로 replace 이동한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `shell-sidebar-learner-nav` 학습자 사이드바 메뉴 — 대시보드·학습·기록·설정 4개 NavLink를 표시하고 활성 항목을 알약으로 강조한다 · 검증: 기존(부분) e2e/layout-visual-gate.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `shell-sidebar-teacher-nav` 교수자 사이드바 메뉴 — 콘솔·학생 분석·페르소나·설정 4개 NavLink를 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `shell-sidebar-admin-nav` 관리자 사이드바 메뉴 — 관리자 5 + 교수자 3 + 학습자 3 + 설정 1, 총 12개 NavLink를 한 목록으로 표시한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `shell-sidebar-ethics-footnote` 사이드바 윤리 고지 — 교육용 시뮬레이션이며 실제 치료·진단을 대체하지 않는다는 고지를 항상 표시한다 · 검증: 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `shell-appshell-focus-modes` 셸 레이아웃 변형(hideNav/hideTopbar/bleed/wide) — 페이지 props로 사이드바 숨김·전체화면·풀블리드·넓은 작업폭을 제공하고 미인증 시 사이드바를 자동 숨긴다 · 검증: 기존 e2e/session-layout.spec.ts
|
||||||
|
- [x] `shell-appshell-role-theming` body[data-role] 역할 테마 적용 — navRole에 따라 body에 data-role을 설정해 역할별 팔레트를 적용하고 언마운트 시 복원한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `shell-auth-bootstrap-loading` 인증 부트스트랩 로딩 화면 — 앱 시작 시 세션을 복원하고 완료 전까지 모든 가드가 중립 BootScreen을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `shell-suspense-fallback` lazy 라우트 청크 로딩 fallback — 모든 페이지가 lazy import이므로 청크 로딩 동안 Suspense fallback으로 BootScreen을 표시한다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `shell-guard-require-auth` RequireAuth 미인증 리다이렉트 — 보호 라우트에서 미인증이면 원래 경로를 state.from에 담아 /login으로 replace 리다이렉트한다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `shell-guard-role-restriction` RequireAuth 역할 제한 리다이렉트 — 역할 불일치 시 빈 화면 대신 자기 역할 홈으로 보내며 admin·adminAccess 예외를 적용한다 · 검증: 기존 e2e/admin.spec.ts + e2e/teacher.spec.ts
|
||||||
|
- [x] `shell-guard-pending-approval` PendingApprovalGate 승인 대기 리다이렉트 — 미승인 사용자는 어떤 경로든 /pending으로 보내고 승인 사용자가 /pending에 오면 초기 경로로 되돌린다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `shell-guard-onboarding` OnboardingGate 온보딩 리다이렉트 — 온보딩 미완료자는 /onboarding으로(admin의 /admin*만 예외), 완료자가 /login·/onboarding에 오면 initialPathForUser로 되돌린다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
|
||||||
|
- [x] `shell-root-redirect` 루트 경로 역할별 분기 — / 접근 시 미승인은 /pending, 인증 사용자는 initialPathForUser, 미인증은 /login으로 replace 이동한다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `shell-unknown-route-redirect` 미정의 경로 리다이렉트 — 정의되지 않은 모든 경로는 404 화면 없이 /로 replace되어 역할 홈으로 재분기된다 · 검증: 신규 spec 필요
|
||||||
|
- [x] `shell-route-error-boundary` 라우트 렌더 오류 fallback — 렌더 예외 시 role=alert 진단 화면을 띄우고 경로가 바뀌면 오류 상태를 자동 리셋한다 · 검증: 기존(부분) e2e/admin.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
|
||||||
|
- [x] `shell-scroll-reset-on-navigate` 경로 변경 시 스크롤 최상단 리셋 — scrollRestoration을 manual로 두고 경로 변경·bfcache 복원 시 스크롤을 최상단으로 되돌린다 · 검증: 기존 e2e/admin.spec.ts
|
||||||
|
- [x] `shell-auth-expired-listener` 세션 만료 이벤트 자동 로그아웃 — API 계층이 401에서 발행하는 auth-expired 이벤트를 받아 사용자 상태를 비워 /login으로 유도한다 · 검증: 기존 e2e/learner.spec.ts
|
||||||
|
- [x] `shell-avatar-expression-board` Live2D 페르소나 표정 보드 — P4~P7 모델별 공유 표정 라이브러리 전체를 모델 경로·모션·페이드 메타와 함께 정적 렌더하는 QA 보드다 · 검증: 기존 e2e/avatar-expression-lab.spec.ts
|
||||||
|
- [x] `shell-avatar-preview-dev-page` 서연 SVG 아바타 dev 미리보기 — 인증 없이 접근 가능한 dev 페이지로 4개 상태 애니메이션과 대표 표정 12종 그리드를 표시한다 · 검증: 신규 spec 필요
|
||||||
|
|
||||||
|
**의심 결함(에이전트 리포트)**:
|
||||||
|
|
||||||
|
- `shell-topbar-space-switcher`: 일반 학습자·교수자에게는 아예 미노출(의도). 컨테이너가 nav가 아닌 div에 aria-label만 부여됐고, accessibleRolesFor 결과를 canAccessRole로 재필터링하는 중복 로직이 있음.
|
||||||
|
- `shell-sidebar-admin-nav`: 12개 항목이 역할 그룹 구분(구분선·소제목) 없이 한 열에 나열되고 review 아이콘이 5개 항목에 중복 사용되어 스캔성이 낮음.
|
||||||
|
- `shell-auth-bootstrap-loading`: me 호출 실패 사유(네트워크 오류 vs 미인증)를 구분하지 않고 catch에서 조용히 user=null 처리해 오프라인 시 로그인 화면으로 떨어짐.
|
||||||
|
- `shell-suspense-fallback`: 청크 로드 실패(배포 후 stale 해시)는 Suspense가 아닌 ErrorBoundary로 전파되는데 별도 새로고침 유도 UI 없이 진단 표만 노출됨.
|
||||||
|
- `shell-guard-onboarding`: 온보딩 미완료 admin은 /admin*만 예외라 /settings·/teach 접근 시 /onboarding으로 튕김. 미인증 방문자의 /dev/avatar-preview는 통과되지만 로그인된 미승인·미온보딩 사용자는 이 dev 페이지도 게이트에 걸려 접근 불가.
|
||||||
|
- `shell-root-redirect`: onboardingCompletedAt==null 분기와 최종 분기가 동일하게 initialPathForUser를 호출해 사실상 죽은 중복 분기.
|
||||||
|
- `shell-unknown-route-redirect`: 전용 404 페이지가 없어 오타 URL이 조용히 홈으로 이동, 사용자가 링크 오류를 인지하지 못함.
|
||||||
|
- `shell-route-error-boundary`: 진단 화면에 componentStack 등 내부 정보가 그대로 노출되며(개발 편의 의도로 보임) 재시도/홈으로 버튼이 없어 사용자는 URL을 직접 바꿔야 복구됨.
|
||||||
|
- `shell-avatar-expression-board`: 학습자 사이드바 등 어떤 GNB에도 링크가 없어 URL 직접 입력으로만 접근 가능한 숨은 라우트. 콘텐츠가 QA용인데 learner 라우트(/learn/*)에 위치.
|
||||||
|
- `shell-avatar-preview-dev-page`: 인증 가드 없는 dev 라우트가 프로덕션 라우터에 그대로 포함되어 누구나 페르소나 리그를 열람 가능(코드 주석상 의도지만 배포 노출 여부 재검토 필요).
|
||||||
|
|
||||||
|
## 발견 결함 로그 (RED → GREEN 추적)
|
||||||
|
|
||||||
|
| # | 영역 | 증상 | 재현 spec | 상태 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | auth | 온보딩 연락처에 임의 텍스트("전화번호아님텍스트") 입력 시 클라이언트·서버 모두 통과되어 DB에 저장됨 | 서버: `test_user_onboarding_validation.py`(#13) · 클라: `Onboarding.tsx` `isValidPhone` + 구체 오류 문구 | **FIXED** (typecheck GREEN, e2e 재검증은 최종 게이트에서) |
|
||||||
|
| 2 | learner-home | 신규 학습자 첫 추천이 고난도 P1인데 AI 코치 문구는 "낮은 난도부터"를 권함 — 상호 모순 | 소유자 결정(기초 우선) → `starterPersona()` 난도 오름차순 기본 선택, RED→GREEN(full-sweep-learner-home 신규 2건) | **FIXED** (42/42 무회귀) |
|
||||||
|
| 3 | learner-home | 대시보드 탭 전환 후에도 패널 aria-label이 "최근 기록 패널"로 고정 | `LearnerHome.tsx` — dashTab에 따라 "성장 지표 패널"로 전환 | **FIXED** (typecheck GREEN) |
|
||||||
|
| 4 | professor | 학생 분석 테이블이 347명 전행 렌더(페이지네이션·가상화 없음) — 규모 성능·탐색성 우려 | 소유자 결정(지금 가상화) → @tanstack/react-virtual 세로 가상화(동적 행 높이·펼침 재측정), RED(300행 전량 렌더 관측)→GREEN | **FIXED** (40/40 무회귀 + professor 게이트 4/4) |
|
||||||
|
| 5 | admin-ai | `/admin/ai` 정상 렌더에도 부트 watchdog 진단 오버레이 표시(`asset=module-not-discovered` 오탐) | `admin.spec.ts` "keeps the boot diagnostic overlay off a rendered AI operations page" — RED 재현 후 `index.html` 셀렉터에 `.aic` 추가 | **FIXED** (1 passed 12.2s) |
|
||||||
|
| 6 | auth/admin | 관리자가 보류(pending) 처리한 사용자가 dev-login 재로그인 시 자동 재승인됨 — dev 한정 | 소유자 결정: **현행 유지**(dev/E2E 편의 의도 확정, 프로덕션은 dev-login 자체 폐쇄) | **CLOSED** (의도 확정, 코드 변경 없음) |
|
||||||
|
| 7 | auth | /pending "승인 상태 새로고침"이 여전히 미승인일 때 아무 피드백 없음(확인 수행 여부 불명) | `PendingApproval.tsx` — 미승인/실패 시각 포함 안내 문구 + refresh 아이콘 신설 | **FIXED** (typecheck GREEN) |
|
||||||
|
| 8 | 백엔드 마스킹 | 평가/코칭 문장의 일반 단어(유의점은·방치되는·마무리했을·연결감과·표현함을·어지러움은)가 `[NAME]`으로 오탐 치환되어 문장 훼손 | `test_orchestrator_masking.py::test_mask_pii_does_not_mask_evaluator_abstract_words_as_names` — RED 재현 후 `guardrail.py` 꼬리 글자 가드 추가 | **FIXED** (마스킹 18/18, 의존 스위트 69/69, 백엔드 전체 401/401) |
|
||||||
|
| 9 | settings | 온보딩 입력 항목 및 동의 철회를 설정 화면에서 수정할 수단 없음 | 소유자 결정 → 아바타 변경(#15)·동의 철회/재동의(#16) 구현 완료. 닉네임·자기소개·연락처·주소 편집은 후속 확장 후보로 남김 | **부분 FIXED** (#15·#16 해결) |
|
||||||
|
| 10 | session | 회기 종료 확인 다이얼로그만 반말 문체("종료할까?", "이동한다") — 앱 전반 존댓말과 불일치 | `Session.tsx` — "종료할까요?"/"이동합니다."로 통일 | **FIXED** (typecheck GREEN) |
|
||||||
|
| 11 | 백엔드 리드모델 | 리뷰 요약 "하늘**와**의 회기는" — 받침 있는 이름 뒤 조사 오류 | `test_review_summary_josa.py` — RED 재현 후 `_josa_wa_gwa` 헬퍼 추가 | **FIXED** (3/3, 백엔드 409/409) |
|
||||||
|
| 12 | 백엔드 리드모델 | 리뷰 헤더 난도가 "P4 · easy" 원문 노출(타 화면은 "기초") | `test_review_summary_josa.py::ReviewPersonaLabelTest` — `_persona_label` 한글 매핑 추가 | **FIXED** (백엔드 410/410) |
|
||||||
|
| 13 | auth | 온보딩 서버 검증 부재(#1의 서버측) — `OnboardingRequest`/`UserProfilePatch` phone 자유 문자열 허용 | `test_user_onboarding_validation.py` — RED 3건 재현 후 `_validate_phone_format` 추가 | **FIXED** (5/5, 백엔드 406/406 당시) · 클라이언트 측 안내는 #1에서 계속 |
|
||||||
|
| 14 | persona-studio | 저작 모드 좌측 레일(.ps-rail)이 무조건 `display:none` — 숨겨진 인터랙티브 마크업 DOM 잔존 | 소유자 결정(정리) → 레일 JSX·전용 CSS 삭제, 특성화 테스트 toHaveCount(0) RED→GREEN | **FIXED** (20/20 무회귀 + persona 게이트 2/2 + dead-code 통과) |
|
||||||
|
| 15 | settings | 설정에 아바타 변경 UI 부재(업로드 API는 온보딩 전용) — #9 세분화 | 소유자 결정(설정에 추가) → 계정 섹션 업로드 컨트롤+미리보기+PATCH avatar_url, fixme 해제 RED→GREEN | **FIXED** (4/4 desktop+mobile, 44/44 무회귀) |
|
||||||
|
| 16 | settings/auth | 동의 철회 UI 부재 + `withdrawConsent` dead code — #9 세분화 | 소유자 결정(전체 흐름) → 설정 동의 섹션(상태·버전 표시+철회 2단계 확인+재동의), 세션 프리스타트 기존 재동의 게이트와 연결, fixme 해제 RED→GREEN | **FIXED** (철회·재동의 실 API round-trip GREEN) |
|
||||||
|
| 17 | settings | 스크롤 스파이가 마지막 섹션(음성)을 활성화하지 못함 — 0 임계값 부재 + 비율 우세 휴리스틱이 scroll-margin 정렬 지점과 충돌(모바일에서 두드러짐) | `full-sweep-settings.spec.ts` 모바일 2건 RED 재현 → `Settings.tsx` 밴드 최하단 섹션 규칙 + 스무스 스크롤 억제 + 바닥 폴백 | **FIXED** (settings desktop+mobile 26/26) |
|
||||||
|
| 18 | session | 모바일 겹침 2건: (a) 절대배치 코치 넛지가 음성 건너뛰기 클릭 가로챔, (b) 타임바 등장 시 3행 그리드 템플릿 밀림으로 컨텍스트 패널이 타임바 종료 버튼을 덮음 | `full-sweep-session.spec.ts` 모바일 RED 재현 → `session.css` `:has(> .sx-timebar)` 4행 템플릿 + 모바일 넛지 플로우 배치 | **FIXED** (session desktop+mobile 28/28, session-layout 8/8, layout-visual-gate 15/15) |
|
||||||
|
| 19 | admin | **관리자 콘솔 무한 렌더 루프 프리즈** — users API 실패(usersData=null) 상태 탭 클릭·실DB 스케일 티켓 검색 입력 시 TanStack `_autoResetPageIndex`↔setState 동기 루프로 영구 프리즈 | 에이전트 CDP 채증 + 결정적 재현 스펙 2건 → `Admin.tsx` users/services 메모화 + `autoResetPageIndex:false` | **FIXED** (재현 2건 GREEN 포함 admin 19/19) |
|
||||||
|
| 20 | admin/백엔드 | **복수 코호트 조용한 유실** — POST/PATCH `/admin/users`가 cohort_ids 첫 항목만 저장(`_cohort_value`가 첫 값만 취함) | 의도적 RED e2e + `test_admin_ops.py::CohortSerializationTest` → 콤마 직렬화 + `notifications.py` 배열 겹침 비교 | **FIXED** (백엔드 412/412, e2e 재실행 GREEN) |
|
||||||
|
| 21 | session-review/백엔드 | 감정 밸런스 타임라인 데이터 소스가 항상 빈 배열 — 사문 렌더 경로 | 소유자 결정(지금 구현) → 혼합 접근: deep 스키마 `turn_valence` 신설(LLM 산출)+저장 데이터 결정론 파생 폴백(counselorBaseline=rapport_signal 누적 평균), `test_review_valence.py` RED 8건→GREEN 9/9 | **FIXED** (백엔드 421/421, e2e 차트 렌더 검증 포함 14/14) |
|
||||||
|
| 22 | persona-studio/백엔드 | **공개 페르소나 보관 전면 불능** — DELETE `/personas/{id}`가 실DB에서 항상 503. `archive_persona_family`의 UPDATE…FROM RETURNING에서 무한정 `code`가 target.code와 모호(AmbiguousColumnError) | 실API RED 재현(QAX1 초안 생성→DELETE 503) → RETURNING 컬럼 card. 한정 → 재시작 후 같은 호출 200/archived | **FIXED** (백엔드 412/412 + 실API GREEN) |
|
||||||
|
| 23 | 스모크 스크립트 | `smoke-resistance-openness-db.py`의 advice_jump 단언(turn_client_state ≥5)이 엔진 변동성에 민감 — 조언점프 5턴 중 2턴에서 evaluator가 client_state 라벨을 비워 3행만 저장(2026-07-27 실행, 코드 회귀 아님 — 당일 diff는 deep-loop 한정) | 스모크 실행 로그 | OPEN·스모크 단언 완화 검토(예: ≥3 또는 재시도) |
|
||||||
Loading…
Add table
Add a link
Reference in a new issue