整理测试代码并清理文档引用

This commit is contained in:
Cheng Zhou
2026-06-22 17:27:11 +08:00
parent 6056c8364a
commit c351ca59c4
75 changed files with 15 additions and 4415 deletions
-11
View File
@@ -1,11 +0,0 @@
from pathlib import Path
def test_new_ae_records_default_to_follow_up_status():
crud_source = Path("app/crud/ae.py").read_text(encoding="utf-8")
model_source = Path("app/models/ae.py").read_text(encoding="utf-8")
assert 'status="FOLLOW_UP"' in crud_source
assert 'default="FOLLOW_UP"' in model_source
assert 'status="NEW"' not in crud_source
assert 'default="NEW"' not in model_source
@@ -1,86 +0,0 @@
"""合同费用基础字段测试"""
from datetime import date
from decimal import Decimal
import uuid
from starlette.requests import Request
from app.api.v1 import fees_contracts
from app.models.contract_fee import ContractFee
from app.schemas.contract_fee import ContractFeeCreate, ContractFeeRead, ContractFeeUpdate
def _request_with_query(query: str) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/api/v1/fees/contracts",
"query_string": query.encode(),
"headers": [],
}
)
def test_contract_fee_list_query_accepts_legacy_project_params():
"""合同费用列表应兼容旧 projectId/centerId 查询参数。"""
study_id = uuid.uuid4()
center_id = uuid.uuid4()
resolver = getattr(fees_contracts, "_resolve_contract_fee_list_query", None)
assert resolver is not None
resolved_study_id, resolved_center_id = resolver(
_request_with_query(f"projectId={study_id}&centerId={center_id}"),
None,
None,
)
assert resolved_study_id == study_id
assert resolved_center_id == center_id
def test_contract_fee_schema_includes_contract_basic_fields():
"""合同费用应包含合同基础信息字段。"""
study_id = uuid.uuid4()
center_id = uuid.uuid4()
payload = ContractFeeCreate(
study_id=study_id,
center_id=center_id,
contract_no="CT-001",
signed_date=date(2026, 5, 27),
contract_amount=Decimal("120000.00"),
currency="CNY",
remark="首版合同",
contract_cases=12,
)
assert payload.contract_no == "CT-001"
assert payload.signed_date == date(2026, 5, 27)
assert payload.currency == "CNY"
assert payload.remark == "首版合同"
assert payload.study_id == study_id
assert "project_id" not in ContractFeeCreate.model_fields
update_payload = ContractFeeUpdate(contract_no="CT-002", currency="USD", remark="变更合同信息")
assert update_payload.model_dump(exclude_unset=True) == {
"contract_no": "CT-002",
"currency": "USD",
"remark": "变更合同信息",
}
for field in ("contract_no", "signed_date", "currency", "remark"):
assert field in ContractFeeRead.model_fields
assert "study_id" in ContractFeeRead.model_fields
assert "project_id" not in ContractFeeRead.model_fields
def test_contract_fee_model_includes_contract_basic_columns():
"""合同费用模型应持久化合同基础信息。"""
columns = ContractFee.__table__.columns
assert "contract_no" in columns
assert "signed_date" in columns
assert "currency" in columns
assert "remark" in columns
@@ -1,81 +0,0 @@
import uuid
from datetime import date
import pytest
from pydantic import ValidationError
from app.schemas.drug_shipment import DrugShipmentCreate
def shipment_payload(**overrides):
payload = {
"direction": "SEND",
"center_id": uuid.uuid4(),
"ship_date": date(2026, 6, 4),
"receive_date": None,
"quantity": 1,
"batch_no": "DP-001",
"carrier": "顺丰",
"tracking_no": "SF100003",
"status": "IN_TRANSIT",
"remark": None,
}
payload.update(overrides)
return payload
def test_create_allows_pending_shipment_execution_fields_to_be_empty():
shipment = DrugShipmentCreate.model_validate(
shipment_payload(
status="PENDING",
ship_date=None,
quantity=None,
batch_no=None,
carrier=None,
tracking_no=None,
receive_date=None,
remark=None,
)
)
assert shipment.ship_date is None
assert shipment.quantity is None
assert shipment.batch_no is None
assert shipment.carrier is None
assert shipment.tracking_no is None
@pytest.mark.parametrize("field_name", ["ship_date", "quantity", "batch_no", "carrier", "tracking_no"])
def test_create_requires_shipment_execution_fields_after_pending(field_name):
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="IN_TRANSIT", **{field_name: None}))
assert "发运信息" in str(exc_info.value)
def test_create_allows_pending_receipt_fields_to_be_empty():
shipment = DrugShipmentCreate.model_validate(shipment_payload())
assert shipment.receive_date is None
assert shipment.remark is None
def test_create_requires_receive_date_when_signed():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="SIGNED", receive_date=None))
assert "接收日期" in str(exc_info.value)
def test_create_rejects_removed_returned_status():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="RETURNED", receive_date=date(2026, 6, 5)))
assert "无效状态" in str(exc_info.value)
def test_create_requires_remark_when_exceptional():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="EXCEPTION", remark=""))
assert "备注" in str(exc_info.value)
-39
View File
@@ -1,39 +0,0 @@
import uuid
from sqlalchemy import text
import pytest
from app.crud.faq_category import create_category
from app.crud.faq_item import create_item
from app.schemas.faq import CategoryCreate, FaqCreate
@pytest.mark.asyncio
async def test_create_category_defaults_to_active(db_session):
result = await db_session.execute(text("SELECT id FROM studies LIMIT 1"))
study_id = result.scalar_one()
category = await create_category(
db_session,
CategoryCreate(study_id=study_id, name="AE", sort_order=0),
)
assert category.is_active is True
@pytest.mark.asyncio
async def test_create_item_defaults_to_active(db_session):
result = await db_session.execute(text("SELECT id FROM studies LIMIT 1"))
study_id = result.scalar_one()
category = await create_category(
db_session,
CategoryCreate(study_id=study_id, name=f"AE-{uuid.uuid4().hex[:8]}", sort_order=0),
)
item = await create_item(
db_session,
FaqCreate(study_id=study_id, category_id=category.id, question="AE 如何记录?"),
created_by=uuid.uuid4(),
)
assert item.is_active is True
-37
View File
@@ -1,37 +0,0 @@
"""单元测试:IP 属地解析服务"""
from app.services.ip_location import Ip2RegionResolver
class FakeSearcher:
def search(self, _ip: str) -> str:
return "中国|广东省|深圳市|电信|CN"
def test_ip_location_handles_special_addresses():
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
assert resolver.lookup(None).location == "未知"
assert resolver.lookup("not-an-ip").location == "未知"
assert resolver.lookup("127.0.0.1").location == "本机"
assert resolver.lookup("192.168.1.1").location == "局域网"
def test_ip2region_result_parses_province_city_isp():
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
resolver._searchers[4] = FakeSearcher()
result = resolver.lookup("8.8.8.8")
assert result.country == "中国"
assert result.province == "广东省"
assert result.city == "深圳市"
assert result.isp == "电信"
assert result.location == "中国 / 广东省 / 深圳市 / 电信"
def test_default_resolver_can_use_packaged_xdb():
resolver = Ip2RegionResolver()
result = resolver.lookup("8.8.8.8")
assert result.location not in {"公网", "未知"}
@@ -1,272 +0,0 @@
"""
第2批模块迁移测试:members 和 sites 模块
测试接口级权限系统在 members 和 sites 模块中的应用
"""
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.project_permissions import role_has_api_permission
from app.models.api_endpoint_permission import ApiEndpointPermission
@pytest.mark.asyncio
async def test_add_member_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以添加项目成员"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="project_members:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:create")
assert allowed is True
@pytest.mark.asyncio
async def test_add_member_without_permission(db_session: AsyncSession):
"""验证无权限的CRA无法添加项目成员"""
study_id = uuid.uuid4()
# 创建权限(拒绝)
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="project_members:create",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "CRA", "project_members:create")
assert allowed is False
@pytest.mark.asyncio
async def test_list_members_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以查询项目成员列表"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="project_members:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:list")
assert allowed is True
@pytest.mark.asyncio
async def test_update_member_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以更新项目成员"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="project_members:update",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:update")
assert allowed is True
@pytest.mark.asyncio
async def test_delete_member_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以删除项目成员"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="project_members:delete",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:delete")
assert allowed is True
@pytest.mark.asyncio
async def test_list_member_candidates_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以查询项目成员候选人"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="project_members:candidates",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:candidates")
assert allowed is True
@pytest.mark.asyncio
async def test_create_site_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建中心"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="sites:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:create")
assert allowed is True
@pytest.mark.asyncio
async def test_list_sites_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以查询中心列表"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="sites:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:list")
assert allowed is True
@pytest.mark.asyncio
async def test_list_sites_cra_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询中心列表"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="sites:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "CRA", "sites:list")
assert allowed is True
@pytest.mark.asyncio
async def test_update_site_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以更新中心"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="sites:update",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:update")
assert allowed is True
@pytest.mark.asyncio
async def test_delete_site_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以删除中心"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="sites:delete",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:delete")
assert allowed is True
@pytest.mark.asyncio
async def test_members_permission_denied_for_cra(db_session: AsyncSession):
"""验证CRA无法执行成员管理操作"""
study_id = uuid.uuid4()
# 创建权限(拒绝)
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="project_members:create",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "CRA", "project_members:create")
assert allowed is False
@pytest.mark.asyncio
async def test_sites_permission_denied_for_cra_write(db_session: AsyncSession):
"""验证CRA无法执行中心写操作"""
study_id = uuid.uuid4()
# 创建权限(拒绝)
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="sites:create",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "CRA", "sites:create")
assert allowed is False
@@ -1,489 +0,0 @@
"""
第3批模块迁移测试:12个模块,63个端点
测试接口级权限系统在所有第3批模块中的应用
"""
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.project_permissions import role_has_api_permission
from app.models.api_endpoint_permission import ApiEndpointPermission
# ============================================================================
# 启动管理 (startup)
# ============================================================================
@pytest.mark.asyncio
async def test_startup_ethics_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建伦理记录"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="startup_ethics:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_ethics:create")
assert allowed is True
@pytest.mark.asyncio
async def test_startup_ethics_list_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以查询伦理记录列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="startup_ethics:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_ethics:list")
assert allowed is True
@pytest.mark.asyncio
async def test_startup_initiation_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建立项记录"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="startup_initiation:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_initiation:create")
assert allowed is True
@pytest.mark.asyncio
async def test_startup_auth_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建启动会或培训授权"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="startup_auth:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_auth:create")
assert allowed is True
@pytest.mark.asyncio
async def test_startup_auth_read_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询启动会或培训授权"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="startup_auth:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "startup_auth:read")
assert allowed is True
# ============================================================================
# 项目权限管理已迁移为系统级权限
# ============================================================================
def test_project_permissions_are_not_project_matrix_permissions():
"""项目权限配置由 system:permissions:project_config 控制,不再进入项目矩阵。"""
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, SYSTEM_PERMISSIONS
assert "permissions:read" not in API_ENDPOINT_PERMISSIONS
assert "permissions:update" not in API_ENDPOINT_PERMISSIONS
assert "system:permissions:project_config" in SYSTEM_PERMISSIONS
# ============================================================================
# 项目概览 (overview) - 1个端点
# ============================================================================
@pytest.mark.asyncio
async def test_overview_get_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以查询项目概览"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="project_overview:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_overview:read")
assert allowed is True
# ============================================================================
# 监查访视问题汇总 (monitoring_visit_issues)
# ============================================================================
@pytest.mark.asyncio
async def test_monitoring_issues_list_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询监查访视问题列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="monitoring_issues:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "monitoring_issues:list")
assert allowed is True
# ============================================================================
# 药物发货 (drug_shipments) - 5个端点
# ============================================================================
@pytest.mark.asyncio
async def test_drug_shipments_create_with_permission(db_session: AsyncSession):
"""验证有权限的CTA可以创建药物发货"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CTA",
endpoint_key="drug_shipments:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CTA", "drug_shipments:create")
assert allowed is True
@pytest.mark.asyncio
async def test_drug_shipments_list_with_permission(db_session: AsyncSession):
"""验证有权限的CTA可以查询药物发货列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CTA",
endpoint_key="drug_shipments:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CTA", "drug_shipments:list")
assert allowed is True
# ============================================================================
# 设备管理 (material_equipments) - 5个端点
# ============================================================================
@pytest.mark.asyncio
async def test_material_equipments_create_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以创建设备"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="material_equipments:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "material_equipments:create")
assert allowed is True
@pytest.mark.asyncio
async def test_material_equipments_list_with_permission(db_session: AsyncSession):
"""验证有权限的CTA可以查询设备列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CTA",
endpoint_key="material_equipments:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CTA", "material_equipments:list")
assert allowed is True
# ============================================================================
# 参与者PD (subject_pds) - 4个端点
# ============================================================================
@pytest.mark.asyncio
async def test_subject_pds_create_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以创建参与者PD"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_pds:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_pds:create")
assert allowed is True
@pytest.mark.asyncio
async def test_subject_pds_list_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询参与者PD列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_pds:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_pds:list")
assert allowed is True
# ============================================================================
# 审计日志 (audit_logs) - 3个端点
# ============================================================================
@pytest.mark.asyncio
async def test_audit_logs_list_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以查询审计日志列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="audit_logs:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "audit_logs:list")
assert allowed is True
@pytest.mark.asyncio
async def test_audit_logs_export_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以导出审计日志"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="audit_logs:export",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "audit_logs:export")
assert allowed is True
# ============================================================================
# 访视管理 (visits) - 5个端点
# ============================================================================
@pytest.mark.asyncio
async def test_visits_create_with_permission(db_session: AsyncSession):
"""验证有权限的PV可以创建访视"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PV",
endpoint_key="visits:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PV", "visits:create")
assert allowed is True
@pytest.mark.asyncio
async def test_visits_list_with_permission(db_session: AsyncSession):
"""验证有权限的PV可以查询访视列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PV",
endpoint_key="visits:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PV", "visits:list")
assert allowed is True
# ============================================================================
# 注意事项 (precautions) - 5个端点
# ============================================================================
@pytest.mark.asyncio
async def test_precautions_create_with_permission(db_session: AsyncSession):
"""验证有权限的QA可以创建注意事项"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="QA",
endpoint_key="precautions:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "QA", "precautions:create")
assert allowed is True
@pytest.mark.asyncio
async def test_precautions_list_with_permission(db_session: AsyncSession):
"""验证有权限的QA可以查询注意事项列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="QA",
endpoint_key="precautions:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "QA", "precautions:list")
assert allowed is True
# ============================================================================
# 病史记录 (subject_histories)
# ============================================================================
@pytest.mark.asyncio
async def test_subject_histories_list_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询病史记录列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_histories:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_histories:list")
assert allowed is True
@pytest.mark.asyncio
async def test_subject_histories_read_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询病史记录详情"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_histories:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_histories:read")
assert allowed is True
# ============================================================================
# 项目里程碑 (project_milestones) - 2个端点
# ============================================================================
@pytest.mark.asyncio
async def test_milestones_list_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以查询项目里程碑列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="project_milestones:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_milestones:read")
assert allowed is True
@pytest.mark.asyncio
async def test_milestones_update_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以更新项目里程碑"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="project_milestones:update",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_milestones:update")
assert allowed is True
# ============================================================================
# 权限拒绝场景
# ============================================================================
@pytest.mark.asyncio
async def test_startup_permission_denied_for_cra(db_session: AsyncSession):
"""验证CRA无法执行启动管理操作"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="startup_ethics:create",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "startup_ethics:create")
assert allowed is False
@pytest.mark.asyncio
async def test_removed_project_permissions_are_denied_for_project_roles(db_session: AsyncSession):
"""项目角色不能再通过 permissions:update 这种残留 key 获得权限管理能力。"""
study_id = uuid.uuid4()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "permissions:update")
assert allowed is False
-107
View File
@@ -1,107 +0,0 @@
from pathlib import Path
def test_is_locked_migration_is_idempotent():
source = Path("alembic/versions/20260116_01_add_is_locked_to_studies.py").read_text(encoding="utf-8")
assert 'if "is_locked" not in columns:' in source
assert 'if "is_locked" in columns:' in source
def test_removed_workflow_tables_are_dropped_conditionally():
source = Path("alembic/versions/20260116_05_remove_document_workflows.py").read_text(encoding="utf-8")
assert 'if "workflow_actions" in tables:' in source
assert 'if "version_workflows" in tables:' in source
assert 'if "workflow_nodes" in tables:' in source
assert 'if "workflow_templates" in tables:' in source
def test_migration_state_check_script_exists():
source = Path("scripts/check_migration_state.py").read_text(encoding="utf-8")
assert "missing alembic_version table" in source
assert "studies" in source
assert "subjects" in source
assert "monitoring_visit_issues" in source
def test_remove_qa_role_migration_casts_json_permissions_for_key_lookup():
source = Path("alembic/versions/20260521_01_remove_qa_role.py").read_text(encoding="utf-8")
assert "permissions ? 'QA'" not in source
assert "permissions::jsonb ? 'QA'" in source
def test_role_template_copy_migration_updates_display_copy_for_current_role_keys():
source = Path("alembic/versions/20260522_01_update_role_template_copy.py").read_text(encoding="utf-8")
assert "项目负责人,统筹项目全局,协调进度、资源与关键决策。" in source
assert "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" in source
assert "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" in source
assert "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" in source
assert "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" in source
assert '"IMP": ("CTA"' in source
assert '"MEDICAL_REVIEW": ("QA"' in source
assert "category = 'QA'" not in source
def test_permission_template_migrations_do_not_seed_stale_startup_permissions():
stale_keys = (
"budget:create",
"budget:list",
"budget:read",
"budget:update",
"budget:delete",
"timeline:create",
"timeline:list",
"timeline:read",
"timeline:update",
"timeline:delete",
)
for path in Path("alembic/versions").glob("*.py"):
if path.name == "20260527_04_remove_stale_startup_permissions.py":
continue
source = path.read_text(encoding="utf-8")
for key in stale_keys:
assert key not in source, f"{key} should not be seeded by {path}"
def test_legacy_startup_ethics_permission_keys_are_removed_by_followup_migration():
source = Path("alembic/versions/20260527_05_remove_legacy_startup_ethics_permission_keys.py").read_text(encoding="utf-8")
assert '"feasibility:create"' in source
assert '"feasibility:list"' in source
assert '"feasibility:read"' in source
assert '"feasibility:update"' in source
assert '"feasibility:delete"' in source
assert '"ethics:create"' in source
assert '"ethics:list"' in source
assert '"ethics:read"' in source
assert '"ethics:update"' in source
assert '"ethics:delete"' in source
assert "startup_initiation:" not in source
assert "startup_ethics:" not in source
assert "DELETE FROM api_endpoint_permissions" in source
assert "api_endpoint_permissions" in source
assert "permission_templates" in source
assert "permission_template_versions" in source
def test_precautions_migration_renames_table_and_attachment_entity_type():
source = Path("alembic/versions/20260527_08_rename_knowledge_notes_to_precautions.py").read_text(encoding="utf-8")
assert 'rename_table("knowledge_notes", "precautions")' in source
assert "entity_type = 'precaution'" in source
assert "entity_type = 'knowledge_note'" in source
assert "api_endpoint_permissions" in source
assert "permission_templates" in source
def test_etmf_migration_reuses_existing_document_scope_type():
source = Path("alembic/versions/20260527_09_add_etmf_nodes.py").read_text(encoding="utf-8")
assert "postgresql.ENUM(" in source
assert 'name="document_scope_type"' in source
assert "create_type=False" in source
assert "scope_type = sa.Enum" not in source
@@ -1,72 +0,0 @@
import uuid
from datetime import datetime, timezone
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.crud import monitoring_visit_issue as issue_crud
from app.models.monitoring_visit_issue import MonitoringVisitIssue
from app.schemas.monitoring_visit_issue import MonitoringVisitIssueCreate
@pytest.mark.asyncio
async def test_monitoring_visit_issue_template_fields_can_be_filtered(tmp_path):
db_path = tmp_path / "monitoring-issues.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(MonitoringVisitIssue.__table__.create)
study_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
site_id = uuid.UUID("22222222-2222-2222-2222-222222222222")
async with SessionLocal() as session:
created = await issue_crud.create_issue(
session,
study_id,
MonitoringVisitIssueCreate(
issue_no="MV-001",
site_id=site_id,
category="原始记录",
subject_code="SUBJ-001",
status="OPEN",
severity="严重",
mark="SDV",
visit_cycle="V1",
center_query="请补充签名日期",
center_latest_reply="待中心回复",
rectification_completed=False,
due_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
),
created_by=None,
)
assert created.site_id == site_id
assert created.severity == "严重"
assert created.mark == "SDV"
assert created.visit_cycle == "V1"
assert created.center_query == "请补充签名日期"
assert created.center_latest_reply == "待中心回复"
assert created.rectification_completed is False
matched = await issue_crud.list_issues(
session,
study_id,
site_id=site_id,
severity="严重",
mark="SDV",
visit_cycle="V1",
rectification_completed=False,
due_from=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
due_to=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
)
unmatched = await issue_crud.list_issues(
session,
study_id,
site_id=uuid.UUID("33333333-3333-3333-3333-333333333333"),
)
await engine.dispose()
assert [item.issue_no for item in matched] == ["MV-001"]
assert unmatched == []
@@ -1,25 +0,0 @@
"""旧合同基础信息模块移除测试"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_api_router_no_longer_registers_legacy_finance_contracts():
router_source = (ROOT / "app" / "api" / "v1" / "router.py").read_text()
assert "finance_contracts" not in router_source
assert "finance-contracts" not in router_source
def test_active_backend_code_no_longer_imports_finance_contract_model():
checked_paths = [
ROOT / "app" / "db" / "base.py",
ROOT / "app" / "crud" / "study.py",
ROOT / "app" / "crud" / "site.py",
ROOT / "app" / "crud" / "overview.py",
]
for path in checked_paths:
assert "FinanceContract" not in path.read_text()
@@ -1,40 +0,0 @@
import uuid
import pytest
from app.models.user import User
from app.services.site_contact_display import build_contact_display
def make_user(user_id: uuid.UUID, full_name: str, email: str) -> User:
return User(
id=user_id,
email=email,
password_hash="hash",
full_name=full_name,
clinical_department="PMO",
)
def test_build_contact_display_resolves_comma_separated_user_ids():
first_id = uuid.uuid4()
second_id = uuid.uuid4()
users = {
first_id: make_user(first_id, "张三", "zhangsan@example.com"),
second_id: make_user(second_id, "李四", "lisi@example.com"),
}
display = build_contact_display(f"{first_id}, {second_id}", users)
assert display == "张三、李四"
@pytest.mark.parametrize("role", ["PM", "CRA", "PV", "QA", "CTA"])
def test_build_contact_display_is_role_independent_for_site_read_roles(role: str):
user_id = uuid.uuid4()
users = {user_id: make_user(user_id, "周成", "zhoucheng@example.com")}
display = build_contact_display(str(user_id), users)
assert role
assert display == "周成"
@@ -1,54 +0,0 @@
from datetime import date, datetime
from types import SimpleNamespace
import uuid
from app.crud.subject import _validate_actual_medication_count
from app.schemas.subject import SubjectRead, SubjectUpdate
def test_subject_update_accepts_actual_medication_count():
payload = SubjectUpdate(actual_medication_count=12)
assert payload.actual_medication_count == 12
def test_subject_update_accepts_screening_date():
payload = SubjectUpdate(screening_date=date(2026, 5, 25))
assert payload.screening_date == date(2026, 5, 25)
def test_subject_update_does_not_expose_status_input():
assert "status" not in SubjectUpdate.model_fields
def test_subject_read_includes_actual_medication_count():
subject = SimpleNamespace(
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
study_id=uuid.UUID("00000000-0000-0000-0000-000000000002"),
site_id=uuid.UUID("00000000-0000-0000-0000-000000000003"),
subject_no="S001",
status="ENROLLED",
screening_date=None,
consent_date=None,
enrollment_date=None,
baseline_date=None,
completion_date=None,
actual_medication_count=10,
drop_reason=None,
created_at=datetime(2026, 5, 9, 0, 0, 0),
updated_at=datetime(2026, 5, 9, 0, 0, 0),
)
data = SubjectRead.model_validate(subject)
assert data.actual_medication_count == 10
def test_actual_medication_count_cannot_be_negative():
try:
_validate_actual_medication_count(-1)
except ValueError as exc:
assert "实际用药次数不能小于0" in str(exc)
else:
raise AssertionError("negative actual medication count should be rejected")
-65
View File
@@ -84,71 +84,6 @@ async def test_admin_always_allowed(db_session, study_id):
assert result is True assert result is True
``` ```
### 2. 权限配置测试
**文件**: `backend/tests/test_api_permissions_config.py`
```python
import pytest
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, MODULE_TO_ENDPOINTS
def test_api_endpoint_permissions_structure():
"""测试API端点权限配置结构"""
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
assert "module" in config
assert "action" in config
assert "description" in config
assert "default_roles" in config
assert config["action"] in ["read", "write"]
assert isinstance(config["default_roles"], list)
def test_module_to_endpoints_mapping():
"""测试模块到端点的映射"""
for module, actions in MODULE_TO_ENDPOINTS.items():
assert "read" in actions
assert "write" in actions
assert isinstance(actions["read"], list)
assert isinstance(actions["write"], list)
# 验证所有端点都在API_ENDPOINT_PERMISSIONS中定义
for endpoint_key in actions["read"] + actions["write"]:
assert endpoint_key in API_ENDPOINT_PERMISSIONS
def test_subjects_endpoints_configured():
"""测试subjects模块的端点配置"""
expected_endpoints = [
"POST:/subjects",
"GET:/subjects",
"GET:/subjects/{id}",
"PATCH:/subjects/{id}",
"DELETE:/subjects/{id}",
]
for endpoint_key in expected_endpoints:
assert endpoint_key in API_ENDPOINT_PERMISSIONS
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "subjects"
def test_fees_endpoints_configured():
"""测试fees模块的端点配置"""
expected_endpoints = [
"POST:/fees/contracts",
"GET:/fees/contracts",
"GET:/fees/contracts/{id}",
"PATCH:/fees/contracts/{id}",
"DELETE:/fees/contracts/{id}",
"POST:/fees/contracts/{id}/payments",
"PATCH:/fees/payments/{id}",
"DELETE:/fees/payments/{id}",
"POST:/finance/contracts",
"GET:/finance/contracts",
"GET:/finance/contracts/{id}",
"PATCH:/finance/contracts/{id}",
"DELETE:/finance/contracts/{id}",
]
for endpoint_key in expected_endpoints:
assert endpoint_key in API_ENDPOINT_PERMISSIONS
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "fees"
```
## 集成测试 ## 集成测试
### 1. 权限管理API测试 ### 1. 权限管理API测试
@@ -37,7 +37,6 @@ Run: `npm run test:unit -- src/views/Login.test.ts`
**Files:** **Files:**
- Modify: `frontend/src/views/Register.vue` - Modify: `frontend/src/views/Register.vue`
- Test: `frontend/src/views/Register.test.ts`
**Step 1: Write failing test** **Step 1: Write failing test**
@@ -45,7 +44,7 @@ Run: `npm run test:unit -- src/views/Login.test.ts`
**Step 2: Run test to verify it fails** **Step 2: Run test to verify it fails**
Run: `npm run test:unit -- src/views/Register.test.ts` Run: `npm run test:unit`
**Step 3: Write minimal implementation** **Step 3: Write minimal implementation**
@@ -53,12 +52,12 @@ Run: `npm run test:unit -- src/views/Register.test.ts`
**Step 4: Run test to verify it passes** **Step 4: Run test to verify it passes**
Run: `npm run test:unit -- src/views/Register.test.ts` Run: `npm run test:unit`
### Task 3: Final verification ### Task 3: Final verification
Run: Run:
- `npm run test:unit -- src/views/Login.test.ts src/views/Register.test.ts` - `npm run test:unit -- src/views/Login.test.ts`
- `npm run type-check` - `npm run type-check`
不执行 git commit,遵循用户指令。 不执行 git commit,遵循用户指令。
+2 -3
View File
@@ -255,7 +255,6 @@ Expected: pass.
- Replace: `frontend/src/views/ia/EtmfPlaceholder.vue` - Replace: `frontend/src/views/ia/EtmfPlaceholder.vue`
- Modify: `frontend/src/locales/zh-CN.ts` - Modify: `frontend/src/locales/zh-CN.ts`
- Test: `frontend/src/views/ia/EtmfPlaceholder.test.ts`
**Step 1: Write failing page test** **Step 1: Write failing page test**
@@ -297,7 +296,7 @@ Run:
```bash ```bash
cd frontend cd frontend
npm test -- src/views/ia/EtmfPlaceholder.test.ts npm test -- src/api/etmf.test.ts
``` ```
Expected: pass. Expected: pass.
@@ -354,7 +353,7 @@ Expected: pass.
```bash ```bash
cd frontend cd frontend
npm test -- src/api/etmf.test.ts src/views/ia/EtmfPlaceholder.test.ts src/utils/projectRoutePermissions.test.ts npm test -- src/api/etmf.test.ts src/utils/projectRoutePermissions.test.ts
``` ```
Expected: pass. Expected: pass.
@@ -16,7 +16,6 @@
- Modify: `frontend/src/components/QuickActions.vue` - Modify: `frontend/src/components/QuickActions.vue`
- Modify: `frontend/src/views/ia/SubjectManagement.vue` - Modify: `frontend/src/views/ia/SubjectManagement.vue`
- Modify: `frontend/src/utils/permission.ts` - Modify: `frontend/src/utils/permission.ts`
- Test: `frontend/src/components/QuickActions.test.ts`
- Test: `frontend/src/views/ia/SubjectManagement.test.ts` - Test: `frontend/src/views/ia/SubjectManagement.test.ts`
- Test: `frontend/src/utils/permission.test.ts` - Test: `frontend/src/utils/permission.test.ts`
@@ -13,9 +13,7 @@
### Task 1: Route And Navigation Tests ### Task 1: Route And Navigation Tests
**Files:** **Files:**
- Modify: `frontend/src/views/detailNavigation.test.ts` - Modify: `frontend/src/router.test.ts`
- Modify: `frontend/src/views/detailBreadcrumbContext.test.ts`
- Modify: `frontend/src/views/ia/MaterialEquipment.test.ts`
**Steps:** **Steps:**
1. Add the future equipment detail view to detail-navigation expectations. 1. Add the future equipment detail view to detail-navigation expectations.
@@ -470,13 +470,7 @@ curl -H "Authorization: Bearer $TOKEN" \
**测试**: **测试**:
- `tests/test_api_permissions.py` - 权限检查测试 - `tests/test_api_permissions.py` - 权限检查测试
- `tests/test_api_permissions_endpoints.py` - 权限管理 API 测试 - `tests/test_api_permissions_endpoints.py` - 权限管理 API 测试
- `tests/test_api_permissions_config.py` - 权限配置测试
- `tests/test_migrated_endpoints.py` - 已迁移端点测试(第1批) - `tests/test_migrated_endpoints.py` - 已迁移端点测试(第1批)
- `tests/test_migrated_endpoints_batch2.py` - 已迁移端点测试(第2批)
- `tests/test_migrated_endpoints_batch3.py` - 已迁移端点测试(第3批)
- `tests/test_permission_performance.py` - 性能测试
- `tests/test_permission_security.py` - 安全测试
- `tests/test_permission_cache.py` - 缓存测试
- `tests/test_permission_monitoring.py` - 监控测试 - `tests/test_permission_monitoring.py` - 监控测试
- `tests/test_permission_monitoring_api.py` - 监控 API 测试 - `tests/test_permission_monitoring_api.py` - 监控 API 测试
-32
View File
@@ -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" } })
);
});
});
-89
View File
@@ -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" },
});
});
});
-23
View File
@@ -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",
]);
});
});
-16
View File
@@ -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");
});
});
+7
View File
@@ -88,4 +88,11 @@ describe("Login protocol agreement", () => {
expect(source).toContain("已安全退出"); expect(source).toContain("已安全退出");
expect(source).toContain(".logout-notice"); 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);");
});
}); });
+2 -3
View File
@@ -305,9 +305,8 @@ const onSubmit = async () => {
.login-container { .login-container {
position: relative; position: relative;
z-index: 10; z-index: 10;
width: 100%; width: clamp(480px, 34vw, 560px);
max-width: 440px; max-width: calc(100vw - 40px);
padding: 0 20px;
} }
.login-card { .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");
});
});
-36
View File
@@ -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);
});
});
-27
View File
@@ -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");
});
});
-31
View File
@@ -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'\"");
});
});
-111
View File
@@ -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");
});
});
-56
View File
@@ -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");
});
});
-141
View File
@@ -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");
});
});