diff --git a/docs/contracts/_ui.json b/docs/contracts/_ui.json index 00f6f704a..2ae346d84 100644 --- a/docs/contracts/_ui.json +++ b/docs/contracts/_ui.json @@ -1,5 +1,5 @@ { - "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, and buttons.\n* Popups with discussion use details on the left and discussion on the right on desktop; stack on mobile.", + "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, and buttons.\n* Popups are one scrollable column at a comfortable reading width, not a two-column page. Keep the title in a sticky header. Lead with one primary fact (due datetime) and one secondary line (relative urgency), then list remaining overlay facts in a table, then reading, then discussion. Do not add a status side column, chip row, or nested collapsible inside the overlay. Side rails belong on pages, not in overlays.", "List Table": "* Used for discovery lists such as courses, sections, and teachers.\n* Primary names follow the active locale; an available alternate localized name is secondary text.\n* Structured fields support comparison.\n* Rows link to detail pages.\n* Across viewport widths, one prominent search remains directly available, advanced filters open in an accessible sheet, and active filters remain removable above results.\n* Compact layouts use cards that avoid page-level horizontal scrolling; wide desktop layouts keep every comparison-table column inside the results container without horizontal scrolling.\n* Pagination uses URL-driven Previous, page, and Next links while preserving filters.", "Data Table Cells": "* Desktop data-table rows keep a consistent compact height; optional secondary lines reserve space when absent.\n* Overflowing text is truncated, with its full value available from a tooltip on pointer hover and keyboard focus.\n* Course and section codes use plain monospace text, never badge styling.\n* Header labels and cell content share the same column edge; textual and categorical values align left, while numeric measures and counts align right.\n* Blank placeholders are hidden from assistive technology and do not replace meaningful labels such as unknown or TBD.\n* Numeric, status, control, and action cells do not receive truncation tooltips when their content fits.", "Detail Hero": "* Used at the top of course, section, and teacher detail pages.\n* Contains breadcrumb, h1, and an optional subtitle.\n* h1 uses the primary display name of the current object, not an internal ID.\n* At mobile widths, stable public identifiers and concise metadata precede a responsive title so long localized names do not crowd out context or actions.", diff --git a/docs/contracts/homework.json b/docs/contracts/homework.json index 7b228a104..33d76db18 100644 --- a/docs/contracts/homework.json +++ b/docs/contracts/homework.json @@ -80,7 +80,7 @@ "filter: incomplete/completed/all", "mobile compact toolbar with direct completion filters and overflow display-mode control", "cards/list view mode persisted in browser storage", - "detail popup order: description, due summary, vertical metadata excluding platform createdAt, action controls, discussion; desktop places discussion to the right of the details", + "detail popup is one scrollable column: due datetime, relative urgency, remaining facts in a table excluding platform createdAt, then description, then full-width discussion; completion stays in the footer; do not use a two-column overlay", "create form exposes the collapsible advisory homework title and description style guide" ] } @@ -247,7 +247,7 @@ "user completion status", "edit action", "cards/list view mode persisted in browser storage", - "detail popup order: description, due summary, vertical metadata excluding platform createdAt, edit/completion controls, inline discussion; desktop places discussion to the right of the details", + "detail popup is one scrollable column: due datetime is primary, relative urgency is secondary, remaining facts (status, tags, publication, submission opens) are a table that does not repeat due date or platform createdAt, then description, then full-width discussion; edit/completion stay in the footer; do not use a two-column overlay", "section cards use a responsive multi-column layout", "create and edit forms expose the same collapsible advisory homework title and description style guide" ] diff --git a/docs/contracts/todo.json b/docs/contracts/todo.json index 649792b91..8a05d87ca 100644 --- a/docs/contracts/todo.json +++ b/docs/contracts/todo.json @@ -136,6 +136,7 @@ "todo.content (description)", "todo.priority", "todo.dueAt", + "detail popup stacks title, then due datetime, relative urgency, a table of remaining facts (priority, completion), then content; completion/edit/delete stay in the footer", "completed status action button", "filter: incomplete/completed/all", "mobile compact toolbar with direct completion filters and overflow display-mode control" diff --git a/messages/en-us.json b/messages/en-us.json index 81e3bda64..2b174ad31 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -2263,6 +2263,7 @@ "contentPlaceholder": "Supports Markdown formatting", "contentEmpty": "No description added.", "priorityLabel": "Priority", + "statusLabel": "Status", "dueAtLabel": "Due date", "dueAtPlaceholder": "Optional date and time", "dueLabel": "Due", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 61ed99b61..6b0d33397 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -2229,6 +2229,7 @@ "contentPlaceholder": "支持 Markdown 格式", "contentEmpty": "暂无内容", "priorityLabel": "优先级", + "statusLabel": "状态", "dueAtLabel": "截止时间", "dueAtPlaceholder": "选择截止时间(可选)", "dueLabel": "截止", diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte index 34f2388ea..369a17195 100644 --- a/src/features/dashboard/components/TodoDetailDialog.svelte +++ b/src/features/dashboard/components/TodoDetailDialog.svelte @@ -8,18 +8,22 @@ import type { } from "@/features/dashboard/lib/dashboard-controller-helpers"; import MarkdownPreview from "$lib/components/MarkdownPreview.svelte"; import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js"; -import { Badge } from "$lib/components/ui/badge/index.js"; import { Button, buttonVariants } from "$lib/components/ui/button/index.js"; import * as Dialog from "$lib/components/ui/dialog/index.js"; import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; import { Separator } from "$lib/components/ui/separator/index.js"; import { Spinner } from "$lib/components/ui/spinner/index.js"; +import * as Table from "$lib/components/ui/table/index.js"; import { cn } from "$lib/utils.js"; export let deleteTodo: (todo: DashboardTodoItem) => void | Promise; export let fmtDate: (value: string | Date | null | undefined) => string; +export let isDueOverdue: (value: string | Date | null | undefined) => boolean; export let onClose: () => void; export let openTodoEditor: (todo: DashboardTodoItem) => void; +export let relativeDueLabel: ( + value: string | Date | null | undefined, +) => string; export let todo: DashboardTodoItem | null; export let todoActionLabel: (todo: DashboardTodoItem) => string; export let todoSavingById: Record; @@ -62,22 +66,63 @@ async function confirmDelete(event: MouseEvent) { - + {todo.title} - + {todosCopy.priority[todo.priority]} · {fmtDate(todo.dueAt)} diff --git a/src/features/dashboard/components/TodosTab.svelte b/src/features/dashboard/components/TodosTab.svelte index 2e2c8c09f..2ce371b7c 100644 --- a/src/features/dashboard/components/TodosTab.svelte +++ b/src/features/dashboard/components/TodosTab.svelte @@ -49,17 +49,25 @@ export let isCreatingTodo: boolean; export let isUpdatingTodo: boolean; let datetimeLocalValue: TodoDateFormatter; let fmtDate: TodoDateFormatter; +let isDueOverdue: (value: Date | string | null | undefined) => boolean; +let relativeDueLabel: TodoDateFormatter; let todoActionLabel: TodoAction; let todoStatus: TodoAction; -$: ({ datetimeLocalValue, fmtDate, todoActionLabel, todoStatus } = - createTodoTabDisplayActions({ - dashboardCopy, - locale, - referenceDate, - sectionCopy, - todosCopy, - })); +$: ({ + datetimeLocalValue, + fmtDate, + isDueOverdue, + relativeDueLabel, + todoActionLabel, + todoStatus, +} = createTodoTabDisplayActions({ + dashboardCopy, + locale, + referenceDate, + sectionCopy, + todosCopy, +})); $: displayTodoFilter = resolveDashboardTaskFilter( todoFilter, todoItems.some((todo) => !todo.completed), @@ -120,7 +128,9 @@ $: displayTodoFilter = resolveDashboardTaskFilter( {fmtDate} {isCreatingTodo} {isUpdatingTodo} + {isDueOverdue} {openTodoEditor} + {relativeDueLabel} bind:selectedTodo bind:showCreateTodo {todoActionLabel} diff --git a/src/features/dashboard/components/TodosTabDialogs.svelte b/src/features/dashboard/components/TodosTabDialogs.svelte index 199bbfb0a..fd3aab323 100644 --- a/src/features/dashboard/components/TodosTabDialogs.svelte +++ b/src/features/dashboard/components/TodosTabDialogs.svelte @@ -21,8 +21,12 @@ export let editTodoError: string; export let editingTodo: DashboardTodoItem | null; export let fmtDate: (value: string | Date | null | undefined) => string; export let isCreatingTodo: boolean; +export let isDueOverdue: (value: string | Date | null | undefined) => boolean; export let isUpdatingTodo: boolean; export let openTodoEditor: (todo: DashboardTodoItem) => void; +export let relativeDueLabel: ( + value: string | Date | null | undefined, +) => string; export let selectedTodo: DashboardTodoItem | null; export let showCreateTodo: boolean; export let todoActionLabel: (todo: DashboardTodoItem) => string; @@ -53,10 +57,12 @@ export let updateTodoAction: SubmitFunction; deleteTodo(todo)} {fmtDate} + {isDueOverdue} onClose={() => { selectedTodo = null; }} {openTodoEditor} + {relativeDueLabel} todo={selectedTodo} {todoActionLabel} {todoSavingById} diff --git a/src/features/dashboard/lib/dashboard-controller-types.ts b/src/features/dashboard/lib/dashboard-controller-types.ts index 6d4e8489d..965fee282 100644 --- a/src/features/dashboard/lib/dashboard-controller-types.ts +++ b/src/features/dashboard/lib/dashboard-controller-types.ts @@ -386,6 +386,7 @@ export type DashboardTodosCopy = DashboardRecord & { saveFailed: string; uncompleteSuccess: string; saving: string; + statusLabel: string; subtitle: string; titleLabel: string; titlePlaceholder: string; diff --git a/src/features/dashboard/lib/todos-tab-display.ts b/src/features/dashboard/lib/todos-tab-display.ts index eaf27970b..be7734d03 100644 --- a/src/features/dashboard/lib/todos-tab-display.ts +++ b/src/features/dashboard/lib/todos-tab-display.ts @@ -5,7 +5,11 @@ import type { DashboardTodoItem, DashboardTodosCopy, } from "./dashboard-controller-types"; -import { formatDashboardDateTime } from "./date-formatters"; +import { + formatDashboardDateTime, + formatDashboardDueRelativeTime, + isDashboardDueOverdue, +} from "./date-formatters"; import { todoActionLabel as buildTodoActionLabel, todoStatus as buildTodoStatus, @@ -44,5 +48,14 @@ export function createTodoTabDisplayActions({ completed: dashboardCopy.completedStatus, pending: dashboardCopy.pendingStatus, }), + relativeDueLabel: (value: Date | string | null | undefined) => + formatDashboardDueRelativeTime( + value, + sectionCopy.dateTBD, + referenceDate, + locale, + ), + isDueOverdue: (value: Date | string | null | undefined) => + isDashboardDueOverdue(value, referenceDate), }; } diff --git a/src/features/homeworks/components/HomeworkDeadlineSummary.svelte b/src/features/homeworks/components/HomeworkDeadlineSummary.svelte index a3a526d2c..99f46e571 100644 --- a/src/features/homeworks/components/HomeworkDeadlineSummary.svelte +++ b/src/features/homeworks/components/HomeworkDeadlineSummary.svelte @@ -4,7 +4,6 @@ import type { HomeworkDeadlineState, HomeworkDetailModel, } from "@/features/homeworks/lib/homework-presentation"; -import { Badge } from "$lib/components/ui/badge/index.js"; import { cn } from "$lib/utils.js"; import type { HomeworkDetailCopy, @@ -25,34 +24,20 @@ $: relativeLabel = relativeEtaLabel(homework.submissionDueAt, referenceDate); $: isOverdue = deadlineState === "overdue"; -
-
-
{copy.submissionDue}
-
- {dueLabel} -
-
+

{copy.submissionDue}

+

+ {dueLabel} +

+ {#if homework.submissionDueAt && relativeLabel} +

- {copy.relativeTime} - - {relativeLabel} -

-
- -
-
{copy.statusLabel}
-
- - {homework.completed ? copy.completedLabel : copy.pendingLabel} - -
-
-
+ {relativeLabel} +

+ {/if} + diff --git a/src/features/homeworks/components/HomeworkDetailDialog.svelte b/src/features/homeworks/components/HomeworkDetailDialog.svelte index a54918762..501d4aebc 100644 --- a/src/features/homeworks/components/HomeworkDetailDialog.svelte +++ b/src/features/homeworks/components/HomeworkDetailDialog.svelte @@ -109,8 +109,8 @@ onMount(() => { referenceDate={liveReferenceDate} {relativeEtaLabel} /> - + {/if} -import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; import type { HomeworkDateValue, HomeworkDetailModel, } from "@/features/homeworks/lib/homework-presentation"; -import { Badge } from "$lib/components/ui/badge/index.js"; -import { Button } from "$lib/components/ui/button/index.js"; -import * as Collapsible from "$lib/components/ui/collapsible/index.js"; +import * as Table from "$lib/components/ui/table/index.js"; import type { HomeworkDetailCopy, HomeworkDetailDateFormatter, @@ -16,11 +13,13 @@ export let copy: HomeworkDetailCopy; export let fmtDate: HomeworkDetailDateFormatter; export let homework: HomeworkDetailModel; -$: detailTags = [ - ...(homework.isMajor ? [copy.tagMajor] : []), - ...(homework.requiresTeam ? [copy.tagTeam] : []), -]; -let detailsOpen = false; +$: flagLabels = [ + homework.isMajor ? copy.tagMajor : null, + homework.requiresTeam ? copy.tagTeam : null, +].filter((label): label is string => Boolean(label)); +$: statusValue = [homework.completed ? copy.completedLabel : copy.pendingLabel] + .concat(flagLabels) + .join(" · "); function displayDate(value: HomeworkDateValue) { return fmtDate(value); @@ -28,54 +27,39 @@ function displayDate(value: HomeworkDateValue) {
- - - {#snippet child({ props })} - - {/snippet} - - -
-
-
{copy.submissionStart}
-
{displayDate(homework.submissionStartAt)}
-
-
-
{copy.publishedAt}
-
{displayDate(homework.publishedAt)}
-
- {#if detailTags.length > 0} -
-
{copy.moreDetails}
-
- {#if homework.isMajor} - {copy.tagMajor} - {/if} - {#if homework.requiresTeam} - {copy.tagTeam} - {/if} -
-
- {/if} -
-
-
+ {copy.statusLabel} + + {statusValue} + + + + {copy.submissionStart} + + + {displayDate(homework.submissionStartAt)} + + + + + {copy.publishedAt} + + + {displayDate(homework.publishedAt)} + + + +
diff --git a/src/features/section-detail/lib/section-detail-controller-types.ts b/src/features/section-detail/lib/section-detail-controller-types.ts index 9b1a85555..88f6d2580 100644 --- a/src/features/section-detail/lib/section-detail-controller-types.ts +++ b/src/features/section-detail/lib/section-detail-controller-types.ts @@ -178,6 +178,7 @@ export type SectionDetailCopy = { descriptionPlaceholder: string; dueDateShortcuts: string; editAction: string; + filterIncomplete: string; helperClear: string; helperMonth: string; helperPublishNow: string; diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte index 2a38793e3..d0b45e455 100644 --- a/src/lib/components/ui/dialog/dialog-content.svelte +++ b/src/lib/components/ui/dialog/dialog-content.svelte @@ -37,9 +37,14 @@ {#if showCloseButton} {#snippet child({ props })} - {/snippet} diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte index 20957dccb..11261a0c0 100644 --- a/src/lib/components/ui/sheet/sheet-content.svelte +++ b/src/lib/components/ui/sheet/sheet-content.svelte @@ -44,9 +44,14 @@ {#if showCloseButton} {#snippet child({ props })} - {/snippet} diff --git a/tests/e2e/src/app/dashboard/homeworks/test.ts b/tests/e2e/src/app/dashboard/homeworks/test.ts index 33abe8c04..112e80eb9 100644 --- a/tests/e2e/src/app/dashboard/homeworks/test.ts +++ b/tests/e2e/src/app/dashboard/homeworks/test.ts @@ -23,6 +23,14 @@ */ import { expect, test } from "@playwright/test"; import { signInAsDebugUser } from "../../../../utils/auth"; +import { + closeDetailDialog, + detailDialog, + expectComfortablePopupWidth, + expectHomeworkDetailOrder, + expectIconOnlyCloseButton, + expectSingleColumnDiscussion, +} from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { cleanupHomeworksForE2e } from "../../../../utils/homeworks"; import { visibleText } from "../../../../utils/locators"; @@ -522,6 +530,34 @@ test.describe("仪表盘作业", () => { await captureStepScreenshot(page, testInfo, "homeworks/completion-error"); }); + test("作业详情弹窗单栏展示截止日期、讨论与图标关闭按钮", async ({ page }) => { + await signInAsDebugUser(page, "/workspace/homeworks"); + await ensureSeedSectionSubscription(page); + await gotoAndWaitForReady(page, "/workspace/homeworks"); + + await page + .getByRole("radio", { name: /全部|All/i }) + .first() + .click(); + + const row = page + .getByRole("row") + .filter({ hasText: DEV_SEED.homeworks.title }) + .first(); + await row + .getByRole("button", { name: new RegExp(DEV_SEED.homeworks.title) }) + .first() + .click(); + + const dialog = detailDialog(page); + await expect(dialog).toBeVisible(); + await expectHomeworkDetailOrder(dialog); + await expectSingleColumnDiscussion(dialog); + await expectComfortablePopupWidth(page, dialog); + await expectIconOnlyCloseButton(dialog); + await closeDetailDialog(page, dialog); + }); + test("作业详情链接到班级页面且不打开第二层详情", async ({ page, }, testInfo) => { diff --git a/tests/e2e/src/app/dashboard/todos/test.ts b/tests/e2e/src/app/dashboard/todos/test.ts index 39859dd6d..4c8ac395e 100644 --- a/tests/e2e/src/app/dashboard/todos/test.ts +++ b/tests/e2e/src/app/dashboard/todos/test.ts @@ -21,6 +21,11 @@ */ import { expect, type Page, test } from "@playwright/test"; import { signInAsDebugUser } from "../../../../utils/auth"; +import { + closeDetailDialog, + detailDialog, + expectDialogAction, +} from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { visibleText } from "../../../../utils/locators"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; @@ -203,6 +208,34 @@ test.describe("仪表盘待办", () => { await captureStepScreenshot(page, testInfo, "dashboard-todos-completed"); }); + test("待办详情弹窗展示优先级、状态与底部操作", async ({ page }, testInfo) => { + await signInAsDebugUser(page, "/workspace/todos"); + + await visibleText(page, DEV_SEED.todos.dueTodayTitle).first().click(); + + const dialog = detailDialog(page); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("heading", { + name: new RegExp(DEV_SEED.todos.dueTodayTitle), + }), + ).toBeVisible(); + const summary = dialog.getByTestId("todo-detail-summary"); + await expect(summary).toBeVisible(); + await expect(summary.getByText(/高|High/i).first()).toBeVisible(); + await expect( + summary.getByText(/待处理|已完成|Pending|Completed/i).first(), + ).toBeVisible(); + + await expectDialogAction(dialog, /删除待办|Delete todo/i); + await expectDialogAction(dialog, /编辑待办|Edit Todo/i); + await expectDialogAction(dialog, /标记为完成|Mark as complete/i); + + await captureStepScreenshot(page, testInfo, "todos/detail-dialog"); + + await closeDetailDialog(page, dialog); + }); + test("嵌套待办路由渲染服务端操作错误", async ({ page }, testInfo) => { await signInAsDebugUser(page, "/workspace/todos"); @@ -264,11 +297,13 @@ test.describe("仪表盘待办", () => { await visibleText(page, title).click(); const detailDialog = page.getByRole("dialog", { name: title }); await expect(detailDialog).toBeVisible(); - const detailText = await detailDialog.innerText(); + const summary = detailDialog.getByTestId("todo-detail-summary"); + await expect(summary).toBeVisible(); + const summaryText = await summary.innerText(); const localizedPriorityMatches = - detailText.match(/\b(?:Low|Medium|High)\b|[低中高]/g) ?? []; + summaryText.match(/\b(?:Low|Medium|High)\b|[低中高]/g) ?? []; expect(localizedPriorityMatches).toHaveLength(1); - expect(detailText).not.toMatch(/\b(?:low|medium|high)\b/); + expect(summaryText).not.toMatch(/\b(?:low|medium|high)\b/); const editButton = detailDialog.getByRole("button", { name: /编辑待办|Edit Todo/i, }); diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts index 094a6fba7..819d02249 100644 --- a/tests/e2e/src/app/sections/[jwId]/test.ts +++ b/tests/e2e/src/app/sections/[jwId]/test.ts @@ -45,6 +45,15 @@ import { snapshotDescriptionTargetForE2e, waitForDescriptionAuditRows, } from "../../../../utils/description-state"; +import { + closeDetailDialog, + detailDialog, + expectComfortablePopupWidth, + expectDetailDialogFitsViewport, + expectHomeworkDetailOrder, + expectIconOnlyCloseButton, + expectSingleColumnDiscussion, +} from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { getCurrentSessionUser } from "../../../../utils/e2e-db"; import { withE2ePrisma } from "../../../../utils/e2e-db/prisma"; @@ -1266,6 +1275,48 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { await captureStepScreenshot(page, testInfo, "section/homework-list-view"); }); + test("作业详情弹窗单栏展示截止日期、讨论与图标关闭按钮", async ({ page }) => { + await signInAsDebugUser(page, SECTION_URL); + await gotoAndWaitForReady(page, SECTION_URL); + await jumpToSection(page, /作业|Homework/i, "#homework"); + + await page + .getByRole("button", { + name: new RegExp(escapeForRegExp(DEV_SEED.homeworks.title)), + }) + .first() + .click(); + + const dialog = detailDialog(page); + await expect(dialog).toBeVisible(); + await expectHomeworkDetailOrder(dialog); + await expectSingleColumnDiscussion(dialog); + await expectComfortablePopupWidth(page, dialog); + await expectIconOnlyCloseButton(dialog); + await closeDetailDialog(page, dialog); + }); + + test("移动端作业详情弹窗纵向排布且不产生横向溢出", async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + await signInAsDebugUser(page, SECTION_URL); + await gotoAndWaitForReady(page, SECTION_URL); + await jumpToSection(page, /作业|Homework/i, "#homework"); + + await page + .getByRole("button", { + name: new RegExp(escapeForRegExp(DEV_SEED.homeworks.title)), + }) + .first() + .click(); + + const dialog = detailDialog(page); + await expect(dialog).toBeVisible(); + await expectDetailDialogFitsViewport(page, dialog); + await expectHomeworkDetailOrder(dialog); + await expectSingleColumnDiscussion(dialog); + await closeDetailDialog(page, dialog); + }); + test("已登录用户可创建作业、查看讨论、切换完成状态并删除", async ({ page, }, testInfo) => { @@ -1436,19 +1487,6 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { ).toHaveCount(0, { timeout: 15_000 }); await expect(detailDialog.getByText(description)).toBeVisible(); - const secondaryDetails = detailDialog.getByTestId( - "homework-secondary-details", - ); - const secondaryDetailsTrigger = secondaryDetails.getByRole("button", { - name: /More details|更多信息/i, - }); - await expect(secondaryDetailsTrigger).toContainText( - /Major assignment|大作业/i, - ); - await expect(secondaryDetailsTrigger).toContainText( - /Team required|需要组队/i, - ); - const deadlineSummary = detailDialog.getByTestId( "homework-deadline-summary", ); @@ -1456,6 +1494,16 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { /2026-12-31|2026\/12\/31|12\/31\/26|12月31日|Dec 31/, ); await expect(deadlineSummary).toContainText(/23:59|11:59 PM/); + + const factsTable = detailDialog.getByTestId("homework-secondary-details"); + await expect(factsTable).toContainText(/Major assignment|大作业/i); + await expect(factsTable).toContainText(/Team required|需要组队/i); + await expect(deadlineSummary).not.toContainText( + /Major assignment|大作业/i, + ); + await expect(deadlineSummary).not.toContainText( + /Team required|需要组队/i, + ); await captureStepScreenshot( page, testInfo, diff --git a/tests/e2e/src/app/test.ts b/tests/e2e/src/app/test.ts index bb9924e08..9114a2ca1 100644 --- a/tests/e2e/src/app/test.ts +++ b/tests/e2e/src/app/test.ts @@ -666,11 +666,25 @@ test("/ shell 折叠桌面侧边栏后图标链接仍可跳转", async ({ page } await expect( page.locator('[data-slot="sidebar"][data-state="collapsed"]'), ).toBeVisible(); - + await expect(catalogGroup).toBeDisabled(); await expect(coursesLink).toBeVisible(); - await coursesLink.click(); + await expect(coursesLink).toHaveAttribute("href", "/catalog/courses"); + // Icon-mode labels animate margin/opacity for 200ms; clicking before that + // can land on the neighboring Sections icon instead of Courses. + await expect + .poll(async () => + Math.round( + await coursesLink.evaluate( + (element) => element.getBoundingClientRect().width, + ), + ), + ) + .toBeLessThanOrEqual(40); - await page.waitForURL("**/catalog/courses"); + await Promise.all([ + page.waitForURL("**/catalog/courses"), + coursesLink.click(), + ]); await waitForUiSettled(page); await expect(page).toHaveURL(/\/catalog\/courses(?:\?.*)?$/); }); diff --git a/tests/e2e/utils/detail-dialog.ts b/tests/e2e/utils/detail-dialog.ts new file mode 100644 index 000000000..8cc801d3a --- /dev/null +++ b/tests/e2e/utils/detail-dialog.ts @@ -0,0 +1,118 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +const DETAIL_DIALOG_SELECTOR = '[data-slot="dialog-content"]'; + +export function detailDialog(page: Page) { + return page.locator(DETAIL_DIALOG_SELECTOR).first(); +} + +/** + * The shared dialog shell names its close control with `aria-label` only, so a + * visible "Close" string next to the icon is a regression. + */ +export async function expectIconOnlyCloseButton(dialog: Locator) { + const close = dialog.getByRole("button", { name: /^(Close|关闭)$/i }).first(); + await expect(close).toBeVisible(); + expect((await close.innerText()).trim()).toBe(""); + return close; +} + +export async function closeDetailDialog(page: Page, dialog: Locator) { + const close = await expectIconOnlyCloseButton(dialog); + await close.click(); + await expect(page.locator(DETAIL_DIALOG_SELECTOR)).toHaveCount(0, { + timeout: 5_000, + }); +} + +export async function expectDetailDialogFitsViewport( + page: Page, + dialog: Locator, +) { + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + + const viewport = page.viewportSize(); + const box = await dialog.boundingBox(); + expect(box).not.toBeNull(); + if (!box || !viewport) return; + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width + 1); + expect(box.height).toBeLessThanOrEqual(viewport.height); +} + +/** + * Homework popup (`docs/contracts/homework.json`): due datetime is primary, + * relative urgency is secondary, and remaining facts are a table. The table + * must not repeat the due date or expose a creation timestamp. + */ +export async function expectHomeworkDetailOrder(dialog: Locator) { + const dueSummary = dialog + .locator('[data-testid="homework-deadline-summary"]') + .first(); + await expect(dueSummary).toBeVisible(); + + const metadata = dialog + .locator('[data-testid="homework-secondary-details"]') + .locator("table") + .first(); + await expect(metadata).toBeVisible(); + await expect(metadata.getByText(/发布日期|Published/i)).toBeVisible(); + await expect(metadata.getByText(/提交开始|Submission opens/i)).toBeVisible(); + await expect(metadata.getByText(/提交截止|Submission due/i)).toHaveCount(0); + await expect(metadata.getByText(/创建于|Created/i)).toHaveCount(0); + + const dueBox = await dueSummary.boundingBox(); + const metadataBox = await metadata.boundingBox(); + expect(dueBox).not.toBeNull(); + expect(metadataBox).not.toBeNull(); + if (dueBox && metadataBox) { + expect(dueBox.y).toBeLessThan(metadataBox.y); + } +} + +/** + * Discussion is the next block in the same reading column, not a right-hand + * rail. Side rails belong on pages (`docs/contracts/_ui.json`). + */ +export async function expectSingleColumnDiscussion(dialog: Locator) { + const dueSummary = dialog + .locator('[data-testid="homework-deadline-summary"]') + .first(); + const discussion = dialog.locator('[data-testid="homework-discussion"]'); + await expect( + discussion.getByRole("heading", { name: /作业讨论|Homework discussion/i }), + ).toBeVisible(); + + const dueBox = await dueSummary.boundingBox(); + const discussionBox = await discussion.boundingBox(); + expect(dueBox).not.toBeNull(); + expect(discussionBox).not.toBeNull(); + if (!dueBox || !discussionBox) return; + + expect(discussionBox.y).toBeGreaterThan(dueBox.y); + expect(Math.abs(discussionBox.x - dueBox.x)).toBeLessThan(24); +} + +/** Overlays stay at a reading width; they must not become a two-column page. */ +export async function expectComfortablePopupWidth(page: Page, dialog: Locator) { + const viewport = page.viewportSize(); + const box = await dialog.boundingBox(); + expect(box).not.toBeNull(); + if (!box || !viewport) return; + if (viewport.width >= 1024) { + // Shared homework shell uses `sm:max-w-3xl` (48rem), not a page overlay. + expect(box.width).toBeLessThanOrEqual(800); + expect(box.width).toBeLessThan(viewport.width * 0.7); + } +} + +/** Primary actions live in the dialog, including the pinned footer. */ +export async function expectDialogAction(dialog: Locator, actionName: RegExp) { + const action = dialog.getByRole("button", { name: actionName }).first(); + await expect(action).toBeVisible(); + return action; +}