236 lines
10 KiB
TypeScript
236 lines
10 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const readLayout = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
|
|
|
describe("Layout breadcrumbs", () => {
|
|
it("shows the file version module before document detail titles", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('match: ["/file-versions", "/documents", "/trial/"]');
|
|
expect(source).toContain("study.viewContext?.pageTitle");
|
|
expect(source).toContain("items.push({ label: study.viewContext.pageTitle, path: route.path })");
|
|
});
|
|
|
|
it("uses context page titles instead of generic route detail titles", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain("const hasContextPageTitle = Boolean(study.viewContext?.pageTitle)");
|
|
expect(source).toContain("if (!hasContextPageTitle && title");
|
|
});
|
|
|
|
it("keeps project management as the parent for admin project detail breadcrumbs", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('if (route.path.startsWith("/admin/projects/"))');
|
|
expect(source).toContain("label: TEXT.menu.projectManagement");
|
|
expect(source).toContain('path: "/admin/projects"');
|
|
});
|
|
|
|
it("restores the permission management parent for admin permission sub-pages", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('if (route.path.startsWith("/admin/permissions/"))');
|
|
expect(source).toContain("label: TEXT.menu.permissionManagement");
|
|
});
|
|
|
|
it("injects the project name level on the admin sites page", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('if (route.path.includes("/sites"))');
|
|
expect(source).toContain("studies.value.find((s) => String(s.id) === pid)");
|
|
expect(source).toContain("items.push({ label: proj.name, path: `/admin/projects/${pid}` })");
|
|
});
|
|
|
|
it("keeps the admin root breadcrumb as a non-clickable label", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain("items.push({ label: TEXT.menu.admin });");
|
|
expect(source).not.toContain('path: isAdmin.value ? "/admin/users" : "/admin/projects"');
|
|
});
|
|
|
|
it("enables sibling navigation dropdowns on admin breadcrumbs", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain("item.navDropdown && item.siblings?.length");
|
|
expect(source).toContain('{ type: \'nav\', value: sib.path }');
|
|
expect(source).toContain("if (cmd.type === 'nav')");
|
|
expect(source).toContain("siblings: topItems");
|
|
});
|
|
|
|
it("builds project breadcrumbs from a permission-filtered nav tree", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain("const navTree: NavNode[] = [");
|
|
expect(source).toContain("label: TEXT.menu.sharedLibrary");
|
|
expect(source).toContain("label: TEXT.menu.riskIssues");
|
|
expect(source).toContain("label: TEXT.menu.materialManagement");
|
|
});
|
|
|
|
it("renders shared-library items as a third-level group, not top-level", () => {
|
|
const source = readLayout();
|
|
|
|
// 三级子项(医学咨询等)必须挂在顶级分组的 children 内,而非顶级数组
|
|
expect(source).toContain("children: [");
|
|
expect(source).toContain("canAccessProjectPath(c.path)");
|
|
expect(source).toContain("childSiblings.length > 1");
|
|
});
|
|
|
|
it("renders a header center region with project status but not ambiguous phase", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('class="header-center"');
|
|
expect(source).toContain("const headerProjectInfo = computed");
|
|
expect(source).toContain("statusTone");
|
|
expect(source).toContain('class="header-status-pill"');
|
|
expect(source).toContain("header-status-dot");
|
|
expect(source).not.toContain("headerProjectInfo.roleLabel");
|
|
expect(source).not.toContain("headerProjectInfo.phase");
|
|
expect(source).not.toContain("TEXT.common.labels.phase");
|
|
});
|
|
|
|
it("renders the project role next to the account menu", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('class="header-account-role"');
|
|
expect(source).toContain("accountProjectRoleLabel");
|
|
expect(source).toContain("v-if=\"accountProjectRoleLabel\"");
|
|
});
|
|
|
|
it("uses chip styling instead of vertical divider lines for header metadata", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain("class=\"header-meta-item header-metric\"");
|
|
expect(source).toContain("class=\"header-context-rail\"");
|
|
expect(source).toContain("border-radius: 14px");
|
|
expect(source).toContain("border: 1px solid transparent");
|
|
expect(source).toContain("background: transparent");
|
|
expect(source).toContain("box-shadow: none");
|
|
expect(source).toContain("overflow: visible");
|
|
expect(source).toContain("class=\"header-meta-value\"");
|
|
expect(source).toContain("font-variant-numeric: tabular-nums");
|
|
expect(source).toContain("border: 1px solid rgba(213, 224, 237, 0.94)");
|
|
expect(source).not.toContain("0 5px 14px rgba(31, 109, 80, 0.14)");
|
|
expect(source).not.toContain("border-left: 1px solid #e2e8f0");
|
|
expect(source).not.toContain("border-right: 1px solid #e2e8f0");
|
|
});
|
|
|
|
it("does not duplicate the account identity in the admin header center", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).not.toContain("const headerAdminInfo = computed");
|
|
expect(source).not.toContain("headerAdminInfo.email");
|
|
expect(source).not.toContain("header-admin-email");
|
|
});
|
|
|
|
it("renders a denser project context rail with planned site and enrollment metrics", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('class="header-context-rail"');
|
|
expect(source).toContain("headerProjectInfo.metrics");
|
|
expect(source).toContain("plannedSiteCount");
|
|
expect(source).toContain("plannedEnrollmentCount");
|
|
expect(source).toContain("headerOverviewStats");
|
|
expect(source).toContain("fetchProjectOverview");
|
|
expect(source).toContain("formatHeaderProgress");
|
|
expect(source).toContain("headerMetric.key");
|
|
expect(source).toContain("TEXT.common.labels.plannedSites");
|
|
expect(source).toContain("TEXT.common.labels.plannedEnrollment");
|
|
expect(source).toContain("TEXT.common.labels.dataUpdatedAt");
|
|
expect(source).toContain("loadHeaderOverviewStats");
|
|
expect(source).toContain("study.currentStudy?.id !== studyId");
|
|
expect(source).toContain("headerClockNow");
|
|
expect(source).toContain("formatHeaderDateTime");
|
|
expect(source).toContain("window.setInterval");
|
|
expect(source).toContain("window.clearInterval");
|
|
});
|
|
|
|
it("adds a compact project reminder dropdown for overdue risks", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('class="header-reminder-dropdown"');
|
|
expect(source).toContain("headerReminderTotal");
|
|
expect(source).toContain("headerReminderItems");
|
|
expect(source).toContain("fetchOverdueAesCount");
|
|
expect(source).toContain("listMonitoringVisitIssues");
|
|
expect(source).toContain("TEXT.common.labels.projectReminders");
|
|
expect(source).toContain("TEXT.common.labels.overdueAes");
|
|
expect(source).toContain("TEXT.common.labels.overdueMonitoringIssues");
|
|
expect(source).toContain("handleReminderCommand");
|
|
});
|
|
|
|
it("styles the app header as a fixed chrome layer above page content", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain("position: sticky");
|
|
expect(source).toContain("top: 0");
|
|
expect(source).toContain("z-index: 30");
|
|
expect(source).toContain(".layout-header::after");
|
|
expect(source).toContain("0 10px 26px rgba(15, 23, 42, 0.08)");
|
|
expect(source).toContain("border-bottom: 1px solid rgba(203, 213, 225, 0.78)");
|
|
});
|
|
|
|
it("omits visual breadcrumb separators and truncates long breadcrumb labels", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).not.toContain("breadcrumb-separator");
|
|
expect(source).not.toContain("TEXT.common.separators.dot");
|
|
expect(source).toContain("text-overflow: ellipsis");
|
|
expect(source).toContain("max-width: 52vw");
|
|
});
|
|
|
|
it("styles header breadcrumbs as compact navigation controls", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('class="breadcrumb-item-wrapper is-link is-study"');
|
|
expect(source).toContain("'is-current': index === breadcrumbs.length - 1");
|
|
expect(source).toContain(".breadcrumb-item-wrapper.is-study");
|
|
expect(source).toContain(".breadcrumb-item-wrapper.is-current");
|
|
expect(source).toContain("border-color: rgba(198, 214, 235, 0.92)");
|
|
expect(source).toContain("height: 26px");
|
|
expect(source).toContain("border-radius: 7px");
|
|
expect(source).toContain("gap: 2px");
|
|
});
|
|
|
|
it("uses stable breadcrumb keys when context titles change", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain(':key="breadcrumbKey(item, index)"');
|
|
expect(source).not.toContain(':key="index"');
|
|
expect(source).toContain("const breadcrumbKey = (item: any, index: number)");
|
|
});
|
|
|
|
it("guards route component rendering while router resolves async views", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('<component v-if="Component" :is="Component" />');
|
|
});
|
|
|
|
it("confirms manual logout and records the logout reason", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('command="logout" :disabled="loggingOut" divided');
|
|
expect(source).toContain("ElMessageBox.confirm(");
|
|
expect(source).toContain("确认退出登录");
|
|
expect(source).toContain("退出后需要重新登录才能继续访问项目数据。");
|
|
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
|
|
expect(source).toContain('loggingOut ? "正在退出..." : TEXT.menu.logout');
|
|
});
|
|
|
|
it("opens profile settings as a desktop-style dialog instead of navigating away", () => {
|
|
const source = readLayout();
|
|
|
|
expect(source).toContain('v-model="profileDialogVisible"');
|
|
expect(source).toContain('class="profile-settings-dialog"');
|
|
expect(source).toContain(':close-on-click-modal="false"');
|
|
expect(source).toContain(':close-on-press-escape="false"');
|
|
expect(source).toContain('@close-request="requestProfileDialogClose"');
|
|
expect(source).toContain('@dirty-change="profileDialogDirty = $event"');
|
|
expect(source).toContain('@saved="profileDialogVisible = false"');
|
|
expect(source).toContain("profileDialogVisible.value = true");
|
|
expect(source).toContain("confirmDiscardProfileChanges");
|
|
expect(source).not.toContain('router.push("/profile")');
|
|
});
|
|
});
|