주기 실회기 검증과 G7 종료계약 보강

This commit is contained in:
Yun Chan 2026-08-09 23:37:19 +09:00
parent 83590e9ef7
commit 7b4955c3fc
23 changed files with 2916 additions and 117 deletions

View file

@ -333,6 +333,7 @@ test.describe("dev dashboard static command center", () => {
for (const viewport of [
{ width: 1440, height: 900 },
{ width: 390, height: 844 },
{ width: 320, height: 568 },
]) {
await page.setViewportSize(viewport);
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);

View file

@ -0,0 +1,426 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
expect,
test,
type APIResponse,
type Page,
} from "@playwright/test";
import {
completeAlliancePreCheckpoint,
useRealApi,
} from "./support";
interface ReturnedPracticeFixture {
schema_version: "vignette.returned-practice-browser-fixture.v1";
login: {
email: string;
role: "learner";
display_name: string;
cohort_ids: string[];
};
source_session_id: string;
practice_session_id: string;
deliberate: {
prescription_id: string;
criterion_id: string;
novelty: "familiar" | "unseen_transfer";
mode:
| "replay"
| "branch"
| "constrained_response"
| "voice_retry"
| "difficulty_ladder";
};
transfer: {
prescription_id: string;
suite_id: string;
trial_id: string;
criterion_id: string;
novelty: "unseen_transfer";
mode:
| "counterevidence_forecast"
| "evidence_recall"
| "uncertainty_range"
| "collect_more_evidence";
};
}
interface SessionDetail {
session_id: string;
persona_code: string;
}
interface AuthMe {
user_id: string;
}
interface DeliberateSubmission {
idempotent_replay: boolean;
}
interface DeliberateReadModel {
episodes: Array<{
session_id?: string | null;
attempts?: unknown[];
}>;
}
interface TransferSubmission {
idempotent_replay: boolean;
assessment: {
execution_count: number;
independent_execution_count: number;
};
}
interface TransferReadModel {
actual_executions: Array<{
original_transfer_trial_record_id: string;
practice_session_id: string;
}>;
}
const LIVE_GATE = process.env.E2E_PERIODIC_LEARNER_REAL_CLOSED_LOOP === "1";
const FIXTURE_PATH = process.env.E2E_RETURNED_PRACTICE_FIXTURE ?? "";
const RESULT_PATH = process.env.E2E_PERIODIC_LEARNER_RESULT ?? "";
function loadFixture(): ReturnedPracticeFixture {
if (!FIXTURE_PATH) {
throw new Error("E2E_RETURNED_PRACTICE_FIXTURE is required");
}
return JSON.parse(
readFileSync(path.resolve(FIXTURE_PATH), "utf8"),
) as ReturnedPracticeFixture;
}
async function expectOk(response: APIResponse) {
expect(response.ok(), await response.text()).toBeTruthy();
}
async function signInExistingFixture(
page: Page,
fixture: ReturnedPracticeFixture,
): Promise<string> {
const login = await page.request.post("/api/auth/dev-login", {
data: fixture.login,
});
await expectOk(login);
const me = await page.request.get("/api/auth/me");
await expectOk(me);
return ((await me.json()) as AuthMe).user_id;
}
function launchSearch(
fixture: ReturnedPracticeFixture,
kind: "deliberate" | "transfer",
): string {
const source = kind === "deliberate" ? fixture.deliberate : fixture.transfer;
const search = new URLSearchParams({
launch: kind,
prescription: source.prescription_id,
source_session: fixture.source_session_id,
criterion: source.criterion_id,
novelty: source.novelty,
mode: source.mode,
});
if (kind === "transfer") {
search.set("suite", fixture.transfer.suite_id);
search.set("trial", fixture.transfer.trial_id);
}
return search.toString();
}
function runtimeAttemptCount(
readModel: DeliberateReadModel,
practiceSessionId: string,
): number {
return readModel.episodes
.filter((episode) => episode.session_id === practiceSessionId)
.reduce((count, episode) => count + (episode.attempts?.length ?? 0), 0);
}
function actualExecutionCount(
readModel: TransferReadModel,
fixture: ReturnedPracticeFixture,
practiceSessionId: string,
): number {
return readModel.actual_executions.filter(
(execution) =>
execution.original_transfer_trial_record_id === fixture.transfer.trial_id &&
execution.practice_session_id === practiceSessionId,
).length;
}
async function readPracticeCount(page: Page, practiceSessionId: string) {
const response = await page.request.get("/api/practice/learners/me");
await expectOk(response);
return runtimeAttemptCount(
(await response.json()) as DeliberateReadModel,
practiceSessionId,
);
}
async function readTransferCount(
page: Page,
fixture: ReturnedPracticeFixture,
practiceSessionId: string,
) {
const response = await page.request.get("/api/calibration/learners/me");
await expectOk(response);
return actualExecutionCount(
(await response.json()) as TransferReadModel,
fixture,
practiceSessionId,
);
}
async function openEvidenceCard(
page: Page,
fixture: ReturnedPracticeFixture,
practiceSessionId: string,
kind: "deliberate" | "transfer",
) {
await page.goto(
`/learn/session/${practiceSessionId}/review?${launchSearch(fixture, kind)}`,
);
const insightsTab = page.locator("#sr-tab-insights");
await expect(insightsTab).toBeVisible({ timeout: 30_000 });
if ((await insightsTab.getAttribute("aria-selected")) !== "true") {
await insightsTab.click();
}
const selector =
kind === "deliberate" ? ".dp-runtime-observation" : ".ct-actual-transfer";
const card = page.locator("#sr-panel-insights").locator(selector);
await expect(card).toBeVisible({ timeout: 30_000 });
return card;
}
function writeResult(result: Record<string, unknown>) {
if (!RESULT_PATH) {
throw new Error("E2E_PERIODIC_LEARNER_RESULT is required");
}
const resolved = path.resolve(RESULT_PATH);
mkdirSync(path.dirname(resolved), { recursive: true });
writeFileSync(resolved, `${JSON.stringify(result, null, 2)}\n`, "utf8");
}
test.describe("periodic same-learner real closed loop", () => {
test.skip(!LIVE_GATE, "Explicit disposable periodic gate only");
test("@single-run home recommendation, SSE session, review, G4/G5 POST and reload stay one learner", async ({
page,
}) => {
test.setTimeout(20 * 60_000);
const fixture = loadFixture();
expect(fixture.schema_version).toBe(
"vignette.returned-practice-browser-fixture.v1",
);
await useRealApi(page);
const learnerId = await signInExistingFixture(page, fixture);
const preparedSession = await page.request.get(
`/api/sessions/${fixture.practice_session_id}`,
);
await expectOk(preparedSession);
const practicePersona = ((await preparedSession.json()) as SessionDetail)
.persona_code;
expect(practicePersona).toBeTruthy();
await page.goto("/learn");
await expect(
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("다음 연습 추천", { exact: true })).toBeVisible();
await expect(page.getByText("리뷰 확인을 우선합니다.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "리뷰 확인하기" }).click();
await expect(page).toHaveURL(/\/learn\/history$/);
await page.goto(`/learn/practice?${launchSearch(fixture, "deliberate")}`);
await expect(
page.getByRole("heading", { name: /처방을 이어받았습니다/ }),
).toBeVisible({ timeout: 30_000 });
const escapedPersona = practicePersona.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const persona = page.getByRole("option", {
name: new RegExp(escapedPersona),
});
await persona.click();
await expect(persona).toHaveAttribute("aria-selected", "true");
await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${escapedPersona}\\?`));
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
const activeUrl = new URL(page.url());
const practiceSessionId = activeUrl.pathname.split("/").filter(Boolean).at(-1);
expect(practiceSessionId).toBeTruthy();
expect(practiceSessionId).not.toBe(practicePersona);
const learnerText =
"그 말을 꺼내기까지 많이 외롭고 조심스러웠던 것 같아요. 제가 이해한 마음이 맞을까요?";
const streamResponse = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
response.url().endsWith(`/api/sessions/${practiceSessionId}/stream`),
{ timeout: 6 * 60_000 },
);
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
const stream = await streamResponse;
await expectOk(stream);
expect(stream.headers()["content-type"] ?? "").toContain("text/event-stream");
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt.is-client").last()).toBeVisible({
timeout: 6 * 60_000,
});
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(
new RegExp(`/learn/session/${practiceSessionId}/review\\?`),
);
await expect
.poll(
async () => {
const review = await page.request.get(
`/api/sessions/${practiceSessionId}/review`,
);
if (!review.ok()) return false;
return Boolean(((await review.json()) as { reviewReady?: boolean }).reviewReady);
},
{ timeout: 6 * 60_000, intervals: [1_000, 2_000, 5_000] },
)
.toBe(true);
expect(await readPracticeCount(page, practiceSessionId!)).toBe(0);
let deliberateCard = await openEvidenceCard(
page,
fixture,
practiceSessionId!,
"deliberate",
);
const g4PostUrl = `/api/practice/${encodeURIComponent(
fixture.deliberate.prescription_id,
)}/attempts/from-session/${practiceSessionId}`;
const g4FirstPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(g4PostUrl),
{ timeout: 5 * 60_000 },
);
await deliberateCard
.getByRole("button", { name: "이번 회기를 독립 관찰로 반영" })
.click();
const g4First = await g4FirstPost;
await expectOk(g4First);
expect(((await g4First.json()) as DeliberateSubmission).idempotent_replay).toBe(
false,
);
const g4FirstCount = await readPracticeCount(page, practiceSessionId!);
expect(g4FirstCount).toBe(1);
await page.reload();
deliberateCard = await openEvidenceCard(
page,
fixture,
practiceSessionId!,
"deliberate",
);
const g4ReplayPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(g4PostUrl),
{ timeout: 5 * 60_000 },
);
await deliberateCard
.getByRole("button", { name: "반영 상태 다시 확인" })
.click();
const g4Replay = await g4ReplayPost;
await expectOk(g4Replay);
expect(((await g4Replay.json()) as DeliberateSubmission).idempotent_replay).toBe(
true,
);
const g4ReplayCount = await readPracticeCount(page, practiceSessionId!);
expect(g4ReplayCount).toBe(1);
expect(await readTransferCount(page, fixture, practiceSessionId!)).toBe(0);
let transferCard = await openEvidenceCard(
page,
fixture,
practiceSessionId!,
"transfer",
);
const g5PostUrl = "/api/calibration/transfer-executions";
const g5FirstPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(g5PostUrl),
{ timeout: 5 * 60_000 },
);
await transferCard
.getByRole("button", { name: "이 회기를 전이 근거로 확인" })
.click();
const g5First = await g5FirstPost;
await expectOk(g5First);
const g5FirstBody = (await g5First.json()) as TransferSubmission;
expect(g5FirstBody.idempotent_replay).toBe(false);
expect(g5FirstBody.assessment.execution_count).toBe(1);
expect(g5FirstBody.assessment.independent_execution_count).toBe(1);
const g5FirstCount = await readTransferCount(
page,
fixture,
practiceSessionId!,
);
expect(g5FirstCount).toBe(1);
await page.reload();
transferCard = await openEvidenceCard(
page,
fixture,
practiceSessionId!,
"transfer",
);
const g5ReplayPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(g5PostUrl),
{ timeout: 5 * 60_000 },
);
await transferCard
.getByRole("button", { name: "같은 회기 기록 다시 확인" })
.click();
const g5Replay = await g5ReplayPost;
await expectOk(g5Replay);
expect(((await g5Replay.json()) as TransferSubmission).idempotent_replay).toBe(
true,
);
const g5ReplayCount = await readTransferCount(
page,
fixture,
practiceSessionId!,
);
expect(g5ReplayCount).toBe(1);
const meAfter = await page.request.get("/api/auth/me");
await expectOk(meAfter);
expect(((await meAfter.json()) as AuthMe).user_id).toBe(learnerId);
writeResult({
schema_version: "vignette.periodic-learner-real-closed-loop.v1",
learner_id: learnerId,
source_session_id: fixture.source_session_id,
practice_session_id: practiceSessionId,
same_learner: true,
recommendation_verified: true,
session_created_in_browser: true,
sse_turn_verified: true,
review_ready: true,
g4: {
initial_count: 0,
first_count: g4FirstCount,
replay_count: g4ReplayCount,
replay_idempotent: true,
},
g5: {
initial_count: 0,
first_count: g5FirstCount,
replay_count: g5ReplayCount,
replay_idempotent: true,
},
});
});
});

View file

@ -1020,6 +1020,20 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
);
await expectReadableButtonContrast(page, "학습 대상 선택");
await expectNoHorizontalOverflow(page);
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await expectInsideInitialViewport(
page,
"[data-learner-primary-action]",
"320px 학습자 홈 핵심 행동",
);
await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "320px 학습 홈 탭");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "home-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await capture(page, testInfo.project.name, "home");
await primaryAction.click();
@ -1061,6 +1075,7 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.locator(".sx-utt").filter({ hasText: LEARNER_TEXT })).toBeVisible();
await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible();
await expect(page.locator(".sx-page")).not.toContainText("[NAME]");
if (testInfo.project.name.includes("mobile")) {
await expect(
page.getByRole("region", { name: "현재 회기 요약" }),
@ -1145,7 +1160,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expect(practiceCard).toContainText("미지 사례 전이 아직 미검증");
await expect(practiceCard).not.toContainText("criterion.reflect-and-check");
await expect(practiceCard).not.toContainText("unseen_transfer_not_verified");
await expect(page.locator(".sr-root")).not.toContainText("[NAME]");
await expectNoHorizontalOverflow(page);
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await practiceCard.scrollIntoViewIfNeeded();
await expectMinimumHitTargets(
page,
".dp-launch-ticket__cta",
"320px 처방 연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "review-prescription-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await practiceCard.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "review-prescription");
@ -1185,6 +1215,16 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expect(page.locator(".lh-root")).toContainText("원본 회기 참조");
await expect(page.locator(".lh-root")).not.toContainText("criterion.reflect-and-check");
await expect(page.locator(".lh-root")).not.toContainText(PRIMARY_SESSION_ID.slice(0, 8));
await expect(page.locator(".lh-root")).not.toContainText("[NAME]");
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await expectMinimumHitTargets(page, ".lh-actions .vg-btn", "320px 재연습 대상 행동");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-home-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect(
page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }),
@ -1209,6 +1249,24 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
"처방 재연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await expectInsideInitialViewport(
page,
".sx-prestart__actions .vg-btn",
"320px 처방 재연습 시작 행동",
);
await expectMinimumHitTargets(
page,
".sx-prestart__actions .vg-btn",
"320px 처방 재연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-prestart-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await capture(page, testInfo.project.name, "repractice-prestart");
await page.getByRole("button", { name: "회기 시작" }).click();
@ -1297,6 +1355,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expect.poll(() => fixture.practiceReadCount).toBeGreaterThan(
readsBeforeSuccess,
);
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await observation.scrollIntoViewIfNeeded();
await expectMinimumHitTargets(
page,
".dp-runtime-observation button",
"320px 서버 관찰 행동",
);
await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8));
await expect(observation).not.toContainText("[NAME]");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-observation-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await observation.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "repractice-observation");

View file

@ -206,13 +206,13 @@ async function expectMainControlsUnclipped(page: Page) {
const controls = [
{ selector: ".sx-compose textarea", parent: ".sx-compose" },
{ selector: ".sx-compose .vg-btn", parent: ".sx-compose" },
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar", minTouchSize: 44 },
{ selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar", minTouchSize: 44 },
{ selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar", minTouchSize: 44 },
];
return controls.map(({ selector, parent }) => {
return controls.map(({ selector, parent, minTouchSize }) => {
const el = document.querySelector<HTMLElement>(selector);
const parentEl = document.querySelector<HTMLElement>(parent);
if (!el || !parentEl) return { selector, ok: false, reason: "missing" };
@ -236,11 +236,21 @@ async function expectMainControlsUnclipped(page: Page) {
rect.left >= parentRect.left - 1 &&
rect.right <= parentRect.right + 1 &&
rect.bottom <= parentRect.bottom + 1;
const touchTargetOk =
minTouchSize === undefined || (rect.width >= minTouchSize && rect.height >= minTouchSize);
return {
selector,
ok: visible && insideParent && !textClipped,
reason: !visible ? "not-visible" : !insideParent ? "outside-parent" : textClipped ? "text-clipped" : "",
ok: visible && insideParent && !textClipped && touchTargetOk,
reason: !visible
? "not-visible"
: !insideParent
? "outside-parent"
: textClipped
? "text-clipped"
: !touchTargetOk
? "touch-target-under-44px"
: "",
rect: {
top: Math.round(rect.top),
right: Math.round(rect.right),