测试分析:增加考勤统计守恒与真实库只读验证

This commit is contained in:
wangzhibiao committed 2026-09-21 21:44:03 +08:00
1 parent 6d82478466
commit 330a7ab1f8
2 files changed
+378 -4

No files matched your search

@@ -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<AbnormalStudentDto> 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<AbnormalStudentDto> 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<NameValueDto> 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<DailyTrendDto> 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<NameValueDto> 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<NameValueDto> dashDist = dashCharts.getAttendanceStatusDistribution();
AttendanceChartsVo attCharts = service.getAttendanceCharts(new AttendanceFilterDto());
List<NameValueDto> 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<Map<String, Object>> 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<Map<String, Object>> selectAttendanceTrend30Days()
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> 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<Map<String, Object>> selectPlanEnrollmentReportComparisonTop10() { return Collections.emptyList(); }
public List<Map<String, Object>> selectAttendanceStatusDistribution()
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> r1 = new HashMap<>();
r1.put("status_code", "1");
r1.put("count_num", 31L);
list.add(r1);
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> selectFilteredDailyTrend(AttendanceFilterDto dto)
{
return selectAttendanceTrend30Days();
}
public List<Map<String, Object>> selectFilteredStatusDistribution(AttendanceFilterDto dto)
{
return selectAttendanceStatusDistribution();
}
public List<Map<String, Object>> selectPlanAttendanceComparison(AttendanceFilterDto dto)
{
return Collections.emptyList();
}
public List<Map<String, Object>> selectCourseAttendanceComparison(AttendanceFilterDto dto)
{
return Collections.emptyList();
}
public List<Map<String, Object>> selectAbnormalStudentTop10(AttendanceFilterDto dto)
{
List<Map<String, Object>> list = new ArrayList<>();
// S_NO_CHECKIN
Map<String, Object> 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<String, Object> 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<String, Object> selectStudentBaseProfile(String studentId)
{
Map<String, Object> map = new HashMap<>();
map.put("student_id", studentId);
map.put("student_name", "测试学员");
map.put("gender", "0");
map.put("status", 1);
return map;
}
public List<Map<String, Object>> selectStudentEnrollmentTimeline(String studentId)
{
return Collections.emptyList();
}
public List<Map<String, Object>> selectStudentAttendanceStats(String studentId)
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> r1 = new HashMap<>();
r1.put("status_code", "1");
r1.put("count_num", 8L);
list.add(r1);
Map<String, Object> r2 = new HashMap<>();
r2.put("status_code", "0");
r2.put("count_num", 1L);
list.add(r2);
Map<String, Object> 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<String, Object> selectStudentAccommodationAuth(String studentId)
{
return Collections.emptyMap();
}
}
}
@@ -63,21 +63,21 @@ public class TrainAnalysisServiceTest
assertEquals(Long.valueOf(30), vo.getTrendNormalCounts().get(0));
assertEquals(Long.valueOf(2), vo.getTrendAbnormalCounts().get(0));
// 验证考勤状态构成包含“无签到记录”
// 验证考勤状态构成包含“未签到”
List<NameValueDto> 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;