G7 증명과 G8 clean-head 승격 준비
This commit is contained in:
parent
94c681d450
commit
5221f79e3f
52 changed files with 6876 additions and 506 deletions
|
|
@ -153,7 +153,7 @@ class Settings(BaseSettings):
|
|||
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_URL",
|
||||
)
|
||||
local_whisper_stt_model: str = Field(
|
||||
default="large-v3",
|
||||
default="small",
|
||||
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_MODEL",
|
||||
)
|
||||
local_whisper_stt_language: str = Field(
|
||||
|
|
|
|||
|
|
@ -206,9 +206,13 @@ async def voice_health() -> JSONResponse:
|
|||
"stt_model": voice_service.stt_model(),
|
||||
"stt_batch_fallback_available": voice_service.batch_stt_available(),
|
||||
"tts_model": (
|
||||
voice_svc.HIGGS_TTS_MODEL
|
||||
if tts_provider == "higgs"
|
||||
else voice_svc.TTS_MODEL
|
||||
voice_svc.MELOTTS_TTS_MODEL
|
||||
if tts_provider == "melotts"
|
||||
else (
|
||||
voice_svc.HIGGS_TTS_MODEL
|
||||
if tts_provider == "higgs"
|
||||
else voice_svc.TTS_MODEL
|
||||
)
|
||||
),
|
||||
"tts_provider": tts_provider,
|
||||
"limits": {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from ..store import InProcSession
|
||||
|
|
@ -44,7 +44,7 @@ class LearnerGrowthMetrics:
|
|||
def iso_datetime(ts: float | None) -> str | None:
|
||||
if ts is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def session_activity_time(sess: InProcSession) -> float:
|
||||
|
|
|
|||
|
|
@ -998,7 +998,7 @@ class VoiceService:
|
|||
local_whisper_stt_model
|
||||
if local_whisper_stt_model is not None
|
||||
else settings.local_whisper_stt_model
|
||||
).strip() or "large-v3"
|
||||
).strip() or "small"
|
||||
self._local_whisper_stt_language = (
|
||||
local_whisper_stt_language
|
||||
if local_whisper_stt_language is not None
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal, Optional, cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -31,6 +31,7 @@ WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
|
|||
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
|
||||
|
||||
LEARNER_VISIBLE_AI_ROLE = "counselor"
|
||||
KOREA_STANDARD_TIME = timezone(timedelta(hours=9), name="KST")
|
||||
MISSING_SESSION_EVALUATION_GRACE_SECONDS = 30.0
|
||||
MISSING_SESSION_EVALUATION_ERROR = (
|
||||
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
|
||||
|
|
@ -478,7 +479,11 @@ def stage_label(stage: object) -> StageLabel:
|
|||
def iso(ts: float | None) -> str | None:
|
||||
if ts is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def session_calendar_date(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, tz=KOREA_STANDARD_TIME).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
|
||||
|
|
@ -1757,7 +1762,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
initial=client_initial,
|
||||
persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)),
|
||||
),
|
||||
date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
|
||||
date=session_calendar_date(sess.created_at),
|
||||
durationLabel=_duration_label(duration_seconds),
|
||||
durationSeconds=duration_seconds,
|
||||
reachedPhase=reached_phase,
|
||||
|
|
|
|||
49
apps/api/app/test_session_read_model.py
Normal file
49
apps/api/app/test_session_read_model.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""회기 읽기 모델의 시간 직렬화 계약 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .session_read_model import iso, session_calendar_date
|
||||
from .services.session_metrics import iso_datetime
|
||||
|
||||
|
||||
class SessionReadModelIsoTest(unittest.TestCase):
|
||||
def test_none_remains_none(self) -> None:
|
||||
self.assertIsNone(iso(None))
|
||||
|
||||
def test_epoch_is_serialized_as_utc_with_offset(self) -> None:
|
||||
self.assertEqual(iso(0), "1970-01-01T00:00:00+00:00")
|
||||
|
||||
def test_timestamp_round_trips_without_local_timezone_drift(self) -> None:
|
||||
timestamp = 1_800_000_120.0
|
||||
|
||||
rendered = iso(timestamp)
|
||||
|
||||
self.assertIsNotNone(rendered)
|
||||
assert rendered is not None
|
||||
parsed = datetime.fromisoformat(rendered)
|
||||
self.assertEqual(parsed.tzinfo, timezone.utc)
|
||||
self.assertEqual(parsed.timestamp(), timestamp)
|
||||
|
||||
def test_serialized_timestamp_has_explicit_utc_suffix(self) -> None:
|
||||
rendered = iso(1_800_000_120.0)
|
||||
|
||||
self.assertIsNotNone(rendered)
|
||||
assert rendered is not None
|
||||
self.assertTrue(rendered.endswith("+00:00"))
|
||||
|
||||
def test_dashboard_metrics_use_the_same_utc_contract(self) -> None:
|
||||
timestamp = 1_800_000_120.0
|
||||
|
||||
self.assertEqual(iso_datetime(timestamp), iso(timestamp))
|
||||
|
||||
def test_review_calendar_date_uses_korea_standard_time(self) -> None:
|
||||
utc_late_afternoon = datetime(2026, 8, 8, 15, 30, tzinfo=timezone.utc)
|
||||
|
||||
self.assertEqual(session_calendar_date(utc_late_afternoon.timestamp()), "2026-08-09")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -693,7 +693,7 @@ class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase):
|
|||
query = parse_qs(urlsplit(url).query)
|
||||
self.assertEqual(urlsplit(url).scheme, "ws")
|
||||
self.assertEqual(urlsplit(url).hostname, "127.0.0.1")
|
||||
self.assertEqual(query["model"], ["large-v3"])
|
||||
self.assertEqual(query["model"], ["small"])
|
||||
self.assertEqual(query["language"], ["ko"])
|
||||
self.assertEqual(query["sample_rate"], ["16000"])
|
||||
self.assertEqual(query["channels"], ["1"])
|
||||
|
|
@ -701,7 +701,7 @@ class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(query["utterance_end_ms"], ["1200"])
|
||||
|
||||
self.assertEqual(result.text, "안녕하세요")
|
||||
self.assertEqual(result.model, "large-v3")
|
||||
self.assertEqual(result.model, "small")
|
||||
self.assertEqual(result.language, "ko")
|
||||
self.assertEqual([word.word for word in result.words], ["안녕하세요"])
|
||||
self.assertAlmostEqual(result.words[0].start, 0.05, places=3)
|
||||
|
|
@ -927,7 +927,7 @@ class LocalWhisperProviderSelectionTest(unittest.TestCase):
|
|||
service = VoiceService(api_key="", stt_provider="local_whisper")
|
||||
self.assertTrue(service.streaming_stt_enabled())
|
||||
self.assertEqual(service.stt_provider(), "local_whisper")
|
||||
self.assertEqual(service.stt_model(), "large-v3")
|
||||
self.assertEqual(service.stt_model(), "small")
|
||||
|
||||
def test_missing_sidecar_url_falls_back_to_batch(self) -> None:
|
||||
service = VoiceService(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from .persona_repository import PersonaVoiceMap
|
|||
from .routes import voice as voice_routes
|
||||
from .services import multimodal_alliance_store
|
||||
from .services.voice import (
|
||||
MELOTTS_TTS_MODEL,
|
||||
StreamingTranscriptEvent,
|
||||
TranscriptResult,
|
||||
TranscriptWord,
|
||||
|
|
@ -150,6 +151,27 @@ class _FakeRouteStreamingSession:
|
|||
self.aborted = True
|
||||
|
||||
|
||||
class VoiceHealthContractTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_melotts_health_reports_the_operated_model(self) -> None:
|
||||
service = SimpleNamespace(
|
||||
is_available=lambda: True,
|
||||
stt_available=lambda: True,
|
||||
tts_available=lambda: True,
|
||||
tts_provider=lambda: "melotts",
|
||||
stt_provider=lambda: "local_whisper",
|
||||
stt_model=lambda: "small",
|
||||
batch_stt_available=lambda: False,
|
||||
)
|
||||
|
||||
with patch.object(voice_routes, "voice_service", service):
|
||||
response = await voice_routes.voice_health()
|
||||
|
||||
body = json.loads(bytes(response.body))
|
||||
self.assertEqual(body["status"], "ok")
|
||||
self.assertEqual(body["tts_provider"], "melotts")
|
||||
self.assertEqual(body["tts_model"], MELOTTS_TTS_MODEL)
|
||||
|
||||
|
||||
class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
|
||||
def _bind_result(self) -> tuple[str, VoicePreset, None, dict[str, object]]:
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ python-multipart==0.0.18
|
|||
httpx==0.28.1
|
||||
websockets==14.2
|
||||
sse-starlette==3.0.3
|
||||
# Windows G7 topology evidence pins and samples exact host-process provenance.
|
||||
psutil==6.1.1
|
||||
# P4(2026-07-13 회의): 자유 양식 엑셀 업로드 → 텍스트 변환 (personas/sources/upload)
|
||||
openpyxl==3.1.5
|
||||
|
||||
|
|
|
|||
|
|
@ -590,6 +590,12 @@ function isOnline(seconds: number) {
|
|||
}
|
||||
|
||||
async function openAdminAndReadHealth(page: Page) {
|
||||
const userSnapshots: AdminUsersResponse[] = [];
|
||||
const collectUsersResponse = async (response: Response) => {
|
||||
if (!isAdminUsersResponse(response) || !response.ok()) return;
|
||||
userSnapshots.push((await response.json()) as AdminUsersResponse);
|
||||
};
|
||||
page.on("response", collectUsersResponse);
|
||||
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
|
||||
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
|
||||
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
|
||||
|
|
@ -613,10 +619,36 @@ async function openAdminAndReadHealth(page: Page) {
|
|||
|
||||
const health = (await healthResponse.json()) as AdminHealthResponse;
|
||||
const usage = (await usageResponse.json()) as AdminUsageResponse;
|
||||
const users = (await usersResponse.json()) as AdminUsersResponse;
|
||||
const initialUsers = (await usersResponse.json()) as AdminUsersResponse;
|
||||
const uptime = (await uptimeResponse.json()) as AdminUptimeResponse;
|
||||
const tickets = (await ticketsResponse.json()) as AdminTicketsResponse;
|
||||
expect(health.services.length).toBeGreaterThan(0);
|
||||
if (userSnapshots.length === 0) userSnapshots.push(initialUsers);
|
||||
|
||||
let users = initialUsers;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const rendered = await page.locator(".vgops-kpi b").allTextContents();
|
||||
const matchingSnapshot = userSnapshots.find((snapshot) => {
|
||||
const activeSessions = snapshot.users.reduce(
|
||||
(sum, user) => sum + Math.max(0, user.active_sessions),
|
||||
0,
|
||||
);
|
||||
const onlineUsers = snapshot.users.filter((user) => isOnline(user.last_seen_at)).length;
|
||||
return (
|
||||
rendered[0] === countLabel(activeSessions) &&
|
||||
rendered[1] === countLabel(onlineUsers) &&
|
||||
rendered[2] === countLabel(snapshot.users.length)
|
||||
);
|
||||
});
|
||||
if (matchingSnapshot) users = matchingSnapshot;
|
||||
return Boolean(matchingSnapshot);
|
||||
},
|
||||
{ message: "관리자 KPI가 실제 /admin/users 응답 중 하나를 반영해야 한다" },
|
||||
)
|
||||
.toBe(true);
|
||||
page.off("response", collectUsersResponse);
|
||||
return { health, usage, users, uptime, tickets };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import http, { type Server } from "node:http";
|
|||
import { promises as fs } from "node:fs";
|
||||
import { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
const repoRoot = path.resolve(process.cwd(), "..", "..");
|
||||
const specDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(specDir, "..", "..", "..");
|
||||
const dashboardPath = path.join(repoRoot, "docs", "dev_dashboard.html");
|
||||
|
||||
interface StaticServer {
|
||||
|
|
@ -144,8 +145,8 @@ async function readMetrics(page: Page): Promise<DashboardMetrics> {
|
|||
|
||||
function expectDerivedMetrics(metrics: DashboardMetrics) {
|
||||
expect(metrics.total).toBeGreaterThan(0);
|
||||
expect(metrics.done).toBe(33);
|
||||
expect(metrics.doing).toBe(2);
|
||||
expect(metrics.done).toBe(32);
|
||||
expect(metrics.doing).toBe(3);
|
||||
expect(metrics.planned).toBe(0);
|
||||
expect(metrics.total).toBe(metrics.done + metrics.doing + metrics.planned);
|
||||
expect(metrics.ownerBoard).toBe(
|
||||
|
|
@ -336,6 +337,30 @@ test.describe("dev dashboard static command center", () => {
|
|||
await page.setViewportSize(viewport);
|
||||
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
const pulseDetails = page.locator(".pulse-details");
|
||||
await expect(pulseDetails).toHaveJSProperty("open", false);
|
||||
await pulseDetails.locator("summary").click();
|
||||
await expect(pulseDetails).toHaveJSProperty("open", true);
|
||||
await expect(pulseDetails).toContainText("detached clean release와 exact Git commit/tree");
|
||||
await pulseDetails.locator("summary").click();
|
||||
await expect(pulseDetails).toHaveJSProperty("open", false);
|
||||
|
||||
if (viewport.width <= 760) {
|
||||
const controlHeights = await page
|
||||
.locator(".owner-pill, .fbtn, .tab-button, .dg-link, .pulse-details summary")
|
||||
.evaluateAll((controls) =>
|
||||
controls.map((control) => Math.ceil(control.getBoundingClientRect().height)),
|
||||
);
|
||||
expect(controlHeights.length).toBeGreaterThan(0);
|
||||
expect(controlHeights.every((height) => height >= 44)).toBe(true);
|
||||
|
||||
const firstScreenPositions = await page.locator("#phase-rail, .kpis").evaluateAll((regions) =>
|
||||
regions.map((region) => Math.floor(region.getBoundingClientRect().top)),
|
||||
);
|
||||
expect(firstScreenPositions).toHaveLength(2);
|
||||
expect(firstScreenPositions.every((top) => top < viewport.height)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -862,6 +862,60 @@ async function capture(page: Page, projectName: string, stage: string) {
|
|||
}
|
||||
|
||||
test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fixture", () => {
|
||||
test("3.5초 경계의 자기주도 경로 전환이 복구되면 부트 진단을 즉시 닫는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSelfDirectedUiFixture(page);
|
||||
await page.goto("/learn");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
|
||||
).toBeVisible();
|
||||
|
||||
// index.html의 첫 watchdog(3.5초)과 실제 자기주도 경로 전환을 겹친다.
|
||||
// route가 잠깐 비어 보이는 동안에는 진단이 뜨되, React 콘텐츠 복구 후에는
|
||||
// 두 번째 watchdog(8초)을 기다리지 않고 바로 사라져야 한다.
|
||||
await page.evaluate(async () => {
|
||||
const remaining = Math.max(0, 3_300 - performance.now());
|
||||
await new Promise((resolve) => window.setTimeout(resolve, remaining));
|
||||
const root = document.getElementById("root");
|
||||
const action = document.querySelector<HTMLElement>(
|
||||
"[data-learner-primary-action]",
|
||||
);
|
||||
if (!root || !action) {
|
||||
throw new Error("self-directed route transition fixture unavailable");
|
||||
}
|
||||
root.style.visibility = "hidden";
|
||||
action.click();
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/learn\/practice$/);
|
||||
await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({
|
||||
timeout: 1_200,
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.getElementById("root")?.style.removeProperty("visibility");
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "연습할 내담자를 선택합니다." }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0, {
|
||||
timeout: 1_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("React 부트가 실제로 비어 있으면 진단을 유지한다", async ({ page }) => {
|
||||
await page.route("**/src/main.tsx", (route) => route.abort("failed"));
|
||||
await page.goto("/learn", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const diagnostic = page.locator("#vignette-boot-diagnostic");
|
||||
await expect(diagnostic).toBeVisible({ timeout: 4_500 });
|
||||
await expect(diagnostic).toContainText("React 화면이 정상 콘텐츠를 표시하지 못해서");
|
||||
await page.waitForTimeout(250);
|
||||
await expect(diagnostic).toBeVisible();
|
||||
await expect(page.locator("#root")).toBeEmpty();
|
||||
});
|
||||
|
||||
test("홈 추천과 목표 선택부터 회기·복기·처방 재연습 시작까지 한 흐름으로 보존한다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
|
|
|||
|
|
@ -432,6 +432,24 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
|
|||
});
|
||||
});
|
||||
|
||||
await page.route(
|
||||
`**/api/sessions/${sessionId}/multimodal-alliance/consent`,
|
||||
async (route) => {
|
||||
const consentBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
submission_id: consentBody.submission_id,
|
||||
consent_snapshot_id: "00000000-0000-4000-8000-000000000777",
|
||||
consent_status: "granted",
|
||||
deletion_request_id: null,
|
||||
idempotent_replay: false,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
sessionStartRequests,
|
||||
liveCoachRequests,
|
||||
|
|
@ -444,6 +462,15 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
async function acceptVoiceInputConsent(page: Page) {
|
||||
const consentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
|
||||
await expect(consentDialog).toBeVisible();
|
||||
await expect(consentDialog).toContainText("원음은 보존하지 않습니다");
|
||||
await expect(consentDialog).toContainText("동의하지 않아도 텍스트로 계속할 수 있습니다");
|
||||
await consentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
|
||||
await expect(consentDialog).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function installVoicePersistenceFailureFixture(page: Page, transcriptText: string) {
|
||||
await page.addInitScript((text) => {
|
||||
const fixture = {
|
||||
|
|
@ -1370,6 +1397,7 @@ test.describe("P1 MVP core loop", () => {
|
|||
const micButton = page.getByRole("button", { name: "마이크 켜기" });
|
||||
await expect(micButton).toBeEnabled();
|
||||
await micButton.click();
|
||||
await acceptVoiceInputConsent(page);
|
||||
await page.getByRole("button", { name: "발화 보내기" }).click();
|
||||
|
||||
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: failedVoiceText });
|
||||
|
|
@ -1410,6 +1438,7 @@ test.describe("P1 MVP core loop", () => {
|
|||
await page.getByRole("button", { name: "이어가기" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "마이크 켜기" }).click();
|
||||
await acceptVoiceInputConsent(page);
|
||||
await page.getByRole("button", { name: "발화 보내기" }).click();
|
||||
|
||||
const transcriptLog = page.getByRole("log", { name: "실시간 상담 축어록" });
|
||||
|
|
@ -1494,6 +1523,7 @@ test.describe("P1 MVP core loop", () => {
|
|||
const micButton = page.getByRole("button", { name: "마이크 켜기" });
|
||||
await expect(micButton).toBeEnabled();
|
||||
await micButton.click();
|
||||
await acceptVoiceInputConsent(page);
|
||||
await page.getByRole("button", { name: "발화 보내기" }).click();
|
||||
|
||||
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: crisisText });
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ interface SessionDetailResponse {
|
|||
session_id: string;
|
||||
theory_mode: string;
|
||||
status: "active" | "ended";
|
||||
started_at: string;
|
||||
ended_at?: string | null;
|
||||
turns?: Array<{
|
||||
speaker: "learner" | "client";
|
||||
text: string;
|
||||
|
|
@ -548,6 +550,8 @@ async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워
|
|||
}
|
||||
|
||||
test.describe("session persistence", () => {
|
||||
test.use({ timezoneId: "Asia/Seoul" });
|
||||
|
||||
test("persists selected CBT theory mode from session UI into DB-backed detail @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -922,6 +926,21 @@ test.describe("session persistence", () => {
|
|||
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const reloadedDetailResponse = await page.request.get(`/api/sessions/${sessionId}`);
|
||||
await expectResponseOk(reloadedDetailResponse);
|
||||
const reloadedDetail = (await reloadedDetailResponse.json()) as SessionDetailResponse;
|
||||
expect(reloadedDetail.status).toBe("active");
|
||||
expect(reloadedDetail.ended_at).toBeNull();
|
||||
expect(reloadedDetail.started_at).toMatch(/(?:Z|[+-]\d{2}:\d{2})$/);
|
||||
const sessionAgeMs = Date.now() - Date.parse(reloadedDetail.started_at);
|
||||
expect(Number.isFinite(sessionAgeMs)).toBe(true);
|
||||
expect(sessionAgeMs).toBeGreaterThanOrEqual(-5_000);
|
||||
expect(sessionAgeMs).toBeLessThan(10 * 60 * 1_000);
|
||||
await expect(
|
||||
page.locator(".sx-session-progress__grid > span").first().locator("b"),
|
||||
).toHaveText(/^0\d:[0-5]\d$/);
|
||||
await expect(page.locator(".sx-timebar.is-over")).toHaveCount(0);
|
||||
await expect(page.getByRole("dialog", { name: "회기 시간이 끝났어요" })).toHaveCount(0);
|
||||
const coachMark = page.locator(".sx-utt.is-learner .sx-utt__coach-mark");
|
||||
await expect(coachMark).toBeVisible({
|
||||
timeout: 15_000,
|
||||
|
|
|
|||
|
|
@ -246,6 +246,13 @@ export async function routeFilledSessionReview(
|
|||
body: JSON.stringify(filledReviewResponse(sessionId)),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function prepostMeasuresResponse(
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ async function expectReviewRegionsReachable(page: Page) {
|
|||
.evaluate((el) => window.getComputedStyle(el).gridTemplateAreas);
|
||||
expect(transcriptAreas).toContain("transcript");
|
||||
|
||||
await page.locator(".sr-overview").scrollIntoViewIfNeeded();
|
||||
await expect(page.locator(".sr-overview")).toBeInViewport();
|
||||
await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded();
|
||||
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
|
||||
|
|
@ -546,9 +547,12 @@ test.describe("session review", () => {
|
|||
};
|
||||
}),
|
||||
);
|
||||
expect(filterMetrics.every((item) => item.height <= 34 && item.whiteSpace === "nowrap")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(filterMetrics.every((item) => item.whiteSpace === "nowrap")).toBe(true);
|
||||
if ((page.viewportSize()?.width ?? 0) <= 720) {
|
||||
expect(filterMetrics.every((item) => item.height >= 44)).toBe(true);
|
||||
} else {
|
||||
expect(filterMetrics.every((item) => item.height <= 34)).toBe(true);
|
||||
}
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
|
|
@ -566,6 +570,7 @@ test.describe("session review", () => {
|
|||
expect(review.session_id).toBe(FILLED_REVIEW_SESSION_ID);
|
||||
expect(review.reviewReady).toBe(true);
|
||||
expect(review.turns).toHaveLength(6);
|
||||
await expect(page.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review$`));
|
||||
await expect(page.getByText("관계 신호가 안정적으로 회복된 회기")).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@
|
|||
var errors = [];
|
||||
var loadedAt = Date.now();
|
||||
var sentDiagnostics = {};
|
||||
var healthyCheckTimer = null;
|
||||
var healthyObserver = null;
|
||||
function short(value) {
|
||||
return String(value || "").slice(0, 900);
|
||||
}
|
||||
|
|
@ -116,14 +118,68 @@
|
|||
style.opacity !== "0"
|
||||
);
|
||||
}
|
||||
function visibleCount() {
|
||||
function visibleCount(scope) {
|
||||
var root = scope || document;
|
||||
var nodes = Array.prototype.slice.call(
|
||||
document.querySelectorAll(
|
||||
root.querySelectorAll(
|
||||
"main,header,h1,h2,p,a,button,input,select,article,section,.vg-shell,.vg-route-error,.vgops-head,.vgops-diagnostic,.lg-root",
|
||||
),
|
||||
);
|
||||
return nodes.filter(isVisiblyRendered).length;
|
||||
}
|
||||
function appHealth() {
|
||||
var root = document.getElementById("root");
|
||||
var path = window.location.pathname;
|
||||
var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0;
|
||||
var text = (root && root.innerText ? root.innerText : "").trim();
|
||||
var visible = root ? visibleCount(root) : 0;
|
||||
var adminContent = root
|
||||
? root.querySelector(
|
||||
"[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']",
|
||||
)
|
||||
: null;
|
||||
var hasAdminContent = !isAdmin || isVisiblyRendered(adminContent);
|
||||
return {
|
||||
healthy: Boolean(
|
||||
root &&
|
||||
root.childElementCount > 0 &&
|
||||
text.length > 24 &&
|
||||
visible > 0 &&
|
||||
hasAdminContent,
|
||||
),
|
||||
visible: visible,
|
||||
};
|
||||
}
|
||||
function dismissDiagnosticIfHealthy() {
|
||||
if (!appHealth().healthy) return false;
|
||||
var panel = document.getElementById("vignette-boot-diagnostic");
|
||||
if (panel) panel.remove();
|
||||
return true;
|
||||
}
|
||||
function scheduleHealthyCheck() {
|
||||
if (healthyCheckTimer !== null) window.clearTimeout(healthyCheckTimer);
|
||||
healthyCheckTimer = window.setTimeout(function () {
|
||||
healthyCheckTimer = null;
|
||||
dismissDiagnosticIfHealthy();
|
||||
}, 60);
|
||||
}
|
||||
function observeAppHealth() {
|
||||
if (healthyObserver || !window.MutationObserver) return;
|
||||
var root = document.getElementById("root");
|
||||
if (!root) {
|
||||
window.setTimeout(observeAppHealth, 100);
|
||||
return;
|
||||
}
|
||||
healthyObserver = new MutationObserver(scheduleHealthyCheck);
|
||||
healthyObserver.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-hidden", "class", "hidden", "style"],
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
scheduleHealthyCheck();
|
||||
}
|
||||
function diagnosticApiBase() {
|
||||
var host = window.location.hostname;
|
||||
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
|
||||
|
|
@ -183,19 +239,16 @@
|
|||
}
|
||||
function render(reason) {
|
||||
var path = window.location.pathname;
|
||||
var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0;
|
||||
var text = (document.body && document.body.innerText ? document.body.innerText : "").trim();
|
||||
var visible = visibleCount();
|
||||
var adminContent = document.querySelector(
|
||||
"[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']",
|
||||
);
|
||||
var hasAdminContent = !isAdmin || isVisiblyRendered(adminContent);
|
||||
if (reason === "watchdog" && text.length > 24 && visible > 0 && hasAdminContent) {
|
||||
var old = document.getElementById("vignette-boot-diagnostic");
|
||||
if (old) old.remove();
|
||||
var health = appHealth();
|
||||
if (health.healthy && reason !== "manual") {
|
||||
if (reason !== "watchdog") {
|
||||
sendDiagnostic(reason, text.length, health.visible);
|
||||
}
|
||||
dismissDiagnosticIfHealthy();
|
||||
return;
|
||||
}
|
||||
sendDiagnostic(reason, text.length, visible);
|
||||
sendDiagnostic(reason, text.length, health.visible);
|
||||
|
||||
var panel = document.getElementById("vignette-boot-diagnostic");
|
||||
if (!panel) {
|
||||
|
|
@ -229,7 +282,7 @@
|
|||
text.length +
|
||||
"</dd>",
|
||||
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">visibleNodes</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
|
||||
visible +
|
||||
health.visible +
|
||||
"</dd>",
|
||||
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">elapsed</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
|
||||
(Date.now() - loadedAt) +
|
||||
|
|
@ -260,6 +313,7 @@
|
|||
render("manual");
|
||||
},
|
||||
};
|
||||
observeAppHealth();
|
||||
window.setTimeout(function () {
|
||||
render("watchdog");
|
||||
}, 3500);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue