세션 평가와 교수자 분석 보강
This commit is contained in:
parent
5c4ac04e06
commit
fe2796f05a
51 changed files with 4928 additions and 240 deletions
336
apps/web/e2e/dev-dashboard.spec.ts
Normal file
336
apps/web/e2e/dev-dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import http, { type Server } from "node:http";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
const repoRoot = path.resolve(process.cwd(), "..", "..");
|
||||
const dashboardPath = path.join(repoRoot, "docs", "dev_dashboard.html");
|
||||
|
||||
interface StaticServer {
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface DashboardMetrics {
|
||||
total: number;
|
||||
done: number;
|
||||
doing: number;
|
||||
planned: number;
|
||||
ownerCards: number;
|
||||
ownerBoard: number;
|
||||
crit: number;
|
||||
ui: Record<string, string>;
|
||||
ownerColumns: Record<string, number>;
|
||||
visibleCards: number;
|
||||
visibleGroups: number;
|
||||
phaseSegments: number;
|
||||
trackRows: number;
|
||||
trackNames: string[];
|
||||
stackSegments: string[];
|
||||
}
|
||||
|
||||
async function startStaticServer(root: string): Promise<StaticServer> {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const requestPath =
|
||||
requestUrl.pathname === "/" ? "/docs/dev_dashboard.html" : decodeURIComponent(requestUrl.pathname);
|
||||
const normalized = path.normalize(requestPath).replace(/^([/\\])+/, "");
|
||||
const filePath = path.join(root, normalized);
|
||||
const relative = path.relative(root, filePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
res.writeHead(403).end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await fs.readFile(filePath);
|
||||
res.writeHead(200, { "content-type": contentType(filePath) });
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Static dashboard server did not expose a TCP port");
|
||||
}
|
||||
return {
|
||||
port: (address as AddressInfo).port,
|
||||
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
||||
};
|
||||
}
|
||||
|
||||
function contentType(filePath: string) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === ".html") return "text/html; charset=utf-8";
|
||||
if (ext === ".css") return "text/css; charset=utf-8";
|
||||
if (ext === ".js" || ext === ".mjs") return "text/javascript; charset=utf-8";
|
||||
if (ext === ".png") return "image/png";
|
||||
if (ext === ".svg") return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
async function openDashboard(page: Page, url: string) {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(msg.text());
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
if (url.startsWith("http")) {
|
||||
expect(response?.ok(), `${url} should load over HTTP`).toBeTruthy();
|
||||
}
|
||||
await expect(page.locator("#phase-rail .pr-seg")).toHaveCount(5);
|
||||
expect(errors).toEqual([]);
|
||||
}
|
||||
|
||||
async function readMetrics(page: Page): Promise<DashboardMetrics> {
|
||||
return await page.evaluate(() => {
|
||||
const cards = Array.from(document.querySelectorAll<HTMLElement>(".scard"));
|
||||
const statusCount = (status: string) => cards.filter((card) => card.dataset.status === status).length;
|
||||
const text = (selector: string) => document.querySelector(selector)?.textContent?.trim() ?? "";
|
||||
const countOwnerColumn = (column: string) =>
|
||||
document.querySelector(`[data-owner-col="${column}"]`)?.querySelectorAll('.ocard[data-owner="1"]').length ?? 0;
|
||||
|
||||
return {
|
||||
total: cards.length,
|
||||
done: statusCount("done"),
|
||||
doing: statusCount("doing"),
|
||||
planned: cards.length - statusCount("done") - statusCount("doing"),
|
||||
ownerCards: cards.filter((card) => card.dataset.owner === "1").length,
|
||||
ownerBoard: document.querySelectorAll('.ocard[data-owner="1"]').length,
|
||||
crit: cards.filter((card) => card.querySelector(".critbadge")).length,
|
||||
ui: {
|
||||
kpiDone: text("#kpi-done"),
|
||||
kpiDoing: text("#kpi-doing"),
|
||||
kpiPlan: text("#kpi-plan"),
|
||||
kpiOwner: text("#kpi-owner"),
|
||||
pillOwner: text("#pill-owner"),
|
||||
total: text("#g-total"),
|
||||
legendDone: text("#lg-done"),
|
||||
legendDoing: text("#lg-doing"),
|
||||
legendPlan: text("#lg-plan"),
|
||||
filterAll: text('[data-count="all"]'),
|
||||
filterDone: text('[data-count="done"]'),
|
||||
filterDoing: text('[data-count="doing"]'),
|
||||
filterPlanned: text('[data-count="planned"]'),
|
||||
filterOwner: text('[data-count="owner"]'),
|
||||
filterCrit: text('[data-count="crit"]'),
|
||||
},
|
||||
ownerColumns: {
|
||||
block: countOwnerColumn("block"),
|
||||
decide: countOwnerColumn("decide"),
|
||||
ext: countOwnerColumn("ext"),
|
||||
},
|
||||
visibleCards: cards.filter((card) => !card.classList.contains("hide")).length,
|
||||
visibleGroups: Array.from(document.querySelectorAll<HTMLElement>("#board .track-group")).filter(
|
||||
(group) => getComputedStyle(group).display !== "none",
|
||||
).length,
|
||||
phaseSegments: document.querySelectorAll("#phase-rail .pr-seg").length,
|
||||
trackRows: document.querySelectorAll("#tracks .track").length,
|
||||
trackNames: Array.from(document.querySelectorAll("#tracks .track .tname")).map(
|
||||
(el) => el.textContent?.trim() ?? "",
|
||||
),
|
||||
stackSegments: Array.from(document.querySelectorAll("#stackbar i")).map((el) => el.textContent?.trim() ?? ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function expectDerivedMetrics(metrics: DashboardMetrics) {
|
||||
expect(metrics.total).toBeGreaterThan(0);
|
||||
expect(metrics.done).toBe(metrics.total);
|
||||
expect(metrics.doing).toBe(0);
|
||||
expect(metrics.planned).toBe(0);
|
||||
expect(metrics.ownerBoard).toBe(
|
||||
metrics.ownerColumns.block + metrics.ownerColumns.decide + metrics.ownerColumns.ext,
|
||||
);
|
||||
expect(metrics.ownerBoard).toBeGreaterThan(0);
|
||||
expect(metrics.crit).toBe(3);
|
||||
expect(metrics.ui).toMatchObject({
|
||||
kpiDone: String(metrics.done),
|
||||
kpiDoing: String(metrics.doing),
|
||||
kpiPlan: String(metrics.planned),
|
||||
kpiOwner: String(metrics.ownerBoard),
|
||||
pillOwner: String(metrics.ownerBoard),
|
||||
total: String(metrics.total),
|
||||
legendDone: String(metrics.done),
|
||||
legendDoing: String(metrics.doing),
|
||||
legendPlan: String(metrics.planned),
|
||||
filterAll: String(metrics.total),
|
||||
filterDone: String(metrics.done),
|
||||
filterDoing: String(metrics.doing),
|
||||
filterPlanned: String(metrics.planned),
|
||||
filterOwner: String(metrics.ownerCards),
|
||||
filterCrit: String(metrics.crit),
|
||||
});
|
||||
expect(metrics.phaseSegments).toBe(5);
|
||||
expect(metrics.trackRows).toBeGreaterThan(1);
|
||||
expect(metrics.trackNames).not.toContain("");
|
||||
expect(metrics.stackSegments).toEqual([String(metrics.done)]);
|
||||
}
|
||||
|
||||
async function expectVisibleCards(page: Page, expected: number) {
|
||||
await expect
|
||||
.poll(async () => (await readMetrics(page)).visibleCards, { message: `expected ${expected} visible cards` })
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("dev dashboard static command center", () => {
|
||||
let staticServer: StaticServer;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
staticServer = await startStaticServer(repoRoot);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await staticServer.close();
|
||||
});
|
||||
|
||||
test("derives counters and graphs on local file and HTTP origins", async ({ page }) => {
|
||||
const urls = [
|
||||
pathToFileURL(dashboardPath).toString(),
|
||||
`http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`,
|
||||
`http://localhost:${staticServer.port}/docs/dev_dashboard.html`,
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
await openDashboard(page, url);
|
||||
expectDerivedMetrics(await readMetrics(page));
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps filters stable under empty states, wrong order, and rapid retries", async ({ page }) => {
|
||||
await openDashboard(page, `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
const initial = await readMetrics(page);
|
||||
expectDerivedMetrics(initial);
|
||||
|
||||
await page.locator('[data-filter="planned"]').click();
|
||||
await expectVisibleCards(page, 0);
|
||||
await expect(page.locator("#board .track-group").first()).not.toBeVisible();
|
||||
await page.locator('[data-filter="done"]').focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator('[data-filter="done"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await expectVisibleCards(page, initial.done);
|
||||
await page.locator('[data-filter="planned"]').focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(page.locator('[data-filter="planned"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await expectVisibleCards(page, 0);
|
||||
|
||||
const rapidSequence = ["all", "crit", "doing", "owner", "planned", "done", "all", "crit", "all"];
|
||||
await page.evaluate((filters) => {
|
||||
for (const filter of filters) {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-filter="${filter}"]`)
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
}, rapidSequence);
|
||||
|
||||
await expect(page.locator('[data-filter="all"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await expectVisibleCards(page, initial.total);
|
||||
expectDerivedMetrics(await readMetrics(page));
|
||||
|
||||
const firstCard = page.locator(".scard").first();
|
||||
const firstHead = firstCard.locator(".scard-head");
|
||||
await firstHead.click();
|
||||
await expect(firstHead).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(firstCard).toHaveClass(/open/);
|
||||
await page.locator('[data-filter="planned"]').click();
|
||||
await expectVisibleCards(page, 0);
|
||||
await page.locator('[data-filter="all"]').click();
|
||||
await expect(firstHead).toHaveAttribute("aria-expanded", "true");
|
||||
await firstHead.click();
|
||||
await expect(firstHead).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
test("keeps local links, images, and hash anchors intact", async ({ page }) => {
|
||||
const dashboardUrl = `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`;
|
||||
await openDashboard(page, dashboardUrl);
|
||||
|
||||
await page.locator(".owner-pill").click();
|
||||
await expect(page).toHaveURL(/#owner$/);
|
||||
await expect(page.locator("#owner")).toBeInViewport();
|
||||
await page.locator(".kpi.k-done").click();
|
||||
await expect(page).toHaveURL(/#board$/);
|
||||
await expect(page.locator("#board")).toBeInViewport();
|
||||
|
||||
const references = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll<HTMLAnchorElement | HTMLImageElement | HTMLLinkElement>("[href],[src]"))
|
||||
.map((el) => ({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
value: el.getAttribute("href") ?? el.getAttribute("src") ?? "",
|
||||
}))
|
||||
.filter((item) => item.value.length > 0),
|
||||
);
|
||||
|
||||
for (const reference of references) {
|
||||
if (reference.value.startsWith("#")) {
|
||||
await expect(page.locator(reference.value)).toHaveCount(1);
|
||||
continue;
|
||||
}
|
||||
const resolved = new URL(reference.value, dashboardUrl);
|
||||
if (resolved.origin !== new URL(dashboardUrl).origin) {
|
||||
expect(["http:", "https:"], `${reference.tag} ${reference.value}`).toContain(resolved.protocol);
|
||||
continue;
|
||||
}
|
||||
const response = await page.request.get(resolved.toString());
|
||||
expect(response.ok(), `${reference.tag} ${reference.value} should resolve`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("supports tab click and keyboard navigation after repeated opening", async ({ page }) => {
|
||||
await openDashboard(page, `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
for (const foldId of ["fold-roadmap", "fold-tabs", "fold-narrative"]) {
|
||||
const fold = page.locator(`#${foldId}`);
|
||||
const summary = fold.locator("summary");
|
||||
await summary.click();
|
||||
await expect(fold).toHaveJSProperty("open", true);
|
||||
await summary.click();
|
||||
await expect(fold).toHaveJSProperty("open", false);
|
||||
await summary.click();
|
||||
await expect(fold).toHaveJSProperty("open", true);
|
||||
}
|
||||
|
||||
const tabs = page.locator('[role="tab"][data-tab]');
|
||||
const tabCount = await tabs.count();
|
||||
expect(tabCount).toBeGreaterThan(3);
|
||||
|
||||
for (let index = tabCount - 1; index >= 0; index -= 1) {
|
||||
const tab = tabs.nth(index);
|
||||
const panelId = await tab.getAttribute("aria-controls");
|
||||
expect(panelId).toBeTruthy();
|
||||
await tab.click();
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator(`#${panelId}`)).toBeVisible();
|
||||
await expect(page.locator('[role="tabpanel"]:not([hidden])')).toHaveCount(1);
|
||||
}
|
||||
|
||||
await tabs.first().focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect(tabs.nth(tabCount - 1)).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("Home");
|
||||
await expect(tabs.first()).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(tabs.first()).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("does not overflow at desktop and mobile widths in the simulated external origin", async ({ page }) => {
|
||||
for (const viewport of [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue