测试考勤:覆盖参训状态签到守卫与真实前端策略

This commit is contained in:
wangzhibiao committed 2026-09-22 00:05:56 +08:00
1 parent e2c15e6216
commit 08b8abe778
2 files changed
+344 -151

No files matched your search

@@ -196,16 +196,18 @@ public class AttendanceActionContractTest
lesson.setPlanCourseId(10L);
lessons.put(1001L, lesson);
// 学员 E_A 属于 PLAN_A
// 学员 E_A 属于 PLAN_A (1:已报到在训)
TrainEnrollment ea = new TrainEnrollment();
ea.setEnrollmentId(201L);
ea.setPlanId("PLAN_A");
ea.setEnrollmentStatus(1);
enrollments.put(201L, ea);
// 学员 E_B 属于 PLAN_B (跨班)
// 学员 E_B 属于 PLAN_B (跨班, 1:已报到在训)
TrainEnrollment eb = new TrainEnrollment();
eb.setEnrollmentId(202L);
eb.setPlanId("PLAN_B");
eb.setEnrollmentStatus(1);
enrollments.put(202L, eb);
}
@@ -594,4 +596,227 @@ public class AttendanceActionContractTest
assertEquals(AttendanceErrorCode.LESSON_NOT_FOUND, (int) e.getCode());
}
}
@Test
public void testSignInLifecycle_Status0_WaitingReport_Rejected()
{
// status=0 待报到学员:严禁直接签到绕过报到生命周期
TrainEnrollment e0 = new TrainEnrollment();
e0.setEnrollmentId(300L);
e0.setPlanId("PLAN_A");
e0.setEnrollmentStatus(0); // 0: 待报到
enrollments.put(300L, e0);
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(300L);
dto.setLessonId(1001L);
try
{
appService.signIn(dto, "admin");
fail("待报到学员必须被拦截禁止签到");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_REQUIRES_REPORTED_ENROLLMENT, (int) e.getCode());
assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_REQUIRES_REPORTED_ENROLLMENT_MESSAGE, e.getMessage());
}
// 保证无任何写产生
assertNull(checkinsByEnrollLesson.get("300_1001"));
}
@Test
public void testSignInLifecycle_Status1_Reported_Allowed()
{
// status=1 已报到学员:允许签到
TrainEnrollment e1 = new TrainEnrollment();
e1.setEnrollmentId(301L);
e1.setPlanId("PLAN_A");
e1.setEnrollmentStatus(1); // 1: 已报到
enrollments.put(301L, e1);
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(301L);
dto.setLessonId(1001L);
TrainStudentCheckin c = appService.signIn(dto, "admin");
assertNotNull(c);
assertNotNull(c.getSignInTime());
assertEquals(Integer.valueOf(1), c.getAttendanceStatus());
}
@Test
public void testSignInLifecycle_Status2_Leave_Allowed_PreviewDefault()
{
// status=2 请假学员:Preview Default 允许签到 (LEAVE_SIGNIN_POLICY: PREVIEW_DEFAULT_ALLOWED)
TrainEnrollment e2 = new TrainEnrollment();
e2.setEnrollmentId(302L);
e2.setPlanId("PLAN_A");
e2.setEnrollmentStatus(2); // 2: 请假
enrollments.put(302L, e2);
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(302L);
dto.setLessonId(1001L);
TrainStudentCheckin c = appService.signIn(dto, "admin");
assertNotNull(c);
assertNotNull(c.getSignInTime());
assertEquals(Integer.valueOf(1), c.getAttendanceStatus());
}
@Test
public void testSignInLifecycle_Status3_Abandoned_Rejected()
{
// status=3 放弃学员:严禁直接签到
TrainEnrollment e3 = new TrainEnrollment();
e3.setEnrollmentId(303L);
e3.setPlanId("PLAN_A");
e3.setEnrollmentStatus(3); // 3: 放弃
enrollments.put(303L, e3);
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(303L);
dto.setLessonId(1001L);
try
{
appService.signIn(dto, "admin");
fail("已放弃学员必须被拦截禁止签到");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_ENROLLMENT_ABANDONED, (int) e.getCode());
assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_ENROLLMENT_ABANDONED_MESSAGE, e.getMessage());
}
assertNull(checkinsByEnrollLesson.get("303_1001"));
}
@Test
public void testSignInLifecycle_Status4_Completed_Rejected()
{
// status=4 已完成学员:严禁直接签到
TrainEnrollment e4 = new TrainEnrollment();
e4.setEnrollmentId(304L);
e4.setPlanId("PLAN_A");
e4.setEnrollmentStatus(4); // 4: 已完成
enrollments.put(304L, e4);
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(304L);
dto.setLessonId(1001L);
try
{
appService.signIn(dto, "admin");
fail("已结训学员必须被拦截禁止签到");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_ENROLLMENT_COMPLETED, (int) e.getCode());
assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_ENROLLMENT_COMPLETED_MESSAGE, e.getMessage());
}
assertNull(checkinsByEnrollLesson.get("304_1001"));
}
@Test
public void testSignInLifecycle_UnknownOrNullStatus_Rejected()
{
// 未知或 null 参训状态:fail closed 严禁签到
TrainEnrollment eNull = new TrainEnrollment();
eNull.setEnrollmentId(305L);
eNull.setPlanId("PLAN_A");
eNull.setEnrollmentStatus(null);
enrollments.put(305L, eNull);
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(305L);
dto.setLessonId(1001L);
try
{
appService.signIn(dto, "admin");
fail("null 状态学员必须被 fail closed 拦截");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_ENROLLMENT_STATUS_INVALID, (int) e.getCode());
}
// 状态 99
eNull.setEnrollmentStatus(99);
try
{
appService.signIn(dto, "admin");
fail("非法状态 99 学员必须被拦截");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_ENROLLMENT_STATUS_INVALID, (int) e.getCode());
}
}
@Test
public void testSignInLifecycle_Status4_WithExistingCheckin_IdempotentRetainsFact()
{
// 场景:历史已经合法签到的学员,后来结训归档变为 status=4。重复调用 signIn 保持幂等返回已有事实,不产生新写
TrainEnrollment e4 = new TrainEnrollment();
e4.setEnrollmentId(306L);
e4.setPlanId("PLAN_A");
e4.setEnrollmentStatus(4); // 4: 已完成
enrollments.put(306L, e4);
TrainStudentCheckin historical = new TrainStudentCheckin();
historical.setCheckinId(6001L);
historical.setEnrollmentId(306L);
historical.setLessonId(1001L);
Date historicalTime = new Date(System.currentTimeMillis() - 86400000L);
historical.setSignInTime(historicalTime);
historical.setAttendanceStatus(1);
checkinsById.put(6001L, historical);
checkinsByEnrollLesson.put("306_1001", historical);
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(306L);
dto.setLessonId(1001L);
TrainStudentCheckin returned = appService.signIn(dto, "admin");
assertNotNull(returned);
assertEquals(Long.valueOf(6001L), returned.getCheckinId());
assertEquals(historicalTime, returned.getSignInTime());
}
@Test
public void testRosterLifecycleEligibility_Status0And4_CannotSignIn_Status1And2_CanSignIn()
{
// 测试名册对于 status 0, 1, 2, 4 的 canSignIn 权限对齐:
// 310 (status=0 待报到), 311 (status=1 已报到), 312 (status=2 请假), 314 (status=4 已完成)
enrollments.clear();
checkinsById.clear();
checkinsByEnrollLesson.clear();
TrainEnrollment e0 = new TrainEnrollment(); e0.setEnrollmentId(310L); e0.setPlanId("PLAN_A"); e0.setEnrollmentStatus(0); enrollments.put(310L, e0);
TrainEnrollment e1 = new TrainEnrollment(); e1.setEnrollmentId(311L); e1.setPlanId("PLAN_A"); e1.setEnrollmentStatus(1); enrollments.put(311L, e1);
TrainEnrollment e2 = new TrainEnrollment(); e2.setEnrollmentId(312L); e2.setPlanId("PLAN_A"); e2.setEnrollmentStatus(2); enrollments.put(312L, e2);
TrainEnrollment e4 = new TrainEnrollment(); e4.setEnrollmentId(314L); e4.setPlanId("PLAN_A"); e4.setEnrollmentStatus(4); enrollments.put(314L, e4);
List<com.ruoyi.train.domain.vo.AttendanceLessonRosterVO> roster = appService.getLessonRoster(1001L);
assertEquals(4, roster.size());
com.ruoyi.train.domain.vo.AttendanceLessonRosterVO r0 = roster.stream().filter(r -> r.getEnrollmentId().equals(310L)).findFirst().orElse(null);
assertNotNull(r0);
assertFalse("status=0 待报到学员名册 canSignIn 必须为 false", Boolean.TRUE.equals(r0.getCanSignIn()));
com.ruoyi.train.domain.vo.AttendanceLessonRosterVO r1 = roster.stream().filter(r -> r.getEnrollmentId().equals(311L)).findFirst().orElse(null);
assertNotNull(r1);
assertTrue("status=1 已报到学员名册 canSignIn 必须为 true", Boolean.TRUE.equals(r1.getCanSignIn()));
com.ruoyi.train.domain.vo.AttendanceLessonRosterVO r2 = roster.stream().filter(r -> r.getEnrollmentId().equals(312L)).findFirst().orElse(null);
assertNotNull(r2);
assertTrue("status=2 请假学员名册 canSignIn 必须为 true (Preview Default)", Boolean.TRUE.equals(r2.getCanSignIn()));
com.ruoyi.train.domain.vo.AttendanceLessonRosterVO r4 = roster.stream().filter(r -> r.getEnrollmentId().equals(314L)).findFirst().orElse(null);
assertNotNull(r4);
assertFalse("status=4 已结训学员名册 canSignIn 必须为 false", Boolean.TRUE.equals(r4.getCanSignIn()));
}
}
+117 -149
View File
@@ -1,29 +1,15 @@
import assert from 'node:assert';
import {
formatAttendanceTag,
formatEnrollmentStatusTag,
isEnrollmentEligibleForSignIn,
computeRosterActionEligibility,
resolveSignInFeedback
} from '../../src/views/train/checkin/attendancePolicy.js';
console.log('=== Testing Attendance Sign-in Entry & Roster Semantics ===');
console.log('=== Testing Real Production Attendance Policy Helper ===');
// 1. 测试考勤状态语义映射:未签到 (NO_CHECKIN) 必须严格与缺勤 (0) 区分
function formatAttendanceTag(status) {
if (status === null || status === undefined) {
return { text: '未签到', type: 'info' };
}
switch (status) {
case 0:
return { text: '缺勤', type: 'danger' };
case 1:
return { text: '正常', type: 'success' };
case 2:
return { text: '迟到', type: 'warning' };
case 3:
return { text: '早退', type: 'warning' };
case 4:
return { text: '请假', type: 'primary' };
default:
return { text: '-', type: '' };
}
}
// A. 验证未打卡学员渲染为“未签到”,绝不等于“缺勤”
// 1. 考勤状态语义映射:未签到 (NO_CHECKIN) 必须严格与缺勤 (0) 区分
{
const tagNull = formatAttendanceTag(null);
assert.strictEqual(tagNull.text, '未签到');
@@ -31,160 +17,142 @@ function formatAttendanceTag(status) {
const tagUndefined = formatAttendanceTag(undefined);
assert.strictEqual(tagUndefined.text, '未签到');
assert.strictEqual(tagUndefined.type, 'info');
const tagAbsent = formatAttendanceTag(0);
assert.strictEqual(tagAbsent.text, '缺勤');
assert.strictEqual(tagAbsent.type, 'danger');
// 红线:未签到和缺勤完全不同
// 严格区分未签到与缺勤事实
assert.notStrictEqual(tagNull.text, tagAbsent.text);
assert.notStrictEqual(tagNull.type, tagAbsent.type);
console.log('✔ PASS: NO_CHECKIN is strictly distinct from ABSENT (0)');
console.log('✔ PASS: Production formatAttendanceTag distinguishes NO_CHECKIN from ABSENT (0)');
}
// 2. 按钮可见性与防重复规则
function getRowActionButtons(row) {
return {
signIn: !!row.canSignIn,
signOut: !!row.canSignOut,
adjust: !!row.canAdjust
};
}
// A. 无 checkin 记录学员:签到按钮可见,签退不可见
// 2. 学员参训状态展示标签
{
const uncheckRow = {
enrollmentId: 101,
assert.strictEqual(formatEnrollmentStatusTag(0).text, '待报到');
assert.strictEqual(formatEnrollmentStatusTag(1).text, '已报到');
assert.strictEqual(formatEnrollmentStatusTag(2).text, '请假');
assert.strictEqual(formatEnrollmentStatusTag(3).text, '放弃');
assert.strictEqual(formatEnrollmentStatusTag(4).text, '已完成');
console.log('✔ PASS: Production formatEnrollmentStatusTag maps all lifecycle statuses correctly');
}
// 3. 参训生命周期首次签到资格规则断言
{
assert.strictEqual(isEnrollmentEligibleForSignIn(0), false, 'status=0 待报到严禁签到');
assert.strictEqual(isEnrollmentEligibleForSignIn(1), true, 'status=1 已报到在训允许签到');
assert.strictEqual(isEnrollmentEligibleForSignIn(2), true, 'status=2 请假学员当前 Preview Default 允许签到');
assert.strictEqual(isEnrollmentEligibleForSignIn(3), false, 'status=3 放弃学员严禁签到');
assert.strictEqual(isEnrollmentEligibleForSignIn(4), false, 'status=4 已完成学员严禁签到');
assert.strictEqual(isEnrollmentEligibleForSignIn(null), false, 'null 状态 fail closed');
assert.strictEqual(isEnrollmentEligibleForSignIn(undefined), false, 'undefined 状态 fail closed');
assert.strictEqual(isEnrollmentEligibleForSignIn(99), false, '未知状态 fail closed');
console.log('✔ PASS: Production isEnrollmentEligibleForSignIn strictly enforces lifecycle guard');
}
// 4. 生产环境操作按钮可用性 (computeRosterActionEligibility) 覆盖
{
// A. status=0 待报到 + 无打卡记录 -> 签到按钮不存在
const row0 = {
enrollmentId: 100,
enrollmentStatus: 0,
checkinId: null,
attendanceStatus: null,
signInTime: null,
signOutTime: null,
canSignIn: true,
canSignOut: false,
canAdjust: false
canSignIn: false
};
const actions = getRowActionButtons(uncheckRow);
assert.strictEqual(actions.signIn, true, '无签到记录学员签到按钮必须可见');
assert.strictEqual(actions.signOut, false, '未签到学员不能显示签退');
assert.strictEqual(actions.adjust, false, '无考勤记录学员不能显示调整');
console.log('✔ PASS: Unchecked student has visible signIn button, no duplicate path');
}
const act0 = computeRosterActionEligibility(row0);
assert.strictEqual(act0.canSignIn, false, 'status=0 待报到学员签到按钮必须不存在');
assert.strictEqual(act0.canSignOut, false);
assert.strictEqual(act0.canAdjust, false);
// B. 已有签到记录:签到按钮不可重复执行
{
const signedInRow = {
// B. status=1 已报到 + NO_CHECKIN -> 签到按钮存在
const row1 = {
enrollmentId: 101,
checkinId: 8001,
enrollmentStatus: 1,
checkinId: null,
attendanceStatus: null,
signInTime: null,
signOutTime: null,
canSignIn: true
};
const act1 = computeRosterActionEligibility(row1);
assert.strictEqual(act1.canSignIn, true, 'status=1 已报到学员未签到时签到按钮必须存在');
assert.strictEqual(act1.canSignOut, false);
assert.strictEqual(act1.canAdjust, false);
// C. status=2 请假 + NO_CHECKIN -> 签到按钮存在 (Preview Default)
const row2 = {
enrollmentId: 102,
enrollmentStatus: 2,
checkinId: null,
attendanceStatus: null,
signInTime: null,
signOutTime: null,
canSignIn: true
};
const act2 = computeRosterActionEligibility(row2);
assert.strictEqual(act2.canSignIn, true, 'status=2 请假学员未签到时签到按钮必须存在 (Preview Default)');
assert.strictEqual(act2.canSignOut, false);
assert.strictEqual(act2.canAdjust, false);
// D. status=4 已完成 + NO_CHECKIN -> 签到按钮不存在
const row4 = {
enrollmentId: 104,
enrollmentStatus: 4,
checkinId: null,
attendanceStatus: null,
signInTime: null,
signOutTime: null,
canSignIn: false
};
const act4 = computeRosterActionEligibility(row4);
assert.strictEqual(act4.canSignIn, false, 'status=4 已完成学员签到按钮必须不存在');
assert.strictEqual(act4.canSignOut, false);
assert.strictEqual(act4.canAdjust, false);
// E. 已有合法签到记录 (无论 status 为何) -> 签到按钮禁止重复存在
const rowSignedIn = {
enrollmentId: 101,
enrollmentStatus: 1,
checkinId: 8888,
attendanceStatus: 1,
signInTime: '2026-09-21 08:30:00',
signInTime: '2026-09-22 08:30:00',
signOutTime: null,
canSignIn: false,
canSignOut: true,
canAdjust: true
};
const actions = getRowActionButtons(signedInRow);
assert.strictEqual(actions.signIn, false, '已有合法签到记录学员不得显示签到按钮');
assert.strictEqual(actions.signOut, true, '已签到学员允许签退');
assert.strictEqual(actions.adjust, true, '已有打卡记录允许人工调整');
console.log('✔ PASS: Signed-in student cannot duplicate sign-in');
const actSigned = computeRosterActionEligibility(rowSignedIn);
assert.strictEqual(actSigned.canSignIn, false, '已签到学员绝对不能再次显示签到按钮');
assert.strictEqual(actSigned.canSignOut, true, '已签到未签退学员激活签退');
assert.strictEqual(actSigned.canAdjust, true, '已有打卡记录激活调整');
console.log('✔ PASS: Production computeRosterActionEligibility covers all status button visibility');
}
// 3. 模拟 signIn 闭环与名册状态刷新
// 5. 后端生命周期报错反馈解析真实性 (resolveSignInFeedback)
{
// 初始名册
let roster = [
{
enrollmentId: 201,
studentName: '张三',
checkinId: null,
attendanceStatus: null,
signInTime: null,
signOutTime: null,
canSignIn: true,
canSignOut: false,
canAdjust: false
}
];
const errWaiting = { code: 40085, msg: '学员尚未完成开班报到,禁止录入课次考勤' };
const fbWaiting = resolveSignInFeedback(errWaiting, '张三');
assert.strictEqual(fbWaiting.isSuccess, false);
assert.strictEqual(fbWaiting.message.includes('学员尚未完成开班报到'), true);
assert.strictEqual(fbWaiting.message.includes('成功'), false);
assert.strictEqual(roster[0].canSignIn, true);
assert.strictEqual(roster[0].attendanceStatus, null);
const errCompleted = { code: 40087, msg: '学员参训档案已结训完成,禁止录入课次考勤' };
const fbCompleted = resolveSignInFeedback(errCompleted, '李四');
assert.strictEqual(fbCompleted.isSuccess, false);
assert.strictEqual(fbCompleted.message.includes('学员参训档案已结训完成'), true);
assert.strictEqual(fbCompleted.message.includes('成功'), false);
// 模拟执行 signIn 成功
function mockSignIn(enrollmentId, lessonId) {
return Promise.resolve({
checkinId: 5001,
enrollmentId: enrollmentId,
lessonId: lessonId,
attendanceStatus: 1,
signInTime: '2026-09-21 09:00:00',
signOutTime: null
});
}
const errAbandoned = { code: 40086, msg: '学员参训状态已放弃,禁止录入课次考勤' };
const fbAbandoned = resolveSignInFeedback(errAbandoned, '王五');
assert.strictEqual(fbAbandoned.isSuccess, false);
assert.strictEqual(fbAbandoned.message.includes('学员参训状态已放弃'), true);
// 模拟刷新 roster
function mockRefreshRoster(updatedRecord) {
roster = roster.map(item => {
if (item.enrollmentId === updatedRecord.enrollmentId) {
return {
...item,
checkinId: updatedRecord.checkinId,
attendanceStatus: updatedRecord.attendanceStatus,
signInTime: updatedRecord.signInTime,
signOutTime: updatedRecord.signOutTime,
canSignIn: false, // 已签到禁止再签到
canSignOut: true,
canAdjust: true
};
}
return item;
});
}
mockSignIn(201, 1001).then(res => {
mockRefreshRoster(res);
assert.strictEqual(roster[0].checkinId, 5001);
assert.strictEqual(roster[0].attendanceStatus, 1);
assert.strictEqual(roster[0].canSignIn, false, '刷新后签到按钮必须消失');
assert.strictEqual(roster[0].canSignOut, true, '刷新后签退按钮必须激活');
assert.strictEqual(roster[0].canAdjust, true, '刷新后调整按钮必须激活');
console.log('✔ PASS: signIn success refreshes roster to current attendance fact');
});
console.log('✔ PASS: Production resolveSignInFeedback faithfully reports errors without false success');
}
// 4. 后端错误真实展示,不得出现假成功
{
function handleSignInError(err) {
// 页面逻辑:
let displayedMsg = '';
let isSuccess = false;
if (err && (err.msg || err.message)) {
displayedMsg = err.msg || err.message;
} else {
displayedMsg = '签到操作失败';
}
return { displayedMsg, isSuccess };
}
const backendError = { code: 400, msg: '课次未关联有效的培训课程计划' };
const feedback = handleSignInError(backendError);
assert.strictEqual(feedback.isSuccess, false);
assert.strictEqual(feedback.displayedMsg, '课次未关联有效的培训课程计划');
assert.strictEqual(feedback.displayedMsg.includes('成功'), false);
console.log('✔ PASS: Backend error is truthfully reported without false success');
}
// 5. 权限可达性检查:train_admin 具备 checkin 权限与 list 权限
{
const trainAdminPerms = ['train:attendance:list', 'train:attendance:checkin', 'train:attendance:edit', 'train:attendance:export'];
const canAccessList = trainAdminPerms.includes('train:attendance:list');
const canPerformSignIn = trainAdminPerms.includes('train:attendance:checkin');
const canPerformAdjust = trainAdminPerms.includes('train:attendance:edit');
assert.strictEqual(canAccessList, true, 'train_admin 必须有查看考勤名册权限');
assert.strictEqual(canPerformSignIn, true, 'train_admin 必须有课次签到权限');
assert.strictEqual(canPerformAdjust, true, 'train_admin 必须有课次调整权限');
console.log('✔ PASS: train_admin has full permission reachability');
}
console.log('ALL FRONTEND ATTENDANCE SIGN-IN ENTRY TESTS PASSED!');
console.log('ALL PRODUCTION ATTENDANCE POLICY TESTS PASSED!');