런타임 계약과 학습자 흐름 보강
This commit is contained in:
parent
f456b8997a
commit
206018b088
56 changed files with 4306 additions and 1008 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue