회기 집계와 기록 복귀 및 감독 근거 링크 수정
Some checks failed
API contract / OpenAPI type drift (push) Failing after 4m46s

This commit is contained in:
Yun Chan 2026-09-09 14:41:54 +09:00
parent aa951c6c4a
commit 1306c524c1
20 changed files with 4631 additions and 60 deletions

View file

@ -99,6 +99,23 @@ async function routeReviewShell(page: Page, reviewSessionId = SESSION_ID) {
);
}
function routeLearnerReadModel(
page: Page,
expectedSessionIds: readonly string[],
handler: (route: Route) => void | Promise<void>,
) {
return page.route(
(url) =>
url.pathname === "/api/calibration/learners/me" &&
url.searchParams.has("session_id"),
async (route) => {
const sessionId = new URL(route.request().url()).searchParams.get("session_id");
expect(expectedSessionIds).toContain(sessionId);
await handler(route);
},
);
}
function initialReadModel(
requestedView: "learner" | "supervisor" = "learner",
): CalibrationTransferReadModelResponse {
@ -516,7 +533,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
await routeLearnerReadModel(page, [SESSION_ID], (route) =>
fulfillReadModel(route, readModel),
);
await page.route("**/api/calibration/predictions/revisions", async (route) => {
@ -687,7 +704,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
await routeLearnerReadModel(page, [SESSION_ID], (route) =>
fulfillReadModel(route, readModel),
);
@ -805,7 +822,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page, PRACTICE_SESSION_ID);
await page.route("**/api/calibration/learners/me", async (route) => {
await routeLearnerReadModel(page, [PRACTICE_SESSION_ID], async (route) => {
readRequests += 1;
await fulfillReadModel(route, readModel);
});
@ -935,7 +952,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeAuth(page, "learner");
await routeReviewShell(page, PRACTICE_SESSION_ID);
await routeReviewShell(page, SESSION_ID);
await page.route("**/api/calibration/learners/me", (route) =>
await routeLearnerReadModel(page, [SESSION_ID, PRACTICE_SESSION_ID], (route) =>
fulfillReadModel(route, readModel),
);
await page.route("**/api/calibration/transfer-executions", async (route) => {
@ -997,7 +1014,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
await routeLearnerReadModel(page, [SESSION_ID], (route) =>
fulfillReadModel(route, readModel),
);
@ -1026,7 +1043,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", async (route) => {
await routeLearnerReadModel(page, [SESSION_ID], async (route) => {
if (responseMode === "pending") {
await firstResponse;
}

View file

@ -239,8 +239,8 @@ test.describe("uc learner practice/history", () => {
"직장 내 대인관계 갈등을 호소",
);
await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
await page.getByRole("button", { name: "완전히 새로 시작" }).click();
await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=fresh$/);
});
// usecase: 기록이 없는 신규 학습자에게 기초 난도부터 추천하되, 사용 불가
@ -438,6 +438,96 @@ test.describe("uc learner practice/history", () => {
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
});
test("검색과 리뷰 필터를 URL에 보존해 리뷰 왕복과 새로고침 뒤에도 복구한다", async ({
page,
}) => {
await installFixtures(page);
await page.goto("/learn/history?source=dashboard");
const search = page.getByLabel("기록 검색");
const chips = page.locator(".lh-history-filter");
const historyLength = await page.evaluate(() => window.history.length);
await search.pressSequentially("성하늘");
await expect(search).toHaveValue("성하늘");
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("성하늘");
await expect
.poll(() => page.evaluate(() => window.history.length))
.toBe(historyLength);
await chips.getByRole("button", { name: "리뷰 필요" }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("status"))
.toBe("review");
await expect
.poll(() => new URL(page.url()).searchParams.get("source"))
.toBe("dashboard");
const revCard = page.locator(".lh-session-card", {
hasText: "AI 피드백 대기",
});
await revCard.getByRole("button", { name: "리뷰", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
await page.goBack();
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("성하늘");
await expect
.poll(() => new URL(page.url()).searchParams.get("status"))
.toBe("review");
await expect(search).toHaveValue("성하늘");
await expect(chips.getByRole("button", { name: "리뷰 필요" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.locator(".lh-session-card")).toHaveCount(1);
await page.reload();
await expect(search).toHaveValue("성하늘");
await expect(chips.getByRole("button", { name: "리뷰 필요" })).toHaveAttribute(
"aria-pressed",
"true",
);
await search.fill("");
await chips.getByRole("button", { name: "전체" }).click();
await expect
.poll(() => new URL(page.url()).searchParams.has("q"))
.toBe(false);
await expect
.poll(() => new URL(page.url()).searchParams.has("status"))
.toBe(false);
await expect
.poll(() => new URL(page.url()).searchParams.get("source"))
.toBe("dashboard");
});
test("잘못된 상태 query는 전체 필터로 안전하게 처리한다", async ({ page }) => {
await installFixtures(page);
await page.goto("/learn/history?status=unknown&q=%EA%B9%80%EB%8F%84%EC%9C%A4&source=dashboard");
const chips = page.locator(".lh-history-filter");
await expect(page.getByLabel("기록 검색")).toHaveValue("김도윤");
await expect(chips.getByRole("button", { name: "전체" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.locator(".lh-session-card")).toHaveCount(1);
await chips.getByRole("button", { name: "리뷰 필요" }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("status"))
.toBe("review");
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("김도윤");
await expect
.poll(() => new URL(page.url()).searchParams.get("source"))
.toBe("dashboard");
});
// usecase: 종료된 기록 카드에서 다음 회기를 열어 같은 내담자의 맥락을 이어간다.
test("종료 기록의 다음 회기 이어가기 버튼이 같은 내담자 새 회기로 이동한다", async ({
page,
@ -450,7 +540,7 @@ test.describe("uc learner practice/history", () => {
await endCard
.getByRole("button", { name: "다음 회기 이어가기", exact: true })
.click();
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=continue$/);
});
// usecase: 상태 필터 칩과 검색어를 함께 걸어 조건을 좁히고, 조건이 어긋나면

View file

@ -760,9 +760,9 @@ test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => {
await resumeSession(page);
const transcript = page.locator(".sx-transcript__scroll");
// 조사까지 자연스럽게 바뀐다: [NAME]은→익명 내담자는, [NAME]이→익명 내담자가.
await expect(transcript).toContainText("익명 내담자는 제 얘기를 끝까지 듣지 않아요.");
await expect(transcript).toContainText("익명 내담자가 자꾸 저를 몰아세워요.");
// 조사까지 자연스럽게 바뀐다: [NAME]은→익명 인물은, [NAME]이→익명 인물이.
await expect(transcript).toContainText("익명 인물은 제 얘기를 끝까지 듣지 않아요.");
await expect(transcript).toContainText("익명 인물이 자꾸 저를 몰아세워요.");
await expect(transcript).not.toContainText("[NAME]");
});

View file

@ -562,6 +562,59 @@ test.describe("uc-teacher-console: 수퍼비전 attention 큐", () => {
page.locator(".pf-depth-strip .is-current"),
).toContainText("학습자 상세");
});
test("legacy 감독 근거 링크는 엄격히 교사 경로로 정규화하고 canonical 경로는 유지한다", async ({
page,
}) => {
const legacySessionId = "62000000-0000-0000-0000-000000000801";
const canonicalSessionId = "62000000-0000-0000-0000-000000000802";
await routeUnmockedApi(page);
await routeAuthedTeacher(page);
await routeSupervision(page, [
attentionItemFixture(1, "legacy-session", "growth_stagnation", [
`/teacher/sessions/${legacySessionId}/`,
]),
attentionItemFixture(2, "canonical-session", "deterioration", [
`/teach/session/${canonicalSessionId}/review`,
]),
attentionItemFixture(3, "legacy-dashboard", "safety_boundary", [
"/teacher/dashboard",
]),
]);
await routeConsole(page, {
pending_reviews: [
{
...pendingReviewFixture(1, "62000000-0000-0000-0000-000000000111"),
session_id: legacySessionId,
},
],
});
await routeTeacherSessionReview(page, legacySessionId);
await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture());
await page.goto("/teach/supervision");
await expect(page.locator(".g6-drilldown a")).toHaveAttribute(
"href",
`/teach/session/${legacySessionId}/review`,
);
await page.getByRole("button", { name: /canonical-session/ }).click();
await expect(page.locator(".g6-drilldown a")).toHaveAttribute(
"href",
`/teach/session/${canonicalSessionId}/review`,
);
await page.getByRole("button", { name: /legacy-dashboard/ }).click();
await expect(page.locator(".g6-drilldown a")).toHaveAttribute("href", "/teach");
await page.getByRole("button", { name: /legacy-session/ }).click();
await page.locator(".g6-drilldown a").click();
await expect(page).toHaveURL(
new RegExp(`/teach/session/${legacySessionId}/review$`),
);
await expect(
page.getByText("교수자 검토 화면", { exact: true }),
).toBeVisible();
});
});
test.describe("uc-teacher-console: 학습자 상세 측정 근거", () => {