diff --git a/backend/tests/test_ae_status.py b/backend/tests/test_ae_status.py deleted file mode 100644 index 5f523551..00000000 --- a/backend/tests/test_ae_status.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_contract_fee_basic_fields.py b/backend/tests/test_contract_fee_basic_fields.py deleted file mode 100644 index 1af958bc..00000000 --- a/backend/tests/test_contract_fee_basic_fields.py +++ /dev/null @@ -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}¢erId={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 diff --git a/backend/tests/test_drug_shipment_schema.py b/backend/tests/test_drug_shipment_schema.py deleted file mode 100644 index 81b3c36b..00000000 --- a/backend/tests/test_drug_shipment_schema.py +++ /dev/null @@ -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) diff --git a/backend/tests/test_faq_category.py b/backend/tests/test_faq_category.py deleted file mode 100644 index 55d7fa8d..00000000 --- a/backend/tests/test_faq_category.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_ip_location.py b/backend/tests/test_ip_location.py deleted file mode 100644 index 7b8fe099..00000000 --- a/backend/tests/test_ip_location.py +++ /dev/null @@ -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 {"公网", "未知"} diff --git a/backend/tests/test_migrated_endpoints_batch2.py b/backend/tests/test_migrated_endpoints_batch2.py deleted file mode 100644 index 6613783c..00000000 --- a/backend/tests/test_migrated_endpoints_batch2.py +++ /dev/null @@ -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 - diff --git a/backend/tests/test_migrated_endpoints_batch3.py b/backend/tests/test_migrated_endpoints_batch3.py deleted file mode 100644 index ea9db9e2..00000000 --- a/backend/tests/test_migrated_endpoints_batch3.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_migration_safety.py b/backend/tests/test_migration_safety.py deleted file mode 100644 index dcbad60d..00000000 --- a/backend/tests/test_migration_safety.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_monitoring_visit_issue_template_fields.py b/backend/tests/test_monitoring_visit_issue_template_fields.py deleted file mode 100644 index 211b6011..00000000 --- a/backend/tests/test_monitoring_visit_issue_template_fields.py +++ /dev/null @@ -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 == [] diff --git a/backend/tests/test_remove_legacy_finance_contracts.py b/backend/tests/test_remove_legacy_finance_contracts.py deleted file mode 100644 index 67a3ae61..00000000 --- a/backend/tests/test_remove_legacy_finance_contracts.py +++ /dev/null @@ -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() diff --git a/backend/tests/test_site_contact_display.py b/backend/tests/test_site_contact_display.py deleted file mode 100644 index 6ec7277e..00000000 --- a/backend/tests/test_site_contact_display.py +++ /dev/null @@ -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 == "周成" diff --git a/backend/tests/test_subject_medication_adherence.py b/backend/tests/test_subject_medication_adherence.py deleted file mode 100644 index bb03083e..00000000 --- a/backend/tests/test_subject_medication_adherence.py +++ /dev/null @@ -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") diff --git a/docs/guides/permission-system-testing.md b/docs/guides/permission-system-testing.md index 2856b0fb..8b0e71bc 100644 --- a/docs/guides/permission-system-testing.md +++ b/docs/guides/permission-system-testing.md @@ -84,71 +84,6 @@ async def test_admin_always_allowed(db_session, study_id): 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测试 diff --git a/docs/plans/2026-05-13-register-protocol-implementation.md b/docs/plans/2026-05-13-register-protocol-implementation.md index 17ee75da..a7f7e31b 100644 --- a/docs/plans/2026-05-13-register-protocol-implementation.md +++ b/docs/plans/2026-05-13-register-protocol-implementation.md @@ -37,7 +37,6 @@ Run: `npm run test:unit -- src/views/Login.test.ts` **Files:** - Modify: `frontend/src/views/Register.vue` -- Test: `frontend/src/views/Register.test.ts` **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** -Run: `npm run test:unit -- src/views/Register.test.ts` +Run: `npm run test:unit` **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** -Run: `npm run test:unit -- src/views/Register.test.ts` +Run: `npm run test:unit` ### Task 3: Final verification 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` 不执行 git commit,遵循用户指令。 diff --git a/docs/plans/2026-05-27-etmf-implementation.md b/docs/plans/2026-05-27-etmf-implementation.md index d88cd94d..234a40c1 100644 --- a/docs/plans/2026-05-27-etmf-implementation.md +++ b/docs/plans/2026-05-27-etmf-implementation.md @@ -255,7 +255,6 @@ Expected: pass. - Replace: `frontend/src/views/ia/EtmfPlaceholder.vue` - Modify: `frontend/src/locales/zh-CN.ts` -- Test: `frontend/src/views/ia/EtmfPlaceholder.test.ts` **Step 1: Write failing page test** @@ -297,7 +296,7 @@ Run: ```bash cd frontend -npm test -- src/views/ia/EtmfPlaceholder.test.ts +npm test -- src/api/etmf.test.ts ``` Expected: pass. @@ -354,7 +353,7 @@ Expected: pass. ```bash 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. diff --git a/docs/plans/2026-05-29-project-permission-consistency-implementation.md b/docs/plans/2026-05-29-project-permission-consistency-implementation.md index 407a55ff..1bd69dee 100644 --- a/docs/plans/2026-05-29-project-permission-consistency-implementation.md +++ b/docs/plans/2026-05-29-project-permission-consistency-implementation.md @@ -16,7 +16,6 @@ - Modify: `frontend/src/components/QuickActions.vue` - Modify: `frontend/src/views/ia/SubjectManagement.vue` - Modify: `frontend/src/utils/permission.ts` -- Test: `frontend/src/components/QuickActions.test.ts` - Test: `frontend/src/views/ia/SubjectManagement.test.ts` - Test: `frontend/src/utils/permission.test.ts` diff --git a/docs/plans/2026-06-02-material-equipment-detail.md b/docs/plans/2026-06-02-material-equipment-detail.md index 764fb578..0f15b988 100644 --- a/docs/plans/2026-06-02-material-equipment-detail.md +++ b/docs/plans/2026-06-02-material-equipment-detail.md @@ -13,9 +13,7 @@ ### Task 1: Route And Navigation Tests **Files:** -- Modify: `frontend/src/views/detailNavigation.test.ts` -- Modify: `frontend/src/views/detailBreadcrumbContext.test.ts` -- Modify: `frontend/src/views/ia/MaterialEquipment.test.ts` +- Modify: `frontend/src/router.test.ts` **Steps:** 1. Add the future equipment detail view to detail-navigation expectations. diff --git a/docs/reports/permission-system-project-completion-summary.md b/docs/reports/permission-system-project-completion-summary.md index 7a236798..87508bdd 100644 --- a/docs/reports/permission-system-project-completion-summary.md +++ b/docs/reports/permission-system-project-completion-summary.md @@ -470,13 +470,7 @@ curl -H "Authorization: Bearer $TOKEN" \ **测试**: - `tests/test_api_permissions.py` - 权限检查测试 - `tests/test_api_permissions_endpoints.py` - 权限管理 API 测试 -- `tests/test_api_permissions_config.py` - 权限配置测试 - `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_api.py` - 监控 API 测试 diff --git a/frontend/src/api/attachments.test.ts b/frontend/src/api/attachments.test.ts deleted file mode 100644 index 204d890e..00000000 --- a/frontend/src/api/attachments.test.ts +++ /dev/null @@ -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" } }) - ); - }); -}); diff --git a/frontend/src/api/faqs.test.ts b/frontend/src/api/faqs.test.ts deleted file mode 100644 index fe345620..00000000 --- a/frontend/src/api/faqs.test.ts +++ /dev/null @@ -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" }, - }); - }); -}); diff --git a/frontend/src/api/feeContracts.test.ts b/frontend/src/api/feeContracts.test.ts deleted file mode 100644 index 0daf3d17..00000000 --- a/frontend/src/api/feeContracts.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/audit/export/auditExportFormatter.test.ts b/frontend/src/audit/export/auditExportFormatter.test.ts deleted file mode 100644 index 65848d67..00000000 --- a/frontend/src/audit/export/auditExportFormatter.test.ts +++ /dev/null @@ -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)"); - }); -}); diff --git a/frontend/src/components/FaqCategoryForm.test.ts b/frontend/src/components/FaqCategoryForm.test.ts deleted file mode 100644 index b3eaab2c..00000000 --- a/frontend/src/components/FaqCategoryForm.test.ts +++ /dev/null @@ -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(" { - const source = readSource(); - - expect(source).toContain("TEXT.modules.knowledgeMedicalConsult.categoryName"); - expect(source).not.toContain(':label="TEXT.common.fields.name"'); - }); -}); diff --git a/frontend/src/components/FaqItemForm.test.ts b/frontend/src/components/FaqItemForm.test.ts deleted file mode 100644 index 907f87e3..00000000 --- a/frontend/src/components/FaqItemForm.test.ts +++ /dev/null @@ -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(" 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(''); - }); - - 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")'); - }); -}); diff --git a/frontend/src/components/LayoutPermissionMonitoring.test.ts b/frontend/src/components/LayoutPermissionMonitoring.test.ts deleted file mode 100644 index 3bfb9fe1..00000000 --- a/frontend/src/components/LayoutPermissionMonitoring.test.ts +++ /dev/null @@ -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(''); - const permissionMenuEnd = source.indexOf("", 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";'); - }); -}); diff --git a/frontend/src/components/PermissionIpLocations.test.ts b/frontend/src/components/PermissionIpLocations.test.ts deleted file mode 100644 index d327c206..00000000 --- a/frontend/src/components/PermissionIpLocations.test.ts +++ /dev/null @@ -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(" { - 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('90天'); - expect(source).toContain('90: "近 90 天"'); - }); - - it("does not overlay the source distribution card with a loading mask", () => { - const source = readSource(); - - expect(source).toContain('
'); - expect(source).toContain(''); - expect(source).not.toContain('
'); - }); - - 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(" { - 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}
中国 / 上海`;'); - 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(" { - 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("{{ card.hint }}"); - 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)"); - }); -}); diff --git a/frontend/src/components/PermissionTemplateSelector.test.ts b/frontend/src/components/PermissionTemplateSelector.test.ts deleted file mode 100644 index 17a3268c..00000000 --- a/frontend/src/components/PermissionTemplateSelector.test.ts +++ /dev/null @@ -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('{{ template.name }}'); - 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: "✅"'); - }); -}); diff --git a/frontend/src/components/PermissionTrendCharts.test.ts b/frontend/src/components/PermissionTrendCharts.test.ts deleted file mode 100644 index be781cca..00000000 --- a/frontend/src/components/PermissionTrendCharts.test.ts +++ /dev/null @@ -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("

拒绝率趋势

"); - expect(source).not.toContain('name: "拒绝率"'); - }); -}); diff --git a/frontend/src/components/QuickActions.test.ts b/frontend/src/components/QuickActions.test.ts deleted file mode 100644 index ba85eefc..00000000 --- a/frontend/src/components/QuickActions.test.ts +++ /dev/null @@ -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\""); - }); -}); diff --git a/frontend/src/components/attachments/AttachmentList.test.ts b/frontend/src/components/attachments/AttachmentList.test.ts deleted file mode 100644 index c161ce79..00000000 --- a/frontend/src/components/attachments/AttachmentList.test.ts +++ /dev/null @@ -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('
'); - }); - - 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"); - }); -}); diff --git a/frontend/src/composables/useRoleTemplateMeta.test.ts b/frontend/src/composables/useRoleTemplateMeta.test.ts deleted file mode 100644 index b2587780..00000000 --- a/frontend/src/composables/useRoleTemplateMeta.test.ts +++ /dev/null @@ -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", - ]); - }); -}); diff --git a/frontend/src/styles/main.test.ts b/frontend/src/styles/main.test.ts deleted file mode 100644 index 38a712dc..00000000 --- a/frontend/src/styles/main.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/utils/attachmentPermissions.test.ts b/frontend/src/utils/attachmentPermissions.test.ts deleted file mode 100644 index 4ba21027..00000000 --- a/frontend/src/utils/attachmentPermissions.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/utils/drawerDirtyGuard.test.ts b/frontend/src/utils/drawerDirtyGuard.test.ts deleted file mode 100644 index 15263fc5..00000000 --- a/frontend/src/utils/drawerDirtyGuard.test.ts +++ /dev/null @@ -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("请先保存或取消编辑"); - }); -}); diff --git a/frontend/src/utils/setupPublishWorkflow.test.ts b/frontend/src/utils/setupPublishWorkflow.test.ts deleted file mode 100644 index 8823c535..00000000 --- a/frontend/src/utils/setupPublishWorkflow.test.ts +++ /dev/null @@ -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([]); - }); -}); diff --git a/frontend/src/views/FaqPermissionConsistency.test.ts b/frontend/src/views/FaqPermissionConsistency.test.ts deleted file mode 100644 index 02fe23c2..00000000 --- a/frontend/src/views/FaqPermissionConsistency.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/views/Login.test.ts b/frontend/src/views/Login.test.ts index 0a5be6a7..22101e6e 100644 --- a/frontend/src/views/Login.test.ts +++ b/frontend/src/views/Login.test.ts @@ -88,4 +88,11 @@ describe("Login protocol agreement", () => { expect(source).toContain("已安全退出"); expect(source).toContain(".logout-notice"); }); + + it("keeps the login card wide enough on desktop while remaining responsive", () => { + const source = readLoginView(); + + expect(source).toContain("width: clamp(480px, 34vw, 560px);"); + expect(source).toContain("max-width: calc(100vw - 40px);"); + }); }); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 2709ee6d..0e79f09a 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -305,9 +305,8 @@ const onSubmit = async () => { .login-container { position: relative; z-index: 10; - width: 100%; - max-width: 440px; - padding: 0 20px; + width: clamp(480px, 34vw, 560px); + max-width: calc(100vw - 40px); } .login-card { diff --git a/frontend/src/views/ProfileSettings.test.ts b/frontend/src/views/ProfileSettings.test.ts deleted file mode 100644 index 36b6c710..00000000 --- a/frontend/src/views/ProfileSettings.test.ts +++ /dev/null @@ -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(''); - 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(" { - 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"); - }); -}); diff --git a/frontend/src/views/Register.test.ts b/frontend/src/views/Register.test.ts deleted file mode 100644 index 0b323ae0..00000000 --- a/frontend/src/views/Register.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/frontend/src/views/StudyHome.test.ts b/frontend/src/views/StudyHome.test.ts deleted file mode 100644 index c42adcf7..00000000 --- a/frontend/src/views/StudyHome.test.ts +++ /dev/null @@ -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)'); - }); -}); diff --git a/frontend/src/views/admin/ProjectMembers.test.ts b/frontend/src/views/admin/ProjectMembers.test.ts deleted file mode 100644 index 40d93a47..00000000 --- a/frontend/src/views/admin/ProjectMembers.test.ts +++ /dev/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"); - 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"); - }); -}); diff --git a/frontend/src/views/admin/SiteForm.test.ts b/frontend/src/views/admin/SiteForm.test.ts deleted file mode 100644 index accf3ac0..00000000 --- a/frontend/src/views/admin/SiteForm.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/views/admin/SystemMonitoringPage.test.ts b/frontend/src/views/admin/SystemMonitoringPage.test.ts deleted file mode 100644 index b139f066..00000000 --- a/frontend/src/views/admin/SystemMonitoringPage.test.ts +++ /dev/null @@ -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)"); - }); -}); diff --git a/frontend/src/views/admin/UserResetPassword.test.ts b/frontend/src/views/admin/UserResetPassword.test.ts deleted file mode 100644 index 32bd5002..00000000 --- a/frontend/src/views/admin/UserResetPassword.test.ts +++ /dev/null @@ -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'\""); - }); -}); diff --git a/frontend/src/views/admin/Users.test.ts b/frontend/src/views/admin/Users.test.ts deleted file mode 100644 index b72bb049..00000000 --- a/frontend/src/views/admin/Users.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/views/detailBreadcrumbContext.test.ts b/frontend/src/views/detailBreadcrumbContext.test.ts deleted file mode 100644 index 00d73082..00000000 --- a/frontend/src/views/detailBreadcrumbContext.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/views/detailNavigation.test.ts b/frontend/src/views/detailNavigation.test.ts deleted file mode 100644 index 88d5fe42..00000000 --- a/frontend/src/views/detailNavigation.test.ts +++ /dev/null @@ -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"); - } - }); -}); diff --git a/frontend/src/views/documents/DocumentListPermissions.test.ts b/frontend/src/views/documents/DocumentListPermissions.test.ts deleted file mode 100644 index ae6fc76c..00000000 --- a/frontend/src/views/documents/DocumentListPermissions.test.ts +++ /dev/null @@ -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"'); - }); -}); diff --git a/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts b/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts deleted file mode 100644 index c55d710d..00000000 --- a/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts +++ /dev/null @@ -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(" { - 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'); - }); -}); diff --git a/frontend/src/views/drug/ShipmentDetail.test.ts b/frontend/src/views/drug/ShipmentDetail.test.ts deleted file mode 100644 index 3eb6d670..00000000 --- a/frontend/src/views/drug/ShipmentDetail.test.ts +++ /dev/null @@ -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(""); - 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(" 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(""); - 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(" { - 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(''); - expect(source).toContain(''); - expect(source).not.toContain("TEXT.common.fields.currency"); - expect(source).not.toContain("detail.currency"); - expect(source).not.toContain("currencyDefault"); - }); -}); diff --git a/frontend/src/views/fees/ContractFeeEditorDrawer.test.ts b/frontend/src/views/fees/ContractFeeEditorDrawer.test.ts deleted file mode 100644 index f6ba51f1..00000000 --- a/frontend/src/views/fees/ContractFeeEditorDrawer.test.ts +++ /dev/null @@ -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(" { - const source = readDrawer(); - - expect(source).toContain("const attachmentPanelRef = ref | 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("'); - }); - - 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:"); - }); -}); diff --git a/frontend/src/views/fees/ContractFeeLocale.test.ts b/frontend/src/views/fees/ContractFeeLocale.test.ts deleted file mode 100644 index 21008d15..00000000 --- a/frontend/src/views/fees/ContractFeeLocale.test.ts +++ /dev/null @@ -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("第"); - }); -}); diff --git a/frontend/src/views/fees/ContractFeePermissions.test.ts b/frontend/src/views/fees/ContractFeePermissions.test.ts deleted file mode 100644 index 5e102c34..00000000 --- a/frontend/src/views/fees/ContractFeePermissions.test.ts +++ /dev/null @@ -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)"); - }); -}); diff --git a/frontend/src/views/ia/EtmfPlaceholder.test.ts b/frontend/src/views/ia/EtmfPlaceholder.test.ts deleted file mode 100644 index 0f72e733..00000000 --- a/frontend/src/views/ia/EtmfPlaceholder.test.ts +++ /dev/null @@ -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"'); - }); -}); diff --git a/frontend/src/views/ia/MaterialEquipment.test.ts b/frontend/src/views/ia/MaterialEquipment.test.ts deleted file mode 100644 index 0966357a..00000000 --- a/frontend/src/views/ia/MaterialEquipment.test.ts +++ /dev/null @@ -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(/]+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(">详情"); - 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"); - }); -}); diff --git a/frontend/src/views/ia/Precautions.test.ts b/frontend/src/views/ia/Precautions.test.ts deleted file mode 100644 index 936fa008..00000000 --- a/frontend/src/views/ia/Precautions.test.ts +++ /dev/null @@ -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(' { - 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"); - }); -}); diff --git a/frontend/src/views/ia/ProjectMilestones.test.ts b/frontend/src/views/ia/ProjectMilestones.test.ts deleted file mode 100644 index 6f97143d..00000000 --- a/frontend/src/views/ia/ProjectMilestones.test.ts +++ /dev/null @@ -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)"); - }); -}); diff --git a/frontend/src/views/ia/RiskIssueMonitoringVisits.test.ts b/frontend/src/views/ia/RiskIssueMonitoringVisits.test.ts deleted file mode 100644 index 95da4549..00000000 --- a/frontend/src/views/ia/RiskIssueMonitoringVisits.test.ts +++ /dev/null @@ -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("权限不足")'); - }); -}); diff --git a/frontend/src/views/ia/RiskIssueSae.test.ts b/frontend/src/views/ia/RiskIssueSae.test.ts deleted file mode 100644 index 65304e18..00000000 --- a/frontend/src/views/ia/RiskIssueSae.test.ts +++ /dev/null @@ -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)"); - }); -}); diff --git a/frontend/src/views/ia/StartupFeasibilityEthics.test.ts b/frontend/src/views/ia/StartupFeasibilityEthics.test.ts deleted file mode 100644 index 6819df8c..00000000 --- a/frontend/src/views/ia/StartupFeasibilityEthics.test.ts +++ /dev/null @@ -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();"); - expect(source).toContain("const editingEthicsId = ref();"); - 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;"); - }); -}); diff --git a/frontend/src/views/ia/project-overview/StageNode.test.ts b/frontend/src/views/ia/project-overview/StageNode.test.ts deleted file mode 100644 index c252fb30..00000000 --- a/frontend/src/views/ia/project-overview/StageNode.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/views/knowledge/PrecautionDetail.test.ts b/frontend/src/views/knowledge/PrecautionDetail.test.ts deleted file mode 100644 index c1f5b954..00000000 --- a/frontend/src/views/knowledge/PrecautionDetail.test.ts +++ /dev/null @@ -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`)"); - }); -}); diff --git a/frontend/src/views/knowledge/PrecautionForm.test.ts b/frontend/src/views/knowledge/PrecautionForm.test.ts deleted file mode 100644 index 0d5d79c2..00000000 --- a/frontend/src/views/knowledge/PrecautionForm.test.ts +++ /dev/null @@ -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(' { - const source = readSource(); - - expect(source).toContain(" 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(""); - 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("{{ displayValue(detail.production_permit_file_name) }}'); - expect(source).not.toContain('label="技术指标">{{ displayValue(detail.tech_index_file_name) }}'); - }); -}); diff --git a/frontend/src/views/materials/MaterialEquipmentEditorDrawer.test.ts b/frontend/src/views/materials/MaterialEquipmentEditorDrawer.test.ts deleted file mode 100644 index 3e1eef6c..00000000 --- a/frontend/src/views/materials/MaterialEquipmentEditorDrawer.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/src/views/overlayMounting.test.ts b/frontend/src/views/overlayMounting.test.ts deleted file mode 100644 index a7ec744c..00000000 --- a/frontend/src/views/overlayMounting.test.ts +++ /dev/null @@ -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: [' ({', - ':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)); - } - } - }); -}); diff --git a/frontend/src/views/startup/KickoffMemberPermissions.test.ts b/frontend/src/views/startup/KickoffMemberPermissions.test.ts deleted file mode 100644 index 7ec3adc6..00000000 --- a/frontend/src/views/startup/KickoffMemberPermissions.test.ts +++ /dev/null @@ -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)"); - }); -}); diff --git a/frontend/src/views/startup/StartupAuthEditorDrawer.test.ts b/frontend/src/views/startup/StartupAuthEditorDrawer.test.ts deleted file mode 100644 index ebb671b2..00000000 --- a/frontend/src/views/startup/StartupAuthEditorDrawer.test.ts +++ /dev/null @@ -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 {"); - }); -}); diff --git a/frontend/src/views/startup/StartupAuthPermissions.test.ts b/frontend/src/views/startup/StartupAuthPermissions.test.ts deleted file mode 100644 index 312c9f06..00000000 --- a/frontend/src/views/startup/StartupAuthPermissions.test.ts +++ /dev/null @@ -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(""); - 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(""); - 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(" { - 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);"); - }); -}); diff --git a/frontend/src/views/startup/StartupFormDetailPermissions.test.ts b/frontend/src/views/startup/StartupFormDetailPermissions.test.ts deleted file mode 100644 index ab713b87..00000000 --- a/frontend/src/views/startup/StartupFormDetailPermissions.test.ts +++ /dev/null @@ -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(" 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"); - }); -}); diff --git a/frontend/src/views/subjects/SubjectForm.test.ts b/frontend/src/views/subjects/SubjectForm.test.ts deleted file mode 100644 index 91247aea..00000000 --- a/frontend/src/views/subjects/SubjectForm.test.ts +++ /dev/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"); - }); -});