整理测试代码并清理文档引用
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./FaqCategoryForm.vue"), "utf8");
|
||||
|
||||
describe("FaqCategoryForm drawer editor", () => {
|
||||
it("uses the shared right-side drawer pattern instead of a dialog", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<el-drawer");
|
||||
expect(source).toContain("faq-category-editor-drawer");
|
||||
expect(source).toContain('direction="rtl"');
|
||||
expect(source).toContain("editor-header");
|
||||
expect(source).toContain("drawer-footer");
|
||||
expect(source).not.toContain("<el-dialog");
|
||||
});
|
||||
|
||||
it("labels the required name field as category name", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("TEXT.modules.knowledgeMedicalConsult.categoryName");
|
||||
expect(source).not.toContain(':label="TEXT.common.fields.name"');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./FaqItemForm.vue"), "utf8");
|
||||
|
||||
describe("FaqItemForm drawer", () => {
|
||||
it("uses the unified drawer pattern instead of a dialog", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<el-drawer");
|
||||
expect(source).toContain('class="faq-item-editor-drawer"');
|
||||
expect(source).toContain('class="editor-header"');
|
||||
expect(source).toContain('class="drawer-footer"');
|
||||
expect(source).not.toContain("<el-dialog");
|
||||
expect(source).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
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")');
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
||||
|
||||
describe("layout system monitoring navigation", () => {
|
||||
it("shows system monitoring as an admin-only peer module outside permission management", () => {
|
||||
const source = readSource();
|
||||
const permissionMenuStart = source.indexOf('<el-sub-menu v-if="canAccessAdminPermissions" index="permissions">');
|
||||
const permissionMenuEnd = source.indexOf("</el-sub-menu>", permissionMenuStart);
|
||||
const permissionMenu = source.slice(permissionMenuStart, permissionMenuEnd);
|
||||
|
||||
expect(source).toContain('v-if="isAdmin" index="/admin/system-monitoring"');
|
||||
expect(source).toContain("{{ TEXT.menu.systemMonitoring }}");
|
||||
expect(permissionMenu).not.toContain("/admin/system-monitoring");
|
||||
expect(permissionMenu).not.toContain("/admin/permission-monitoring");
|
||||
expect(permissionMenu).not.toContain("/admin/permissions/monitoring");
|
||||
expect(source).not.toContain('index="/admin/permissions/monitoring"');
|
||||
expect(source).toContain('return "/admin/system-monitoring";');
|
||||
});
|
||||
});
|
||||
@@ -1,347 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
|
||||
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
|
||||
const readWorldMapSource = () => readFileSync(resolve(__dirname, "./worldMapSource.ts"), "utf8");
|
||||
|
||||
describe("PermissionIpLocations", () => {
|
||||
it("renders IP location visualization without duplicate region detail table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("fetchIpLocations");
|
||||
expect(source).toContain("unique_ip_count");
|
||||
expect(source).not.toContain("来源地域明细");
|
||||
expect(source).not.toContain("table-card");
|
||||
expect(source).not.toContain("<el-table :data=\"items\"");
|
||||
});
|
||||
|
||||
it("renders an interactive China map and primary metric cards", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("VChart");
|
||||
expect(source).toContain("sourceMapOption");
|
||||
expect(source).toContain("来源分布");
|
||||
expect(source).toContain("访问次数");
|
||||
expect(source).toContain("访问用户数");
|
||||
expect(source).toContain("来源 IP 数");
|
||||
expect(source).toContain("summary");
|
||||
});
|
||||
|
||||
it("defaults the access source period to 90 days", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const days = ref(90);");
|
||||
expect(source).not.toContain("const days = ref(7);");
|
||||
expect(source).toContain('<el-radio-button :value="90">90天</el-radio-button>');
|
||||
expect(source).toContain('90: "近 90 天"');
|
||||
});
|
||||
|
||||
it("does not overlay the source distribution card with a loading mask", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('<section class="distribution-card">');
|
||||
expect(source).toContain('<el-button :loading="loading" size="small" :disabled="loading" @click="loadData">');
|
||||
expect(source).not.toContain('<section v-loading="loading" class="distribution-card">');
|
||||
});
|
||||
|
||||
it("supports switching source distribution between China and global flow views without the global heat map", () => {
|
||||
const source = readSource();
|
||||
const worldMapSource = readWorldMapSource();
|
||||
|
||||
expect(source).toContain("../assets/world.json");
|
||||
expect(source).toContain('registerMap("ctms-world"');
|
||||
expect(source).toContain('const mapView = ref<"china" | "flow">("china");');
|
||||
expect(source).toContain("mapViewOptions");
|
||||
expect(source).toContain("<el-segmented v-model=\"mapView\"");
|
||||
expect(source).toContain("sourceMapOption");
|
||||
expect(source).toContain('label: "中国"');
|
||||
expect(source).toContain('label: "全球"');
|
||||
expect(source).not.toContain('label: "全球", value: "world"');
|
||||
expect(source).not.toContain("worldData");
|
||||
expect(source).not.toContain("buildMapOption");
|
||||
expect(worldMapSource).toContain("unpkg.com/echarts@4.9.0/map/json/world.json");
|
||||
expect(worldMapSource).toContain("ECharts 世界地图 GeoJSON");
|
||||
expect(worldMapSource).toContain('"United States": "United States"');
|
||||
});
|
||||
|
||||
it("supports a global access flow view pointing sources to the server location", () => {
|
||||
const source = readSource();
|
||||
const worldMapSource = readWorldMapSource();
|
||||
|
||||
expect(source).toContain('const mapView = ref<"china" | "flow">("china");');
|
||||
expect(source).toContain('label: "全球"');
|
||||
expect(source).toContain("LinesChart");
|
||||
expect(source).toContain("EffectScatterChart");
|
||||
expect(source).toContain("GeoComponent");
|
||||
expect(source).toContain("buildFlowMapOption");
|
||||
expect(source).toContain("serverLocation");
|
||||
expect(source).toContain("resolveSourceCoordinate");
|
||||
expect(source).toContain('coordinateSystem: "geo"');
|
||||
expect(source).toContain("coords: [point.coord, serverLocation.coord]");
|
||||
expect(source).toContain("服务器所在地");
|
||||
expect(worldMapSource).toContain("resolveSourceCoordinate");
|
||||
expect(worldMapSource).toContain('"United States": [-95.7129, 37.0902]');
|
||||
});
|
||||
|
||||
it("uses a China access flow map for the China view", () => {
|
||||
const source = readSource();
|
||||
const worldMapSource = readWorldMapSource();
|
||||
|
||||
expect(source).toContain("chinaFlowSourceData");
|
||||
expect(source).toContain('buildFlowMapOption(chinaFlowSourceData.value, "ctms-china", 1.08, "中国访问链路")');
|
||||
expect(source).not.toContain('buildMapOption("ctms-china", provinceData.value, 1.08)');
|
||||
expect(source).toContain('mapView.value === "china" ? chinaFlowSourceData.value');
|
||||
expect(worldMapSource).toContain('"江苏省": [118.7633, 32.0617]');
|
||||
expect(worldMapSource).toContain('"上海市": [121.4737, 31.2304]');
|
||||
});
|
||||
|
||||
it("uses the same source rows for the global flow map and the source ranking", () => {
|
||||
const source = readSource();
|
||||
const worldMapSource = readWorldMapSource();
|
||||
|
||||
expect(source).toContain("flowSourceData");
|
||||
expect(source).toContain('mapView.value === "flow" ? flowSourceData.value : rankedSourceRows.value');
|
||||
expect(source).toContain("buildFlowMapOption(flowSourceData.value)");
|
||||
expect(source).not.toContain("buildFlowMapOption(worldData.value)");
|
||||
expect(source).toContain('const buildFlowMapOption = (data: IpLocationStatItem[], mapName = "ctms-world"');
|
||||
expect(source).toContain("coord: resolveSourceCoordinate(item)");
|
||||
expect(source).toContain("name: formatLocation(point)");
|
||||
expect(worldMapSource).toContain("cityCoordinates");
|
||||
expect(worldMapSource).toContain('"重庆市": [106.5516, 29.563]');
|
||||
expect(worldMapSource).toContain('"南京市": [118.7969, 32.0603]');
|
||||
expect(worldMapSource).toContain('"Istanbul": [28.9784, 41.0082]');
|
||||
expect(worldMapSource).toContain('"South Holland": [4.493, 52.0208]');
|
||||
});
|
||||
|
||||
it("keeps global flow lines thin and readable", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const lineWidth = point.abnormal ? 1.1 : 0.8");
|
||||
expect(source).toContain("width: lineWidth");
|
||||
expect(source).toContain("opacity: point.abnormal ? 0.58 : 0.38");
|
||||
expect(source).not.toContain("width: Math.min(5");
|
||||
expect(source).not.toContain("Math.sqrt(point.total_count");
|
||||
});
|
||||
|
||||
it("keeps domestic flow markers compact so nearby China locations stay visible", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const isDomestic = !params?.data?.abnormal");
|
||||
expect(source).toContain("const maxSize = isDomestic ? 12 : 16");
|
||||
expect(source).toContain("const minSize = isDomestic ? 5 : 7");
|
||||
expect(source).toContain("scale: 1.7");
|
||||
expect(source).toContain("scale: 1.5");
|
||||
expect(source).toContain("symbolSize: 9");
|
||||
expect(source).not.toContain("scale: 3");
|
||||
expect(source).not.toContain("scale: 2.4");
|
||||
expect(source).not.toContain("symbolSize: 14");
|
||||
});
|
||||
|
||||
it("shows a compact flow legend and keeps the server tooltip address-only", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("source-map-wrap");
|
||||
expect(source).toContain("fullscreen-map-wrap");
|
||||
expect(source).toContain("flow-legend");
|
||||
expect(source).toContain("map-legend");
|
||||
expect(source).toContain("服务器");
|
||||
expect(source).toContain("正常访问点");
|
||||
expect(source).toContain("异常访问点");
|
||||
expect(source).toContain('class="legend-dot server"');
|
||||
expect(source).toContain('class="legend-dot normal"');
|
||||
expect(source).toContain('class="legend-dot abnormal"');
|
||||
expect(source).toContain('if (row.isServer) return "中国 / 上海";');
|
||||
expect(source).not.toContain('if (row.isServer) return `${serverLocation.name}<br/>中国 / 上海`;');
|
||||
expect(source).not.toContain("fullscreen-legend");
|
||||
});
|
||||
|
||||
it("supports fullscreen preview for the current source map", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("fullscreenVisible");
|
||||
expect(source).toContain("openFullscreenPreview");
|
||||
expect(source).toContain("resizeFullscreenChart");
|
||||
expect(source).toContain("<el-dialog");
|
||||
expect(source).toContain("fullscreen");
|
||||
expect(source).toContain("destroy-on-close");
|
||||
expect(source).toContain(':close-on-click-modal="false"');
|
||||
expect(source).toContain('aria-label="全屏预览"');
|
||||
expect(source).toContain("fullscreenMapKey");
|
||||
expect(source).toContain("fullscreen-source-map");
|
||||
expect(source).toContain("全屏预览");
|
||||
expect(source).toContain("退出全屏");
|
||||
expect(source).toContain(":option=\"sourceMapOption\"");
|
||||
});
|
||||
|
||||
it("raises canvas pixel ratio for sharper fullscreen map rendering", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("getDevicePixelRatio");
|
||||
expect(source).toContain("chartInitOptions");
|
||||
expect(source).toContain("fullscreenChartInitOptions");
|
||||
expect(source).toContain(":init-options=\"chartInitOptions\"");
|
||||
expect(source).toContain(":init-options=\"fullscreenChartInitOptions\"");
|
||||
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
|
||||
expect(source).toContain("Math.min(getDevicePixelRatio() * 1.5, 3)");
|
||||
});
|
||||
|
||||
it("uses a flexible full-page layout so the source map fills the remaining viewport", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("height: 100%");
|
||||
expect(source).toContain("min-height: 0");
|
||||
expect(source).toContain("flex: 1 1 auto");
|
||||
expect(source).toContain("grid-template-rows: minmax(0, 1fr)");
|
||||
expect(source).toContain("height: 100%");
|
||||
expect(source).toContain("overflow-y: auto");
|
||||
expect(source).not.toContain("height: 320px");
|
||||
expect(source).not.toContain("min-height: 430px");
|
||||
});
|
||||
|
||||
it("prevents horizontal scrolling in the source ranking panel", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(".rank-panel");
|
||||
expect(source).toContain("overflow: hidden");
|
||||
expect(source).toContain("overflow-x: hidden");
|
||||
expect(source).toContain("overflow-y: auto");
|
||||
expect(source).toContain("box-sizing: border-box");
|
||||
expect(source).toContain("grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content)");
|
||||
expect(source).toContain("max-width: 100%");
|
||||
expect(source).toContain("text-overflow: ellipsis");
|
||||
expect(source).toContain("text-align: right");
|
||||
});
|
||||
|
||||
it("recreates the chart when switching map modes to avoid stale geo state", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(':key="mapView"');
|
||||
expect(source).toContain(':update-options="chartUpdateOptions"');
|
||||
expect(source).toContain("const chartUpdateOptions = { notMerge: true }");
|
||||
});
|
||||
|
||||
it("resizes the source map after data load and keeps the chart container full width", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('ref="sourceChartRef"');
|
||||
expect(source).toContain("const sourceChartRef = ref<{ resize?: () => void } | null>(null)");
|
||||
expect(source).toContain("const resizeSourceCharts = async () =>");
|
||||
expect(source).toContain("sourceChartRef.value?.resize?.()");
|
||||
expect(source).toContain("await resizeSourceCharts()");
|
||||
expect(source).toContain("defineExpose({ refresh: loadData, resize: resizeSourceCharts })");
|
||||
expect(source).not.toContain(".source-map :deep(> div)");
|
||||
});
|
||||
|
||||
it("uses source-analysis copy instead of IP-location tab wording", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("访问来源分析");
|
||||
expect(source).toContain("来源分布");
|
||||
expect(source).toContain("热点来源地");
|
||||
expect(source).not.toContain("查看访问来源的地理分布");
|
||||
expect(source).not.toContain("访问来源地理分布热力图");
|
||||
expect(source).not.toContain("按访问次数排序");
|
||||
expect(source).not.toContain("IP 属地分析");
|
||||
expect(source).not.toContain("中国地图");
|
||||
expect(source).not.toContain("热点属地");
|
||||
expect(source).not.toContain("用户分布图");
|
||||
});
|
||||
|
||||
it("uses the standard geojson.cn China map data instead of generated rectangles", () => {
|
||||
const source = readSource();
|
||||
const mapSource = readMapSource();
|
||||
|
||||
expect(source).toContain("../assets/china.json");
|
||||
expect(mapSource).toContain("geojson.cn/api/china/1.6.3/china.json");
|
||||
expect(mapSource).not.toContain("ProvinceBox");
|
||||
expect(mapSource).not.toContain("provinceBoxes");
|
||||
});
|
||||
|
||||
it("removes heat-map visual legends and keeps map zoom interactions disabled", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("visualMap:");
|
||||
expect(source).not.toContain("VisualMapComponent");
|
||||
expect(source).not.toContain("hasMapData");
|
||||
expect(source).not.toContain("maxValue");
|
||||
expect(source).not.toContain('color: ["#93c5fd", "#60a5fa", "#3b82f6", "#2563eb", "#1d4ed8"]');
|
||||
expect(source).not.toContain("outOfRange:");
|
||||
expect(source).toContain("roam: false");
|
||||
expect(source).not.toContain("scaleLimit");
|
||||
});
|
||||
|
||||
it("uses a stronger base map style so borders remain visible behind flow lines", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('areaColor: "#e6edf8"');
|
||||
expect(source).toContain('borderColor: "#aebed2"');
|
||||
expect(source).toContain("borderWidth: 1");
|
||||
expect(source).toContain('shadowColor: "rgba(37, 99, 235, 0.08)"');
|
||||
expect(source).toContain("shadowBlur: 10");
|
||||
expect(source).toContain("shadowOffsetY: 2");
|
||||
expect(source).toContain('areaColor: "#dbeafe", borderColor: "#64748b", borderWidth: 1.3');
|
||||
expect(source).not.toContain('areaColor: "#eef4ff"');
|
||||
expect(source).not.toContain('borderColor: "#dbe7f7"');
|
||||
expect(source).not.toContain("borderWidth: 0.8");
|
||||
});
|
||||
|
||||
it("keeps geo hover interaction while avoiding empty white tooltip panels", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const formatGeoTooltip = (params: any) =>");
|
||||
expect(source).toContain('return name || "暂无访问数据";');
|
||||
expect(source).toContain('trigger: "item"');
|
||||
expect(source).toContain("confine: true");
|
||||
expect(source).toContain("formatter: formatGeoTooltip");
|
||||
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatGeoTooltip,\n }");
|
||||
expect(source).toContain("formatLineTooltip");
|
||||
expect(source).toContain("formatPointTooltip");
|
||||
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatLineTooltip,\n }");
|
||||
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatPointTooltip,\n }");
|
||||
expect(source).not.toContain("silent: true");
|
||||
expect(source).not.toContain("tooltip: { show: false }");
|
||||
});
|
||||
|
||||
it("uses narrow metric cards without helper subtitles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("min-height: 64px");
|
||||
expect(source).toContain("padding: 10px 16px");
|
||||
expect(source).toContain("right: -34px");
|
||||
expect(source).toContain("bottom: -42px");
|
||||
expect(source).toContain("border-radius: 18px");
|
||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
||||
expect(source).not.toContain("hint:");
|
||||
expect(source).not.toContain("权限检查总量");
|
||||
});
|
||||
|
||||
it("removes the duplicated hero copy above the map", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("ip-locations-toolbar");
|
||||
expect(source).not.toContain("权限监控 / IP属地");
|
||||
expect(source).not.toContain("按访问日志聚合省市、用户与来源 IP");
|
||||
expect(source).not.toContain("悬停省份查看访问次数、访问用户数和来源 IP 数");
|
||||
expect(source).not.toContain("hero-desc");
|
||||
expect(source).not.toContain("eyebrow");
|
||||
});
|
||||
|
||||
it("places the period filter toolbar on the right", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(".ip-hero");
|
||||
expect(source).toContain("justify-content: space-between");
|
||||
expect(source).toContain("margin-left: auto");
|
||||
});
|
||||
|
||||
it("keeps non-China source labels visible and lists top 10 regions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('row.country && row.country !== "中国" ? row.country : ""');
|
||||
expect(source).toContain("row.isp");
|
||||
expect(source).toContain('parts.join(" / ")');
|
||||
expect(source).toContain(".slice(0, 10)");
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
|
||||
|
||||
describe("PermissionTemplateSelector", () => {
|
||||
it("renders compact role permission cards without footer placeholder space", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("角色权限概览");
|
||||
expect(source).toContain("align-items: start");
|
||||
expect(source).toContain("padding: 12px 14px");
|
||||
expect(source).not.toContain("card-footer");
|
||||
expect(source).not.toContain("点击编辑权限");
|
||||
expect(source).not.toContain("inactive-label");
|
||||
expect(source).not.toContain("edit-hint");
|
||||
});
|
||||
|
||||
it("reads current project permissions by role key, not by role label", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-for="template in sortedTemplates"');
|
||||
expect(source).toContain('<span class="card-name">{{ template.name }}</span>');
|
||||
expect(source).toContain("refreshKey?: number;");
|
||||
expect(source).toContain("watch(() => props.refreshKey, loadTemplates);");
|
||||
expect(source).not.toContain("ROLE_LABELS");
|
||||
expect(source).toContain("template.category && props.currentPermissions[template.category]");
|
||||
expect(source).toContain("enabledPercent(template.category)");
|
||||
expect(source).toContain("countCurrentEnabled(template.category)");
|
||||
expect(source).toContain("countCurrentDisabled(template.category)");
|
||||
expect(source).toContain("const perms = props.currentPermissions?.[role];");
|
||||
expect(source).toContain("emit('edit-role', template.category!)");
|
||||
});
|
||||
|
||||
it("sorts role cards with the shared role template order", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("compareRolesByTemplateOrder");
|
||||
expect(source).toContain("const sortedTemplates = computed");
|
||||
expect(source).toContain("compareRolesByTemplateOrder(a.category, b.category)");
|
||||
});
|
||||
|
||||
it("does not keep hard-coded preset role labels or icons", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain('QA: "QA"');
|
||||
expect(source).not.toContain('CTA: "CTA"');
|
||||
expect(source).not.toContain('QA: "✅"');
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
|
||||
|
||||
describe("PermissionTrendCharts", () => {
|
||||
it("uses operation-oriented trend copy", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("系统性能趋势");
|
||||
expect(source).toContain("监测事件趋势");
|
||||
expect(source).toContain("响应时间趋势");
|
||||
expect(source).toContain("缓存命中率");
|
||||
expect(source).toContain("异常拒绝率趋势");
|
||||
expect(source).toContain('name: "异常拒绝率"');
|
||||
|
||||
expect(source).not.toContain("数据趋势");
|
||||
expect(source).not.toContain("查看系统运行监测指标的变化趋势");
|
||||
expect(source).not.toContain("查看权限系统各项指标的变化趋势");
|
||||
expect(source).not.toContain("检查量趋势");
|
||||
expect(source).not.toContain("<h4>拒绝率趋势</h4>");
|
||||
expect(source).not.toContain('name: "拒绝率"');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readQuickActionsSource = () => readFileSync(resolve(__dirname, "./QuickActions.vue"), "utf8");
|
||||
|
||||
describe("QuickActions project permissions", () => {
|
||||
it("filters quick action entries with the same route permission contract as sidebar and router", () => {
|
||||
const source = readQuickActionsSource();
|
||||
|
||||
expect(source).toContain("useAuthStore");
|
||||
expect(source).toContain("useStudyStore");
|
||||
expect(source).toContain("getProjectRoutePermission");
|
||||
expect(source).toContain("hasProjectPermission");
|
||||
expect(source).toContain("isSystemAdmin");
|
||||
expect(source).toContain("visibleActions");
|
||||
expect(source).toContain("v-for=\"item in visibleActions\"");
|
||||
expect(source).not.toContain("v-for=\"item in actions\"");
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readAttachmentList = () => readFileSync(resolve(__dirname, "./AttachmentList.vue"), "utf8");
|
||||
|
||||
describe("AttachmentList permissions", () => {
|
||||
it("does not require project member list permission to render uploaders", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).not.toContain("listMembers");
|
||||
});
|
||||
|
||||
it("matches the contract-fee attachment table layout", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain('prop="filename" :label="TEXT.common.labels.filename" min-width="360" show-overflow-tooltip');
|
||||
expect(source).toContain(':label="TEXT.common.labels.size" min-width="180"');
|
||||
expect(source).toContain(':label="TEXT.common.labels.uploader" min-width="180"');
|
||||
expect(source).toContain('prop="uploaded_at" :label="TEXT.common.labels.uploadedAt" min-width="180" show-overflow-tooltip');
|
||||
expect(source).toContain(':label="TEXT.common.labels.actions" min-width="180"');
|
||||
expect(source).not.toContain('width="160"');
|
||||
expect(source).toContain('class="attachment-actions"');
|
||||
expect(source).toContain("flex-wrap: nowrap;");
|
||||
expect(source).toContain("gap: 4px;");
|
||||
});
|
||||
|
||||
it("can aggregate multiple attachment entity types into one table with an attachment type column", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("entityGroups?: AttachmentEntityGroup[]");
|
||||
expect(source).toContain('v-if="hasEntityGroups"');
|
||||
expect(source).toContain('label="附件类型"');
|
||||
expect(source).toContain("scope.row.attachment_type_label");
|
||||
expect(source).toContain("fetchAttachments(props.studyId, group.entityType, props.entityId)");
|
||||
expect(source).toContain("attachment_type_label: group.label");
|
||||
expect(source).toContain("attachment_entity_type: group.entityType");
|
||||
});
|
||||
|
||||
it("can reload attachments when the parent refresh key changes", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("refreshKey?: number");
|
||||
expect(source).toContain("() => props.refreshKey");
|
||||
expect(source).toContain("load();");
|
||||
});
|
||||
|
||||
it("can hide the uploader while keeping the attachment table visible", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("hideUploader?: boolean");
|
||||
expect(source).toContain("canShowUploader");
|
||||
expect(source).toContain("!props.hideUploader && canCreate.value");
|
||||
});
|
||||
|
||||
it("hides the default attachment title row when uploader is hidden and no explicit title is provided", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("const showHeader = computed(() => !!props.title || canShowUploader.value)");
|
||||
expect(source).toContain('<div v-if="showHeader" class="header">');
|
||||
});
|
||||
|
||||
it("uses the shared upload implementation for table-mode immediate uploads", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).not.toContain("AttachmentUploader");
|
||||
expect(source).toContain(':http-request="uploadImmediate"');
|
||||
expect(source).toContain("const uploadImmediate = async");
|
||||
expect(source).toContain("await uploadFile(group, file, props.entityId)");
|
||||
expect(source).toContain("ElMessage.success(TEXT.common.messages.uploadSuccess)");
|
||||
expect(source).toContain("load();");
|
||||
});
|
||||
|
||||
it("supports upload-card mode without rendering the table header", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain('mode?: "table" | "upload"');
|
||||
expect(source).toContain("displayMode");
|
||||
expect(source).toContain('v-if="displayMode === \'table\'"');
|
||||
expect(source).toContain("v-else");
|
||||
expect(source).toContain('class="attachment-upload-card"');
|
||||
expect(source).toContain(':auto-upload="false"');
|
||||
expect(source).toContain("queuePendingUpload(group.key, file)");
|
||||
expect(source).toContain("点击上传文件");
|
||||
});
|
||||
|
||||
it("allows create forms to choose attachments before the entity id exists", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("const pendingSnapshot = () =>");
|
||||
expect(source).toContain("const uploadPending = async");
|
||||
expect(source).toContain("const targetEntityId = entityId || props.entityId");
|
||||
expect(source).toContain("defineExpose({");
|
||||
expect(source).toContain("pendingSnapshot");
|
||||
expect(source).toContain("uploadPending");
|
||||
expect(source).toContain("pendingMap[group.key] = remainItems");
|
||||
expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)");
|
||||
});
|
||||
|
||||
it("fails pending upload when selected files have no saved entity id", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("const hasPendingUploads = computed");
|
||||
expect(source).toContain("if (!targetEntityId && hasPendingUploads.value)");
|
||||
expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)");
|
||||
});
|
||||
|
||||
it("uses entity groups as upload groups when an editor has multiple attachment types", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("const uploadGroups = computed");
|
||||
expect(source).toContain("props.entityGroups.map((group)");
|
||||
expect(source).toContain("key: group.entityType");
|
||||
expect(source).toContain("entityType: group.entityType");
|
||||
expect(source).toContain("uploadAttachment(props.studyId, group.entityType, targetEntityId, file");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user