런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -174,6 +174,10 @@ async function gateScreen(
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
await prepareReady();
await expect(
page.locator("html"),
`[${screen} @ ${vp.label}] layout gate must capture the dark UI surface`,
).toHaveAttribute("data-theme", "dark");
await expectNoHorizontalOverflow(page);
const report = await auditClipping(page);
expect(
@ -227,22 +231,26 @@ async function expectEmptyReviewNoDeadThirdColumn(page: Page) {
).toBeLessThanOrEqual(2);
}
async function expectFilledReviewLearnerTwoColumn(page: Page) {
async function expectFilledReviewLearnerWorkbench(page: Page) {
const report = await page.evaluate(() => {
const cols = document.querySelector<HTMLElement>(".sr-cols--learner");
const transcript = document.querySelector<HTMLElement>(".sr-card--transcript");
const overview = document.querySelector<HTMLElement>(".sr-overview");
const rubric = document.querySelector<HTMLElement>(".sr-card--rubric");
const worksheet = document.querySelector<HTMLElement>(".sr-card--worksheet");
if (!cols || !transcript || !overview || !rubric || !worksheet) {
const prepost = document.querySelector<HTMLElement>(".sr-card--prepost");
if (!cols || !transcript || !overview || !rubric || !worksheet || !prepost) {
return {
present: false,
viewportWidth: window.innerWidth,
columnCount: 0,
overviewRight: 0,
transcriptLeft: 0,
transcriptRight: 0,
overviewLeft: 0,
rubricLeft: 0,
worksheetLeft: 0,
worksheetRight: 0,
prepostLeft: 0,
};
}
@ -251,23 +259,28 @@ async function expectFilledReviewLearnerTwoColumn(page: Page) {
const overviewRect = overview.getBoundingClientRect();
const rubricRect = rubric.getBoundingClientRect();
const worksheetRect = worksheet.getBoundingClientRect();
const prepostRect = prepost.getBoundingClientRect();
return {
present: true,
viewportWidth: window.innerWidth,
columnCount,
overviewRight: Math.ceil(overviewRect.right),
transcriptLeft: Math.floor(transcriptRect.left),
transcriptRight: Math.ceil(transcriptRect.right),
overviewLeft: Math.floor(overviewRect.left),
rubricLeft: Math.floor(rubricRect.left),
worksheetLeft: Math.floor(worksheetRect.left),
worksheetRight: Math.ceil(worksheetRect.right),
prepostLeft: Math.floor(prepostRect.left),
};
});
expect(report.present, "filled learner review layout should be mounted").toBe(true);
if (report.viewportWidth > 1180) {
expect(report.columnCount, "desktop learner review should use transcript + work column").toBe(2);
expect(report.transcriptRight).toBeLessThanOrEqual(report.overviewLeft);
expect(report.rubricLeft).toBeGreaterThanOrEqual(report.overviewLeft);
expect(report.worksheetLeft).toBeGreaterThanOrEqual(report.overviewLeft);
expect(report.columnCount, "desktop learner review should use a 3-column workbench").toBe(3);
expect(report.overviewRight).toBeLessThanOrEqual(report.transcriptLeft);
expect(report.transcriptRight).toBeLessThanOrEqual(report.rubricLeft);
expect(report.worksheetLeft).toBeLessThan(report.transcriptLeft);
expect(report.worksheetRight).toBeLessThanOrEqual(report.prepostLeft);
}
}
@ -310,6 +323,13 @@ test.describe("layout visual gate @single-run", () => {
await page.goto("/learn");
await gateScreen(page, "learner-home", async () => {
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".lh-dashboard-status .lh-metric-card").first()).toBeVisible({
timeout: 15_000,
});
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
timeout: 15_000,
});
});
});
@ -343,7 +363,7 @@ test.describe("layout visual gate @single-run", () => {
await gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expectFilledReviewLearnerTwoColumn(page);
await expectFilledReviewLearnerWorkbench(page);
});
});

View file

@ -110,6 +110,44 @@ test.describe("learner app shell and session launcher", () => {
expect(await page.evaluate(() => window.localStorage.getItem("vignette.dev-auth"))).toBeNull();
});
test("redirects to login when a protected learner API returns 401 after boot", async ({ page }) => {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "stale-learner",
email: "stale.learner@hs.ac.kr",
display_name: "Stale Learner",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1,
onboarding_completed_at: 1,
nickname: "Stale Learner",
self_introduction: "",
avatar_url: "",
}),
});
});
await page.route(/\/api\/(personas|sessions(?:\/dashboard)?)$/, async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.goto("/learn/practice");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
await expect(page.getByText("내담자 목록을 불러오지 못했습니다.")).toHaveCount(0);
});
test("renders API personas without legacy session rows", async ({ page }) => {
await signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner");
await page.goto("/learn/practice");

View file

@ -39,6 +39,9 @@ interface VoiceUiProbeState {
getUserMediaCalls: number;
recorderStarts: number;
recorderStops: number;
workletModuleLoads: number;
workletNodes: number;
workletChunks: number;
trackStops: number;
audioPlays: number;
audioContextResumes: number;
@ -375,6 +378,9 @@ async function installSyntheticVoiceCapture(
getUserMediaCalls: number;
recorderStarts: number;
recorderStops: number;
workletModuleLoads: number;
workletNodes: number;
workletChunks: number;
trackStops: number;
audioPlays: number;
audioContextResumes: number;
@ -388,6 +394,9 @@ async function installSyntheticVoiceCapture(
getUserMediaCalls: 0,
recorderStarts: 0,
recorderStops: 0,
workletModuleLoads: 0,
workletNodes: 0,
workletChunks: 0,
trackStops: 0,
audioPlays: 0,
audioContextResumes: 0,
@ -560,9 +569,68 @@ async function installSyntheticVoiceCapture(
}
}
class FakeWorkletPort {
onmessage: ((event: MessageEvent) => void) | null = null;
private closed = false;
postMessage(message: unknown) {
if ((message as { type?: string })?.type === "flush") {
this.emitChunk();
}
}
close() {
this.closed = true;
}
emitChunk() {
if (this.closed) return;
probe.workletChunks += 1;
const pcm = new Int16Array([0, 1024, -1024, 0]);
this.onmessage?.({ data: { type: "chunk", pcm: pcm.buffer } } as MessageEvent);
}
}
class FakeAudioWorkletNode {
port = new FakeWorkletPort();
constructor(_ctx: unknown, _name: string, _options?: unknown) {
probe.workletNodes += 1;
}
connect() {
return this;
}
disconnect() {
return undefined;
}
__start() {
window.setTimeout(() => this.port.emitChunk(), 25);
}
}
class FakeMediaStreamSource {
connect(node: { __start?: () => void }) {
node.__start?.();
return node;
}
disconnect() {
return undefined;
}
}
class FakeAudioContext {
state = "running";
destination = {};
sampleRate = 16000;
audioWorklet = {
addModule: async (_url: string) => {
probe.workletModuleLoads += 1;
},
};
async resume() {
probe.audioContextResumes += 1;
@ -584,8 +652,16 @@ async function installSyntheticVoiceCapture(
createBufferSource() {
return new FakeBufferSource();
}
createMediaStreamSource(_stream: unknown) {
return new FakeMediaStreamSource();
}
}
Object.defineProperty(window, "AudioWorkletNode", {
configurable: true,
value: FakeAudioWorkletNode,
});
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: FakeAudioContext,
@ -883,7 +959,13 @@ test.describe("voice cascade success path", () => {
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).recorderStarts, { timeout: 10_000 })
.poll(async () => (await readVoiceUiProbe(page)).workletModuleLoads, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).workletNodes, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).workletChunks, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => {
@ -892,7 +974,9 @@ test.describe("voice cascade success path", () => {
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_start"'),
message.data?.includes('"audio_start"') &&
message.data?.includes('"format":"pcm"') &&
message.data?.includes('"sample_rate":16000'),
);
}, { timeout: 10_000 })
.toBeTruthy();