测试分析:增加考勤统计守恒与真实库只读验证
This commit is contained in:
1 parent
6d82478466
commit
330a7ab1f8
2 files changed
+378
-4
No files matched your search
+373
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user