개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -30,6 +30,7 @@ interface AdminManagedUser {
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
learner_feedback_enabled: boolean;
account_status: "pending" | "approved" | "suspended";
affiliation: string;
cohort_ids: string[];
@ -676,7 +677,9 @@ async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
const clippedControls = await form.evaluate((element) => {
const formRect = element.getBoundingClientRect();
const controls = Array.from(element.querySelectorAll<HTMLElement>("input, select, button"));
const controls = Array.from(
element.querySelectorAll<HTMLElement>("input, select, output, button"),
);
return controls
.map((control) => {
@ -1398,7 +1401,7 @@ test.describe("admin route", () => {
await expectNoHorizontalOverflow(page);
});
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
test("allows an admin to manage real server-known users", async ({ page, browser }, testInfo) => {
test.setTimeout(60_000);
await signInAsAdmin(page);
@ -1427,16 +1430,19 @@ test.describe("admin route", () => {
await sessionHeader.getByRole("button").click();
await expect(sessionHeader).toHaveAttribute("aria-sort", "descending");
await page.getByRole("tab", { name: "사용자 등록" }).click();
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
await page.getByLabel("새 사용자 역할").selectOption("learner");
await page.getByLabel("새 사용자 승인 상태").selectOption("pending");
await expect(page.getByLabel("새 사용자 승인 상태")).toHaveAttribute(
"data-value",
"pending",
);
await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`);
const createPromise = page.waitForResponse(isAdminUserCreate);
const reloadAfterCreatePromise = page.waitForResponse(isAdminUsersResponse);
await page.getByRole("button", { name: "사용자 등록" }).click();
await page.getByRole("button", { name: "연구참여자 사전등록" }).click();
const createResponse = await createPromise;
await expectResponseOk(createResponse);
await expectResponseOk(await reloadAfterCreatePromise);
@ -1461,17 +1467,54 @@ test.describe("admin route", () => {
await expect(approvalCard).toContainText("승인 대기");
await expectVisibleButtonsFit(page, ".vgops-approval__actions .vg-btn", "admin approval buttons");
const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id));
await approvalCard.getByRole("button", { name: "승인" }).click();
const approveResponse = await approvePromise;
await expectResponseOk(approveResponse);
const approved = (await approveResponse.json()) as AdminManagedUser;
expect(approved).toMatchObject({
user_id: created.user_id,
email,
account_status: "approved",
const pendingContext = await browser.newContext({
baseURL: String(testInfo.project.use.baseURL),
});
await expect(approvalCard).toHaveCount(0);
try {
const pendingPage = await pendingContext.newPage();
const pendingLogin = await pendingPage.request.post("/api/auth/dev-login", {
data: {
email,
role: "learner",
display_name: displayName,
},
});
await expectResponseOk(pendingLogin);
expect(await pendingLogin.json()).toMatchObject({
email,
account_status: "pending",
approval_required: true,
});
const blockedPersonas = await pendingPage.request.get("/api/personas");
expect(blockedPersonas.status()).toBe(403);
expect(await blockedPersonas.json()).toMatchObject({ detail: "account_pending" });
await pendingPage.goto("/learn");
await expect(pendingPage).toHaveURL(/\/pending$/);
const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id));
await approvalCard.getByRole("button", { name: "승인" }).click();
const approveResponse = await approvePromise;
await expectResponseOk(approveResponse);
const approved = (await approveResponse.json()) as AdminManagedUser;
expect(approved).toMatchObject({
user_id: created.user_id,
email,
account_status: "approved",
});
await expect(approvalCard).toHaveCount(0);
const approvedMe = await pendingPage.request.get("/api/auth/me");
await expectResponseOk(approvedMe);
expect(await approvedMe.json()).toMatchObject({
email,
account_status: "approved",
approval_required: false,
});
await expectResponseOk(await pendingPage.request.get("/api/personas"));
} finally {
await pendingContext.close();
}
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
@ -1484,6 +1527,7 @@ test.describe("admin route", () => {
const nextName = `교수자 ${testInfo.project.name}`;
const nameInput = card.getByLabel(`${email} 표시 이름`);
const roleSelect = card.getByLabel(`${email} 역할`);
const feedbackToggle = card.getByLabel(`${email} 학습자 AI 피드백`);
const cohortInput = card.getByLabel(`${email} 코호트`);
const saveButton = card.getByRole("button", { name: "저장" });
const nextCohort = `cohort-${testInfo.project.name}`;
@ -1492,6 +1536,9 @@ test.describe("admin route", () => {
await page.evaluate(() => new Promise(requestAnimationFrame));
await roleSelect.selectOption("teacher");
await expect(roleSelect).toHaveValue("teacher");
await expect(feedbackToggle).toBeChecked();
await feedbackToggle.uncheck();
await expect(feedbackToggle).not.toBeChecked();
await cohortInput.fill(nextCohort);
await expect(cohortInput).toHaveValue(nextCohort);
await page.evaluate(() => new Promise(requestAnimationFrame));
@ -1507,14 +1554,23 @@ test.describe("admin route", () => {
email,
display_name: nextName,
role: "teacher",
learner_feedback_enabled: false,
account_status: "approved",
cohort_ids: [nextCohort],
});
await expect(nameInput).toHaveValue(nextName);
await expect(card).toContainText("교수자");
await expect(feedbackToggle).not.toBeChecked();
await expect(cohortInput).toHaveValue(nextCohort);
const persistedUsersResponse = await page.request.get("/api/admin/users");
await expectResponseOk(persistedUsersResponse);
const persistedUsers = (await persistedUsersResponse.json()) as AdminUsersResponse;
expect(
persistedUsers.users.find((user) => user.user_id === created.user_id),
).toMatchObject({ learner_feedback_enabled: false });
const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id));
await card.getByRole("button", { name: "비활성화" }).click();
const deleteResponse = await deletePromise;
@ -1663,7 +1719,7 @@ test.describe("admin route", () => {
await signInAsAdmin(page);
const users = await openAdminAndReadUsers(page);
await page.getByRole("tab", { name: "사용자 등록" }).click();
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
const layout = await page.evaluate(() => {
const form = document.querySelector<HTMLElement>(".vgops-user-create");
if (!form) throw new Error("admin user create form was not rendered");
@ -1702,7 +1758,7 @@ test.describe("admin route", () => {
for (const width of [861, 900, 1024]) {
await page.setViewportSize({ width, height: 900 });
await openAdminAndReadUsers(page);
await page.getByRole("tab", { name: "사용자 등록" }).click();
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, width);

View file

@ -1,6 +1,7 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import {
FILLED_REVIEW_SESSION_ID,
FILLED_REVIEW_TURN_IDS,
filledReviewResponse,
routeFilledSessionReview,
routePrepostMeasures,
@ -58,7 +59,7 @@ function routeReviewUser(page: Page, role: Role) {
function measurements(): PulseMeasurement[] {
const evidence = {
turn_id: "t6",
turn_id: FILLED_REVIEW_TURN_IDS[5],
seq: 6,
speaker: "client",
text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
@ -170,7 +171,7 @@ test.describe("G1 치료 동맹 펄스", () => {
expect(submittedBody).toEqual({
checkpoint: "post",
scores: { goal: 0.75, task: 0.5, bond: 1 },
evidence_turn_ids: ["t1"],
evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]],
});
await expect(page.getByLabel("치료 동맹 관점 비교")).toBeVisible({
timeout: 5_000,
@ -316,7 +317,7 @@ test.describe("G1 치료 동맹 펄스", () => {
await expect(supervisor.getByText("교수자 판정을 원장에 추가했습니다.")).toBeVisible();
expect(submittedBody).toEqual({
scores: { goal: 0.75, task: 0.5, bond: 0.75 },
evidence_turn_ids: ["t1"],
evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]],
note: "목표 합의는 안정적이지만 과업 속도는 다음 지도에서 다시 확인합니다.",
});
await expectNoHorizontalOverflow(page);

View file

@ -68,7 +68,8 @@ test.describe("auth domain policy", () => {
const googleButtons = page.locator(".lg-obtn");
await expect(googleButtons).toHaveCount(2);
await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains);
expect(config.allowed_email_domains).toEqual([]);
await expect(page.locator(".lg-policy b")).toHaveText("모든 Google 계정");
const redirectHost = new URL(config.redirect_uri).hostname;
const devOAuthUnavailable =
config.dev_login_enabled &&
@ -233,7 +234,7 @@ test.describe("auth domain policy", () => {
google_oauth_configured: true,
dev_login_enabled: false,
});
expect(config.allowed_email_domains).toEqual(expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]));
expect(config.allowed_email_domains).toEqual([]);
await page.goto(`${publicBase}/login`, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);

View file

@ -42,7 +42,7 @@ const READY_AUTH_CONFIG = {
login_path: "/auth/login?provider=saml",
},
],
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: true,
};
@ -153,7 +153,7 @@ test.describe("full sweep auth", () => {
});
// 검증 checklist: auth-login-generic-google-button
test("secondary Google button shows the second allowed domain and starts the same google OAuth flow", async ({
test("both Google buttons advertise unrestricted accounts and start the same OAuth flow", async ({
page,
}) => {
await mockUnauthenticatedMe(page);
@ -175,11 +175,9 @@ test.describe("full sweep auth", () => {
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");
await expect(secondary).toContainText("다른 Google 계정 선택");
await expect(secondary.locator(".sub")).toHaveText("이메일 확인 후 바로 시작");
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("모든 Google 계정");
// 클릭 시 primary와 동일한 provider=google 시작 URL로 이동한다.
await Promise.all([
@ -189,7 +187,7 @@ test.describe("full sweep auth", () => {
});
// 검증 checklist: auth-login-brand-domain-badges
test("brand panel domain badges distinguish checking, unset, and loaded states", async ({
test("brand panel Google policy badge distinguishes checking, unset, and ready states", async ({
page,
}) => {
await mockUnauthenticatedMe(page);
@ -198,7 +196,7 @@ test.describe("full sweep auth", () => {
/* 응답을 보류해 조회 중 상태를 고정한다 */
});
await page.goto("/login");
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
await expect(page.locator(".lg-policy")).toContainText("Google 로그인");
await expect(page.locator(".lg-policy b")).toHaveText("확인 중");
// 2) 설정 조회 실패: "설정 필요" 배지.
@ -213,14 +211,11 @@ test.describe("full sweep auth", () => {
await page.goto("/login");
await expect(page.locator(".lg-policy b")).toHaveText("설정 필요");
// 3) 정상 로드: allowed_email_domains를 배지로 나열.
// 3) 정상 로드: 모든 provider-verified Google 계정을 허용한다.
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");
await expect(page.locator(".lg-policy b")).toHaveText("모든 Google 계정");
});
// 검증 checklist: auth-onboarding-avatar-upload

View file

@ -196,11 +196,12 @@ test.describe("learner app shell and session launcher", () => {
await expect(launcher).toBeVisible();
await expect(launcher.getByRole("option")).toHaveCount(personas.length, { timeout: 15_000 });
for (const persona of personas) {
await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible();
const codePattern = new RegExp(`^${persona.code}(?:\\s|$)`);
const option = launcher.getByRole("option", { name: codePattern });
await expect(option).toBeVisible();
// display_name 은 가운뎃점 3개 이상이면 이름줄과 주호소줄로 나뉘어 렌더된다
// (2026-07-27 D3: 한 줄에 · 1개 제한). 이어 붙인 원문 대신 각 조각이 모두
// 옵션 안에 있는지 확인해, 표시 방식이 바뀌어도 내용 누락만 잡히게 한다.
const option = launcher.getByRole("option", { name: new RegExp(persona.code) });
for (const part of persona.display_name.split("·").map((piece) => piece.trim()).filter(Boolean)) {
await expect(option).toContainText(part);
}

File diff suppressed because it is too large Load diff

View file

@ -71,7 +71,7 @@ test.describe("public Google OAuth turn smoke", () => {
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
await expect(await configResponse.json()).toMatchObject({
google_oauth_configured: true,
allowed_email_domains: expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]),
allowed_email_domains: [],
dev_login_enabled: false,
});

View file

@ -1,3 +1,4 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { expect, test, type Page } from "@playwright/test";
import {
completeAlliancePreCheckpoint,
@ -10,6 +11,8 @@ import {
interface SessionStartResponse {
session_id: string;
case_id: string;
session_no: number;
degraded: boolean;
}
@ -173,6 +176,126 @@ interface PrepostMeasuresResponse {
measures: PrepostMeasureItem[];
}
interface ControlledFailureGateway {
url: string;
clientRequests: () => number;
evaluatorRequests: () => number;
close: () => Promise<void>;
}
async function startControlledEvaluationFailureGateway(
model: string,
fallbackProvider: string,
): Promise<ControlledFailureGateway> {
let clientRequestCount = 0;
let evaluatorRequestCount = 0;
const sendJson = (response: ServerResponse, status: number, payload: unknown) => {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(payload));
};
const readJson = async (request: IncomingMessage) => {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString("utf8");
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
};
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const provider = url.searchParams.get("provider") || fallbackProvider;
if (request.method === "GET" && url.pathname === "/v1/capabilities") {
sendJson(response, 200, {
provider,
available: true,
source: "live_cli",
models: [
{
id: model,
label: "REQ-007 controlled gateway",
description: "client succeeds while evaluator returns HTTP 500",
reasoning_efforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
default_reasoning_effort: "high",
is_default: true,
},
],
default_model: model,
default_reasoning_effort: "high",
detail: "controlled gateway ready",
fetched_at: Date.now() / 1000,
});
return;
}
if (request.method === "GET" && ["/ready", "/health"].includes(url.pathname)) {
sendJson(response, 200, {
ok: true,
status: "ok",
detail: "controlled gateway ready",
cached: false,
});
return;
}
if (request.method === "DELETE" && url.pathname.startsWith("/session/")) {
sendJson(response, 200, { closed: true });
return;
}
if (request.method === "POST" && url.pathname === "/v1/generate") {
const payload = await readJson(request);
if (payload.ai_role === "evaluator") {
evaluatorRequestCount += 1;
sendJson(response, 500, { detail: "controlled evaluator failure" });
return;
}
clientRequestCount += 1;
sendJson(response, 200, {
text: "조금 더 이야기해볼게요.",
model,
provider: String(payload.provider || fallbackProvider),
tokens_in: 7,
tokens_out: 9,
cost_usd: 0,
});
return;
}
sendJson(response, 404, { detail: `unexpected controlled gateway path: ${url.pathname}` });
} catch (error) {
sendJson(response, 500, {
detail: error instanceof Error ? error.message : String(error),
});
}
});
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once("error", onError);
server.listen(0, "127.0.0.1", () => {
server.off("error", onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
await new Promise<void>((resolve) => server.close(() => resolve()));
throw new Error("controlled gateway did not expose a TCP port");
}
return {
url: `http://127.0.0.1:${address.port}`,
clientRequests: () => clientRequestCount,
evaluatorRequests: () => evaluatorRequestCount,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
}),
};
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
@ -1357,6 +1480,158 @@ test.describe("session persistence", () => {
}
});
test("keeps the failed review durable while the next persona session accepts a stored turn @single-run", async ({
page,
}) => {
test.setTimeout(180_000);
await withGlobalEngineConfigLock("req-007-evaluation-failure-continuity", async () => {
await signInAsAdmin(page);
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
expect(currentEngine.source, "REQ-007 continuity proof requires the real DB lane").toBe(
"database",
);
expect(currentEngine.durable, "REQ-007 engine configuration must be durable").toBe(true);
const gateway = await startControlledEvaluationFailureGateway(
currentEngine.model,
currentEngine.engine_mode,
);
try {
const controlledPatch = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: gateway.url,
model: currentEngine.model,
reasoning_effort: currentEngine.reasoning_effort,
},
});
await expectResponseOk(controlledPatch);
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page);
const firstStartResponse = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
await expectResponseOk(firstStartResponse);
expect(firstStartResponse.status()).toBe(201);
const first = (await firstStartResponse.json()) as SessionStartResponse;
expect(first.degraded).toBe(false);
const firstTurnResponse = await page.request.post(
`/api/sessions/${first.session_id}/turn`,
{
data: { text: "오늘 가장 버거웠던 순간부터 이야기해도 괜찮을까요?" },
},
);
await expectResponseOk(firstTurnResponse);
const endResponse = await page.request.post(`/api/sessions/${first.session_id}/end`);
await expectResponseOk(endResponse);
expect(endResponse.status()).toBe(200);
const endedDetailResponse = await page.request.get(`/api/sessions/${first.session_id}`);
await expectResponseOk(endedDetailResponse);
const endedDetail = (await endedDetailResponse.json()) as SessionDetailResponse;
expect(endedDetail.status).toBe("ended");
expect(endedDetail.ended_at).toBeTruthy();
expect(endedDetail.turns?.length ?? 0).toBeGreaterThanOrEqual(2);
const nextStartResponse = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
await expectResponseOk(nextStartResponse);
expect(nextStartResponse.status()).toBe(201);
const next = (await nextStartResponse.json()) as SessionStartResponse;
expect(next.degraded).toBe(false);
expect(next.session_id).not.toBe(first.session_id);
expect(next.case_id).toBe(first.case_id);
expect(next.session_no).toBe(first.session_no + 1);
const nextTurnResponse = await page.request.post(
`/api/sessions/${next.session_id}/turn`,
{
data: { text: "지난 회기의 이야기를 이어서 천천히 살펴볼까요?" },
},
);
await expectResponseOk(nextTurnResponse);
const nextTurn = (await nextTurnResponse.json()) as {
client_reply?: string | null;
output_error?: string | null;
};
expect(nextTurn.client_reply).toBe("조금 더 이야기해볼게요.");
expect(nextTurn.output_error ?? null).toBeNull();
const nextDetailResponse = await page.request.get(`/api/sessions/${next.session_id}`);
await expectResponseOk(nextDetailResponse);
const nextDetail = (await nextDetailResponse.json()) as SessionDetailResponse;
expect(nextDetail.status).toBe("active");
expect(nextDetail.turns?.length ?? 0).toBeGreaterThanOrEqual(2);
expect(gateway.clientRequests()).toBeGreaterThanOrEqual(2);
await expect
.poll(() => gateway.evaluatorRequests(), {
timeout: 30_000,
message: "the ended session must reach the controlled evaluator failure",
})
.toBeGreaterThan(0);
await signInAsTeacher(page);
await expect
.poll(async () => evaluationState(page, first.session_id), {
timeout: 30_000,
intervals: [250, 500, 1_000],
message: "the evaluator HTTP 500 must become a durable error row",
})
.toMatch(/^error:.*:durable$/);
const reviewResponse = await page.request.get(`/api/sessions/${first.session_id}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.reviewReady).toBe(false);
expect(review.degraded).toBe(true);
expect(review.supervisorState).toBe("평가 실패");
expect(review.summary ?? "").toContain("AI 평가 재시도가 필요합니다");
await page.goto(`/teach/session/${first.session_id}/review`);
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible({
timeout: 15_000,
});
const evaluatorRequestsBeforeRetry = gateway.evaluatorRequests();
const retryResponse = await page.request.post(
`/api/eval/sessions/${first.session_id}/reevaluate`,
{ data: { scope: "session_end" } },
);
expect(retryResponse.status()).toBe(503);
await expect
.poll(() => gateway.evaluatorRequests(), { timeout: 10_000 })
.toBeGreaterThan(evaluatorRequestsBeforeRetry);
await expect
.poll(async () => evaluationState(page, first.session_id), {
timeout: 10_000,
})
.toMatch(/^error:.*:durable$/);
} finally {
try {
await signInAsAdmin(page);
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
reasoning_effort: currentEngine.reasoning_effort,
},
});
await expectResponseOk(restoreResponse);
} finally {
await gateway.close();
}
}
});
});
test("finishes session end evaluation into a durable DB-backed teacher review @single-run", async ({
page,
}) => {

View file

@ -5,14 +5,25 @@ import type {
UserPrepostMeasuresResponse,
} from "../src/lib/api";
export const FILLED_REVIEW_SESSION_ID = "filled-review-visual";
export const EMPTY_REVIEW_SESSION_ID = "empty-review-visual";
export const FILLED_REVIEW_SESSION_ID =
"00000000-0000-4000-8000-000000000110";
export const EMPTY_REVIEW_SESSION_ID =
"00000000-0000-4000-8000-000000000111";
export const FILLED_REVIEW_TURN_IDS = [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002",
"00000000-0000-4000-8000-000000000003",
"00000000-0000-4000-8000-000000000004",
"00000000-0000-4000-8000-000000000005",
"00000000-0000-4000-8000-000000000006",
] as const;
export function filledReviewResponse(
sessionId: string = FILLED_REVIEW_SESSION_ID,
): SessionReviewResponse {
return {
session_id: sessionId,
learnerFeedbackEnabled: true,
client: {
name: "서연",
initial: "서",
@ -54,6 +65,7 @@ export function filledReviewResponse(
turns: [
{
id: "t1",
turn_id: FILLED_REVIEW_TURN_IDS[0],
ts: "03:12",
speaker: "client",
who: "서연",
@ -64,6 +76,7 @@ export function filledReviewResponse(
},
{
id: "t2",
turn_id: FILLED_REVIEW_TURN_IDS[1],
ts: "05:04",
speaker: "learner",
who: "나",
@ -86,6 +99,7 @@ export function filledReviewResponse(
},
{
id: "t3",
turn_id: FILLED_REVIEW_TURN_IDS[2],
ts: "12:41",
speaker: "client",
who: "서연",
@ -96,6 +110,7 @@ export function filledReviewResponse(
},
{
id: "t4",
turn_id: FILLED_REVIEW_TURN_IDS[3],
ts: "16:05",
speaker: "learner",
who: "나",
@ -115,6 +130,7 @@ export function filledReviewResponse(
},
{
id: "t5",
turn_id: FILLED_REVIEW_TURN_IDS[4],
ts: "24:18",
speaker: "learner",
who: "나",
@ -134,6 +150,7 @@ export function filledReviewResponse(
},
{
id: "t6",
turn_id: FILLED_REVIEW_TURN_IDS[5],
ts: "29:52",
speaker: "client",
who: "서연",
@ -224,6 +241,41 @@ export function filledReviewResponse(
"위험도 평가는 별도 안전평가 결과와 함께 확인해야 합니다.",
],
},
firstSessionChecklist: {
version: "first-session-rapport-open-question.v1",
applicable: true,
status: "ready",
title: "첫 회기 라포·개방질문 체크리스트",
note: "축어록에서 관찰 가능한 대화 행동을 규칙 기반으로 점검하며, 임상 평가나 성적 판정이 아닙니다.",
criteria: [
{
criterionId: "first-session.rapport-reflection",
label: "라포 형성 반영",
description: "내담자의 감정이나 경험을 평가 없이 되짚는 발화를 확인합니다.",
status: "met",
evidenceTurns: [
{
turnId: "t2",
turnSeq: 2,
quote: "혼자서 버티고 있었다는 느낌이 들어요.",
},
],
},
{
criterionId: "first-session.open-question-one-focus",
label: "한 초점 개방질문",
description: "한 번에 한 초점을 묻는 개방형 질문 발화를 확인합니다.",
status: "met",
evidenceTurns: [
{
turnId: "t2",
turnSeq: 2,
quote: "비교가 반복될수록 스스로를 의심하게 됐던 걸까요?",
},
],
},
],
},
nextLine:
"그 말을 들은 바로 그 순간, 몸에서는 어떤 반응이 먼저 올라왔나요?",
clientFeedback:

View file

@ -603,6 +603,34 @@ test.describe("session review", () => {
await expect(page.getByText("방어를 낮춘 감정 반영")).toBeVisible();
await expect(page.getByText("개입 전 준비도 확인")).toBeVisible();
await expect(page.locator(".sr-chart__svg")).toBeVisible();
const firstSessionChecklist = page.locator(
".sr-card--first-session-checklist",
);
await expect(firstSessionChecklist).toContainText(
"첫 회기 라포·개방질문 체크리스트",
);
await expect(firstSessionChecklist).toContainText(
"first-session-rapport-open-question.v1",
);
await expect(firstSessionChecklist).toContainText("라포 형성 반영");
await expect(firstSessionChecklist).toContainText("한 초점 개방질문");
await expect(firstSessionChecklist.getByText("근거 확인")).toHaveCount(2);
await expect(firstSessionChecklist).toContainText(
"임상 평가나 성적 판정이 아닙니다.",
);
const checklistEvidence = firstSessionChecklist.getByRole("button", {
name: "라포 형성 반영 근거 2번 발화로 이동",
});
await expect(checklistEvidence).toBeVisible();
const checklistEvidenceHeight = await checklistEvidence.evaluate((button) =>
Math.ceil(button.getBoundingClientRect().height),
);
expect(checklistEvidenceHeight).toBeGreaterThanOrEqual(44);
await checklistEvidence.click();
await expect(page.locator("#sr-panel-transcript")).toBeVisible();
await expect(page.locator(".sr-turn--active")).toContainText(
"혼자서 버티고 있었다는 느낌",
);
await openReviewTab(page, "워크시트");
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expect(page.locator(".sr-ws-input").first()).toBeVisible();
@ -612,31 +640,35 @@ test.describe("session review", () => {
await expectNoHorizontalOverflow(page);
});
test("keeps masked API transcript while naturalizing transcript placeholders", async ({
test("renders role-safe counselor and client tokens without exposing mask syntax", async ({
page,
}) => {
await routeLearnerReviewUser(page);
await routePrepostMeasures(page);
const sessionId = "review-placeholder-display";
const sessionId = "00000000-0000-4000-8000-000000000112";
const response = filledReviewResponse(sessionId);
response.turns[0] = {
...response.turns[0],
text: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.",
text: "저는 [CLIENT]이고 [ORG]에서 상담받고 있어요.",
};
response.turns[1] = {
...response.turns[1],
text: "[COUNSELOR]가 [CLIENT]의 말을 천천히 되짚었습니다.",
};
response.summary =
"평가 AI는 [NAME]가 [ORG]에서 보인 조심스러운 반응을 분석했습니다.";
"평가 AI는 [CLIENT]가 [ORG]에서 보인 반응과 [COUNSELOR]의 반영을 분석했습니다.";
response.goodMoments[0] = {
...response.goodMoments[0],
title: "[NAME]가 보인 강점",
body: "[ORG] 장면에서 내담자 [NAME]을 기다렸습니다.",
title: "[COUNSELOR]가 보인 강점",
body: "[ORG] 장면에서 [CLIENT] 반응을 기다렸습니다.",
};
response.growthPoints[0] = {
...response.growthPoints[0],
title: "[NAME] 반응을 서두르지 않기",
body: "[ORG] 장면에서 내담자 [NAME]을 한 번 더 확인합니다.",
body: "[ORG] 장면에서 익명 인물의 반응을 한 번 더 확인합니다.",
};
response.nextLine = "[NAME]의 말을 [ORG] 맥락에서 다시 확인할까요?";
response.clientFeedback = "[NAME] 이야기를 [ORG]에서 안전하게 다뤘어요.";
response.nextLine = "[CLIENT]의 말을 [ORG] 맥락에서 다시 확인할까요?";
response.clientFeedback = "[CLIENT] 이야기를 [ORG]에서 안전하게 다뤘어요.";
if (!response.caseWorksheet) {
throw new Error("filled review fixture must include a worksheet");
}
@ -644,7 +676,7 @@ test.describe("session review", () => {
{
turnId: "t1",
speaker: "client",
quote: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.",
quote: "저는 [CLIENT]이고 [ORG]에서 상담받고 있어요.",
},
];
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
@ -660,37 +692,48 @@ test.describe("session review", () => {
const reviewResponse = await reviewResponsePromise;
await expectResponseOk(reviewResponse);
const apiReview = await reviewResponse.json();
expect(apiReview.turns[0].text).toContain("[NAME]");
expect(apiReview.turns[0].text).toContain("[CLIENT]");
expect(apiReview.turns[1].text).toContain("[COUNSELOR]");
expect(apiReview.growthPoints[0].title).toContain("[NAME]");
const transcript = page.locator(".sr-card--transcript");
await expect(transcript).toContainText(
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
"저는 내담자이고 소속 기관에서 상담받고 있어요.",
);
await expect(transcript).toContainText(
"상담자가 내담자의 말을 천천히 되짚었습니다.",
);
await expect(transcript).not.toContainText("[NAME]");
await expect(transcript).not.toContainText("[COUNSELOR]");
await expect(transcript).not.toContainText("[CLIENT]");
const summary = page.locator(".sr-summary");
await expect(summary).toContainText(
"평가 AI는 익명 내담자가 소속 기관에서 보인 조심스러운 반응을 분석했습니다.",
"평가 AI는 내담자가 소속 기관에서 보인 반응과 상담자의 반영을 분석했습니다.",
);
await expect(summary).not.toContainText("[NAME]");
await expect(summary).not.toContainText("[ORG]");
await openReviewTab(page, "피드백");
const feedback = page.locator("#sr-panel-feedback");
await expect(feedback).toContainText("익명 내담자가 보인 강점");
await expect(feedback).toContainText("담자가 보인 강점");
await expect(feedback).toContainText("소속 기관 장면에서 내담자 반응을 기다렸습니다.");
await expect(feedback).toContainText("익명 인물 반응을 서두르지 않기");
await expect(feedback).toContainText(
"익명 내담자의 말을 소속 기관 맥락에서 다시 확인할까요?",
"내담자의 말을 소속 기관 맥락에서 다시 확인할까요?",
);
await expect(feedback).not.toContainText("[NAME]");
await expect(feedback).not.toContainText("[COUNSELOR]");
await expect(feedback).not.toContainText("[CLIENT]");
await expect(feedback).not.toContainText("[ORG]");
await openReviewTab(page, "워크시트");
const worksheetPanel = page.locator("#sr-panel-worksheet");
const worksheetEvidence = worksheetPanel.locator(".sr-ws-evidence").filter({
hasText: "저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
hasText: "저는 내담자이고 소속 기관에서 상담받고 있어요.",
});
await expect(worksheetEvidence).toContainText(
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
"저는 내담자이고 소속 기관에서 상담받고 있어요.",
);
await expect(worksheetPanel).not.toContainText("[NAME]");
await expect(worksheetPanel).not.toContainText("[CLIENT]");
await expect(worksheetPanel).not.toContainText("[ORG]");
});
@ -726,4 +769,288 @@ test.describe("session review", () => {
await expect(prepostCard.getByRole("alert")).toContainText("기존 값을 비우려면 새 점수를 입력해 주세요.");
await expect(prepostCard.getByRole("button", { name: "점수 저장" })).toBeDisabled();
});
test("keeps learner inputs and privacy controls while disabled AI feedback makes no derived requests", async ({
page,
}) => {
await routeLearnerReviewUser(page);
await routePrepostMeasures(page);
const sessionId = "feedback-disabled-review";
const base = filledReviewResponse(sessionId);
const derivedRequests: string[] = [];
const derivedRequestCounts = {
rupture: 0,
outcomeTrajectory: 0,
deliberatePractice: 0,
calibrationSupervisor: 0,
multimodalDerived: 0,
sessionEvaluation: 0,
liveCoach: 0,
share: 0,
};
let calibrationInputReads = 0;
let multimodalPrivacyReads = 0;
page.on("request", (request) => {
const url = new URL(request.url());
const path = url.pathname;
if (path.includes(`/sessions/${sessionId}/ruptures`)) {
derivedRequestCounts.rupture += 1;
}
if (path.includes(`/sessions/${sessionId}/outcome-trajectory`)) {
derivedRequestCounts.outcomeTrajectory += 1;
}
if (path.endsWith("/practice/learners/me") || path.includes("/practice/attempts/")) {
derivedRequestCounts.deliberatePractice += 1;
}
if (
path.includes("/internal/") &&
(path.includes("/calibration/") || path.includes("/transfer-suites")) ||
path.endsWith("/calibration/reviews") ||
/\/calibration\/learners\/(?!me$)/.test(path)
) {
derivedRequestCounts.calibrationSupervisor += 1;
}
if (
url.pathname.endsWith(`/sessions/${sessionId}/multimodal-alliance`) &&
url.searchParams.get("include_derived") !== "false"
) {
derivedRequestCounts.multimodalDerived += 1;
}
if (path.includes(`/eval/sessions/${sessionId}`)) {
derivedRequestCounts.sessionEvaluation += 1;
}
if (path.includes(`/sessions/${sessionId}/live-coach`)) {
derivedRequestCounts.liveCoach += 1;
}
if (path.endsWith(`/sessions/${sessionId}/share`)) {
derivedRequestCounts.share += 1;
}
if (Object.values(derivedRequestCounts).some((count) => count > 0)) {
derivedRequests.push(`${request.method()} ${url.pathname}${url.search}`);
}
});
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...base,
sessionNo: 2,
learnerFeedbackEnabled: false,
reviewReady: false,
degraded: false,
supervisorState: "피드백 비활성",
summary:
"관리자가 이 계정의 AI 학습 피드백을 비활성화했습니다. 회기 축어록과 학습자 입력은 그대로 보존됩니다.",
caseWorksheet: {
...base.caseWorksheet,
status: "saved_by_learner",
generatedBy: "learner",
savedAt: "2026-08-27T01:02:03Z",
sections: [
{
key: "learner-notes",
title: "내 기록",
items: [
{
key: "presenting-complaint",
label: "주호소",
value: "학습자가 직접 저장한 원문",
evidence: [],
confidence: "none",
emptyReason: null,
},
],
},
],
limitations: ["학습자 작성"],
},
}),
});
});
await page.route("**/api/calibration/learners/me**", async (route) => {
calibrationInputReads += 1;
const url = new URL(route.request().url());
expect(url.searchParams.get("session_id")).toBe(sessionId);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
learner_id: "00000000-0000-0000-0000-000000000101",
requested_view: "learner",
clinical_claim_allowed: false,
prediction_histories: [],
calibration_assessments: [],
transfer_suites: [],
teacher_reviews: [],
actual_executions: [],
actual_transfer_assessments: [],
}),
});
});
await page.route(
`**/api/sessions/${sessionId}/multimodal-alliance**`,
async (route) => {
const url = new URL(route.request().url());
if (url.pathname.includes("/raw-audio/")) {
await route.fulfill({
status: 200,
contentType: "audio/webm",
body: "",
});
return;
}
if (url.pathname.endsWith("/raw-audio")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
audio_asset_id: "00000000-0000-4000-8000-000000000901",
session_id: sessionId,
media_type: "audio/webm",
byte_size: 8192,
duration_ms: 12000,
retained_until: "2026-09-26T01:02:03Z",
created_at: "2026-08-27T01:02:03Z",
},
],
}),
});
return;
}
multimodalPrivacyReads += 1;
expect(url.searchParams.get("include_derived")).toBe("false");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
learner_id: "00000000-0000-0000-0000-000000000101",
clinical_claim_allowed: false,
consent_snapshots: [
{
consent_snapshot_id: "00000000-0000-4000-8000-000000000902",
sequence_no: 1,
consent_status: "granted",
retain_audio: true,
retain_derived_features: false,
transcript_retained: true,
retention_days: 30,
policy_version: "vignette.multimodal-consent.v1",
reason_code: null,
created_at: "2026-08-27T01:02:03Z",
},
],
timelines: [],
word_timestamps: [],
voice_events: [],
measurements: [],
fusion_decisions: [],
deletion_requests: [],
}),
});
},
);
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
pulse_id: "00000000-0000-4000-8000-000000000903",
checkpoint: "post",
status: "ready",
learner_locked_at: "2026-08-27T01:02:03Z",
revealed_at: "2026-08-27T01:03:03Z",
error_code: null,
self_scores: { goal: 0.8, task: 0.6, bond: 1.0 },
measurements: [
{
perspective: "independent_observer",
rationale: "학습자에게 노출되면 안 되는 AI 비밀 근거",
evidence: [{ text: "AI 비밀 근거" }],
},
],
},
],
}),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: "공유 URL 복사" })).toHaveCount(0);
await expect(page.locator(".ap-card")).toContainText("저장한 내 치료 동맹 자기평가");
await expect(page.locator(".ap-card")).toContainText("목표80점");
await expect(page.locator(".ap-comparison")).toHaveCount(0);
await expect(page.getByText("AI 비밀 근거", { exact: false })).toHaveCount(0);
await openReviewTab(page, "피드백");
await expect(
page.getByRole("heading", {
name: "내가 관찰한 현재 상태를 직접 기록합니다",
}),
).toBeVisible();
await expect(
page.getByRole("heading", {
name: "먼저 예측하고 잠그는 내 기록만 보존합니다",
}),
).toBeVisible();
await expect(
page.getByRole("heading", {
name: "동의·보존·삭제 범위를 직접 관리합니다",
}),
).toBeVisible();
await expect(page.getByText(/학습자 본인 계정에서만 접근 가능/)).toBeVisible();
await expect(page.locator(".rr-card, .dp-card")).toHaveCount(0);
await expect(
page.locator(
".ot-timeline-wrap, .ct-calibration, .ct-transfer, .mma-clock, .mma-axis-board",
),
).toHaveCount(0);
await expect(
page.locator(
".sr-card--chart, .sr-card--flow, .sr-card--rubric, .sr-card--first-session-checklist, .sr-feedback",
),
).toHaveCount(0);
const privacyDelete = page.getByRole("button", {
name: "선택 데이터 삭제 요청",
});
await privacyDelete.scrollIntoViewIfNeeded();
const privacyDeleteHeight = await privacyDelete.evaluate((button) =>
Math.ceil(button.getBoundingClientRect().height),
);
expect(privacyDeleteHeight).toBeGreaterThanOrEqual(44);
await expectNoHorizontalOverflow(page);
await openReviewTab(page, "워크시트");
await expect(page.locator(".sr-ws-input").first()).toHaveValue(
"학습자가 직접 저장한 원문",
);
await openReviewTab(page, "축어록");
await expect(page.locator(".sr-card--transcript")).toContainText(
"다른 친구들이랑 비교할 때마다",
);
await expectNoHorizontalOverflow(page);
expect(calibrationInputReads).toBeGreaterThanOrEqual(1);
expect(multimodalPrivacyReads).toBeGreaterThanOrEqual(1);
expect(derivedRequestCounts).toEqual({
rupture: 0,
outcomeTrajectory: 0,
deliberatePractice: 0,
calibrationSupervisor: 0,
multimodalDerived: 0,
sessionEvaluation: 0,
liveCoach: 0,
share: 0,
});
expect(derivedRequests).toEqual([]);
});
});

View file

@ -28,10 +28,9 @@ function uniqueE2EEmail(prefix: string, domain: string) {
return `${prefix}.${Date.now()}.${e2eEmailCounter}@${domain}`;
}
export async function withGlobalEngineConfigLock<T>(
export async function acquireGlobalEngineConfigLock(
label: string,
action: () => Promise<T>,
): Promise<T> {
): Promise<() => Promise<void>> {
const dir = path.join(process.cwd(), "node_modules", ".tmp");
const lockPath = path.join(dir, "engine-config.lock");
const startedAt = Date.now();
@ -43,11 +42,18 @@ export async function withGlobalEngineConfigLock<T>(
const handle = await fs.open(lockPath, "wx");
try {
await handle.writeFile(`${process.pid} ${label} ${new Date().toISOString()}`);
return await action();
} finally {
} catch (err) {
await handle.close().catch(() => undefined);
await fs.unlink(lockPath).catch(() => undefined);
throw err;
}
let released = false;
return async () => {
if (released) return;
released = true;
await handle.close().catch(() => undefined);
await fs.unlink(lockPath).catch(() => undefined);
};
} catch (err) {
const code =
typeof err === "object" && err !== null && "code" in err
@ -68,6 +74,18 @@ export async function withGlobalEngineConfigLock<T>(
}
}
export async function withGlobalEngineConfigLock<T>(
label: string,
action: () => Promise<T>,
): Promise<T> {
const release = await acquireGlobalEngineConfigLock(label);
try {
return await action();
} finally {
await release();
}
}
const E2E_COHORT_ID = "e2e-hanshin";
export async function signInAsLearner(page: Page) {

View file

@ -88,7 +88,7 @@ async function routeLoginScreen(page: Page) {
await page.route("**/auth/config", (route) =>
route.fulfill(
jsonRoute({
allowed_email_domains: ["hs.ac.kr"],
allowed_email_domains: [],
dev_login_enabled: false,
google_oauth_configured: true,
providers: [
@ -905,7 +905,7 @@ async function openLogin(page: Page) {
await routeLoginScreen(page);
await page.goto("/login");
await expect(page.locator(".lg-root, .lg-enter").first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^Google 계정으로 계속/ })).toBeVisible();
}
async function openLearnerHome(page: Page) {
@ -974,12 +974,12 @@ test.describe("uc a11y-mobile — 주요 6화면 접근성·모바일 감사", (
});
// usecase: 저시력 사용자가 주 로그인 버튼의 문구를 읽는다
test("로그인 주 CTA(학교 Google 계정으로 계속)의 색 대비가 4.5:1 이상이다", async ({ page }) => {
test("로그인 주 CTA(Google 계정으로 계속)의 색 대비가 4.5:1 이상이다", async ({ page }) => {
await openLogin(page);
await expectCtaContrast(
page.getByRole("button", { name: /학교 Google 계정으로 계속/ }),
page.getByRole("button", { name: /^Google 계정으로 계속/ }),
"login",
"학교 Google 계정으로 계속",
"Google 계정으로 계속",
);
});

View file

@ -39,6 +39,7 @@ interface FixtureUser {
display_name: string;
role: "learner" | "teacher" | "admin";
admin_access: boolean;
learner_feedback_enabled: boolean;
super_admin: boolean;
account_status: "pending" | "approved" | "suspended";
affiliation: string;
@ -55,6 +56,7 @@ function makeUser(overrides: Partial<FixtureUser> & { user_id: string }): Fixtur
display_name: overrides.user_id,
role: "learner",
admin_access: false,
learner_feedback_enabled: true,
super_admin: false,
account_status: "approved",
affiliation: "",
@ -90,6 +92,43 @@ interface FixtureTicket {
updated_at: number;
}
interface FixtureProtocol {
protocol_id: string;
source_id: string;
title: string;
source: string;
version: number;
license: "A" | "B" | "C" | "D";
external_llm_ok: boolean;
content: string;
content_hash: string;
status: "draft" | "active" | "retired";
registered_by: string;
registered_at: string;
activated_at: string | null;
retired_at: string | null;
}
function makeProtocol(
overrides: Partial<FixtureProtocol> & { protocol_id: string; title: string },
): FixtureProtocol {
return {
source_id: `protocol:${overrides.protocol_id}`,
source: "https://example.edu/protocols/default",
version: 1,
license: "B",
external_llm_ok: false,
content: "위험도를 먼저 확인한다.",
content_hash: "a".repeat(64),
status: "draft",
registered_by: "uc-admin-1",
registered_at: CREATED_AT,
activated_at: null,
retired_at: null,
...overrides,
};
}
function makeTicket(
overrides: Partial<FixtureTicket> & { ticket_id: string; subject: string },
): FixtureTicket {
@ -140,6 +179,8 @@ function ticketsSummary(tickets: FixtureTicket[]) {
interface AdminMockOptions {
users?: FixtureUser[];
tickets?: FixtureTicket[];
protocols?: FixtureProtocol[];
protocolActivationFailures?: number;
services?: Array<{
key: string;
name: string;
@ -164,6 +205,10 @@ interface AdminMockOptions {
async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
const users = options.users ?? [];
let tickets = options.tickets ?? [];
let protocols = options.protocols ?? [];
let protocolActivationFailures = options.protocolActivationFailures ?? 0;
const createdUserBodies: Array<Record<string, unknown>> = [];
const createdProtocolBodies: Array<Record<string, unknown>> = [];
const patchedUserBodies: Array<Record<string, unknown>> = [];
const patchedTicketBodies: Array<Record<string, unknown>> = [];
@ -218,6 +263,101 @@ async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
return;
}
if (method === "GET" && path.endsWith("/admin/protocols")) {
await fulfill({ protocols, total: protocols.length });
return;
}
if (method === "POST" && path.endsWith("/admin/protocols")) {
const body = request.postDataJSON() as Record<string, unknown>;
createdProtocolBodies.push(body);
const protocolId = `71000000-0000-4000-8000-${String(
createdProtocolBodies.length,
).padStart(12, "0")}`;
const created = makeProtocol({
protocol_id: protocolId,
title: String(body.title ?? ""),
source: String(body.source ?? ""),
version: Number(body.version ?? 1),
license: (body.license as FixtureProtocol["license"] | undefined) ?? "B",
external_llm_ok: body.external_llm_ok === true,
content: String(body.content ?? ""),
content_hash: "b".repeat(64),
});
protocols = [created, ...protocols];
await fulfill(created, 201);
return;
}
const protocolAction = /\/admin\/protocols\/([^/]+)\/(activate|retire)$/.exec(path);
if (method === "POST" && protocolAction) {
const [, protocolId, action] = protocolAction;
const index = protocols.findIndex((item) => item.protocol_id === protocolId);
if (index === -1) {
await fulfill({ detail: "unknown protocol" }, 404);
return;
}
if (action === "activate") {
if (protocolActivationFailures > 0) {
protocolActivationFailures -= 1;
await fulfill({ detail: "vector unavailable" }, 503);
return;
}
if (protocols[index].status !== "draft") {
await fulfill({ detail: "draft required" }, 409);
return;
}
protocols[index] = {
...protocols[index],
status: "active",
activated_at: CREATED_AT,
};
await fulfill({
protocol: protocols[index],
chunks_indexed: 2,
skipped_unchanged: false,
embedded: true,
degraded: false,
});
return;
}
if (protocols[index].status !== "active") {
await fulfill({ detail: "active required" }, 409);
return;
}
protocols[index] = {
...protocols[index],
status: "retired",
retired_at: CREATED_AT,
};
await fulfill(protocols[index]);
return;
}
if (method === "POST" && path.endsWith("/admin/users")) {
const body = request.postDataJSON() as Record<string, unknown>;
createdUserBodies.push(body);
const created = makeUser({
user_id: `preregistered-${createdUserBodies.length}`,
email: String(body.email ?? ""),
display_name: String(body.display_name ?? ""),
role: (body.role as FixtureUser["role"] | undefined) ?? "learner",
admin_access: Boolean(body.admin_access),
learner_feedback_enabled: body.learner_feedback_enabled !== false,
account_status:
(body.account_status as FixtureUser["account_status"] | undefined) ?? "pending",
affiliation: String(body.affiliation ?? ""),
cohort_ids: Array.isArray(body.cohort_ids)
? body.cohort_ids.map((value) => String(value))
: [],
created_at: NOW,
last_seen_at: NOW,
});
users.unshift(created);
await fulfill(created, 201);
return;
}
const userPatch = /\/admin\/users\/([^/]+)$/.exec(path);
if (method === "PATCH" && userPatch) {
const body = request.postDataJSON() as Record<string, unknown>;
@ -369,7 +509,12 @@ async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
await fulfill({ detail: `unmocked API request: ${method} ${path}` }, 404);
});
return { patchedUserBodies, patchedTicketBodies };
return {
createdUserBodies,
createdProtocolBodies,
patchedUserBodies,
patchedTicketBodies,
};
}
/** G8 지속 개선 콕핏 fixture — 모델 게이트는 증거 2종만, 릴리스 게이트는 4종 완비. */
@ -1041,4 +1186,260 @@ test.describe("관리자 콘솔 유스케이스 여정", () => {
await page.getByLabel("사용자 검색").fill("");
await expect(rows).toHaveCount(3);
});
// usecase: 관리자가 외부 연구참여자를 exact-email로 사전등록하고 별도로 승인한다
test("외부 연구참여자 사전등록은 pending으로 생성되고 승인 큐를 거친다", async ({
page,
}) => {
const { createdUserBodies, patchedUserBodies } = await installAdminMock(page, {
users: [],
});
await page.goto("/admin/users");
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
await expect(
page.getByRole("heading", { name: "외부 연구참여자 사전등록" }),
).toBeVisible();
await expect(page.getByText("정확한 이메일 주소로만 Google·개발 로그인을 통과"))
.toBeVisible();
const fixedApprovalStatus = page.getByLabel("새 사용자 승인 상태");
await expect(fixedApprovalStatus).toHaveAttribute("data-value", "pending");
await expect(fixedApprovalStatus).toHaveText("승인 대기");
await expect(
page.getByRole("combobox", { name: "새 사용자 승인 상태" }),
).toHaveCount(0);
const exactEmail = "participant.exact@gmail.com";
await page.getByLabel("새 사용자 이메일").fill(exactEmail);
await page.getByLabel("새 사용자 표시 이름").fill("외부 연구참여자");
await page.getByLabel("새 사용자 코호트").fill("external-study-2026");
const createRequest = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname.endsWith("/admin/users"),
);
await page.getByRole("button", { name: "연구참여자 사전등록" }).click();
const request = await createRequest;
expect(request.postDataJSON()).toMatchObject({
email: exactEmail,
display_name: "외부 연구참여자",
role: "learner",
account_status: "pending",
cohort_ids: ["external-study-2026"],
});
expect(createdUserBodies).toHaveLength(1);
await expect(page.getByRole("tab", { name: "사용자 목록" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
await expect(page.getByLabel("새 사용자 승인 상태")).toHaveAttribute(
"data-value",
"pending",
);
const secondEmail = "participant.second@outlook.com";
await page.getByLabel("새 사용자 이메일").fill(secondEmail);
await page.getByLabel("새 사용자 표시 이름").fill("두 번째 연구참여자");
const secondCreateRequest = page.waitForRequest(
(nextRequest) =>
nextRequest.method() === "POST" &&
new URL(nextRequest.url()).pathname.endsWith("/admin/users"),
);
await page.getByRole("button", { name: "연구참여자 사전등록" }).click();
const secondRequest = await secondCreateRequest;
expect(secondRequest.postDataJSON()).toMatchObject({
email: secondEmail,
display_name: "두 번째 연구참여자",
role: "learner",
account_status: "pending",
});
expect(createdUserBodies).toHaveLength(2);
expect(createdUserBodies.every((body) => body.account_status === "pending")).toBe(true);
await page.getByRole("tab", { name: "가입 승인 2" }).click();
const card = page.locator(".vgops-approval").filter({ hasText: exactEmail });
await expect(card).toContainText("승인 대기");
await card.getByRole("button", { name: "승인" }).click();
const secondCard = page.locator(".vgops-approval").filter({ hasText: secondEmail });
await expect(secondCard).toContainText("승인 대기");
await secondCard.getByRole("button", { name: "승인" }).click();
expect(patchedUserBodies).toContainEqual({
user_id: "preregistered-1",
account_status: "approved",
});
expect(patchedUserBodies).toContainEqual({
user_id: "preregistered-2",
account_status: "approved",
});
await expect(page.getByText("처리할 가입 요청이 없습니다")).toBeVisible();
});
test("사용자별 학습자 AI 피드백을 끄고 관리자 API에 저장한다", async ({ page }) => {
const user = makeUser({
user_id: "feedback-policy-user",
email: "feedback-policy@hs.ac.kr",
display_name: "피드백 정책 대상",
});
const { patchedUserBodies } = await installAdminMock(page, { users: [user] });
await page.goto("/admin/users");
await page.getByRole("tab", { name: "사용자 목록" }).click();
const row = page
.locator(".vgops-user-table tbody tr")
.filter({ hasText: user.email });
const tableScroll = page.locator(".vgops-user-table-scroll");
await expect
.poll(() =>
tableScroll.evaluate(
(element) => element.scrollWidth > element.clientWidth,
),
)
.toBe(true);
const toggle = row.getByLabel(`${user.email} 학습자 AI 피드백`);
await expect(toggle).toBeChecked();
await toggle.focus();
await toggle.press("Space");
await expect(toggle).not.toBeChecked();
const patchRequest = page.waitForRequest(
(request) =>
request.method() === "PATCH" &&
request.url().includes(`/admin/users/${user.user_id}`),
);
await row.getByRole("button", { name: "저장" }).click();
const request = await patchRequest;
expect(request.postDataJSON()).toMatchObject({ learner_feedback_enabled: false });
expect(patchedUserBodies.at(-1)).toMatchObject({
user_id: user.user_id,
learner_feedback_enabled: false,
});
await expect(toggle).not.toBeChecked();
});
test("상담 프로토콜을 초안 등록하고 활성화한 뒤 퇴역한다", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const { createdProtocolBodies } = await installAdminMock(page, { protocols: [] });
await page.goto("/admin/access");
const protocolTab = page.getByRole("tab", { name: "상담 프로토콜" });
await protocolTab.focus();
await protocolTab.press("Enter");
await expect(page.getByRole("heading", { name: "상담 프로토콜 초안 등록" }))
.toBeVisible();
const title = "초기면담 위기 확인 프로토콜";
await page.getByLabel("프로토콜 제목").fill(title);
await page
.getByLabel("프로토콜 출처")
.fill("https://example.edu/protocols/intake-v2");
await page.getByLabel("프로토콜 버전").fill("0");
await page.getByLabel("프로토콜 라이선스").selectOption("B");
await page.getByLabel("외부 LLM 사용 허용").check();
await page
.getByLabel("프로토콜 원문")
.fill("자해 위험과 즉시 안전 여부를 먼저 확인한다.");
await page.getByRole("button", { name: "초안 등록", exact: true }).click();
await expect(page.getByRole("alert")).toContainText(
"버전은 1부터 1,000,000 사이의 정수여야 합니다.",
);
expect(createdProtocolBodies).toHaveLength(0);
await page.getByLabel("프로토콜 버전").fill("2");
const createRequest = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname.endsWith("/admin/protocols"),
);
await page.getByRole("button", { name: "초안 등록", exact: true }).click();
const request = await createRequest;
expect(request.postDataJSON()).toMatchObject({
title,
version: 2,
license: "B",
external_llm_ok: true,
});
expect(createdProtocolBodies).toHaveLength(1);
const card = page.locator(".vgops-protocol-card").filter({ hasText: title });
await expect(card).toContainText("초안");
const activate = card.getByRole("button", { name: `${title} 활성화` });
await activate.focus();
await activate.press("Enter");
await expect(card).toContainText("활성");
const retire = card.getByRole("button", { name: `${title} 퇴역` });
await retire.focus();
await retire.press("Enter");
await expect(card).toContainText("퇴역");
await expect(card.getByText("변경 불가")).toBeVisible();
});
test("모바일에서 C/D 외부 사용을 차단하고 활성화 오류를 복구한다", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 568 });
const protocol = makeProtocol({
protocol_id: "71000000-0000-4000-8000-000000000099",
title: "모바일 복구 프로토콜",
license: "A",
external_llm_ok: true,
});
await installAdminMock(page, {
protocols: [protocol],
protocolActivationFailures: 1,
});
await page.goto("/admin/access");
const protocolTab = page.getByRole("tab", { name: "상담 프로토콜" });
await protocolTab.click();
await expect(protocolTab).toHaveAttribute("aria-selected", "true");
await page.getByLabel("프로토콜 라이선스").selectOption("C");
const externalToggle = page.getByLabel("외부 LLM 사용 허용");
await expect(externalToggle).toBeDisabled();
await expect(externalToggle).not.toBeChecked();
await expect(page.getByText("라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."))
.toBeVisible();
const card = page.locator(".vgops-protocol-card").filter({
hasText: protocol.title,
});
const activate = card.getByRole("button", { name: `${protocol.title} 활성화` });
await activate.click();
await expect(page.getByRole("alert")).toContainText("vector unavailable");
await activate.click();
await expect(card).toContainText("활성");
await expect(page.getByRole("alert")).toHaveCount(0);
const protocolRegion = page.locator(".vgops-protocols");
await expect
.poll(() =>
protocolRegion.evaluate(
(element) => element.scrollWidth <= element.clientWidth,
),
)
.toBe(true);
await expect
.poll(() =>
page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
)
.toBe(true);
const touchTargets = page.locator(
".vgops-protocols button, .vgops-protocols input:not([type=checkbox]), " +
".vgops-protocols select, .vgops-protocols textarea, .vgops-protocol-check",
);
const targetCount = await touchTargets.count();
expect(targetCount).toBeGreaterThan(0);
for (let index = 0; index < targetCount; index += 1) {
const box = await touchTargets.nth(index).boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
expect((await protocolTab.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
});
});

View file

@ -8,7 +8,7 @@
:
- src/pages/Login.tsx + login/LoginBrand.tsx + login/LoginPanel.tsx
( , , OAuth )
( Google , , OAuth )
- src/pages/Onboarding.tsx (canSubmit · , isValidPhone,
details, source_note)
- src/pages/PendingApproval.tsx ( , )
@ -44,7 +44,7 @@ const READY_AUTH_CONFIG = {
{ 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"],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
};
@ -156,8 +156,8 @@ async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") {
}
test.describe("uc auth-onboarding", () => {
// usecase: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·허용 도메인·비치료 고지를 읽는다
test("로그인 화면에서 서비스 성격과 허용 도메인, 비치료 고지를 확인한다", async ({ page }) => {
// usecase: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·Google 계정 정책·비치료 고지를 읽는다
test("로그인 화면에서 서비스 성격과 Google 계정 정책, 비치료 고지를 확인한다", async ({ page }) => {
await routeUnmockedApi(page);
await routeUnauthenticated(page);
await routeAuthConfig(page);
@ -165,13 +165,13 @@ test.describe("uc auth-onboarding", () => {
await page.goto("/login");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
await expect(page.locator(".lg-lead")).toContainText(
"학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다.",
"이메일이 확인된 Google 계정이면 도메인이나 사전등록 없이 바로 시작할 수 있습니다.",
);
// 허용 도메인 안내: 브랜드 패널 배지 + 버튼 서브라벨.
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
await expect(page.locator(".lg-policy b").first()).toHaveText("hs.ac.kr");
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr");
// 제한 없는 Google 계정 안내: 브랜드 패널 배지 + 버튼 서브라벨.
await expect(page.locator(".lg-policy")).toContainText("Google 로그인");
await expect(page.locator(".lg-policy b")).toHaveText("모든 Google 계정");
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("모든 Google 계정");
// 비치료 고지: 교육용 도구임을 명시한다.
await expect(page.locator(".lg-note")).toContainText(

View file

@ -233,7 +233,7 @@ const READY_AUTH_CONFIG = {
login_path: "/auth/login?provider=google",
},
],
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
};
@ -500,7 +500,7 @@ test.describe("uc error-resilience-shell", () => {
await expect(page).toHaveURL(/\/login$/);
await expect(
page.getByRole("button", { name: /학교 Google 계정으로 계속/ }),
page.getByRole("button", { name: /Google 계정으로 계속/ }),
).toBeVisible();
});

View file

@ -130,6 +130,14 @@ const S_END = {
const BASE_SESSIONS = [S_ACT, S_END];
const TRAINING_EXPOSURE_CONTRACT = {
version: "training-exposure-dominant-share.v1",
minimum_completed_sessions: 4,
attention_threshold: 0.75,
definition:
"종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, 공정성 평가나 임상진단이 아닙니다.",
};
// 평가가 아직 없는 학습자 — 리뷰 0건, 평가 0회, 라포 평균 null.
const DASH_NO_EVAL = {
message: "학습 현황 요약",
@ -185,6 +193,16 @@ const DASH_NO_EVAL = {
},
],
achievements: [],
training_exposure: {
...TRAINING_EXPOSURE_CONTRACT,
status: "insufficient",
label: "판정 근거 부족",
completed_sessions: 1,
dominant_persona_code: "P2",
dominant_persona_name: "김도윤",
dominant_sessions: 1,
dominant_share: 1,
},
};
// 라포 게이지 검증용 — 서로 다른 rapport_percent(81/47)를 가진 진행 기록.
@ -240,6 +258,16 @@ const EMPTY_DASHBOARD = {
recent_feedback: [],
persona_progress: [],
achievements: [],
training_exposure: {
...TRAINING_EXPOSURE_CONTRACT,
status: "insufficient",
label: "판정 근거 부족",
completed_sessions: 0,
dominant_persona_code: null,
dominant_persona_name: null,
dominant_sessions: 0,
dominant_share: null,
},
};
// 스포트라이트(진행 중 회기) 상세 — 리캡 fetch 오류 상태를 만들지 않기 위해 고정.
@ -309,6 +337,36 @@ const DASH_BULK = {
learner_turns: 150,
client_turns: 150,
},
training_exposure: {
...TRAINING_EXPOSURE_CONTRACT,
status: "balanced",
label: "균형",
completed_sessions: 50,
dominant_persona_code: "P1",
dominant_persona_name: "성하늘",
dominant_sessions: 25,
dominant_share: 0.5,
},
};
const DASH_ATTENTION = {
...DASH_NO_EVAL,
overview: {
...DASH_NO_EVAL.overview,
total_sessions: 4,
active_sessions: 0,
completed_sessions: 4,
},
training_exposure: {
...TRAINING_EXPOSURE_CONTRACT,
status: "attention",
label: "훈련 집중 주의",
completed_sessions: 4,
dominant_persona_code: "P1",
dominant_persona_name: "성하늘",
dominant_sessions: 3,
dominant_share: 0.75,
},
};
interface FixtureOptions {
@ -366,6 +424,68 @@ test.describe("uc learner home dashboard", () => {
await expect(rapportCard).toContainText("성하늘 · 1회");
});
test("종료 회기 0회는 훈련 노출 판정 근거 부족으로 표시한다", async ({ page }) => {
await installFixtures(page, {
sessions: [],
dashboard: EMPTY_DASHBOARD,
detail: null,
});
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
hasText: "훈련 노출 점검",
});
await expect(exposure).toContainText("판정 근거 부족");
await expect(exposure).toContainText("종료 회기 0/4회");
await expect(exposure).toContainText(
"공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.",
);
});
test("종료 회기 1회는 비중과 무관하게 훈련 노출 판정을 보류한다", async ({ page }) => {
await installFixtures(page);
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
hasText: "훈련 노출 점검",
});
await expect(exposure).toContainText("판정 근거 부족");
await expect(exposure).toContainText("종료 회기 1/4회");
});
test("종료 회기 50회의 동률 노출은 균형으로 표시한다", async ({ page }) => {
await installFixtures(page, {
sessions: BULK_SESSIONS,
dashboard: DASH_BULK,
detail: null,
});
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
hasText: "훈련 노출 점검",
});
await expect(exposure).toContainText("균형");
await expect(exposure).toContainText("성하늘 25/50회 · 최다 노출 50%");
});
test("종료 회기 4회 중 3회 노출은 훈련 집중 주의로 표시한다", async ({ page }) => {
await installFixtures(page, { dashboard: DASH_ATTENTION });
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
hasText: "훈련 노출 점검",
});
await expect(exposure).toContainText("훈련 집중 주의");
await expect(exposure).toContainText("성하늘 3/4회 · 최다 노출 75%");
await expect(exposure).toContainText(
"공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.",
);
});
// usecase: 학습자가 '리뷰 대기' 메트릭 카드를 눌러 기록 화면으로 이동한다
test("리뷰 대기 메트릭 카드를 누르면 기록 화면으로 이동한다", async ({ page }) => {
await installFixtures(page);

View file

@ -22,6 +22,7 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import {
FILLED_REVIEW_SESSION_ID,
FILLED_REVIEW_TURN_IDS,
routeFilledSessionReview,
routePrepostMeasures,
} from "./session-review-fixture";
@ -297,7 +298,7 @@ function revealedMeasurements() {
: "목표와 과업을 함께 확인한 발화를 근거로 판단했습니다.",
evidence: [
{
turn_id: "t6",
turn_id: FILLED_REVIEW_TURN_IDS[5],
seq: 6,
speaker: "client",
text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
@ -504,6 +505,44 @@ test.describe("UC 치료 동맹 펄스 — 회기 중 자기점검(mid)", () =>
// ── post: 리뷰에서 잠금·공개·수정 불가 ─────────────────────────────
test.describe("UC 치료 동맹 펄스 — 리뷰(post)", () => {
test("근거 장면은 화면용 t번호가 아니라 영속 UUID로 잠근다", async ({ page }) => {
await routeReviewPage(page);
let submittedBody: Record<string, unknown> | null = null;
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
async (route) => {
if (route.request().method() === "POST") {
submittedBody = route.request().postDataJSON() as Record<string, unknown>;
await fulfillJson(
route,
{ pulse_id: "pulse-post-uuid", status: "awaiting_agents" },
202,
);
return;
}
await fulfillJson(route, { items: [] });
},
);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await pickReviewScore(page, "목표 합의 평가", "4 대체로 그렇다");
await pickReviewScore(page, "과업 합의 평가", "3 보통이다");
await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다");
await page.getByText("내 판단의 근거 장면 선택").click();
await page.getByRole("checkbox").first().check();
await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click();
await expect.poll(() => submittedBody).not.toBeNull();
expect(submittedBody).toEqual({
checkpoint: "post",
scores: { goal: 0.75, task: 0.5, bond: 1 },
evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]],
});
expect(FILLED_REVIEW_TURN_IDS[0]).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
// usecase: 리뷰에서 근거 장면 없이 세 축만 고른 학습자는 post 판단을 잠글 수 없다
test("리뷰에서 근거 장면 없이 세 축만 고르면 post 판단을 잠글 수 없다", async ({
page,

View file

@ -545,6 +545,42 @@ test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => {
expect(stream.calls).toBe(2);
});
// usecase: HTTP 200 스트림이 오류 이벤트 없이 끊겨도 성공으로 오인하지 않고 같은 발화로 회복한다
test("done 없이 스트림이 끝나면 실패로 표시하고 같은 발화 재시도를 보장한다", async ({
page,
}) => {
await routeSessionScreen(page);
const partialText = "연결이 끊기기 전 일부 응답";
const stream = await installStream(page, async (index, route) => {
if (index === 0) {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: ["event: token", `data: ${partialText}`, ""].join("\n"),
});
return;
}
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
});
await startFreshSession(page);
const composer = composerOf(page);
await composer.fill(LEARNER_TEXT);
await sendButtonOf(page).click();
const alert = page.getByRole("alert").filter({ hasText: "응답 연결이 중간에 끊겼습니다" });
await expect(alert).toBeVisible();
await expect(alert).toContainText("입력 내용은 복원했습니다");
await expect(page.locator(".sx-utt")).toHaveCount(0);
await expect(page.getByText(partialText)).toHaveCount(0);
await expect(composer).toHaveValue(LEARNER_TEXT);
await sendButtonOf(page).click();
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
await expect(alert).toHaveCount(0);
expect(stream.calls).toBe(2);
});
// usecase: 내담자가 응답을 내지 못한 턴은 빈 말풍선 없이 정직하게 끝나고 다음 발화를 이어갈 수 있다
test("빈 내담자 응답 턴은 가짜 말풍선 없이 종료되고 대화를 이어갈 수 있다", async ({
page,