개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
1145
apps/web/e2e/persona-db-lifecycle.spec.ts
Normal file
1145
apps/web/e2e/persona-db-lifecycle.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 계정으로 계속",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue