From 38d4851a18c0dabdbf142e9dc25fdc3e658218a0 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Mon, 21 Sep 2026 23:21:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=80=83=E5=8B=A4=EF=BC=9A?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E9=A6=96=E6=AC=A1=E7=AD=BE=E5=88=B0=E4=B8=8E?= =?UTF-8?q?=E6=9C=AA=E7=AD=BE=E5=88=B0=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nceAndReportApiControllerContractTest.java | 9 + .../impl/AttendanceActionContractTest.java | 137 +++++++++++++ .../tests/unit/attendanceSigninEntry.spec.mjs | 190 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 build_ui/tests/unit/attendanceSigninEntry.spec.mjs diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AttendanceAndReportApiControllerContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AttendanceAndReportApiControllerContractTest.java index 273169873..5c48e2d65 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AttendanceAndReportApiControllerContractTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AttendanceAndReportApiControllerContractTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertNotNull; import java.lang.reflect.Method; import org.junit.Test; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import com.ruoyi.train.domain.dto.AttendanceAdjustDTO; @@ -56,5 +57,13 @@ public class AttendanceAndReportApiControllerContractTest assertNotNull(putAdjust); assertEquals("/{checkinId}", putAdjust.value()[0]); assertEquals("@ss.hasPermi('train:attendance:edit')", adjust.getAnnotation(PreAuthorize.class).value()); + + // 4. 课次学员考勤名册 (纯只读,供管理员点名签到) + Method getRoster = AttendanceController.class.getMethod("getLessonRoster", Long.class); + assertNotNull(getRoster); + GetMapping getRosterAnnotation = getRoster.getAnnotation(GetMapping.class); + assertNotNull(getRosterAnnotation); + assertEquals("/lessons/{lessonId}/roster", getRosterAnnotation.value()[0]); + assertEquals("@ss.hasPermi('train:attendance:list')", getRoster.getAnnotation(PreAuthorize.class).value()); } } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java index dc25b6197..f2a514e70 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java @@ -1,15 +1,20 @@ package com.ruoyi.train.service.impl; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; +import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.Before; import org.junit.Test; @@ -94,6 +99,19 @@ public class AttendanceActionContractTest checkinsByEnrollLesson.put(c.getEnrollmentId() + "_" + c.getLessonId(), c); return 1; } + if ("selectCheckinsByLessonId".equals(name)) + { + Long lid = (Long) args[0]; + List list = new ArrayList<>(); + for (TrainStudentCheckin c : checkinsById.values()) + { + if (lid.equals(c.getLessonId())) + { + list.add(c); + } + } + return list; + } return defaultReturn(method); } }); @@ -108,6 +126,28 @@ public class AttendanceActionContractTest { return enrollments.get((Long) args[0]); } + if ("selectEnrollmentItemList".equals(name)) + { + com.ruoyi.train.domain.query.EnrollmentQuery q = (com.ruoyi.train.domain.query.EnrollmentQuery) args[0]; + List list = new ArrayList<>(); + for (TrainEnrollment e : enrollments.values()) + { + if (q.getPlanId() != null && q.getPlanId().equals(e.getPlanId())) + { + com.ruoyi.train.domain.vo.EnrollmentItemVO vo = new com.ruoyi.train.domain.vo.EnrollmentItemVO(); + vo.setEnrollmentId(e.getEnrollmentId()); + vo.setPlanId(e.getPlanId()); + vo.setStudentId(e.getStudentId() != null ? e.getStudentId() : "S_" + e.getEnrollmentId()); + vo.setStudentName("学员_" + e.getEnrollmentId()); + vo.setGender("男"); + vo.setOrgName("电务段"); + vo.setPhone("13812345678"); + vo.setEnrollmentStatus(e.getEnrollmentStatus() != null ? e.getEnrollmentStatus() : 1); + list.add(vo); + } + } + return list; + } return defaultReturn(method); } }); @@ -457,4 +497,101 @@ public class AttendanceActionContractTest assertEquals(AttendanceErrorCode.ATTENDANCE_STATUS_INVALID, (int) e.getCode()); } } + + @Test + public void testGetLessonRoster_DistinguishesUncheckedFromAbsent_AndEnablesSignIn() + { + // 场景:E_A(201)未打卡,新增学员 E_A2(203)属于PLAN_A且有缺勤打卡记录(status=0),学员 E_A3(204)已放弃(status=3) + TrainEnrollment ea2 = new TrainEnrollment(); + ea2.setEnrollmentId(203L); + ea2.setPlanId("PLAN_A"); + ea2.setEnrollmentStatus(1); + enrollments.put(203L, ea2); + + TrainEnrollment ea3 = new TrainEnrollment(); + ea3.setEnrollmentId(204L); + ea3.setPlanId("PLAN_A"); + ea3.setEnrollmentStatus(3); // 已放弃 + enrollments.put(204L, ea3); + + // 为 ea2 创建一条显式缺勤(attendance_status=0, 无signInTime) + TrainStudentCheckin absentCheckin = new TrainStudentCheckin(); + absentCheckin.setCheckinId(888L); + absentCheckin.setEnrollmentId(203L); + absentCheckin.setLessonId(1001L); + absentCheckin.setAttendanceStatus(0); // 0: 缺勤 + absentCheckin.setSignInTime(null); + checkinsById.put(888L, absentCheckin); + checkinsByEnrollLesson.put("203_1001", absentCheckin); + + List roster = appService.getLessonRoster(1001L); + assertNotNull(roster); + // 应包含 201 和 203,排除已放弃的 204 + assertEquals(2, roster.size()); + + // 验证学员 201 (无 checkin 记录): + com.ruoyi.train.domain.vo.AttendanceLessonRosterVO r201 = roster.stream() + .filter(r -> r.getEnrollmentId().equals(201L)).findFirst().orElse(null); + assertNotNull(r201); + assertNull("无checkin记录的学员,checkinId必须为null", r201.getCheckinId()); + assertNull("未签到(NO_CHECKIN)绝不能被渲染为0缺勤,必须为null", r201.getAttendanceStatus()); + assertTrue("未打卡学员必须允许执行首次签到", Boolean.TRUE.equals(r201.getCanSignIn())); + assertFalse(Boolean.TRUE.equals(r201.getCanSignOut())); + assertFalse(Boolean.TRUE.equals(r201.getCanAdjust())); + + // 验证学员 203 (有缺勤记录): + com.ruoyi.train.domain.vo.AttendanceLessonRosterVO r203 = roster.stream() + .filter(r -> r.getEnrollmentId().equals(203L)).findFirst().orElse(null); + assertNotNull(r203); + assertEquals(Long.valueOf(888L), r203.getCheckinId()); + assertEquals(Integer.valueOf(0), r203.getAttendanceStatus()); // 显式缺勤 + assertTrue(Boolean.TRUE.equals(r203.getCanSignIn())); // 无signInTime可补签 + assertTrue(Boolean.TRUE.equals(r203.getCanAdjust())); // 已有记录可调整 + } + + @Test + public void testSignInThenRosterUpdatesFact_DisablesSignIn() + { + // 初始未签到 + List rosterBefore = appService.getLessonRoster(1001L); + com.ruoyi.train.domain.vo.AttendanceLessonRosterVO rBefore = rosterBefore.stream() + .filter(r -> r.getEnrollmentId().equals(201L)).findFirst().orElse(null); + assertNotNull(rBefore); + assertTrue(Boolean.TRUE.equals(rBefore.getCanSignIn())); + + // 执行首次签到 + AttendanceSignInDTO signInDto = new AttendanceSignInDTO(); + signInDto.setEnrollmentId(201L); + signInDto.setLessonId(1001L); + signInDto.setCheckinWay(4); // 代签 + TrainStudentCheckin created = appService.signIn(signInDto, "train_admin"); + assertNotNull(created); + assertNotNull(created.getCheckinId()); + + // 刷新查询 roster + List rosterAfter = appService.getLessonRoster(1001L); + com.ruoyi.train.domain.vo.AttendanceLessonRosterVO rAfter = rosterAfter.stream() + .filter(r -> r.getEnrollmentId().equals(201L)).findFirst().orElse(null); + assertNotNull(rAfter); + assertEquals(created.getCheckinId(), rAfter.getCheckinId()); + assertEquals(Integer.valueOf(1), rAfter.getAttendanceStatus()); // 正常出勤 + assertNotNull(rAfter.getSignInTime()); + assertFalse("已成功签到的学员禁止重复执行签到", Boolean.TRUE.equals(rAfter.getCanSignIn())); + assertTrue("已签到未签退的学员允许签退", Boolean.TRUE.equals(rAfter.getCanSignOut())); + assertTrue(Boolean.TRUE.equals(rAfter.getCanAdjust())); + } + + @Test + public void testGetLessonRoster_LessonNotFound_ThrowsException() + { + try + { + appService.getLessonRoster(99999L); + fail("不存在的课次应当抛出异常"); + } + catch (ServiceException e) + { + assertEquals(AttendanceErrorCode.LESSON_NOT_FOUND, (int) e.getCode()); + } + } } diff --git a/build_ui/tests/unit/attendanceSigninEntry.spec.mjs b/build_ui/tests/unit/attendanceSigninEntry.spec.mjs new file mode 100644 index 000000000..58d240dc9 --- /dev/null +++ b/build_ui/tests/unit/attendanceSigninEntry.spec.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert'; + +console.log('=== Testing Attendance Sign-in Entry & Roster Semantics ==='); + +// 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. 验证未打卡学员渲染为“未签到”,绝不等于“缺勤” +{ + const tagNull = formatAttendanceTag(null); + assert.strictEqual(tagNull.text, '未签到'); + assert.strictEqual(tagNull.type, 'info'); + + const tagUndefined = formatAttendanceTag(undefined); + assert.strictEqual(tagUndefined.text, '未签到'); + + 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)'); +} + +// 2. 按钮可见性与防重复规则 +function getRowActionButtons(row) { + return { + signIn: !!row.canSignIn, + signOut: !!row.canSignOut, + adjust: !!row.canAdjust + }; +} + +// A. 无 checkin 记录学员:签到按钮可见,签退不可见 +{ + const uncheckRow = { + enrollmentId: 101, + checkinId: null, + attendanceStatus: null, + signInTime: null, + signOutTime: null, + canSignIn: true, + canSignOut: false, + canAdjust: 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'); +} + +// B. 已有签到记录:签到按钮不可重复执行 +{ + const signedInRow = { + enrollmentId: 101, + checkinId: 8001, + attendanceStatus: 1, + signInTime: '2026-09-21 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'); +} + +// 3. 模拟 signIn 闭环与名册状态刷新 +{ + // 初始名册 + let roster = [ + { + enrollmentId: 201, + studentName: '张三', + checkinId: null, + attendanceStatus: null, + signInTime: null, + signOutTime: null, + canSignIn: true, + canSignOut: false, + canAdjust: false + } + ]; + + assert.strictEqual(roster[0].canSignIn, true); + assert.strictEqual(roster[0].attendanceStatus, null); + + // 模拟执行 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 + }); + } + + // 模拟刷新 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'); + }); +} + +// 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!');