세션 평가와 교수자 분석 보강

This commit is contained in:
Yun Chan 2026-07-01 12:10:52 +09:00
parent 5c4ac04e06
commit fe2796f05a
51 changed files with 4928 additions and 240 deletions

View 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);
}
});
});