整理测试代码并清理文档引用
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiDelete = vi.fn();
|
||||
|
||||
vi.mock("./axios", () => ({
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiDelete,
|
||||
}));
|
||||
|
||||
describe("attachments api", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses canonical collection URLs with trailing slash", async () => {
|
||||
const { fetchAttachments, uploadAttachment } = await import("./attachments");
|
||||
const file = new File(["x"], "x.txt", { type: "text/plain" });
|
||||
|
||||
fetchAttachments("study-1", "startup_feasibility", "entity-1");
|
||||
uploadAttachment("study-1", "startup_feasibility", "entity-1", file);
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/");
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
"/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/",
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({ headers: { "Content-Type": "multipart/form-data" } })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiPost = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
const apiDelete = vi.fn();
|
||||
const apiGet = vi.fn();
|
||||
|
||||
vi.mock("./axios", () => ({
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPatch,
|
||||
apiDelete,
|
||||
}));
|
||||
|
||||
describe("faqs category api", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes study_id as a query parameter for category write permission checks", async () => {
|
||||
const { createFaqCategory, updateFaqCategory, deleteFaqCategory } = await import("./faqs");
|
||||
const payload = { study_id: "study-1", name: "用药咨询" };
|
||||
|
||||
createFaqCategory(payload);
|
||||
updateFaqCategory("category-1", payload);
|
||||
deleteFaqCategory("category-1", "study-1");
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/categories/", payload, { params: { study_id: "study-1" } });
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/categories/category-1", payload, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/categories/category-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("faqs item api", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes study_id as a query parameter for item write permission checks", async () => {
|
||||
const { createFaqItem, updateFaqItem, deleteFaqItem } = await import("./faqs");
|
||||
const payload = { study_id: "study-1", category_id: "category-1", question: "是否需要空腹用药?" };
|
||||
|
||||
createFaqItem(payload);
|
||||
updateFaqItem("item-1", payload);
|
||||
deleteFaqItem("item-1", "study-1");
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/", payload, { params: { study_id: "study-1" } });
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", payload, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes study_id as a query parameter for item detail permission checks", async () => {
|
||||
const { fetchFaqItem, fetchFaqReplies, createFaqReply, deleteFaqReply, setFaqBestReply, setFaqStatus } = await import("./faqs");
|
||||
|
||||
fetchFaqItem("item-1", "study-1");
|
||||
fetchFaqReplies("item-1", "study-1");
|
||||
createFaqReply("item-1", "study-1", { content: "建议复查。" });
|
||||
deleteFaqReply("item-1", "reply-1", "study-1");
|
||||
setFaqBestReply("item-1", "study-1", { best_reply_id: "reply-1" });
|
||||
setFaqStatus("item-1", "study-1", { status: "RESOLVED" });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", { content: "建议复查。" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies/reply-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/best-reply", { best_reply_id: "reply-1" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/status", { status: "RESOLVED" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./feeContracts.ts"), "utf8");
|
||||
|
||||
describe("feeContracts API naming", () => {
|
||||
it("uses study_id for contract fee project identity", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("study_id: string");
|
||||
expect(source).toContain("params: { study_id: string; center_id?: string; q?: string }");
|
||||
expect(source).not.toContain("project_id: string");
|
||||
expect(source).not.toContain("projectId");
|
||||
expect(source).not.toContain("centerId");
|
||||
});
|
||||
|
||||
it("does not expose currency in contract fee payloads because amounts are fixed to ten-thousand yuan", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("currency?: string");
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuditRow } from "./auditExportFormatter";
|
||||
import type { AuditEvent } from "..";
|
||||
|
||||
const baseEvent: AuditEvent = {
|
||||
eventType: "DOCUMENT_UPDATED",
|
||||
eventLabel: "更新文档",
|
||||
actorId: "operator-1",
|
||||
actorName: "张三",
|
||||
actorRole: "PM",
|
||||
actorRoleLabel: "项目经理",
|
||||
targetType: "DOCUMENT",
|
||||
targetTypeLabel: "文档",
|
||||
targetId: "doc-001",
|
||||
actionText: "更新了文档",
|
||||
result: "SUCCESS",
|
||||
resultLabel: "成功",
|
||||
timestamp: "2026-06-04T10:27:02Z",
|
||||
};
|
||||
|
||||
describe("formatAuditRow", () => {
|
||||
it("keeps target identifiers inside a complete Chinese parenthesis pair", () => {
|
||||
const row = formatAuditRow(baseEvent);
|
||||
|
||||
expect(row.description).toContain("对象:文档(doc-001)");
|
||||
expect(row.description).not.toContain("doc-001)");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { useRoleTemplateMeta } from "./useRoleTemplateMeta";
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchPermissionTemplates: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ category: "PV", name: "PV" },
|
||||
{ category: "QA", name: "QA" },
|
||||
{ category: "CTA", name: "CTA" },
|
||||
{ category: "CRA", name: "CRA" },
|
||||
{ category: "PM", name: "PM" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./useRoleTemplateMeta.ts"), "utf8");
|
||||
|
||||
describe("useRoleTemplateMeta fallback copy", () => {
|
||||
it("uses QA and CTA as first-class preset role keys", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('PM: { label: "PM"');
|
||||
expect(source).toContain("项目负责人,统筹项目全局,协调进度、资源与关键决策。");
|
||||
expect(source).toContain("负责各中心临床监查执行,跟进现场质量、数据和问题闭环。");
|
||||
expect(source).toContain("负责合同、药品及相关项目事务管理,保障执行支持与物资协同。");
|
||||
expect(source).toContain('QA: { label: "QA"');
|
||||
expect(source).toContain("负责医学审核与稽查,关注质量风险、合规性和医学一致性。");
|
||||
expect(source).toContain('CTA: { label: "CTA"');
|
||||
expect(source).toContain("负责药物警戒相关工作,跟踪安全性事件并支持风险评估。");
|
||||
});
|
||||
|
||||
it("pins PM first before applying the role template list order", async () => {
|
||||
const { compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
await loadRoleTemplates();
|
||||
|
||||
expect(["CRA", "PV", "QA", "PM", "CTA"].sort(compareRolesByTemplateOrder)).toEqual([
|
||||
"PM",
|
||||
"PV",
|
||||
"QA",
|
||||
"CTA",
|
||||
"CRA",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readMainCss = () => readFileSync(resolve(__dirname, "./main.css"), "utf8");
|
||||
|
||||
describe("global table styles", () => {
|
||||
it("keeps Element Plus table sizing on the root node without overriding internal layout", () => {
|
||||
const css = readMainCss();
|
||||
|
||||
expect(css).toContain(".el-table {\n color: var(--ctms-text-regular);\n width: 100%;\n max-width: 100%;\n}");
|
||||
expect(css).not.toContain(".el-table__header,\n.el-table__body,\n.el-table__footer");
|
||||
expect(css).not.toContain(".el-table__inner-wrapper,\n.el-table__header-wrapper");
|
||||
expect(css).not.toContain(".el-table colgroup col");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAttachmentPermissionKey } from "./attachmentPermissions";
|
||||
|
||||
describe("attachment permission mapping", () => {
|
||||
it("maps attachment entity types to module attachment permissions", () => {
|
||||
expect(getAttachmentPermissionKey("contract_fee_contract", "create")).toBe("fees_contracts:create");
|
||||
expect(getAttachmentPermissionKey("contract_fee_contract", "read")).toBe("fees_contracts:read");
|
||||
expect(getAttachmentPermissionKey("contract_fee_contract", "delete")).toBe("fees_contracts_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("startup_feasibility", "create")).toBe("startup_initiation:create");
|
||||
expect(getAttachmentPermissionKey("startup_feasibility", "read")).toBe("startup_initiation:read");
|
||||
expect(getAttachmentPermissionKey("startup_feasibility", "delete")).toBe("startup_initiation_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("startup_ethics", "create")).toBe("startup_ethics:create");
|
||||
expect(getAttachmentPermissionKey("startup_ethics", "read")).toBe("startup_ethics:read");
|
||||
expect(getAttachmentPermissionKey("startup_ethics", "delete")).toBe("startup_ethics_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "create")).toBe("startup_auth:create");
|
||||
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "read")).toBe("startup_auth:read");
|
||||
expect(getAttachmentPermissionKey("training_authorization", "delete")).toBe("startup_auth_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("drug_shipment", "create")).toBe("drug_shipments:create");
|
||||
expect(getAttachmentPermissionKey("drug_shipment", "read")).toBe("drug_shipments:read");
|
||||
expect(getAttachmentPermissionKey("drug_shipment", "delete")).toBe("drug_shipments_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("material_equipment", "create")).toBe("material_equipments:update");
|
||||
expect(getAttachmentPermissionKey("material_equipment", "read")).toBe("material_equipments:read");
|
||||
expect(getAttachmentPermissionKey("material_equipment", "delete")).toBe("material_equipments_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("precaution", "create")).toBe("precautions:create");
|
||||
expect(getAttachmentPermissionKey("precaution", "read")).toBe("precautions:read");
|
||||
expect(getAttachmentPermissionKey("precaution", "delete")).toBe("precautions_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("faq_replies", "create")).toBe("faq_reply:create");
|
||||
expect(getAttachmentPermissionKey("faq_replies", "read")).toBe("faq:read");
|
||||
expect(getAttachmentPermissionKey("faq_replies", "delete")).toBe("faq_attachments:delete");
|
||||
});
|
||||
|
||||
it("does not return generic attachments permissions", () => {
|
||||
expect(getAttachmentPermissionKey("unknown", "read")).toBeNull();
|
||||
expect(getAttachmentPermissionKey("precaution", "read")).not.toBe("attachments:read");
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { reactive } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useDrawerDirtyGuard } from "./drawerDirtyGuard";
|
||||
|
||||
vi.mock("element-plus", () => ({
|
||||
ElMessage: {
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useDrawerDirtyGuard", () => {
|
||||
it("allows drawer close when the form has no changes", () => {
|
||||
const form = reactive({ name: "设备A", status: "启用" });
|
||||
const guard = useDrawerDirtyGuard(() => form);
|
||||
const done = vi.fn();
|
||||
|
||||
guard.syncBaseline();
|
||||
guard.beforeClose(done);
|
||||
|
||||
expect(done).toHaveBeenCalledTimes(1);
|
||||
expect(ElMessage.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks drawer close when the form has unsaved changes", () => {
|
||||
const form = reactive({ name: "设备A", status: "启用" });
|
||||
const guard = useDrawerDirtyGuard(() => form);
|
||||
const done = vi.fn();
|
||||
|
||||
guard.syncBaseline();
|
||||
form.name = "设备B";
|
||||
guard.beforeClose(done);
|
||||
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
expect(ElMessage.warning).toHaveBeenCalledWith("请先保存或取消编辑");
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProjectPublishSnapshot } from "../types/setupConfig";
|
||||
import { buildProjectDiffRows } from "./setupPublishWorkflow";
|
||||
import { serializeDiffValue } from "./setupDiffRows";
|
||||
|
||||
const emptyProjectSnapshot = (): ProjectPublishSnapshot => ({
|
||||
code: "",
|
||||
name: "",
|
||||
project_full_name: "",
|
||||
sponsor: "",
|
||||
protocol_no: "",
|
||||
lead_unit: "",
|
||||
principal_investigator: "",
|
||||
main_pm: "",
|
||||
research_analysis: "",
|
||||
research_product: "",
|
||||
control_product: "",
|
||||
indication: "",
|
||||
research_population: "",
|
||||
research_design: "",
|
||||
plan_start_date: "",
|
||||
plan_end_date: "",
|
||||
planned_site_count: null,
|
||||
planned_enrollment_count: null,
|
||||
status: "",
|
||||
visit_schedule: [],
|
||||
});
|
||||
|
||||
describe("setup publish workflow project diff rows", () => {
|
||||
it("shows visit schedule changes under the summary module", () => {
|
||||
const current = emptyProjectSnapshot();
|
||||
current.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const rows = buildProjectDiffRows(current, emptyProjectSnapshot(), serializeDiffValue);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
moduleLabel: "方案摘要",
|
||||
path: "访视计划",
|
||||
changeType: "修改",
|
||||
localValue: "已配置列表(1项)",
|
||||
serverValue: "已配置列表(0项)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat equivalent visit schedule arrays as changed", () => {
|
||||
const current = emptyProjectSnapshot();
|
||||
const base = emptyProjectSnapshot();
|
||||
current.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
base.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildProjectDiffRows(current, base, serializeDiffValue)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("FAQ project permissions", () => {
|
||||
it("maps FAQ, category, and reply actions to backend operation permissions", () => {
|
||||
const source = read("../utils/permission.ts");
|
||||
|
||||
expect(source).toContain('"faq.update": "faq:update"');
|
||||
expect(source).toContain('"faq.delete": "faq:delete"');
|
||||
expect(source).toContain('"faq.category.read": "faq_category:read"');
|
||||
expect(source).toContain('"faq.category.create": "faq_category:create"');
|
||||
expect(source).toContain('"faq.category.update": "faq_category:update"');
|
||||
expect(source).toContain('"faq.category.delete": "faq_category:delete"');
|
||||
expect(source).toContain('"faq.reply.delete": "faq_reply:delete"');
|
||||
});
|
||||
|
||||
it("passes item update and delete permissions separately to the FAQ list", () => {
|
||||
const source = read("./Faq.vue");
|
||||
|
||||
expect(source).toContain('const canReadCategories = computed(() => can("faq.category.read"))');
|
||||
expect(source).toContain('const canUpdateFaq = computed(() => can("faq.update"))');
|
||||
expect(source).toContain('const canDeleteFaq = computed(() => can("faq.delete"))');
|
||||
expect(source).toContain('v-if="canReadCategories"');
|
||||
expect(source).toContain('if (!canReadCategories.value)');
|
||||
expect(source).toContain(':can-update="canUpdateFaq"');
|
||||
expect(source).toContain(':can-delete="canDeleteFaq"');
|
||||
});
|
||||
|
||||
it("does not expose FAQ item delete through creator ownership when backend requires delete permission", () => {
|
||||
const source = read("../components/FaqList.vue");
|
||||
|
||||
expect(source).toContain("canDelete: boolean");
|
||||
expect(source).toContain('v-if="canDelete"');
|
||||
expect(source).not.toContain("row.created_by === auth.user?.id");
|
||||
});
|
||||
|
||||
it("opens FAQ detail from any row cell while preserving action button click isolation", () => {
|
||||
const source = read("../components/FaqList.vue");
|
||||
|
||||
expect(source).toContain('@row-click="onRowClick"');
|
||||
expect(source).toContain("const onRowClick = (row: FaqItem) =>");
|
||||
expect(source).not.toContain('column?.property !== "question"');
|
||||
expect(source).toContain('@click.stop="remove(scope.row)"');
|
||||
});
|
||||
|
||||
it("splits FAQ category create update and delete controls by backend operation permissions", () => {
|
||||
const source = read("../components/FaqCategoryPanel.vue");
|
||||
|
||||
expect(source).toContain('const canCreateCategory = computed(() => can("faq.category.create"))');
|
||||
expect(source).toContain('const canUpdateCategory = computed(() => can("faq.category.update"))');
|
||||
expect(source).toContain('const canDeleteCategory = computed(() => can("faq.category.delete"))');
|
||||
expect(source).toContain(':can-create="canCreateCategory"');
|
||||
expect(source).toContain(':can-update="canUpdateCategory"');
|
||||
expect(source).toContain(':can-delete="canDeleteCategory"');
|
||||
expect(source).not.toContain("const canDelete = computed(() => isAdmin.value)");
|
||||
});
|
||||
|
||||
it("uses FAQ update and reply delete permissions for detail actions that hit protected endpoints", () => {
|
||||
const source = read("./FaqDetail.vue");
|
||||
|
||||
expect(source).toContain('return can("faq.update")');
|
||||
expect(source).toContain('return can("faq.reply.delete")');
|
||||
expect(source).toContain('const canReadCategories = computed(() => can("faq.category.read"))');
|
||||
expect(source).toContain('const canReadMembers = computed(() => can("project.members.list"))');
|
||||
expect(source).toContain("if (canReadCategories.value)");
|
||||
expect(source).toContain("if (!canReadMembers.value)");
|
||||
expect(source).not.toContain("item.value.created_by === auth.user?.id");
|
||||
expect(source).not.toContain("reply.created_by === auth.user?.id");
|
||||
});
|
||||
});
|
||||
@@ -88,4 +88,11 @@ describe("Login protocol agreement", () => {
|
||||
expect(source).toContain("已安全退出");
|
||||
expect(source).toContain(".logout-notice");
|
||||
});
|
||||
|
||||
it("keeps the login card wide enough on desktop while remaining responsive", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain("width: clamp(480px, 34vw, 560px);");
|
||||
expect(source).toContain("max-width: calc(100vw - 40px);");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,9 +305,8 @@ const onSubmit = async () => {
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 0 20px;
|
||||
width: clamp(480px, 34vw, 560px);
|
||||
max-width: calc(100vw - 40px);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readProfileView = () => readFileSync(resolve(__dirname, "./ProfileSettings.vue"), "utf8");
|
||||
|
||||
describe("profile settings autocomplete", () => {
|
||||
it("disables browser autofill for profile name, department, and current password", () => {
|
||||
const source = readProfileView();
|
||||
|
||||
expect(source).toContain('<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">');
|
||||
expect(source).toContain('autocomplete="off"');
|
||||
expect(source).toContain('autocomplete="new-password"');
|
||||
expect(source).toContain('name="ctms-profile-current-password"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile settings layout", () => {
|
||||
it("uses a settings-window layout, grouped sections, and aligned actions", () => {
|
||||
const source = readProfileView();
|
||||
|
||||
expect(source).not.toContain("<el-card");
|
||||
expect(source).toContain('class="profile-layout"');
|
||||
expect(source).toContain('class="avatar-panel"');
|
||||
expect(source).toContain('aria-label="关闭个人中心"');
|
||||
expect(source).toContain("@click=\"emit('close-request')\"");
|
||||
expect(source).toContain('saved: []');
|
||||
expect(source).toContain('emit("saved")');
|
||||
expect(source).toContain('class="form-section"');
|
||||
expect(source).toContain('class="form-section form-section--password"');
|
||||
expect(source).toContain("grid-template-columns: 260px minmax(0, 1fr);");
|
||||
expect(source).toContain("min-height: 620px;");
|
||||
expect(source).toContain("padding-left: 112px;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile avatar upload", () => {
|
||||
it("limits avatar uploads to supported image MIME types", () => {
|
||||
const source = readProfileView();
|
||||
|
||||
expect(source).toContain('accept="image/png,image/jpeg,image/gif,image/webp"');
|
||||
expect(source).toContain(':before-upload="beforeAvatarUpload"');
|
||||
expect(source).toContain('const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])');
|
||||
expect(source).toContain("avatarAcceptedTypes.has(file.type)");
|
||||
expect(source).toContain("TEXT.modules.profile.avatarTypeInvalid");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readRegisterView = () => readFileSync(resolve(__dirname, "./Register.vue"), "utf8");
|
||||
|
||||
describe("Register protocol agreement", () => {
|
||||
it("opens readable service terms and privacy policy dialogs from the agreement text", () => {
|
||||
const source = readRegisterView();
|
||||
|
||||
expect(source).toContain('@click.stop.prevent="openProtocolDialog(\'terms\')"');
|
||||
expect(source).toContain('@click.stop.prevent="openProtocolDialog(\'privacy\')"');
|
||||
expect(source).toContain('v-model="protocolDialogVisible"');
|
||||
expect(source).toContain("activeProtocolTitle");
|
||||
expect(source).toContain("activeProtocolSections");
|
||||
expect(source).toContain("closeProtocolDialog");
|
||||
expect(source).toContain("serviceTermsSections");
|
||||
expect(source).toContain("privacyPolicySections");
|
||||
});
|
||||
|
||||
it("shows an in-page pending admin approval notice after successful registration", () => {
|
||||
const source = readRegisterView();
|
||||
const successFlagIndex = source.indexOf("registrationSubmitted.value = true");
|
||||
const resetIndex = source.indexOf("Object.assign(form");
|
||||
|
||||
expect(source).toContain('v-if="registrationSubmitted"');
|
||||
expect(source).toContain("注册申请已提交");
|
||||
expect(source).toContain("待管理员审核通过后方可登录");
|
||||
expect(source).toContain("管理员审核通过前,请勿重复提交注册申请");
|
||||
expect(source).toContain("registrationSubmitted");
|
||||
expect(source).not.toContain("approval-login-link");
|
||||
expect(successFlagIndex).toBeGreaterThan(-1);
|
||||
expect(resetIndex).toBeGreaterThan(-1);
|
||||
expect(successFlagIndex).toBeLessThan(resetIndex);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./StudyHome.vue"), "utf8");
|
||||
|
||||
describe("StudyHome role labels", () => {
|
||||
it("uses permission template names for the current project role label", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("const roleLabel = computed(() => displayRoleLabel(projectRole.value));");
|
||||
expect(source).toContain("loadRoleTemplates();");
|
||||
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, projectRole.value)");
|
||||
});
|
||||
|
||||
it("gates the finance KPI by the contract fee read permission", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-if="canReadFinanceContracts"');
|
||||
expect(source).toContain('const canReadFinanceContracts = computed');
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('study.currentPermissions?.[role]?.["fees_contracts:read"]');
|
||||
expect(source).toContain('canReadFinanceContracts.value ? fetchFinanceSummary(studyId) : Promise.resolve(null)');
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readProjectMembers = () => readFileSync(resolve(__dirname, "./ProjectMembers.vue"), "utf8");
|
||||
|
||||
describe("ProjectMembers user directory access", () => {
|
||||
it("does not load the admin-only global user directory", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).not.toContain("fetchUsers");
|
||||
expect(source).not.toContain("../../api/users");
|
||||
expect(source).toContain('const canListMembers = computed(() => permission.can("project.members.list"));');
|
||||
expect(source).toContain('const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));');
|
||||
expect(source).toContain('const canCreateMember = computed(() => permission.can("project.members.create"));');
|
||||
expect(source).toContain('const canUpdateMember = computed(() => permission.can("project.members.update"));');
|
||||
expect(source).toContain('const canDeleteMember = computed(() => permission.can("project.members.delete"));');
|
||||
expect(source).toContain("if (!canListMembers.value");
|
||||
expect(source).toContain("if (!canListMemberCandidates.value");
|
||||
expect(source).toContain("return;");
|
||||
expect(source).toContain('v-if="canAddMember"');
|
||||
expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })");
|
||||
expect(source).not.toContain("project.members.manage");
|
||||
});
|
||||
|
||||
it("uses the member API embedded user data when the global directory is unavailable", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain("users.value = members.value");
|
||||
expect(source).toContain(".map((member) => member.user)");
|
||||
expect(source).toContain("users.value.find((u) => u.id === m.user_id) || m.user");
|
||||
});
|
||||
|
||||
it("prevents project managers from editing themselves or assigning higher roles", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain("const roleRank: Record<string, number>");
|
||||
expect(source).toContain("if (row.user_id === auth.user?.id) return false;");
|
||||
expect(source).toContain("if (row.user?.is_admin) return false;");
|
||||
expect(source).toContain("const canAssignRole = (role: string)");
|
||||
expect(source).toContain(":disabled=\"!canAssignRole(role.value)\"");
|
||||
expect(source).toContain(':disabled="!canEditMember(scope.row)');
|
||||
expect(source).toContain(':disabled="!canDeleteProjectMember(scope.row)"');
|
||||
});
|
||||
|
||||
it("uses permission template names for project role display options", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));");
|
||||
expect(source).toContain("{{ roleLabel(scope.row.role_in_study) }}");
|
||||
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, scope.row.role_in_study)");
|
||||
expect(source).not.toContain(':label="TEXT.enums.userRole.PM"');
|
||||
});
|
||||
|
||||
it("offers QA and CTA as project member role presets", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain('"QA"');
|
||||
expect(source).toContain('"CTA"');
|
||||
expect(source).toContain("QA: 60");
|
||||
expect(source).toContain("CTA: 40");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSiteForm = () => readFileSync(resolve(__dirname, "./SiteForm.vue"), "utf8");
|
||||
const readSiteTypes = () => readFileSync(resolve(__dirname, "../../types/api.ts"), "utf8");
|
||||
|
||||
describe("Admin site form phone field", () => {
|
||||
it("hydrates and submits the phone field", () => {
|
||||
const formSource = readSiteForm();
|
||||
const typeSource = readSiteTypes();
|
||||
|
||||
expect(formSource).toContain("form.phone = props.site.phone || (props.site as any)?.contact_phone || \"\"");
|
||||
expect(formSource).toContain("phone: form.phone");
|
||||
expect(formSource).toContain('v-model="form.phone"');
|
||||
expect(typeSource).toContain("phone?: string | null;");
|
||||
expect(typeSource).toContain("contact_phone?: string | null;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin site PM user loading", () => {
|
||||
it("uses project member embedded user data instead of the global user directory", () => {
|
||||
const sitesSource = readFileSync(resolve(__dirname, "./Sites.vue"), "utf8");
|
||||
const formSource = readSiteForm();
|
||||
|
||||
expect(sitesSource).not.toContain("../../api/users");
|
||||
expect(sitesSource).not.toContain("fetchUsers");
|
||||
expect(sitesSource).toContain(".map((member) => member.user)");
|
||||
expect(formSource).toContain("m.user?.full_name");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./SystemMonitoringPage.vue"), "utf8");
|
||||
|
||||
describe("SystemMonitoringPage", () => {
|
||||
it("uses a fixed viewport shell so monitoring tabs manage their own scrolling", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("display: flex");
|
||||
expect(source).toContain("height: calc(100dvh - 48px)");
|
||||
expect(source).toContain("min-height: 0");
|
||||
expect(source).toContain("overflow: hidden");
|
||||
expect(source).not.toContain("min-height: calc(100dvh - 52px)");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readResetForm = () => readFileSync(resolve(__dirname, "./UserResetPassword.vue"), "utf8");
|
||||
|
||||
describe("Admin reset password autofill", () => {
|
||||
it("disables browser autofill for confirm username and manual password", () => {
|
||||
const source = readResetForm();
|
||||
|
||||
expect(source).toContain('autocomplete="off" name="ctms-reset-confirm-username"');
|
||||
expect(source).toContain('autocomplete="new-password"');
|
||||
expect(source).toContain('name="ctms-reset-temp-password"');
|
||||
expect(source).toContain('name="ctms-reset-manual-password"');
|
||||
expect(source).toContain(":prop=\"form.mode === 'auto' ? 'tempPassword' : 'manualPassword'\"");
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readUsersView = () => readFileSync(resolve(__dirname, "./Users.vue"), "utf8");
|
||||
const readUserForm = () => readFileSync(resolve(__dirname, "./UserForm.vue"), "utf8");
|
||||
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
|
||||
|
||||
describe("Admin users table labels", () => {
|
||||
it("shows clinical_department as 科室 in the account management table", () => {
|
||||
const viewSource = readUsersView();
|
||||
const localeSource = readLocale();
|
||||
|
||||
expect(viewSource).toContain(':label="TEXT.modules.adminUsers.clinicalDepartmentLabel"');
|
||||
expect(viewSource).not.toContain(':label="TEXT.common.fields.clinicalDepartment"');
|
||||
expect(localeSource).toContain('clinicalDepartmentLabel: "科室"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin users table layout", () => {
|
||||
it("uses a wider name column, even remaining columns, and compact icon actions", () => {
|
||||
const viewSource = readUsersView();
|
||||
|
||||
expect(viewSource).toContain('table-layout="fixed"');
|
||||
expect(viewSource).toContain(':label="TEXT.common.fields.name" width="360"');
|
||||
expect(viewSource).not.toContain('width="20%"');
|
||||
expect(viewSource).not.toContain('width="180"');
|
||||
expect(viewSource).not.toContain('width="160"');
|
||||
expect(viewSource).not.toContain('width="100"');
|
||||
expect(viewSource).not.toContain('fixed="right"');
|
||||
expect(viewSource).toContain('class="action-btn"');
|
||||
expect(viewSource).toContain("width: 28px;");
|
||||
expect(viewSource).toContain("gap: 4px;");
|
||||
});
|
||||
|
||||
it("shows names without avatar icons in the account management table", () => {
|
||||
const viewSource = readUsersView();
|
||||
|
||||
expect(viewSource).not.toContain("user-avatar");
|
||||
expect(viewSource).not.toContain("getInitials(scope.row.full_name)");
|
||||
expect(viewSource).not.toContain("linear-gradient(135deg, #3f8f6b, #2d7a5a)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin users summary header", () => {
|
||||
it("keeps the management summary header compact", () => {
|
||||
const viewSource = readUsersView();
|
||||
|
||||
expect(viewSource).toContain("padding: 10px 16px;");
|
||||
expect(viewSource).toContain("padding: 9px 12px;");
|
||||
expect(viewSource).toContain("width: 32px;");
|
||||
expect(viewSource).toContain("height: 32px;");
|
||||
expect(viewSource).toContain("font-size: 20px;");
|
||||
expect(viewSource).toContain("font-size: 11px;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin users filters", () => {
|
||||
it("applies keyword automatically and status filters from the first page", () => {
|
||||
const viewSource = readUsersView();
|
||||
|
||||
expect(viewSource).toContain("@keyup.enter=\"applyFilters\"");
|
||||
expect(viewSource).toContain("@change=\"applyFilters\"");
|
||||
expect(viewSource).not.toContain("@click=\"applyFilters\"");
|
||||
expect(viewSource).not.toContain(":icon=\"Refresh\"");
|
||||
expect(viewSource).not.toContain("Refresh } from");
|
||||
expect(viewSource).toContain("watch(searchKeyword, () => {");
|
||||
expect(viewSource).toContain("scheduleKeywordSearch();");
|
||||
expect(viewSource).toContain("keywordSearchTimer = setTimeout(() => {");
|
||||
expect(viewSource).toContain("}, 300);");
|
||||
expect(viewSource).toContain("page.value = 1");
|
||||
expect(viewSource).toContain("keyword: searchKeyword.value || undefined");
|
||||
expect(viewSource).toContain("status: statusFilter.value || undefined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin user form autocomplete", () => {
|
||||
it("prevents browser credential autofill when creating a user", () => {
|
||||
const formSource = readUserForm();
|
||||
|
||||
expect(formSource).toContain('autocomplete="off"');
|
||||
expect(formSource).toContain('name="ctms-create-initial-password"');
|
||||
expect(formSource).toContain('autocomplete="new-password"');
|
||||
expect(formSource).not.toContain("current_password");
|
||||
expect(formSource).not.toContain("confirmPassword");
|
||||
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
||||
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin user create form password", () => {
|
||||
it("requires an initial password before creating an account", () => {
|
||||
const formSource = readUserForm();
|
||||
|
||||
expect(formSource).toContain('v-if="!user" :label="TEXT.modules.adminUsers.passwordInitial" prop="password"');
|
||||
expect(formSource).toContain("TEXT.modules.adminUsers.passwordInitialRequired");
|
||||
expect(formSource).toContain("/^(?=.*[A-Za-z])(?=.*\\d).{8,}$/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin user edit form defaults", () => {
|
||||
it("hydrates the selected user immediately when the dialog is mounted open", () => {
|
||||
const formSource = readUserForm();
|
||||
|
||||
expect(formSource).toContain("immediate: true");
|
||||
expect(formSource).not.toContain("current_password");
|
||||
expect(formSource).not.toContain("confirmPassword");
|
||||
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
||||
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readView = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
|
||||
|
||||
describe("detail breadcrumb contexts", () => {
|
||||
it("uses contract numbers for contract fee detail breadcrumbs", () => {
|
||||
const source = readView("./fees/ContractFeeDetail.vue");
|
||||
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("siteName");
|
||||
expect(source).toContain("pageTitle: detail.contract_no || TEXT.menu.feeContracts");
|
||||
});
|
||||
|
||||
it("uses shipment tracking or batch numbers for drug shipment detail breadcrumbs", () => {
|
||||
const source = readView("./drug/ShipmentDetail.vue");
|
||||
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("siteName: detail.site_name || TEXT.common.fallback");
|
||||
expect(source).toContain("pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle");
|
||||
});
|
||||
|
||||
it("uses equipment names for material equipment detail breadcrumbs", () => {
|
||||
const source = readView("./materials/MaterialEquipmentDetail.vue");
|
||||
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle");
|
||||
});
|
||||
|
||||
it("uses subject numbers and centers for subject detail breadcrumbs", () => {
|
||||
const source = readView("./subjects/SubjectDetail.vue");
|
||||
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("siteName: siteMap.value[detail.site_id] || TEXT.common.fallback");
|
||||
expect(source).toContain("pageTitle: detail.subject_no || TEXT.modules.subjectManagement.screeningNo");
|
||||
});
|
||||
|
||||
it("uses startup record identifiers for startup detail breadcrumbs", () => {
|
||||
const feasibility = readView("./startup/FeasibilityDetail.vue");
|
||||
const ethics = readView("./startup/EthicsDetail.vue");
|
||||
const kickoff = readView("./startup/KickoffDetail.vue");
|
||||
const training = readView("./startup/TrainingDetail.vue");
|
||||
|
||||
expect(feasibility).toContain("pageTitle: detail.project_no ||");
|
||||
expect(ethics).toContain("pageTitle: detail.approval_no ||");
|
||||
expect(kickoff).toContain("pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}`");
|
||||
expect(training).toContain("pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle");
|
||||
});
|
||||
|
||||
it("uses knowledge item titles for knowledge detail breadcrumbs", () => {
|
||||
const precaution = readView("./knowledge/PrecautionDetail.vue");
|
||||
const faq = readView("./FaqDetail.vue");
|
||||
|
||||
expect(precaution).toContain("pageTitle: detail.title || TEXT.modules.knowledgeNotes.detailTitle");
|
||||
expect(faq).toContain("const questionTitle = String(faqData.question || \"\").trim()");
|
||||
expect(faq).toContain("pageTitle: questionTitle ? questionTitle.slice(0, 24) : TEXT.modules.knowledgeMedicalConsult.detailTitle");
|
||||
});
|
||||
|
||||
it("uses project names for admin project detail breadcrumbs", () => {
|
||||
const source = readView("./admin/ProjectDetail.vue");
|
||||
|
||||
expect(source).toContain("studyStore.setViewContext({");
|
||||
expect(source).toContain("pageTitle: project.value.name || TEXT.modules.adminProjects.detailTitle");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readView = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
const detailViews = [
|
||||
"./fees/ContractFeeDetail.vue",
|
||||
"./drug/ShipmentDetail.vue",
|
||||
"./materials/MaterialEquipmentDetail.vue",
|
||||
"./documents/DocumentDetail.vue",
|
||||
"./subjects/SubjectDetail.vue",
|
||||
"./knowledge/PrecautionDetail.vue",
|
||||
"./startup/FeasibilityDetail.vue",
|
||||
"./startup/EthicsDetail.vue",
|
||||
"./startup/KickoffDetail.vue",
|
||||
"./startup/TrainingDetail.vue",
|
||||
];
|
||||
|
||||
describe("detail page navigation", () => {
|
||||
it("uses header breadcrumbs instead of page-local back actions", () => {
|
||||
for (const viewPath of detailViews) {
|
||||
const source = readView(viewPath);
|
||||
|
||||
expect(source, viewPath).not.toContain("TEXT.common.actions.back");
|
||||
expect(source, viewPath).not.toContain('@click="goBack"');
|
||||
expect(source, viewPath).not.toContain("const goBack =");
|
||||
expect(source, viewPath).not.toContain("function goBack");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./DocumentList.vue"), "utf8");
|
||||
|
||||
describe("DocumentList project permissions", () => {
|
||||
it("hides document create/update/delete controls and guards direct mutation calls by backend permissions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-if="canCreate"');
|
||||
expect(source).toContain('v-if="canUpdate"');
|
||||
expect(source).toContain('v-if="canDelete"');
|
||||
expect(source).toContain("if (!canCreate.value)");
|
||||
expect(source).toContain("if (!canUpdate.value)");
|
||||
expect(source).toContain("if (!canDelete.value)");
|
||||
expect(source).toContain("if (isInactiveSite(row.site_id))");
|
||||
expect(source).toContain('@click.stop="openEdit(row)"');
|
||||
expect(source).not.toContain(':disabled="!canCreate"');
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipmentEditorDrawer.vue"), "utf8");
|
||||
|
||||
describe("DrugShipmentEditorDrawer attachments", () => {
|
||||
it("uses a compact upload card instead of the full attachment table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("AttachmentList");
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain('entity-type="drug_shipment"');
|
||||
expect(source).toContain(':mode="\'upload\'"');
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)");
|
||||
expect(source).toContain("uploadPending");
|
||||
expect(source).not.toContain("uploadAttachment");
|
||||
expect(source).not.toContain("pendingFiles");
|
||||
expect(source).not.toContain("upload-card-label");
|
||||
expect(source).not.toContain("<el-table");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DrugShipmentEditorDrawer shipment validation", () => {
|
||||
it("keeps shipment execution fields optional while status is pending", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("requiresShipmentDetails");
|
||||
expect(source).toContain('status !== "PENDING"');
|
||||
expect(source).not.toContain('ship_date: [{ required: true');
|
||||
expect(source).not.toContain('batch_no: [{ required: true');
|
||||
expect(source).not.toContain('carrier: [{ required: true');
|
||||
expect(source).not.toContain('tracking_no: [{ required: true');
|
||||
});
|
||||
|
||||
it("does not expose the removed returned status", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("RETURNED");
|
||||
expect(source).not.toContain("已回收");
|
||||
});
|
||||
|
||||
it("requires receive date when shipment is signed", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("requiresReceiveDate");
|
||||
expect(source).toContain('status === "SIGNED"');
|
||||
expect(source).not.toContain('receive_date: [{ required: true');
|
||||
});
|
||||
|
||||
it("keeps remark optional unless shipment is exceptional", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("requiresRemark");
|
||||
expect(source).toContain('status === "EXCEPTION"');
|
||||
expect(source).not.toContain('remark: [{ required: true');
|
||||
});
|
||||
});
|
||||
@@ -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, "./ShipmentDetail.vue"), "utf8");
|
||||
|
||||
describe("ShipmentDetail edit drawer", () => {
|
||||
it("shows an edit action and opens the shipment editor drawer on the detail page", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<DrugShipmentEditorDrawer");
|
||||
expect(source).toContain('v-if="canUpdateShipment"');
|
||||
expect(source).toContain("editorVisible.value = true");
|
||||
expect(source).toContain("const handleEditorSaved = () =>");
|
||||
expect(source).toContain('"drug_shipments:update"');
|
||||
expect(source).toContain(":readonly=\"isReadOnly\"");
|
||||
});
|
||||
|
||||
it("hides the upload button in the detail attachment table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(":hide-uploader=\"true\"");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readDetail = () => readFileSync(resolve(__dirname, "./ContractFeeDetail.vue"), "utf8");
|
||||
|
||||
describe("ContractFeeDetail permissions", () => {
|
||||
it("suppresses optional site lookup permission errors on the detail page", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain('fetchSites(studyId.value, { limit: 500 }, { suppressErrorMessage: true })');
|
||||
});
|
||||
|
||||
it("opens the edit drawer on the detail page without navigating back to the list", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain("<ContractFeeEditorDrawer");
|
||||
expect(source).toContain("editorVisible.value = true");
|
||||
expect(source).toContain("const handleEditorSaved = () =>");
|
||||
expect(source).not.toContain('router.push({ path: "/fees/contracts"');
|
||||
expect(source).not.toContain("editContractId");
|
||||
});
|
||||
|
||||
it("renders contract fee attachments as one aggregated table with attachment type labels", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(":entity-groups=\"attachmentGroups\"");
|
||||
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
||||
expect(source).toContain("attachmentRefreshKey.value += 1");
|
||||
expect(source).not.toContain("<FeeAttachmentPanel");
|
||||
expect(source).toContain('entityType: "contract_fee_contract"');
|
||||
expect(source).toContain('entityType: "contract_fee_voucher"');
|
||||
expect(source).toContain('entityType: "contract_fee_invoice"');
|
||||
});
|
||||
|
||||
it("does not show attachment group descriptions on the detail page", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).not.toContain("attachmentContractDesc");
|
||||
expect(source).not.toContain("attachmentVoucherDesc");
|
||||
expect(source).not.toContain("attachmentInvoiceDesc");
|
||||
});
|
||||
|
||||
it("keeps payment table columns evenly distributed with clear labels", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain('v-for="(payment, index) in detail.payments"');
|
||||
expect(source).toContain("TEXT.modules.feeContracts.paymentSeq");
|
||||
expect(source).toContain("TEXT.modules.feeContracts.paidFlag");
|
||||
expect(source).toContain("TEXT.modules.feeContracts.verifiedFlag");
|
||||
expect(source).toContain("tl-panel-amount");
|
||||
expect(source).not.toContain('width="20%"');
|
||||
expect(source).not.toContain(".payment-table :deep(col:nth-child(-n + 5))");
|
||||
});
|
||||
|
||||
it("uses ten-thousand yuan as the only amount unit and hides currency", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain("formatAmountWan(detail.contract_amount)");
|
||||
expect(source).toContain("formatAmountWan(payment.amount)");
|
||||
expect(source).toContain('<span class="stat-tile-unit">万</span>');
|
||||
expect(source).toContain('<span class="amount-unit-sm">万</span>');
|
||||
expect(source).not.toContain("TEXT.common.fields.currency");
|
||||
expect(source).not.toContain("detail.currency");
|
||||
expect(source).not.toContain("currencyDefault");
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readDrawer = () => readFileSync(resolve(__dirname, "./ContractFeeEditorDrawer.vue"), "utf8");
|
||||
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
|
||||
|
||||
describe("ContractFeeEditorDrawer attachments", () => {
|
||||
it("uses upload-card mode for attachments instead of the detail table", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(":entity-groups=\"attachmentGroups\"");
|
||||
expect(source).not.toContain("PendingAttachmentUpload");
|
||||
expect(source).not.toContain("FeeAttachmentPanel");
|
||||
expect(source).not.toContain("attachmentContractDesc");
|
||||
expect(source).not.toContain("attachmentVoucherDesc");
|
||||
expect(source).not.toContain("attachmentInvoiceDesc");
|
||||
});
|
||||
|
||||
it("uploads selected attachments only after the form save succeeds", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain("const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null)");
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(source.indexOf("await attachmentPanelRef.value?.uploadPending(savedId)")).toBeLessThan(
|
||||
source.indexOf('emit("update:modelValue", false)')
|
||||
);
|
||||
});
|
||||
|
||||
it("shows attachment upload cards while creating a new contract fee", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('class="form-section"');
|
||||
expect(source).toContain(':entity-id="form.id"');
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain(':upload-card-columns="3"');
|
||||
expect(source).not.toContain(':hide-upload-card-labels="true"');
|
||||
expect(source).not.toContain('<div v-if="form.id" class="form-group">');
|
||||
});
|
||||
|
||||
it("uses centered three-column upload cards with contract-specific copy", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('uploadText: "点击上传合同"');
|
||||
expect(source).toContain('uploadText: "点击上传凭证"');
|
||||
expect(source).toContain('uploadText: "点击上传发票"');
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain(':upload-card-columns="3"');
|
||||
expect(source).not.toContain(':hide-upload-card-labels="true"');
|
||||
});
|
||||
|
||||
it("labels the section as attachments instead of contract attachments", () => {
|
||||
const source = readLocale();
|
||||
|
||||
expect(source).toContain('attachmentTitle: "附件"');
|
||||
expect(source).not.toContain('attachmentTitle: "合同附件"');
|
||||
});
|
||||
|
||||
it("uses a payment grid that keeps date pickers inside their columns", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('class="field-grid field-grid--3"');
|
||||
expect(source).toContain('class="status-row"');
|
||||
expect(source).toContain('class="status-datepicker"');
|
||||
expect(source).toContain("grid-template-columns: 1fr 1fr 1fr;");
|
||||
expect(source).toContain("min-width: 0;");
|
||||
expect(source).toContain(".status-datepicker :deep(.el-date-editor.el-input)");
|
||||
});
|
||||
|
||||
it("uses ten-thousand yuan as the only amount unit and removes currency selection", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain(':label="`${TEXT.modules.feeContracts.contractAmount}(万元)`"');
|
||||
expect(source).toContain(':label="`${TEXT.common.fields.amount}(万元)`"');
|
||||
expect(source).toContain("contract_amount: Number(detail.contract_amount || 0) / 10000");
|
||||
expect(source).toContain("amount: Number(payment.amount || 0) / 10000");
|
||||
expect(source).toContain("contract_amount: Number(form.contract_amount || 0) * 10000");
|
||||
expect(source).toContain("amount: Number(payment.amount || 0) * 10000");
|
||||
expect(source).not.toContain("prop=\"currency\"");
|
||||
expect(source).not.toContain("form.currency");
|
||||
expect(source).not.toContain("payload.currency");
|
||||
expect(source).not.toContain("currency:");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
describe("contract fee attachment labels", () => {
|
||||
it("uses a concise attachment section title", () => {
|
||||
expect(TEXT.modules.feeContracts.attachmentTitle).toBe("附件");
|
||||
});
|
||||
|
||||
it("uses a clear payment sequence column label", () => {
|
||||
expect(TEXT.modules.feeContracts.paymentSeqColumn).toBe("付款期次");
|
||||
expect(TEXT.modules.feeContracts.paymentSeq).toBe("第");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("Contract fee project permissions", () => {
|
||||
it("hides list create/delete controls when the matching backend operation is not allowed", () => {
|
||||
const source = read("./ContractFees.vue");
|
||||
|
||||
expect(source).toContain('v-if="canCreate"');
|
||||
expect(source).toContain('v-if="canDelete"');
|
||||
expect(source).toContain("if (!canCreate.value)");
|
||||
expect(source).toContain("if (!canDelete.value)");
|
||||
expect(source).not.toContain(':disabled="!canCreate"');
|
||||
expect(source).not.toContain(':disabled="!canDelete || isInactiveSite(scope.row.center_id)"');
|
||||
});
|
||||
|
||||
it("hides detail edit controls and blocks direct navigation without update permission", () => {
|
||||
const source = read("./ContractFeeDetail.vue");
|
||||
|
||||
expect(source).toContain('v-if="canWrite"');
|
||||
expect(source).toContain("if (!canWrite.value)");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8");
|
||||
|
||||
describe("EtmfPlaceholder project permissions", () => {
|
||||
it("hides eTMF create controls and guards dialog submissions with document create permission", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-if="canCreate"');
|
||||
expect(source).toContain('v-if="canCreate && selectedNode"');
|
||||
expect(source).toContain("if (!canCreate.value)");
|
||||
expect(source).not.toContain(':disabled="!canCreate"');
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
|
||||
|
||||
describe("MaterialEquipment project permissions", () => {
|
||||
it("projects material equipment create update and delete permissions into all write actions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("useAuthStore");
|
||||
expect(source).toContain("isSystemAdmin");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('["material_equipments:create"]');
|
||||
expect(source).toContain('["material_equipments:update"]');
|
||||
expect(source).toContain('["material_equipments:delete"]');
|
||||
expect(source).toContain('v-if="canCreate"');
|
||||
expect(source).toContain('v-if="canUpdate"');
|
||||
expect(source).toContain('v-if="canDelete"');
|
||||
expect(source).toContain("if (!canDelete.value)");
|
||||
});
|
||||
|
||||
it("keeps the visible table columns evenly distributed", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('class="equipment-table"');
|
||||
expect(source).toContain('style="width: 100%"');
|
||||
expect(source).toContain('table-layout="fixed"');
|
||||
expect(source).toContain('label="操作"');
|
||||
expect(source).not.toMatch(/<el-table-column[^>]+prop="(?:name|specModel|unit|brand)"[^>]+(?:width|min-width)=/);
|
||||
});
|
||||
|
||||
it("opens the detail page from row clicks instead of a detail action button", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('@row-click="onRowClick"');
|
||||
expect(source).toContain(':row-class-name="equipmentRowClass"');
|
||||
expect(source).toContain('name: "MaterialEquipmentDetail"');
|
||||
expect(source).toContain("equipmentId: row.id");
|
||||
expect(source).not.toContain(">详情</el-button>");
|
||||
expect(source).toContain('@click.stop="openEdit(row)"');
|
||||
expect(source).toContain('@click.stop="removeRow(row)"');
|
||||
});
|
||||
|
||||
it("uploads qualification files as equipment attachments after saving the drawer form", () => {
|
||||
const source = readSource();
|
||||
const calibrationIndex = source.indexOf("校准设置");
|
||||
const qualificationIndex = source.indexOf("附件");
|
||||
|
||||
expect(calibrationIndex).toBeGreaterThan(-1);
|
||||
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
||||
expect(source).toContain("AttachmentList");
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain('entity-type="material_equipment"');
|
||||
expect(source).toContain(':entity-id="editingId"');
|
||||
expect(source).toContain(':mode="\'upload\'"');
|
||||
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)");
|
||||
expect(source).toContain("uploadPending");
|
||||
expect(source).toContain("material_equipment");
|
||||
expect(source).not.toContain("uploadAttachment");
|
||||
expect(source).not.toContain("pendingFiles");
|
||||
expect(source).not.toContain("qualificationRows");
|
||||
expect(source).not.toContain("material_equipment_production_permit");
|
||||
expect(source).not.toContain("material_equipment_tech_index");
|
||||
expect(source).not.toContain("资质文件");
|
||||
expect(source).not.toContain("生产许可证");
|
||||
expect(source).not.toContain("技术指标");
|
||||
expect(source).not.toContain("handleUploadChange");
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("precautions project permissions", () => {
|
||||
it("splits list create and delete controls by backend operation permissions", () => {
|
||||
const source = readSource("./Precautions.vue");
|
||||
|
||||
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
|
||||
expect(source).toContain('const canDeletePrecaution = computed(() => can("precautions.delete"))');
|
||||
expect(source).toContain('v-if="canCreatePrecaution"');
|
||||
expect(source).toContain('v-if="canDeletePrecaution"');
|
||||
expect(source).toContain("if (!canDeletePrecaution.value)");
|
||||
expect(source).not.toContain("canWritePrecautions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("precautions drawer editor", () => {
|
||||
it("opens create form in a drawer instead of routing to the form page", () => {
|
||||
const source = readSource("./Precautions.vue");
|
||||
|
||||
expect(source).toContain("PrecautionEditorDrawer");
|
||||
expect(source).toContain("precautionDrawerVisible");
|
||||
expect(source).not.toContain('router.push("/knowledge/precautions/new")');
|
||||
});
|
||||
|
||||
it("uses selects for site and level fields in the create drawer", () => {
|
||||
const source = readSource("../knowledge/PrecautionEditorDrawer.vue");
|
||||
|
||||
expect(source).toContain('<el-select v-model="form.site_name"');
|
||||
expect(source).toContain('v-for="site in sites"');
|
||||
expect(source).toContain(':value="site.name"');
|
||||
expect(source).toContain('<el-select v-model="form.level"');
|
||||
expect(source).toContain("const levelOptions = precautionLevelOptions");
|
||||
expect(source).toContain('v-for="level in levelOptions"');
|
||||
expect(source).not.toContain('<el-input v-model="form.site_name"');
|
||||
expect(source).not.toContain('<el-input v-model="form.level"');
|
||||
});
|
||||
|
||||
it("moves precaution attachment upload into the editor drawer", () => {
|
||||
const drawer = readSource("../knowledge/PrecautionEditorDrawer.vue");
|
||||
const detail = readSource("../knowledge/PrecautionDetail.vue");
|
||||
|
||||
expect(drawer).toContain("AttachmentList");
|
||||
expect(drawer).toContain('ref="attachmentPanelRef"');
|
||||
expect(drawer).toContain(':entity-type="\'precaution\'"');
|
||||
expect(drawer).toContain(':mode="\'upload\'"');
|
||||
expect(drawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(drawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
|
||||
expect(detail).toContain(":hide-uploader=\"true\"");
|
||||
expect(detail).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
||||
expect(detail).toContain("attachmentRefreshKey.value += 1");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./ProjectMilestones.vue"), "utf8");
|
||||
|
||||
describe("ProjectMilestones project permissions", () => {
|
||||
it("hides edit controls and blocks saves without project milestone update permission", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('const canUpdateMilestones = computed(() => can("project.milestones.update"))');
|
||||
expect(source).toContain('v-if="canUpdateMilestones"');
|
||||
expect(source).toContain("if (!canUpdateMilestones.value)");
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readRiskIssueMonitoringVisits = () => readFileSync(resolve(__dirname, "./RiskIssueMonitoringVisits.vue"), "utf8");
|
||||
|
||||
describe("RiskIssueMonitoringVisits permissions", () => {
|
||||
it("gates monitoring visit issue actions by concrete API permissions", () => {
|
||||
const source = readRiskIssueMonitoringVisits();
|
||||
|
||||
expect(source).toContain("useAuthStore");
|
||||
expect(source).toContain("isSystemAdmin");
|
||||
expect(source).toContain("getProjectRole");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
[
|
||||
"monitoring_issues:read",
|
||||
"monitoring_issues:read",
|
||||
"monitoring_issues:create",
|
||||
"monitoring_issues:update",
|
||||
"monitoring_issues:delete",
|
||||
].forEach((operationKey) => {
|
||||
expect(source).toContain(operationKey);
|
||||
});
|
||||
expect(source).toContain('v-if="canCreateIssue"');
|
||||
expect(source).toContain('v-if="canListIssues"');
|
||||
expect(source).toContain('v-if="canReadIssue"');
|
||||
expect(source).toContain('v-if="canUpdateIssue"');
|
||||
expect(source).toContain('v-if="canDeleteIssue"');
|
||||
expect(source).toContain('v-if="canSaveIssue"');
|
||||
expect(source).not.toContain(':disabled="!canSaveIssue"');
|
||||
expect(source).toContain("const canSaveIssue = computed(() => (formMode.value === \"edit\" ? canUpdateIssue.value : canCreateIssue.value));");
|
||||
expect(source).toContain('ElMessage.warning("权限不足")');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readRiskIssueSae = () => readFileSync(resolve(__dirname, "./RiskIssueSae.vue"), "utf8");
|
||||
|
||||
describe("RiskIssueSae status", () => {
|
||||
it("only exposes follow-up and closed statuses in the risk issue status column", () => {
|
||||
const source = readRiskIssueSae();
|
||||
|
||||
expect(source).toContain("const aeStatusOptions = [");
|
||||
expect(source).toContain('{ value: "FOLLOW_UP", label: TEXT.enums.aeStatus.FOLLOW_UP }');
|
||||
expect(source).toContain('{ value: "CLOSED", label: TEXT.enums.aeStatus.CLOSED }');
|
||||
expect(source).toContain("normalizeAeStatus");
|
||||
expect(source).not.toContain("Object.entries(TEXT.enums.aeStatus)");
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./StartupFeasibilityEthics.vue"), "utf8");
|
||||
|
||||
describe("StartupFeasibilityEthics project permissions", () => {
|
||||
it("hides initiation and ethics create/delete actions by their backend operation permissions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('canUseApiPermission("startup_initiation:create")');
|
||||
expect(source).toContain('canUseApiPermission("startup_initiation:read")');
|
||||
expect(source).toContain('canUseApiPermission("startup_initiation:update")');
|
||||
expect(source).toContain('canUseApiPermission("startup_initiation:delete")');
|
||||
expect(source).toContain('canUseApiPermission("startup_ethics:read")');
|
||||
expect(source).toContain('canUseApiPermission("startup_ethics:create")');
|
||||
expect(source).toContain('canUseApiPermission("startup_ethics:update")');
|
||||
expect(source).toContain('canUseApiPermission("startup_ethics:delete")');
|
||||
expect(source).toContain('v-if="canReadFeasibility"');
|
||||
expect(source).toContain('v-if="canReadEthics"');
|
||||
expect(source).toContain("if (!canReadFeasibility.value)");
|
||||
expect(source).toContain("if (!canReadEthics.value)");
|
||||
expect(source).toContain('v-if="activeTab === \'feasibility\' && canCreateFeasibility"');
|
||||
expect(source).toContain('v-if="canUpdateFeasibility"');
|
||||
expect(source).toContain('v-if="canDeleteFeasibility"');
|
||||
expect(source).toContain('v-if="activeTab === \'ethics\' && canCreateEthics"');
|
||||
expect(source).toContain('v-if="canUpdateEthics"');
|
||||
expect(source).toContain('v-if="canDeleteEthics"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("StartupFeasibilityEthics drawer editors", () => {
|
||||
it("opens initiation and ethics create forms in drawers instead of routing to form pages", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("FeasibilityEditorDrawer");
|
||||
expect(source).toContain("EthicsEditorDrawer");
|
||||
expect(source).toContain("feasibilityDrawerVisible");
|
||||
expect(source).toContain("ethicsDrawerVisible");
|
||||
expect(source).not.toContain('router.push("/startup/feasibility/new")');
|
||||
expect(source).not.toContain('router.push("/startup/ethics/new")');
|
||||
});
|
||||
|
||||
it("opens initiation and ethics edit drawers from table action columns", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(':record-id="editingFeasibilityId"');
|
||||
expect(source).toContain(':record-id="editingEthicsId"');
|
||||
expect(source).toContain("const editingFeasibilityId = ref<string | undefined>();");
|
||||
expect(source).toContain("const editingEthicsId = ref<string | undefined>();");
|
||||
expect(source).toContain("@click.stop=\"openFeasibilityEditor(scope.row)\"");
|
||||
expect(source).toContain("@click.stop=\"openEthicsEditor(scope.row)\"");
|
||||
expect(source).toContain("editingFeasibilityId.value = row?.id;");
|
||||
expect(source).toContain("editingEthicsId.value = row?.id;");
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readStageNode = () => readFileSync(resolve(__dirname, "./StageNode.vue"), "utf8");
|
||||
const readCenterProgressRow = () => readFileSync(resolve(__dirname, "./CenterProgressRow.vue"), "utf8");
|
||||
const readProjectOverview = () => readFileSync(resolve(__dirname, "../ProjectOverview.vue"), "utf8");
|
||||
|
||||
describe("project overview stage progress styling", () => {
|
||||
it("makes the in-progress stage visually distinct from pending stages", () => {
|
||||
const source = readStageNode();
|
||||
|
||||
expect(source).toContain(".stage-in_progress .stage-dot");
|
||||
expect(source).toContain("border: 3px solid #2f5f86");
|
||||
expect(source).toContain(".stage-in_progress .stage-dot::after");
|
||||
expect(source).toContain(".stage-in_progress .stage-dot::before");
|
||||
expect(source).toContain("animation: progress-halo");
|
||||
expect(source).toContain(".stage-in_progress .stage-label");
|
||||
expect(source).toContain("font-weight: 800");
|
||||
});
|
||||
|
||||
it("highlights the active connector and legend consistently", () => {
|
||||
const rowSource = readCenterProgressRow();
|
||||
const overviewSource = readProjectOverview();
|
||||
|
||||
expect(rowSource).toContain(".connector-in_progress");
|
||||
expect(rowSource).toContain("height: 3px");
|
||||
expect(rowSource).toContain("linear-gradient(90deg, #2f5f86");
|
||||
expect(overviewSource).toContain(".legend-dot.active::after");
|
||||
expect(overviewSource).toContain("background: #2f5f86");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PrecautionDetail.vue"), "utf8");
|
||||
|
||||
describe("PrecautionDetail project permissions", () => {
|
||||
it("hides edit controls unless the backend update operation is allowed", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('const canUpdatePrecaution = computed(() => can("precautions.update"))');
|
||||
expect(source).toContain('v-if="canUpdatePrecaution"');
|
||||
expect(source).toContain("if (!canUpdatePrecaution.value)");
|
||||
expect(source).not.toContain("canWritePrecautions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PrecautionDetail drawer editor", () => {
|
||||
it("opens edit in the shared drawer editor instead of routing to the form page", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("PrecautionEditorDrawer");
|
||||
expect(source).toContain("precautionEditorVisible");
|
||||
expect(source).toContain("@success=\"handlePrecautionEditorSuccess\"");
|
||||
expect(source).not.toContain("router.push(`/knowledge/precautions/${precautionId}/edit`)");
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PrecautionForm.vue"), "utf8");
|
||||
|
||||
describe("PrecautionForm project permissions", () => {
|
||||
it("uses create permission for new records and update permission for edits", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
|
||||
expect(source).toContain('const canUpdatePrecaution = computed(() => can("precautions.update"))');
|
||||
expect(source).toContain("const canSavePrecaution = computed(() => (isEdit.value ? canUpdatePrecaution.value : canCreatePrecaution.value))");
|
||||
expect(source).toContain("if (!canSavePrecaution.value)");
|
||||
expect(source).not.toContain("canWritePrecautions");
|
||||
});
|
||||
|
||||
it("uses selects for site and level fields", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('<el-select v-model="form.site_name"');
|
||||
expect(source).toContain('v-for="site in sites"');
|
||||
expect(source).toContain(':value="site.name"');
|
||||
expect(source).toContain('<el-select v-model="form.level"');
|
||||
expect(source).toContain("const levelOptions = precautionLevelOptions");
|
||||
expect(source).toContain('v-for="level in levelOptions"');
|
||||
expect(source).not.toContain('<el-input v-model="form.site_name"');
|
||||
expect(source).not.toContain('<el-input v-model="form.level"');
|
||||
});
|
||||
|
||||
it("allows attachments to be selected before creating a precaution", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain('entity-type="precaution"');
|
||||
expect(source).toContain(':entity-id="precautionId || \'\'"');
|
||||
expect(source).toContain(':mode="\'upload\'"');
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||
expect(source).not.toContain('v-if="isEdit && precautionId"');
|
||||
expect(source).not.toContain("StateEmpty");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentDetail.vue"), "utf8");
|
||||
|
||||
describe("MaterialEquipmentDetail edit drawer", () => {
|
||||
it("shows an edit action and opens the equipment editor drawer on the detail page", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<MaterialEquipmentEditorDrawer");
|
||||
expect(source).toContain('v-if="canUpdateEquipment"');
|
||||
expect(source).toContain("editorVisible.value = true");
|
||||
expect(source).toContain("const handleEditorSaved = () =>");
|
||||
expect(source).toContain('"material_equipments:update"');
|
||||
});
|
||||
|
||||
it("places attachments below calibration settings and renders them as one attachment table", () => {
|
||||
const source = readSource();
|
||||
const calibrationIndex = source.indexOf("校准设置");
|
||||
const qualificationIndex = source.indexOf("TEXT.common.labels.attachments");
|
||||
|
||||
expect(calibrationIndex).toBeGreaterThan(-1);
|
||||
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain('entity-type="material_equipment"');
|
||||
expect(source).toContain(':hide-uploader="true"');
|
||||
expect(source).toContain(':refresh-key="attachmentRefreshKey"');
|
||||
expect(source).toContain("attachmentRefreshKey.value += 1");
|
||||
expect(source).not.toContain("fetchAttachments");
|
||||
expect(source).not.toContain("deleteAttachment");
|
||||
expect(source).not.toContain("formatFileSize");
|
||||
expect(source).not.toContain("qualificationAttachments");
|
||||
expect(source).not.toContain('class="attachment-table qualification-table"');
|
||||
expect(source).not.toContain("previewVisible");
|
||||
expect(source).not.toContain("getDownloadUrl");
|
||||
expect(source).not.toContain("removeAttachment");
|
||||
expect(source).toContain("material_equipment");
|
||||
expect(source).not.toContain("material_equipment_production_permit");
|
||||
expect(source).not.toContain("material_equipment_tech_index");
|
||||
expect(source).not.toContain("资质文件");
|
||||
expect(source).not.toContain("生产许可证");
|
||||
expect(source).not.toContain("技术指标");
|
||||
expect(source).not.toContain('label="生产许可证">{{ displayValue(detail.production_permit_file_name) }}');
|
||||
expect(source).not.toContain('label="技术指标">{{ displayValue(detail.tech_index_file_name) }}');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentEditorDrawer.vue"), "utf8");
|
||||
|
||||
describe("MaterialEquipmentEditorDrawer attachments", () => {
|
||||
it("queues qualification files and uploads them as business attachments on save", () => {
|
||||
const source = readSource();
|
||||
const calibrationIndex = source.indexOf("校准设置");
|
||||
const qualificationIndex = source.indexOf("附件");
|
||||
|
||||
expect(calibrationIndex).toBeGreaterThan(-1);
|
||||
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
||||
expect(source).toContain("AttachmentList");
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain('entity-type="material_equipment"');
|
||||
expect(source).toContain(':mode="\'upload\'"');
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.equipmentId)");
|
||||
expect(source).toContain("uploadPending");
|
||||
expect(source).toContain("material_equipment");
|
||||
expect(source).not.toContain("uploadAttachment");
|
||||
expect(source).not.toContain("pendingFiles");
|
||||
expect(source).not.toContain("qualificationRows");
|
||||
expect(source).not.toContain("material_equipment_production_permit");
|
||||
expect(source).not.toContain("material_equipment_tech_index");
|
||||
expect(source).not.toContain("资质文件");
|
||||
expect(source).not.toContain("生产许可证");
|
||||
expect(source).not.toContain("技术指标");
|
||||
expect(source).not.toContain("handleUploadChange");
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readView = (relativePath: string) =>
|
||||
readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
const normalizeWhitespace = (source: string) => source.replace(/\s+/g, " ").trim();
|
||||
|
||||
describe("route view overlays are mounted only when visible", () => {
|
||||
it("gates high-risk admin and subject overlays behind visible flags", () => {
|
||||
const checks = [
|
||||
{
|
||||
file: "./admin/ProjectMembers.vue",
|
||||
snippets: ['<el-dialog v-if="addVisible"', 'v-model="addVisible"'],
|
||||
},
|
||||
{
|
||||
file: "./admin/Sites.vue",
|
||||
snippets: ['<SiteForm', 'v-if="projectId && formVisible"'],
|
||||
},
|
||||
{
|
||||
file: "./admin/SiteForm.vue",
|
||||
snippets: ['<el-dialog v-if="visibleProxy"', 'v-model="visibleProxy"'],
|
||||
},
|
||||
{
|
||||
file: "./admin/SiteCraBinding.vue",
|
||||
snippets: ['<el-dialog v-if="visibleProxy"', 'v-model="visibleProxy"'],
|
||||
},
|
||||
{
|
||||
file: "./subjects/SubjectDetail.vue",
|
||||
snippets: [
|
||||
'<el-dialog v-if="historyDialogVisible"',
|
||||
'<VisitEditorDrawer',
|
||||
'<el-drawer v-if="adherenceDrawerVisible"',
|
||||
'<el-drawer v-if="aeDrawerVisible"',
|
||||
'<el-drawer v-if="pdDrawerVisible"',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "./ia/ProjectMilestones.vue",
|
||||
snippets: ['<el-drawer v-if="editorVisible"', 'v-model="editorVisible"'],
|
||||
},
|
||||
{
|
||||
file: "./ia/MaterialEquipment.vue",
|
||||
snippets: ['<el-drawer v-if="drawerVisible"', 'v-model="drawerVisible"'],
|
||||
},
|
||||
{
|
||||
file: "./ia/RiskIssueMonitoringVisits.vue",
|
||||
snippets: [
|
||||
'<el-drawer v-if="formDialogVisible"',
|
||||
'v-model="formDialogVisible"',
|
||||
'<el-dialog v-if="viewDialogVisible"',
|
||||
'v-model="viewDialogVisible"',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "../components/attachments/AttachmentList.vue",
|
||||
snippets: ['<el-dialog v-if="previewVisible"', 'append-to-body', 'v-model="previewVisible"'],
|
||||
},
|
||||
{
|
||||
file: "./admin/ProjectDetail.vue",
|
||||
snippets: [
|
||||
'<el-dialog v-if="publishConfirmVisible"',
|
||||
'v-model="publishConfirmVisible"',
|
||||
'v-if="rollbackDialogVisible"',
|
||||
'v-model="rollbackDialogVisible"',
|
||||
'<el-drawer v-if="projectMilestoneEditorVisible"',
|
||||
'<el-drawer v-if="siteMilestoneEditorVisible"',
|
||||
'<el-drawer v-if="siteEnrollmentEditorVisible"',
|
||||
'const getRollbackAxisLeftStyle = (axisX?: number | null) => ({',
|
||||
':style="getRollbackAxisLeftStyle(lane.x)"',
|
||||
':style="getRollbackAxisLeftStyle(row.axisX)"',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const check of checks) {
|
||||
const source = normalizeWhitespace(readView(check.file));
|
||||
for (const snippet of check.snippets) {
|
||||
expect(source, `${check.file} should include ${snippet}`).toContain(normalizeWhitespace(snippet));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("allows mask close on drawers while guarding dirty editor forms", () => {
|
||||
const checks = [
|
||||
{
|
||||
file: "./ia/DrugShipments.vue",
|
||||
snippets: [':close-on-click-modal="true"', ':before-close="drawerDirtyGuard.beforeClose"'],
|
||||
},
|
||||
{
|
||||
file: "./ia/MaterialEquipment.vue",
|
||||
snippets: [':close-on-click-modal="true"', ':before-close="drawerDirtyGuard.beforeClose"'],
|
||||
},
|
||||
{
|
||||
file: "./ia/ProjectMilestones.vue",
|
||||
snippets: [':close-on-click-modal="true"', ':before-close="editorDirtyGuard.beforeClose"'],
|
||||
},
|
||||
{
|
||||
file: "./ia/RiskIssueMonitoringVisits.vue",
|
||||
snippets: [':close-on-click-modal="true"', ':before-close="formDirtyGuard.beforeClose"'],
|
||||
},
|
||||
{
|
||||
file: "./admin/ProjectDetail.vue",
|
||||
snippets: [
|
||||
':before-close="projectMilestoneEditorDirtyGuard.beforeClose"',
|
||||
':before-close="siteMilestoneEditorDirtyGuard.beforeClose"',
|
||||
':before-close="siteEnrollmentEditorDirtyGuard.beforeClose"',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "./admin/PermissionManagement.vue",
|
||||
snippets: [':close-on-click-modal="true"', ':before-close="handleTemplateDrawerBeforeClose"'],
|
||||
},
|
||||
{
|
||||
file: "./documents/DocumentList.vue",
|
||||
snippets: [':close-on-click-modal="true"', ':before-close="editorDirtyGuard.beforeClose"'],
|
||||
},
|
||||
{
|
||||
file: "./documents/DocumentDetail.vue",
|
||||
snippets: [
|
||||
':close-on-click-modal="true"',
|
||||
':before-close="editorDirtyGuard.beforeClose"',
|
||||
':before-close="uploadDirtyGuard.beforeClose"',
|
||||
':before-close="distributeDirtyGuard.beforeClose"',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "./admin/AuditLogs.vue",
|
||||
snippets: [':close-on-click-modal="true"'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const check of checks) {
|
||||
const source = normalizeWhitespace(readView(check.file));
|
||||
for (const snippet of check.snippets) {
|
||||
expect(source, `${check.file} should include ${snippet}`).toContain(normalizeWhitespace(snippet));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readForm = () => readFileSync(resolve(__dirname, "./KickoffForm.vue"), "utf8");
|
||||
const readDetail = () => readFileSync(resolve(__dirname, "./KickoffDetail.vue"), "utf8");
|
||||
|
||||
describe("Kickoff member permissions", () => {
|
||||
it("does not load project members in the form unless the role can read project members", () => {
|
||||
const source = readForm();
|
||||
|
||||
expect(source).toContain('can("project.members.list")');
|
||||
expect(source).toContain("if (canReadMembers.value)");
|
||||
});
|
||||
|
||||
it("does not load project members in the detail page unless the role can read project members", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain('can("project.members.list")');
|
||||
expect(source).toContain("if (canReadMembers.value)");
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("startup auth editor drawer attachments", () => {
|
||||
it("uses upload cards for kickoff attachments instead of full attachment tables", () => {
|
||||
const source = read("./KickoffEditorDrawer.vue");
|
||||
|
||||
expect(source).toContain("AttachmentList");
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain(':mode="\'upload\'"');
|
||||
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
||||
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.meetingId)");
|
||||
expect(source).toContain('entityType: "startup_kickoff_minutes"');
|
||||
expect(source).toContain('entityType: "startup_kickoff_signin"');
|
||||
expect(source).toContain('entityType: "startup_kickoff_ppt"');
|
||||
expect(source).toContain('entityType: "startup_kickoff"');
|
||||
expect(source).not.toContain("PendingAttachmentUpload");
|
||||
expect(source).not.toContain("attendees");
|
||||
expect(source).not.toContain("parseAttendees");
|
||||
expect(source).not.toContain("attendeesPlaceholder");
|
||||
});
|
||||
|
||||
it("does not expose attachments in the training authorization editor", () => {
|
||||
const source = read("./TrainingEditorDrawer.vue");
|
||||
|
||||
expect(source).not.toContain("PendingAttachmentUpload");
|
||||
expect(source).not.toContain("AttachmentList");
|
||||
expect(source).not.toContain("training_authorization");
|
||||
expect(source).not.toContain("attachments:");
|
||||
expect(source).not.toContain("uploadPending");
|
||||
});
|
||||
|
||||
it("supports batch creating and single-row updating training authorizations in the same drawer", () => {
|
||||
const source = read("./TrainingEditorDrawer.vue");
|
||||
|
||||
expect(source).toContain("recordId?: string");
|
||||
expect(source).toContain('editorTitle');
|
||||
expect(source).toContain("createTrainingAuthorization");
|
||||
expect(source).toContain("updateTrainingAuthorization");
|
||||
expect(source).toContain("const isCreateMode = computed(() => !props.recordId)");
|
||||
expect(source).toContain("const canCreateAuth = computed(() => can(\"startup.auth.create\"))");
|
||||
expect(source).toContain("const canSaveAuth = computed(() => (isCreateMode.value ? canCreateAuth.value : canUpdateAuth.value))");
|
||||
expect(source).toContain("batchRows");
|
||||
expect(source).toContain("addBatchRow");
|
||||
expect(source).toContain("removeBatchRow");
|
||||
expect(source).toContain("for (const row of batchRows.value)");
|
||||
});
|
||||
|
||||
it("keeps training authorization editor compact and hides site input while preserving site context", () => {
|
||||
const source = read("./TrainingEditorDrawer.vue");
|
||||
|
||||
expect(source).not.toContain(':label="TEXT.common.fields.site"');
|
||||
expect(source).not.toContain('prop="site_name"');
|
||||
expect(source).toContain("site_name: props.initialSiteName || form.site_name || null");
|
||||
expect(source).toContain('class="compact-row-grid"');
|
||||
expect(source).toContain('class="compact-field compact-field--name"');
|
||||
expect(source).toContain('class="compact-field compact-field--status"');
|
||||
expect(source).toContain('class="compact-field compact-field--remark"');
|
||||
});
|
||||
|
||||
it("uses a single-layer batch editor layout for training authorization creation", () => {
|
||||
const source = read("./TrainingEditorDrawer.vue");
|
||||
|
||||
expect(source).toContain('class="form-section training-batch-editor"');
|
||||
expect(source).toContain('class="batch-editor"');
|
||||
expect(source).toContain('class="batch-entry"');
|
||||
expect(source).toContain('class="status-inline"');
|
||||
expect(source).toContain('class="status-date"');
|
||||
expect(source).not.toContain('class="batch-row"');
|
||||
expect(source).not.toContain(".batch-row {");
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("startup auth form/detail permissions", () => {
|
||||
it("maps startup auth actions to backend operation permissions", () => {
|
||||
const source = read("../../utils/permission.ts");
|
||||
|
||||
expect(source).toContain('"startup.auth.create": "startup_auth:create"');
|
||||
expect(source).toContain('"startup.auth.update": "startup_auth:update"');
|
||||
expect(source).toContain('"startup.auth.delete": "startup_auth:delete"');
|
||||
});
|
||||
|
||||
it("uses create/update permissions in kickoff and training forms", () => {
|
||||
const kickoffForm = read("./KickoffForm.vue");
|
||||
const trainingForm = read("./TrainingForm.vue");
|
||||
|
||||
expect(kickoffForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
||||
expect(kickoffForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
||||
expect(kickoffForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
|
||||
expect(kickoffForm).toContain("if (!canSaveAuth.value)");
|
||||
expect(trainingForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
||||
expect(trainingForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
||||
expect(trainingForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
|
||||
expect(trainingForm).toContain("if (!canSaveAuth.value)");
|
||||
});
|
||||
|
||||
it("opens kickoff detail edits in a drawer and keeps training actions split by backend operations", () => {
|
||||
const source = read("./KickoffDetail.vue");
|
||||
|
||||
expect(source).toContain("<KickoffEditorDrawer");
|
||||
expect(source).toContain("<TrainingEditorDrawer");
|
||||
expect(source).toContain("editorVisible.value = true");
|
||||
expect(source).toContain("trainingEditorVisible.value = true");
|
||||
expect(source).toContain("const handleEditorSaved = () =>");
|
||||
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
||||
expect(source).toContain("attachmentRefreshKey.value += 1");
|
||||
expect(source).toContain("const handleTrainingEditorSaved = () =>");
|
||||
expect(source).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
||||
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
||||
expect(source).toContain('const canDeleteAuth = computed(() => can("startup.auth.delete"))');
|
||||
expect(source).toContain('v-if="canUpdateAuth"');
|
||||
expect(source).toContain('v-if="canCreateAuth || canUpdateAuth || canDeleteAuth"');
|
||||
expect(source).toContain("if (!canCreateAuth.value)");
|
||||
expect(source).toContain("if (!canUpdateAuth.value)");
|
||||
expect(source).toContain("if (!canDeleteAuth.value)");
|
||||
expect(source).not.toContain("trainingRowDraft");
|
||||
expect(source).not.toContain("isTrainingEditing");
|
||||
expect(source).not.toContain("saveTraining");
|
||||
expect(source).not.toContain("cancelTrainingEdit");
|
||||
expect(source).not.toContain("editMode");
|
||||
expect(source).not.toContain("saveEdit");
|
||||
});
|
||||
|
||||
it("opens training detail edits in a drawer without navigating to the old edit page", () => {
|
||||
const source = read("./TrainingDetail.vue");
|
||||
|
||||
expect(source).toContain("<TrainingEditorDrawer");
|
||||
expect(source).toContain("editorVisible.value = true");
|
||||
expect(source).toContain("const handleEditorSaved = () =>");
|
||||
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
||||
expect(source).toContain('v-if="canUpdateAuth"');
|
||||
expect(source).toContain("if (!canUpdateAuth.value)");
|
||||
expect(source).not.toContain("AttachmentList");
|
||||
expect(source).not.toContain("training_authorization");
|
||||
expect(source).not.toContain("attachmentRefreshKey");
|
||||
expect(source).not.toContain("router.push(`/startup/training/${recordId}/edit`)");
|
||||
});
|
||||
|
||||
it("renders kickoff attachments as one aggregated table with attachment type labels", () => {
|
||||
const source = read("./KickoffDetail.vue");
|
||||
|
||||
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
||||
expect(source).not.toContain("v-for=\"group in kickoffAttachmentGroups\"");
|
||||
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel');
|
||||
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel');
|
||||
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffPptLabel');
|
||||
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel');
|
||||
});
|
||||
|
||||
it("allows legacy kickoff and training forms to queue attachments before saving", () => {
|
||||
const kickoffForm = read("./KickoffForm.vue");
|
||||
const trainingForm = read("./TrainingForm.vue");
|
||||
|
||||
expect(kickoffForm).toContain("<AttachmentList");
|
||||
expect(kickoffForm).toContain('ref="attachmentPanelRef"');
|
||||
expect(kickoffForm).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
||||
expect(kickoffForm).toContain(':entity-id="meetingId || \'\'"');
|
||||
expect(kickoffForm).toContain(':mode="\'upload\'"');
|
||||
expect(kickoffForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||
expect(kickoffForm).not.toContain('v-for="group in kickoffAttachmentGroups"');
|
||||
expect(kickoffForm).not.toContain("StateEmpty");
|
||||
|
||||
expect(trainingForm).toContain("<AttachmentList");
|
||||
expect(trainingForm).toContain('ref="attachmentPanelRef"');
|
||||
expect(trainingForm).toContain('entity-type="training_authorization"');
|
||||
expect(trainingForm).toContain(':entity-id="recordId || \'\'"');
|
||||
expect(trainingForm).toContain(':mode="\'upload\'"');
|
||||
expect(trainingForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||
expect(trainingForm).not.toContain('v-if="isEdit && recordId"');
|
||||
expect(trainingForm).not.toContain("StateEmpty");
|
||||
});
|
||||
|
||||
it("shows training and authorization dates under positive status in the training table", () => {
|
||||
const source = read("./KickoffDetail.vue");
|
||||
|
||||
expect(source).toContain("status-dot--yes");
|
||||
expect(source).toContain("status-dot--no");
|
||||
expect(source).toContain("scope.row.trained && scope.row.trained_date");
|
||||
expect(source).toContain("scope.row.authorized && scope.row.authorized_date");
|
||||
expect(source).toContain('class="status-date"');
|
||||
expect(source).toContain("scope.row.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
|
||||
expect(source).toContain("scope.row.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
|
||||
});
|
||||
|
||||
it("loads training authorizations for the current kickoff site only", () => {
|
||||
const source = read("./KickoffDetail.vue");
|
||||
|
||||
expect(source).toContain("if (!studyId || !siteInfo.name) return;");
|
||||
expect(source).toContain("listTrainingAuthorizations(studyId, { site_name: siteInfo.name })");
|
||||
expect(source).not.toContain("listTrainingAuthorizations(studyId);");
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("startup initiation and ethics form/detail permissions", () => {
|
||||
it("uses startup initiation create/update permissions in feasibility form and detail", () => {
|
||||
const formSource = read("./FeasibilityForm.vue");
|
||||
const detailSource = read("./FeasibilityDetail.vue");
|
||||
|
||||
expect(formSource).toContain('const canCreateInitiation = computed(() => can("startup.initiation.create"))');
|
||||
expect(formSource).toContain('const canUpdateInitiation = computed(() => can("startup.initiation.update"))');
|
||||
expect(formSource).toContain("const canSaveInitiation = computed(() => (isEdit.value ? canUpdateInitiation.value : canCreateInitiation.value))");
|
||||
expect(formSource).toContain("if (!canSaveInitiation.value)");
|
||||
expect(detailSource).toContain('const canUpdateInitiation = computed(() => can("startup.initiation.update"))');
|
||||
expect(detailSource).toContain('v-if="canUpdateInitiation"');
|
||||
expect(detailSource).toContain("if (!canUpdateInitiation.value)");
|
||||
});
|
||||
|
||||
it("uses startup ethics create/update permissions in ethics form and detail", () => {
|
||||
const formSource = read("./EthicsForm.vue");
|
||||
const detailSource = read("./EthicsDetail.vue");
|
||||
|
||||
expect(formSource).toContain('const canCreateEthics = computed(() => can("startup.ethics.create"))');
|
||||
expect(formSource).toContain('const canUpdateEthics = computed(() => can("startup.ethics.update"))');
|
||||
expect(formSource).toContain("const canSaveEthics = computed(() => (isEdit.value ? canUpdateEthics.value : canCreateEthics.value))");
|
||||
expect(formSource).toContain("if (!canSaveEthics.value)");
|
||||
expect(detailSource).toContain('const canUpdateEthics = computed(() => can("startup.ethics.update"))');
|
||||
expect(detailSource).toContain('v-if="canUpdateEthics"');
|
||||
expect(detailSource).toContain("if (!canUpdateEthics.value)");
|
||||
});
|
||||
|
||||
it("keeps detail attachment areas display-only and moves upload into editor drawers", () => {
|
||||
const feasibilityDetail = read("./FeasibilityDetail.vue");
|
||||
const ethicsDetail = read("./EthicsDetail.vue");
|
||||
const feasibilityDrawer = read("./FeasibilityEditorDrawer.vue");
|
||||
const ethicsDrawer = read("./EthicsEditorDrawer.vue");
|
||||
|
||||
expect(feasibilityDetail).toContain(":hide-uploader=\"true\"");
|
||||
expect(ethicsDetail).toContain(":hide-uploader=\"true\"");
|
||||
expect(feasibilityDrawer).toContain("AttachmentList");
|
||||
expect(feasibilityDrawer).toContain('ref="attachmentPanelRef"');
|
||||
expect(feasibilityDrawer).toContain('entity-type="startup_feasibility"');
|
||||
expect(feasibilityDrawer).toContain(":entity-id=\"recordId || ''\"");
|
||||
expect(feasibilityDrawer).toContain(':mode="\'upload\'"');
|
||||
expect(feasibilityDrawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(feasibilityDrawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
|
||||
expect(feasibilityDrawer).not.toContain("PendingAttachmentUpload");
|
||||
expect(ethicsDrawer).toContain("AttachmentList");
|
||||
expect(ethicsDrawer).toContain('ref="attachmentPanelRef"');
|
||||
expect(ethicsDrawer).toContain('entity-type="startup_ethics"');
|
||||
expect(ethicsDrawer).toContain(":entity-id=\"recordId || ''\"");
|
||||
expect(ethicsDrawer).toContain(':mode="\'upload\'"');
|
||||
expect(ethicsDrawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(ethicsDrawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
|
||||
expect(ethicsDrawer).not.toContain("PendingAttachmentUpload");
|
||||
expect(feasibilityDrawer).not.toContain('class="attachment-group"');
|
||||
expect(ethicsDrawer).not.toContain('class="attachment-group"');
|
||||
});
|
||||
|
||||
it("allows legacy feasibility and ethics forms to queue attachments before the record exists", () => {
|
||||
const feasibilityForm = read("./FeasibilityForm.vue");
|
||||
const ethicsForm = read("./EthicsForm.vue");
|
||||
|
||||
expect(feasibilityForm).toContain("<AttachmentList");
|
||||
expect(feasibilityForm).toContain('ref="attachmentPanelRef"');
|
||||
expect(feasibilityForm).toContain('entity-type="startup_feasibility"');
|
||||
expect(feasibilityForm).toContain(':entity-id="recordId || \'\'"');
|
||||
expect(feasibilityForm).toContain(':mode="\'upload\'"');
|
||||
expect(feasibilityForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||
expect(feasibilityForm).not.toContain('v-if="isEdit && recordId"');
|
||||
expect(feasibilityForm).not.toContain("StateEmpty");
|
||||
|
||||
expect(ethicsForm).toContain("<AttachmentList");
|
||||
expect(ethicsForm).toContain('ref="attachmentPanelRef"');
|
||||
expect(ethicsForm).toContain('entity-type="startup_ethics"');
|
||||
expect(ethicsForm).toContain(':entity-id="recordId || \'\'"');
|
||||
expect(ethicsForm).toContain(':mode="\'upload\'"');
|
||||
expect(ethicsForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||
expect(ethicsForm).not.toContain('v-if="isEdit && recordId"');
|
||||
expect(ethicsForm).not.toContain("StateEmpty");
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSubjectEditorDrawer = () => readFileSync(resolve(__dirname, "./SubjectEditorDrawer.vue"), "utf8");
|
||||
|
||||
describe("SubjectEditorDrawer screening date editing", () => {
|
||||
it("keeps screening date editable when editing an existing participant", () => {
|
||||
const source = readSubjectEditorDrawer();
|
||||
const screeningDateField = source.slice(
|
||||
source.indexOf('v-model="form.screening_date"'),
|
||||
source.indexOf('v-model="form.consent_date"')
|
||||
);
|
||||
|
||||
expect(screeningDateField).toContain(':disabled="isReadOnly"');
|
||||
expect(screeningDateField).not.toContain(':disabled="isEdit || isReadOnly"');
|
||||
});
|
||||
|
||||
it("submits screening date in the edit payload", () => {
|
||||
const source = readSubjectEditorDrawer();
|
||||
const editPayload = source.slice(
|
||||
source.indexOf("await updateSubject(studyId.value, props.subjectId, {"),
|
||||
source.indexOf("});", source.indexOf("await updateSubject(studyId.value, props.subjectId, {"))
|
||||
);
|
||||
|
||||
expect(editPayload).toContain("screening_date: form.screening_date || null");
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSubjectForm = () => readFileSync(resolve(__dirname, "./SubjectForm.vue"), "utf8");
|
||||
|
||||
describe("SubjectForm screening date editing", () => {
|
||||
it("keeps screening date editable when editing an existing participant", () => {
|
||||
const source = readSubjectForm();
|
||||
const screeningDateField = source.slice(
|
||||
source.indexOf('v-model="form.screening_date"'),
|
||||
source.indexOf('v-model="form.consent_date"')
|
||||
);
|
||||
|
||||
expect(screeningDateField).toContain(':disabled="isReadOnly"');
|
||||
expect(screeningDateField).not.toContain(':disabled="isEdit || isReadOnly"');
|
||||
});
|
||||
|
||||
it("submits screening date in the edit payload", () => {
|
||||
const source = readSubjectForm();
|
||||
const editPayload = source.slice(
|
||||
source.indexOf("const payload = {"),
|
||||
source.indexOf("};", source.indexOf("const payload = {"))
|
||||
);
|
||||
|
||||
expect(editPayload).toContain("screening_date: form.screening_date || null");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user