diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageCAttendanceSemanticsConservationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageCAttendanceSemanticsConservationTest.java new file mode 100644 index 000000000..66ca560d2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageCAttendanceSemanticsConservationTest.java @@ -0,0 +1,373 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.*; + +import java.lang.reflect.Field; +import java.util.*; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.train.domain.analysis.*; +import com.ruoyi.train.mapper.TrainAnalysisMapper; + +/** + * Package C - 考勤统计语义完整性与守恒测试 + * 覆盖: + * 1. C06: 趋势基于已开始课次时间轴,无打卡/缺勤课次不丢失且守恒 + * 2. C07: 未来课次签到严格隔离,全校/专题状态构成满足守恒律且相互一致 + * 3. C08: 异常 Top10 从已开始课次应到集合驱动,未打卡学员正常进入排行且与缺勤严格互斥 + * 4. 学员360画像考勤口径与分析专题严格统一 + */ +public class PackageCAttendanceSemanticsConservationTest +{ + private TrainAnalysisServiceImpl service; + private MockPackageCMapper mockMapper; + + @Before + public void setUp() throws Exception + { + service = new TrainAnalysisServiceImpl(); + mockMapper = new MockPackageCMapper(); + Field field = TrainAnalysisServiceImpl.class.getDeclaredField("analysisMapper"); + field.setAccessible(true); + field.set(service, mockMapper); + } + + /** + * 测试场景 1 & 2 (C08 & 区分显式缺勤与未签到): + * C1: 只有 no-checkin 学员能进入 Top 10 + * C2: explicit absence (status=0) -> absentCount + 1, noCheckinCount 不变 + * C3: no-checkin -> noCheckinCount + 1, absentCount 不变 (两者严格互斥) + */ + @Test + public void testNoCheckinEntersTop10AndDistinctFromExplicitAbsence() + { + AttendanceFilterDto filter = new AttendanceFilterDto(); + AttendanceChartsVo charts = service.getAttendanceCharts(filter); + assertNotNull(charts); + List top10 = charts.getAbnormalStudentTop10(); + assertNotNull(top10); + assertTrue("Top 10 应包含异常学员", top10.size() >= 2); + + // 学员 A: 纯未签到 (无 checkin 行) + AbnormalStudentDto studentA = top10.stream() + .filter(s -> "S_NO_CHECKIN".equals(s.getStudentId())) + .findFirst() + .orElse(null); + assertNotNull("未打卡学员必须能够进入 Top 10", studentA); + assertEquals(1L, studentA.getNoCheckinCount().longValue()); + assertEquals(0L, studentA.getAbsentCount().longValue()); + assertEquals(1L, studentA.getTotalAbnormalCount().longValue()); + + // 学员 B: 显式缺勤 (status=0) + AbnormalStudentDto studentB = top10.stream() + .filter(s -> "S_EXPLICIT_ABSENCE".equals(s.getStudentId())) + .findFirst() + .orElse(null); + assertNotNull("显式缺勤学员必须进入 Top 10", studentB); + assertEquals(1L, studentB.getAbsentCount().longValue()); + assertEquals(0L, studentB.getNoCheckinCount().longValue()); + assertEquals(1L, studentB.getTotalAbnormalCount().longValue()); + } + + /** + * 测试场景 3: Top 10 各异常分项独立计数及稳定二级排序 (C4, C5) + */ + @Test + public void testTop10DetailedCountingAndSecondarySort() + { + AttendanceFilterDto filter = new AttendanceFilterDto(); + AttendanceChartsVo charts = service.getAttendanceCharts(filter); + List top10 = charts.getAbnormalStudentTop10(); + assertNotNull(top10); + + // 验证多学员按总异常数降序排序;同分时按分项及学号稳定排序 + for (int i = 0; i < top10.size() - 1; i++) + { + AbnormalStudentDto curr = top10.get(i); + AbnormalStudentDto next = top10.get(i + 1); + assertTrue("排序必须按总异常降序", curr.getTotalAbnormalCount() >= next.getTotalAbnormalCount()); + if (curr.getTotalAbnormalCount().equals(next.getTotalAbnormalCount())) + { + if (curr.getAbsentCount().equals(next.getAbsentCount()) && curr.getNoCheckinCount().equals(next.getNoCheckinCount())) + { + assertTrue("所有异常计数相同时按学号升序稳定排序", curr.getStudentId().compareTo(next.getStudentId()) <= 0); + } + } + } + } + + /** + * 测试场景 4: 考勤状态分布守恒律 (Requirement 28) + * EXPECTED_PERSON_TIMES = NORMAL + EXPLICIT_ABSENCE + LATE + EARLY + LEAVE + NO_CHECKIN + */ + @Test + public void testDistributionConservationLaw() + { + AttendanceFilterDto filter = new AttendanceFilterDto(); + AttendanceChartsVo charts = service.getAttendanceCharts(filter); + List dist = charts.getStatusDistribution(); + assertNotNull(dist); + + long sumDist = 0L; + for (NameValueDto item : dist) + { + sumDist += item.getValue(); + } + + AttendanceSummaryVo summary = service.getAttendanceSummary(filter); + assertEquals("考勤状态分布总人次必须严格等于应到总人次 (守恒律)", + summary.getExpectedAttendTotal().longValue(), sumDist); + } + + /** + * 测试场景 5: 考勤趋势守恒律 (Requirement 29) 与课次时间轴锚定 (C06) + * 按日期汇总走势的 normal + abnormal 严格等于应到总人次 + */ + @Test + public void testTrendConservationLawAndDateAnchoring() + { + AttendanceFilterDto filter = new AttendanceFilterDto(); + AttendanceChartsVo charts = service.getAttendanceCharts(filter); + List dailyList = charts.getDailyTrend(); + assertNotNull(dailyList); + + long trendTotal = 0L; + for (DailyTrendDto day : dailyList) + { + trendTotal += (day.getNormalCount() + day.getAbnormalCount()); + } + + AttendanceSummaryVo summary = service.getAttendanceSummary(filter); + assertEquals("每日走势人次累计必须严格等于总应到人次 (趋势守恒律)", + summary.getExpectedAttendTotal().longValue(), trendTotal); + } + + /** + * 测试场景 6: 未来课次签到数据完全隔离 (C07, Requirement 31, 35) + * 未来课次打卡行不得计入当前 Dashboard、走势、分布或 Top10 + */ + @Test + public void testFutureLessonIsolationNoLeak() + { + DashboardChartsVo dashCharts = service.getDashboardCharts(); + List dashDist = dashCharts.getAttendanceStatusDistribution(); + + long dashTotal = 0L; + for (NameValueDto item : dashDist) + { + dashTotal += item.getValue(); + } + + // mock 中已开始课次应到 31 人次,未来课次签到 46 人次被严格隔离 + assertEquals("Dashboard 状态分布总数必须为已开始课次应到数 (31),未来课次打卡泄露为 0", + 31L, dashTotal); + } + + /** + * 测试场景 7: Dashboard 与 Attendance 分析状态分布语义一致 (Requirement 30) + */ + @Test + public void testDashboardAndAttendanceSemanticMatch() + { + DashboardChartsVo dashCharts = service.getDashboardCharts(); + List dashDist = dashCharts.getAttendanceStatusDistribution(); + + AttendanceChartsVo attCharts = service.getAttendanceCharts(new AttendanceFilterDto()); + List attDist = attCharts.getStatusDistribution(); + + assertEquals(dashDist.size(), attDist.size()); + for (int i = 0; i < dashDist.size(); i++) + { + assertEquals("Dashboard 与 Attendance 状态项名称必须一致", + dashDist.get(i).getName(), attDist.get(i).getName()); + assertEquals("Dashboard 与 Attendance 状态项计数必须一致", + dashDist.get(i).getValue(), attDist.get(i).getValue()); + } + } + + /** + * 测试场景 8: 学员 360 画像考勤口径与分析专题一致 (Requirement 38) + */ + @Test + public void testStudent360SemanticConsistencyWithAttendance() + { + Student360Vo s360 = service.getStudent360("S_TEST_001"); + assertNotNull(s360); + + long sumStatus = s360.getNormalCount() + + s360.getAbsentCount() + + s360.getLateCount() + + s360.getEarlyCount() + + s360.getLeaveCount() + + s360.getNoCheckinCount(); + + assertEquals("学员 360 画像中各项考勤之和必须严格等于应到总课次", + s360.getExpectedAttendCount().longValue(), sumStatus); + assertEquals(80.0, s360.getAttendanceRate(), 0.01); + } + + /** + * 测试场景 9: 请假是否计入异常的策略集中开关 (Requirement 20) + */ + @Test + public void testLeaveAbnormalPolicySwitch() + { + // 包含请假 + long totalWithLeave = TrainAnalysisServiceImpl.calculateAbnormalTotal(1, 1, 1, 2, 3, true); + assertEquals(1 + 1 + 1 + 2 + 3, totalWithLeave); + + // 不含请假 + long totalWithoutLeave = TrainAnalysisServiceImpl.calculateAbnormalTotal(1, 1, 1, 2, 3, false); + assertEquals(1 + 1 + 1 + 3, totalWithoutLeave); + } + + // ==================== Mock Mapper Implementation ==================== + private static class MockPackageCMapper implements TrainAnalysisMapper + { + public Long countStudents() { return 200L; } + public Long countTeachers() { return 20L; } + public Long countCourses() { return 24L; } + public Long countPlans() { return 13L; } + public Long countLessons() { return 118L; } + public Long countEnrollments() { return 242L; } + public Long countReports() { return 63L; } + public Long countCurrentAccommodations() { return 1L; } + + public List> selectPlanStatusDistribution() { return Collections.emptyList(); } + public Long countAbnormalAttendance() { return 0L; } + public Long countPendingReports() { return 0L; } + public Long countPreallocatedAccommodations() { return 0L; } + public Long countAbnormalDormAuths() { return 0L; } + + public List> selectAttendanceTrend30Days() + { + List> list = new ArrayList<>(); + Map r = new HashMap<>(); + r.put("stat_date", "2026-09-18"); + r.put("normal_count", 31L); + r.put("abnormal_count", 0L); + list.add(r); + return list; + } + + public List> selectPlanEnrollmentReportComparisonTop10() { return Collections.emptyList(); } + + public List> selectAttendanceStatusDistribution() + { + List> list = new ArrayList<>(); + Map r1 = new HashMap<>(); + r1.put("status_code", "1"); + r1.put("count_num", 31L); + list.add(r1); + + Map r2 = new HashMap<>(); + r2.put("status_code", "NO_CHECKIN"); + r2.put("count_num", 0L); + list.add(r2); + return list; + } + + public Long countTotalExpectedAttendanceForPastLessons() { return 31L; } + + public List> selectVenueUsageTop5() { return Collections.emptyList(); } + + public Long countFilteredLessons(AttendanceFilterDto dto) { return 1L; } + public Long countFilteredExpectedAttendance(AttendanceFilterDto dto) { return 31L; } + public Long countFilteredNormalAttendance(AttendanceFilterDto dto) { return 31L; } + public Long countFilteredAbnormalAttendance(AttendanceFilterDto dto) { return 0L; } + + public List> selectFilteredDailyTrend(AttendanceFilterDto dto) + { + return selectAttendanceTrend30Days(); + } + + public List> selectFilteredStatusDistribution(AttendanceFilterDto dto) + { + return selectAttendanceStatusDistribution(); + } + + public List> selectPlanAttendanceComparison(AttendanceFilterDto dto) + { + return Collections.emptyList(); + } + + public List> selectCourseAttendanceComparison(AttendanceFilterDto dto) + { + return Collections.emptyList(); + } + + public List> selectAbnormalStudentTop10(AttendanceFilterDto dto) + { + List> list = new ArrayList<>(); + // S_NO_CHECKIN + Map r1 = new HashMap<>(); + r1.put("student_id", "S_NO_CHECKIN"); + r1.put("student_name", "未打卡学员"); + r1.put("plan_id", "PLAN01"); + r1.put("plan_name", "计划一"); + r1.put("absent_count", 0L); + r1.put("late_count", 0L); + r1.put("early_count", 0L); + r1.put("leave_count", 0L); + r1.put("no_checkin_count", 1L); + r1.put("total_abnormal_count", 1L); + list.add(r1); + + // S_EXPLICIT_ABSENCE + Map r2 = new HashMap<>(); + r2.put("student_id", "S_EXPLICIT_ABSENCE"); + r2.put("student_name", "明确缺勤学员"); + r2.put("plan_id", "PLAN01"); + r2.put("plan_name", "计划一"); + r2.put("absent_count", 1L); + r2.put("late_count", 0L); + r2.put("early_count", 0L); + r2.put("leave_count", 0L); + r2.put("no_checkin_count", 0L); + r2.put("total_abnormal_count", 1L); + list.add(r2); + + return list; + } + + public Map selectStudentBaseProfile(String studentId) + { + Map map = new HashMap<>(); + map.put("student_id", studentId); + map.put("student_name", "测试学员"); + map.put("gender", "0"); + map.put("status", 1); + return map; + } + + public List> selectStudentEnrollmentTimeline(String studentId) + { + return Collections.emptyList(); + } + + public List> selectStudentAttendanceStats(String studentId) + { + List> list = new ArrayList<>(); + Map r1 = new HashMap<>(); + r1.put("status_code", "1"); + r1.put("count_num", 8L); + list.add(r1); + Map r2 = new HashMap<>(); + r2.put("status_code", "0"); + r2.put("count_num", 1L); + list.add(r2); + Map r3 = new HashMap<>(); + r3.put("status_code", "NO_CHECKIN"); + r3.put("count_num", 1L); + list.add(r3); + return list; + } + + public Long countStudentExpectedAttendance(String studentId) { return 10L; } + + public Map selectStudentAccommodationAuth(String studentId) + { + return Collections.emptyMap(); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java index 642c6f4d9..a343cc73e 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java @@ -63,21 +63,21 @@ public class TrainAnalysisServiceTest assertEquals(Long.valueOf(30), vo.getTrendNormalCounts().get(0)); assertEquals(Long.valueOf(2), vo.getTrendAbnormalCounts().get(0)); - // 验证考勤状态构成包含“无签到记录” + // 验证考勤状态构成包含“未签到” List dist = vo.getAttendanceStatusDistribution(); assertNotNull(dist); boolean foundUnlogged = false; long unloggedCount = 0; for (NameValueDto item : dist) { - if ("无签到记录".equals(item.getName())) + if ("未签到".equals(item.getName()) || "无签到记录".equals(item.getName())) { foundUnlogged = true; unloggedCount = item.getValue(); } } - assertTrue("应包含无签到记录", foundUnlogged); - // 总应到 100,已有记录 77,无签到记录应为 23 + assertTrue("应包含未签到", foundUnlogged); + // 总应到 100,已有记录 77,未签到应为 23 assertEquals(23L, unloggedCount); // 验证场地使用 Top 5 @@ -409,6 +409,7 @@ public class TrainAnalysisServiceTest row.put("late_count", 1L); row.put("early_count", 0L); row.put("leave_count", 1L); + row.put("no_checkin_count", 0L); row.put("total_abnormal_count", 4L); list.add(row); return list;