Compare commits
6
Commits
0413013f24
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16149e83ee | ||
|
|
5e0b922b3f | ||
|
|
3e4c0645ad | ||
|
|
11a7c8aaea | ||
|
|
e691dfaa95 | ||
|
|
a6f684c8b7 |
@@ -0,0 +1,4 @@
|
||||
# 行尾规范:文本文件统一 LF,Windows 批处理保留 CRLF
|
||||
* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
@@ -44,16 +44,17 @@ lpt-be/src/main/java/com/guo/learningprogresstracker/
|
||||
- entity 使用 `@TableName`、`@TableField` 映射数据库字段。
|
||||
- mapStruct 承载 DTO 转换。
|
||||
|
||||
## API 端点总览(32 个)
|
||||
## API 端点总览(44 个)
|
||||
|
||||
| 模块 | 端点数 | 端点 |
|
||||
|------|--------|------|
|
||||
| 学习会话 | 9 | `POST /study-sessions/{taskNum}/start`, `PUT .../pause`, `PUT .../resume`, `PUT .../end`, `POST .../fragments`, `GET .../history`, `PUT .../expectation`, `GET .../expectation`, `GET .../report-draft` |
|
||||
| 标准思维导图 | 6 | `GET /review/standard-mind-map/{taskNum}`, `POST .../regenerate`, `PUT ...`, `POST .../recall`, `POST .../find-node`, `GET .../recall-records` |
|
||||
| 复习模块 | 6 | `GET /review/feed`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/recall-records/{recordId}`, `GET /review/tasks` |
|
||||
| 任务管理 | 6 | `GET /tasks`, `POST /tasks`, `PUT /tasks/{taskNum}`, `DELETE /tasks/{taskNum}`, `GET /tasks/priority-weights`, `PUT /tasks/priority-weights` |
|
||||
| 应用场景 | 4 | `GET/POST/PUT/DELETE /tasks/{taskNum}/applications[/{id}]` |
|
||||
| 登录认证 | 2 | `POST /login`, `POST /logout` |
|
||||
| 学习会话 | 11 | `PUT /study-sessions/{sessionNum}/expectation`, `GET /study-sessions/{sessionNum}/expectation`, `GET /study-sessions/{sessionNum}`, `POST /study-sessions/{sessionNum}/study-sessions/continue`, `POST /study-sessions/{sessionNum}/study-sessions/pause`, `POST /study-sessions/{sessionNum}/study-sessions/ended`, `GET /study-sessions/{sessionNum}/all-fragments`, `GET /study-sessions/{sessionNum}/report-draft`, `GET /study-sessions/tasks/{taskNum}/fragments`, `GET /study-sessions/tasks/{taskNum}/reports`, `GET /study-sessions/active` |
|
||||
| 任务管理 | 13 | `GET/POST /tasks`, `GET/PUT/DELETE /tasks/{taskId}`, `GET/PUT /tasks/priority-weights`, `GET/POST /tasks/{taskNum}/applications`, `PUT/DELETE /tasks/applications/{id}`, `GET /tasks/{taskNum}/study-sessions/start-or-continue`, `GET /tasks/{taskNum}/not-ended-study-session` |
|
||||
| 复习与导图 | 13 | `GET /review/feed`, `GET /review/tasks`, `GET /review/tasks/{taskNum}`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/{taskNum}`, `POST /review/standard-mind-map/{taskNum}/regenerate`, `PUT /review/standard-mind-map/{taskNum}`, `POST /review/standard-mind-map/{taskNum}/recall`, `POST /review/standard-mind-map/{taskNum}/find-node`, `GET /review/standard-mind-map/{taskNum}/recall-records`, `GET /review/standard-mind-map/recall-records/{recordId}` |
|
||||
| 学习残片 | 3 | `POST /report-fragments`, `PUT /report-fragments/{id}`, `GET /report-fragments/session/{sessionNum}` |
|
||||
| 工具 | 1 | `GET /utils/fetch-title?url=...` |
|
||||
| 测试 | 1 | `GET /test/{id}` |
|
||||
|
||||
## 数据库(10 个核心表)
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# 让 Lombok 生成的方法带 @Generated 注解,JaCoCo 自动排除生成代码,覆盖率只统计手写逻辑
|
||||
lombok.addLombokGeneratedAnnotation = true
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.google.protobuf.ServiceException;
|
||||
import com.guo.learningprogresstracker.dto.request.AbortStudySessionRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
|
||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||
@@ -93,6 +94,16 @@ public class StudySessionController {
|
||||
return message != null ? CommonResult.success(message) : CommonResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 误操作结束一个【学习会话】:需输入确认语,严格零数据(删除会话与预期),仅未产生学习残片时可用
|
||||
*/
|
||||
@PostMapping("/{sessionNum}/study-sessions/abort")
|
||||
public CommonResult<Void> abortStudySession(@PathVariable("sessionNum") String sessionNum,
|
||||
@Valid @RequestBody AbortStudySessionRequest request) throws ErrorParameterException {
|
||||
studySessionsServiceImpl.abortStudySession(sessionNum, request.getConfirmation());
|
||||
return CommonResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取【学习会话】所有的学习残片数据,以列表返回
|
||||
*/
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.guo.learningprogresstracker.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 误操作结束学习会话的请求体
|
||||
*/
|
||||
@Data
|
||||
public class AbortStudySessionRequest {
|
||||
|
||||
@NotBlank(message = "请填写确认语")
|
||||
private String confirmation;
|
||||
}
|
||||
@@ -17,4 +17,9 @@ public interface StudyExpectationsService {
|
||||
* 查询学习会话的学习预期,不存在时返回 null
|
||||
*/
|
||||
StudyExpectationsEntity getBySessionNum(String sessionNum);
|
||||
|
||||
/**
|
||||
* 删除学习会话的学习预期(误操作结束时会话不留任何数据)
|
||||
*/
|
||||
void deleteBySessionNum(String sessionNum);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
|
||||
|
||||
String endedStudySession(String sessionNum, String content) throws ErrorParameterException;
|
||||
|
||||
/**
|
||||
* 误操作结束学习会话:需输入确认语,严格零数据(删除会话与预期),仅未产生学习残片时可用
|
||||
*/
|
||||
void abortStudySession(String sessionNum, String confirmation) throws ErrorParameterException;
|
||||
|
||||
void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException;
|
||||
|
||||
void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException;
|
||||
|
||||
+7
@@ -52,4 +52,11 @@ public class StudyExpectationsServiceImpl implements StudyExpectationsService {
|
||||
.eq(StudyExpectationsEntity::getSessionNum, sessionNum)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteBySessionNum(String sessionNum) {
|
||||
studyExpectationsMapper.delete(
|
||||
Wrappers.<StudyExpectationsEntity>lambdaQuery()
|
||||
.eq(StudyExpectationsEntity::getSessionNum, sessionNum));
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -40,6 +40,11 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
|
||||
public static final int WORK_DURATION = 25;
|
||||
|
||||
/**
|
||||
* 误操作结束的确认语:用户必须输入完全一致的内容才能零数据关闭会话
|
||||
*/
|
||||
public static final String MISOPERATION_CONFIRMATION = "我误操作导致开启了本次学习";
|
||||
|
||||
private final TasksServiceImpl tasksServiceImpl;
|
||||
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
|
||||
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||
@@ -120,6 +125,31 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@Override
|
||||
@Transactional
|
||||
public void abortStudySession(String sessionNum, String confirmation) throws ErrorParameterException {
|
||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
|
||||
log.warn("会话[{}]已结束,不能误操作结束", sessionNum);
|
||||
throw new ErrorParameterException("这次学习会话已经结束,不能再误操作结束了");
|
||||
}
|
||||
if (!MISOPERATION_CONFIRMATION.equals(confirmation == null ? "" : confirmation.trim())) {
|
||||
log.warn("会话[{}]误操作结束的确认语不正确", sessionNum);
|
||||
throw new ErrorParameterException("确认语输入不正确,请核对后重新输入");
|
||||
}
|
||||
long fragmentCount = studyReportFragmentsMapper.selectCount(Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
|
||||
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum));
|
||||
if (fragmentCount > 0) {
|
||||
log.warn("会话[{}]已产生{}条学习残片,不能误操作结束", sessionNum, fragmentCount);
|
||||
throw new ErrorParameterException("这次学习已经产生了学习残片,不能按误操作结束了哦");
|
||||
}
|
||||
// 严格零数据:学习预期与会话本身一并删除,系统视角下会话从未发生
|
||||
studyExpectationsService.deleteBySessionNum(sessionNum);
|
||||
this.removeById(studySessionsEntity.getId());
|
||||
log.info("会话[{}]误操作结束,未产生任何学习数据", sessionNum);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
|
||||
|
||||
@@ -10,7 +10,7 @@ public class CalculatedPriorityTool {
|
||||
|
||||
/**
|
||||
* 使用系统默认权重计算加权优先级。
|
||||
* 急迫性 0.35 / 重要性 0.25 / 内容难度 0.20 / 未来价值 0.10 / 主观优先级 0.10
|
||||
* 紧急性 0.35 / 重要性 0.25 / 内容难度 0.20 / 未来价值 0.10 / 主观优先级 0.10
|
||||
*/
|
||||
public static Double calculatedPriority(PriorityDto priorityDto) {
|
||||
return calculatedPriority(priorityDto, UserPriorityWeightsEntity.defaults());
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.guo.learningprogresstracker;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
/**
|
||||
* 应用启动类 main 方法覆盖测试:
|
||||
* 以非 Web 模式 + H2 内存库拉起 Spring 容器,正常返回即覆盖 main 方法体两行。
|
||||
*/
|
||||
class ApplicationMainTest {
|
||||
|
||||
@Test
|
||||
void main_startsApplicationContextInNonWebMode() {
|
||||
assertDoesNotThrow(() -> LearningProgressTrackerApplication.main(new String[]{
|
||||
"--spring.main.web-application-type=none",
|
||||
"--spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"--spring.datasource.url=jdbc:h2:mem:lpt_main_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;NON_KEYWORDS=USER",
|
||||
"--spring.datasource.username=sa",
|
||||
"--spring.datasource.password=",
|
||||
"--spring.flyway.enabled=false",
|
||||
"--mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.nologging.NoLoggingImpl"
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.guo.learningprogresstracker.common;
|
||||
|
||||
import cn.dev33.satoken.exception.NotLoginException;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.exception.AppException;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* GlobalExceptionHandler 单元测试:直接实例化并调用各 handler 方法,
|
||||
* 断言 CommonResult 的 code / message 语义。
|
||||
*/
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
|
||||
|
||||
@Test
|
||||
void errorParameterException_returnsBadRequestWithMessage() {
|
||||
CommonResult result = handler.errorParameterException(new ErrorParameterException("参数有误"));
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
|
||||
assertEquals("参数有误", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notFindEntitiesException_returnsBadRequestWithMessage() {
|
||||
CommonResult result = handler.notFindEntitiesException(new NotFindEntitiesException("数据不存在"));
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
|
||||
assertEquals("数据不存在", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void operationFailedException_returnsBadRequestWithMessage() {
|
||||
CommonResult result = handler.operationFailedException(new OperationFailedException("操作失败"));
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
|
||||
assertEquals("操作失败", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appException_returnsServerErrorWithMessage() {
|
||||
CommonResult result = handler.AppException(new AppException("业务异常"));
|
||||
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getCode());
|
||||
assertEquals("业务异常", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void methodArgumentNotValidException_returnsFirstFieldErrorMessage() {
|
||||
MethodArgumentNotValidException ex =
|
||||
new MethodArgumentNotValidException(null, new BeanPropertyBindingResult(new Object(), "o"));
|
||||
ex.getBindingResult().addError(new FieldError("o", "f", "字段校验失败"));
|
||||
|
||||
CommonResult result = handler.MyMethodArgumentNotValidException(ex);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
|
||||
assertEquals("字段校验失败", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleNotLogin_setsHttp401AndReturnsUnauthorizedBody() {
|
||||
NotLoginException ex = new NotLoginException(
|
||||
NotLoginException.NOT_TOKEN_MESSAGE, "login", NotLoginException.NOT_TOKEN);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
CommonResult result = handler.handleNotLogin(ex, request, response);
|
||||
|
||||
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
|
||||
assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getCode());
|
||||
assertEquals("未登录,请重新登录", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallbackException_returnsGenericBadRequest() {
|
||||
CommonResult result = handler.Exception(new RuntimeException("意外错误"));
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
|
||||
assertEquals("操作没有成功,请稍后再试", result.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.guo.learningprogresstracker.config;
|
||||
|
||||
import cn.dev33.satoken.SaManager;
|
||||
import cn.dev33.satoken.context.SaTokenContext;
|
||||
import cn.dev33.satoken.context.SaTokenContextForThreadLocal;
|
||||
import cn.dev33.satoken.context.SaTokenContextForThreadLocalStorage;
|
||||
import cn.dev33.satoken.exception.NotLoginException;
|
||||
import cn.dev33.satoken.servlet.model.SaRequestForServlet;
|
||||
import cn.dev33.satoken.servlet.model.SaResponseForServlet;
|
||||
import cn.dev33.satoken.servlet.model.SaStorageForServlet;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* MybatisPlusConfig 单元测试:通过拦截器链反射取出 TenantLineHandler,
|
||||
* 校验忽略表清单、租户列名,以及无登录态时 getTenantId 抛 NotLoginException。
|
||||
*/
|
||||
class MybatisPlusConfigTest {
|
||||
|
||||
private SaTokenContext previousContext;
|
||||
|
||||
@BeforeEach
|
||||
void setUpSaTokenThreadLocalContext() {
|
||||
previousContext = SaManager.getSaTokenContext();
|
||||
SaManager.setSaTokenContext(new SaTokenContextForThreadLocal());
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
SaTokenContextForThreadLocalStorage.setBox(
|
||||
new SaRequestForServlet(request),
|
||||
new SaResponseForServlet(new MockHttpServletResponse()),
|
||||
new SaStorageForServlet(request));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreSaTokenContext() {
|
||||
SaTokenContextForThreadLocalStorage.clearBox();
|
||||
SaManager.setSaTokenContext(previousContext);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mybatisPlusInterceptor_registersPaginationAndTenantInterceptors() throws Exception {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusConfig().mybatisPlusInterceptor();
|
||||
|
||||
List<InnerInterceptor> innerInterceptors = interceptor.getInterceptors();
|
||||
assertEquals(2, innerInterceptors.size());
|
||||
assertInstanceOf(PaginationInnerInterceptor.class, innerInterceptors.get(0));
|
||||
TenantLineInnerInterceptor tenantInterceptor =
|
||||
assertInstanceOf(TenantLineInnerInterceptor.class, innerInterceptors.get(1));
|
||||
|
||||
TenantLineHandler handler = tenantLineHandler(tenantInterceptor);
|
||||
assertEquals("created_by", handler.getTenantIdColumn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoreTable_excludesSystemTablesWithoutCreatedBy() throws Exception {
|
||||
TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig()));
|
||||
|
||||
assertTrue(handler.ignoreTable("user"));
|
||||
assertTrue(handler.ignoreTable("flyway_schema_history"));
|
||||
assertTrue(handler.ignoreTable("databasechangelog"));
|
||||
assertTrue(handler.ignoreTable("databasechangeloglock"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoreTable_businessTablesAreFiltered() throws Exception {
|
||||
TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig()));
|
||||
|
||||
assertFalse(handler.ignoreTable("task"));
|
||||
assertFalse(handler.ignoreTable("study_sessions"));
|
||||
assertFalse(handler.ignoreTable("TASK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTenantId_withoutLoginState_throwsNotLoginException() throws Exception {
|
||||
TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig()));
|
||||
|
||||
assertThrows(NotLoginException.class, handler::getTenantId);
|
||||
}
|
||||
|
||||
private TenantLineInnerInterceptor tenantInterceptorOf(MybatisPlusConfig config) {
|
||||
return (TenantLineInnerInterceptor) config.mybatisPlusInterceptor().getInterceptors().get(1);
|
||||
}
|
||||
|
||||
/** TenantLineInnerInterceptor 未暴露 handler 访问器,通过反射读取私有字段。 */
|
||||
private TenantLineHandler tenantLineHandler(TenantLineInnerInterceptor interceptor) throws Exception {
|
||||
Field field = TenantLineInnerInterceptor.class.getDeclaredField("tenantLineHandler");
|
||||
field.setAccessible(true);
|
||||
return (TenantLineHandler) field.get(interceptor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.guo.learningprogresstracker.config;
|
||||
|
||||
import cn.dev33.satoken.context.SaTokenContext;
|
||||
import cn.dev33.satoken.exception.NotLoginException;
|
||||
import cn.dev33.satoken.interceptor.SaInterceptor;
|
||||
import cn.dev33.satoken.spring.SaTokenContextForSpringInJakartaServlet;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.handler.MappedInterceptor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* WebMvcConfig 单元测试:
|
||||
* - addCorsMappings 用真实 CorsRegistry 验证三个配置分支的注册结果;
|
||||
* - addInterceptors 取出注册的 SaInterceptor,借助 RequestContextHolder + sa-token Spring 上下文
|
||||
* 驱动 preHandle,覆盖 OPTIONS 放行 lambda 与 GET 未登录抛 NotLoginException 分支。
|
||||
*/
|
||||
class WebMvcConfigTest {
|
||||
|
||||
private SaTokenContext previousContext;
|
||||
|
||||
@BeforeEach
|
||||
void setUpSaTokenContext() {
|
||||
previousContext = cn.dev33.satoken.SaManager.getSaTokenContext();
|
||||
cn.dev33.satoken.SaManager.setSaTokenContext(new SaTokenContextForSpringInJakartaServlet());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreSaTokenContext() {
|
||||
cn.dev33.satoken.SaManager.setSaTokenContext(previousContext);
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
// ============ addCorsMappings ============
|
||||
|
||||
@Test
|
||||
void addCorsMappings_wildcardOrigin_registersOriginPatternsWithCredentials() {
|
||||
CorsProperties props = new CorsProperties();
|
||||
props.setAllowedOrigins(new String[]{"*"});
|
||||
WebMvcConfig config = new WebMvcConfig(props);
|
||||
|
||||
InspectableCorsRegistry registry = new InspectableCorsRegistry();
|
||||
config.addCorsMappings(registry);
|
||||
|
||||
Map<String, CorsConfiguration> configurations = registry.getCorsConfigurationsPublic();
|
||||
assertEquals(1, configurations.size());
|
||||
CorsConfiguration cors = configurations.get("/**");
|
||||
assertEquals(List.of("*"), cors.getAllowedOriginPatterns());
|
||||
assertTrue(cors.getAllowedMethods().contains("GET"));
|
||||
assertTrue(cors.getAllowedMethods().contains("OPTIONS"));
|
||||
assertTrue(cors.getAllowCredentials());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addCorsMappings_explicitOrigins_nullCredentials_defaultsToTrue() {
|
||||
CorsProperties props = new CorsProperties();
|
||||
props.setAllowedOrigins(new String[]{"http://localhost:5158"});
|
||||
props.setAllowCredentials(null);
|
||||
WebMvcConfig config = new WebMvcConfig(props);
|
||||
|
||||
InspectableCorsRegistry registry = new InspectableCorsRegistry();
|
||||
config.addCorsMappings(registry);
|
||||
|
||||
CorsConfiguration cors = registry.getCorsConfigurationsPublic().get("/**");
|
||||
assertEquals(List.of("http://localhost:5158"), cors.getAllowedOrigins());
|
||||
assertTrue(cors.getAllowCredentials());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addCorsMappings_explicitOrigins_credentialsFalse_staysFalse() {
|
||||
CorsProperties props = new CorsProperties();
|
||||
props.setAllowedOrigins(new String[]{"http://localhost:5158"});
|
||||
props.setAllowCredentials(Boolean.FALSE);
|
||||
WebMvcConfig config = new WebMvcConfig(props);
|
||||
|
||||
InspectableCorsRegistry registry = new InspectableCorsRegistry();
|
||||
config.addCorsMappings(registry);
|
||||
|
||||
CorsConfiguration cors = registry.getCorsConfigurationsPublic().get("/**");
|
||||
assertFalse(cors.getAllowCredentials());
|
||||
}
|
||||
|
||||
// ============ addInterceptors ============
|
||||
|
||||
@Test
|
||||
void addInterceptors_registersSingleSaInterceptor() {
|
||||
WebMvcConfig config = new WebMvcConfig(new CorsProperties());
|
||||
|
||||
InspectableInterceptorRegistry registry = new InspectableInterceptorRegistry();
|
||||
config.addInterceptors(registry);
|
||||
|
||||
assertEquals(1, registry.getInterceptorsPublic().size());
|
||||
HandlerInterceptor interceptor = (HandlerInterceptor) registry.getInterceptorsPublic().get(0);
|
||||
// 注册时配置了 include/exclude 路径,框架会包装为 MappedInterceptor,最终都指向 SaInterceptor
|
||||
assertTrue(interceptor instanceof MappedInterceptor || interceptor instanceof SaInterceptor);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_optionsRequest_passesWithoutLoginCheck() throws Exception {
|
||||
SaInterceptor interceptor = registeredSaInterceptor();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", "/tasks");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_getRequestWithoutToken_throwsNotLoginException() {
|
||||
SaInterceptor interceptor = registeredSaInterceptor();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/tasks");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
assertThrows(NotLoginException.class,
|
||||
() -> interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
private SaInterceptor registeredSaInterceptor() {
|
||||
WebMvcConfig config = new WebMvcConfig(new CorsProperties());
|
||||
InspectableInterceptorRegistry registry = new InspectableInterceptorRegistry();
|
||||
config.addInterceptors(registry);
|
||||
return assertInstanceOf(SaInterceptor.class, unwrap(registry.getInterceptorsPublic().get(0)));
|
||||
}
|
||||
|
||||
private Object unwrap(Object interceptor) {
|
||||
if (interceptor instanceof MappedInterceptor mapped) {
|
||||
return mapped.getInterceptor();
|
||||
}
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
/** InterceptorRegistry#getInterceptors 是 protected,这里仅在测试内拓宽可见性。 */
|
||||
private static class InspectableInterceptorRegistry extends InterceptorRegistry {
|
||||
@Override
|
||||
public List<Object> getInterceptors() {
|
||||
return super.getInterceptors();
|
||||
}
|
||||
|
||||
List<Object> getInterceptorsPublic() {
|
||||
return getInterceptors();
|
||||
}
|
||||
}
|
||||
|
||||
/** CorsRegistry#getCorsConfigurations 是 protected,这里仅在测试内拓宽可见性。 */
|
||||
private static class InspectableCorsRegistry extends CorsRegistry {
|
||||
@Override
|
||||
public Map<String, CorsConfiguration> getCorsConfigurations() {
|
||||
return super.getCorsConfigurations();
|
||||
}
|
||||
|
||||
Map<String, CorsConfiguration> getCorsConfigurationsPublic() {
|
||||
return getCorsConfigurations();
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.guo.learningprogresstracker.config.serializer;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonFactory;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.StringWriter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* LocalDateTimeSerializer 单元测试:注册到 ObjectMapper 序列化非 null 值;
|
||||
* null 分支 Jackson 默认不回调自定义序列化器,故直接驱动 serialize(null, gen, null) 覆盖。
|
||||
*/
|
||||
class LocalDateTimeSerializerTest {
|
||||
|
||||
@Test
|
||||
void serialize_nonNullValue_writesFormattedDateTime() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
|
||||
mapper.registerModule(module);
|
||||
|
||||
Bean bean = new Bean();
|
||||
bean.setTime(LocalDateTime.of(2024, 5, 1, 8, 30, 5));
|
||||
|
||||
assertEquals("{\"time\":\"2024-05-01T08:30:05\"}", mapper.writeValueAsString(bean));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serialize_nullValue_writesJsonNull() throws Exception {
|
||||
StringWriter writer = new StringWriter();
|
||||
JsonGenerator generator = new JsonFactory().createGenerator(writer);
|
||||
|
||||
new LocalDateTimeSerializer().serialize(null, generator, null);
|
||||
|
||||
generator.close();
|
||||
assertEquals("null", writer.toString());
|
||||
}
|
||||
|
||||
/** 简单载体,字段名为 time。 */
|
||||
public static class Bean {
|
||||
private LocalDateTime time;
|
||||
|
||||
public LocalDateTime getTime() {
|
||||
return time;
|
||||
}
|
||||
|
||||
public void setTime(LocalDateTime time) {
|
||||
this.time = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.request.LoginBody;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.service.LoginService;
|
||||
import com.guo.learningprogresstracker.service.UserService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* LoginController 纯单元测试:mock LoginService / UserService。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoginControllerUnitTest {
|
||||
|
||||
@Mock
|
||||
private LoginService loginService;
|
||||
|
||||
@Mock
|
||||
private UserService userServiceImpl;
|
||||
|
||||
@InjectMocks
|
||||
private LoginController controller;
|
||||
|
||||
@Test
|
||||
void login_authenticatesThenIssuesToken() throws Exception {
|
||||
LoginBody body = new LoginBody();
|
||||
body.setUsername("alice");
|
||||
body.setPassword("secret");
|
||||
when(userServiceImpl.authenticate("alice", "secret")).thenReturn("U-1");
|
||||
when(loginService.login("U-1")).thenReturn("token-abc");
|
||||
|
||||
CommonResult<String> result = controller.login(body);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("登录成功!", result.getMessage());
|
||||
assertEquals("token-abc", result.getData());
|
||||
verify(loginService).login("U-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_delegatesToLoginService() {
|
||||
CommonResult<Void> result = controller.logout();
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("已登出", result.getMessage());
|
||||
verify(loginService).logout();
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.service.impl.StudyReportFragmentsServiceImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* reportFragmentsController 纯单元测试(类名小写开头,保持与生产代码一致):
|
||||
* 直接 new 控制器并 mock StudyReportFragmentsServiceImpl。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ReportFragmentsControllerUnitTest {
|
||||
|
||||
@Mock
|
||||
private StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
|
||||
|
||||
@InjectMocks
|
||||
private reportFragmentsController controller;
|
||||
|
||||
@Test
|
||||
void createFragments_delegatesToService() throws Exception {
|
||||
CreateFragmentsRequest request = new CreateFragmentsRequest();
|
||||
request.setSessionNum("S-1");
|
||||
request.setContent("今日学习内容");
|
||||
|
||||
CommonResult<Void> result = controller.createFragments(request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
verify(studyReportFragmentsServiceImpl).createFragments(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateFragments_delegatesToService() throws Exception {
|
||||
UpdateFragmentsRequest request = new UpdateFragmentsRequest();
|
||||
request.setContent("更新后的内容");
|
||||
|
||||
CommonResult<Void> result = controller.updateFragments(9, request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
verify(studyReportFragmentsServiceImpl).updateFragments(9, request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFragmentsBySession_returnsFragmentList() {
|
||||
List<StudyReportFragmentsEntity> fragments = List.of(new StudyReportFragmentsEntity());
|
||||
when(studyReportFragmentsServiceImpl.getFragmentsBySession("S-2")).thenReturn(fragments);
|
||||
|
||||
CommonResult<List<StudyReportFragmentsEntity>> result = controller.getFragmentsBySession("S-2");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(fragments, result.getData());
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||
import com.guo.learningprogresstracker.dto.request.RecallCompareRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
|
||||
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.service.ReviewService;
|
||||
import com.guo.learningprogresstracker.service.StandardMindMapService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* ReviewController 纯单元测试:直接 new 控制器并 mock 两个服务接口。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ReviewControllerUnitTest {
|
||||
|
||||
@Mock
|
||||
private ReviewService reviewService;
|
||||
|
||||
@Mock
|
||||
private StandardMindMapService standardMindMapService;
|
||||
|
||||
@InjectMocks
|
||||
private ReviewController controller;
|
||||
|
||||
@Test
|
||||
void getReviewFeed_passesLimitAndMode() {
|
||||
List<ReviewFeedItem> feed = List.of(new ReviewFeedItem());
|
||||
when(reviewService.getReviewFeed(30, "recent")).thenReturn(feed);
|
||||
|
||||
CommonResult<List<ReviewFeedItem>> result = controller.getReviewFeed(30, "recent");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(feed, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReviewTaskStats_noArg_returnsAllTaskStats() {
|
||||
List<ReviewTaskStats> stats = List.of(new ReviewTaskStats());
|
||||
when(reviewService.getReviewTaskStats()).thenReturn(stats);
|
||||
|
||||
CommonResult<List<ReviewTaskStats>> result = controller.getReviewTaskStats();
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(stats, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReviewTaskStats_byTaskNum_returnsSingleStats() {
|
||||
ReviewTaskStats stats = new ReviewTaskStats();
|
||||
when(reviewService.getReviewTaskStats("T001")).thenReturn(stats);
|
||||
|
||||
CommonResult<ReviewTaskStats> result = controller.getReviewTaskStats("T001");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(stats, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTaskReview_returnsTaskFeed() {
|
||||
List<ReviewFeedItem> feed = List.of(new ReviewFeedItem());
|
||||
when(reviewService.getTaskReview("T002")).thenReturn(feed);
|
||||
|
||||
CommonResult<List<ReviewFeedItem>> result = controller.getTaskReview("T002");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(feed, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReportDetail_delegatesToService() throws Exception {
|
||||
StudyReportsEntity report = new StudyReportsEntity();
|
||||
when(reviewService.getReportDetail(11)).thenReturn(report);
|
||||
|
||||
CommonResult<StudyReportsEntity> result = controller.getReportDetail(11);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(report, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFragmentDetail_delegatesToService() throws Exception {
|
||||
StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity();
|
||||
when(reviewService.getFragmentDetail(22)).thenReturn(fragment);
|
||||
|
||||
CommonResult<StudyReportFragmentsEntity> result = controller.getFragmentDetail(22);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(fragment, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStandardMindMap_delegatesToService() throws Exception {
|
||||
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
|
||||
when(standardMindMapService.getOrGenerate("T003")).thenReturn(mindMap);
|
||||
|
||||
CommonResult<ReviewStandardMindMapEntity> result = controller.getStandardMindMap("T003");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(mindMap, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void regenerateStandardMindMap_incrementalMode_usesIncrementalGenerate() throws Exception {
|
||||
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
|
||||
when(standardMindMapService.incrementalGenerate("T004")).thenReturn(mindMap);
|
||||
|
||||
CommonResult<ReviewStandardMindMapEntity> result = controller.regenerateStandardMindMap("T004", "incremental");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(mindMap, result.getData());
|
||||
verify(standardMindMapService).incrementalGenerate("T004");
|
||||
}
|
||||
|
||||
@Test
|
||||
void regenerateStandardMindMap_defaultMode_usesFullRegenerate() throws Exception {
|
||||
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
|
||||
when(standardMindMapService.regenerate("T005")).thenReturn(mindMap);
|
||||
|
||||
CommonResult<ReviewStandardMindMapEntity> result = controller.regenerateStandardMindMap("T005", "full");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(mindMap, result.getData());
|
||||
verify(standardMindMapService).regenerate("T005");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateStandardMindMap_passesOutline() throws Exception {
|
||||
UpdateStandardMindMapRequest request = new UpdateStandardMindMapRequest();
|
||||
request.setOutline("- 根节点\n- 子节点");
|
||||
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
|
||||
when(standardMindMapService.updateByOutline("T006", "- 根节点\n- 子节点")).thenReturn(mindMap);
|
||||
|
||||
CommonResult<ReviewStandardMindMapEntity> result = controller.updateStandardMindMap("T006", request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(mindMap, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recallCompare_passesOutlineAndFocusPath() throws Exception {
|
||||
RecallCompareRequest request = new RecallCompareRequest();
|
||||
request.setRecallOutline("- 回忆内容");
|
||||
request.setFocusPath("根/子");
|
||||
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
|
||||
when(standardMindMapService.recallCompare("T007", "- 回忆内容", "根/子")).thenReturn(mindMap);
|
||||
|
||||
CommonResult<ReviewStandardMindMapEntity> result = controller.recallCompare("T007", request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(mindMap, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findNode_nullBody_usesEmptyContent() throws Exception {
|
||||
Map<String, Object> matched = Map.of("id", "n1");
|
||||
when(standardMindMapService.findNode("T008", "")).thenReturn(matched);
|
||||
|
||||
CommonResult<Map<String, Object>> result = controller.findNode("T008", null);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(matched, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findNode_bodyWithoutContentKey_usesEmptyContent() throws Exception {
|
||||
Map<String, Object> matched = Map.of("id", "n2");
|
||||
when(standardMindMapService.findNode("T009", "")).thenReturn(matched);
|
||||
|
||||
CommonResult<Map<String, Object>> result = controller.findNode("T009", Map.of("other", "x"));
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(matched, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findNode_bodyWithContent_usesContent() throws Exception {
|
||||
Map<String, Object> matched = Map.of("id", "n3");
|
||||
when(standardMindMapService.findNode("T010", "租户隔离")).thenReturn(matched);
|
||||
|
||||
CommonResult<Map<String, Object>> result = controller.findNode("T010", Map.of("content", "租户隔离"));
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(matched, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listRecallRecords_returnsRecords() throws Exception {
|
||||
List<ReviewRecallRecordEntity> records = List.of(new ReviewRecallRecordEntity());
|
||||
when(standardMindMapService.listRecallRecords("T011")).thenReturn(records);
|
||||
|
||||
CommonResult<List<ReviewRecallRecordEntity>> result = controller.listRecallRecords("T011");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(records, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecallRecord_returnsSingleRecord() throws Exception {
|
||||
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
|
||||
when(standardMindMapService.getRecallRecord(33)).thenReturn(record);
|
||||
|
||||
CommonResult<ReviewRecallRecordEntity> result = controller.getRecallRecord(33);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(record, result.getData());
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.guo.learningprogresstracker.dto.request.AbortStudySessionRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
|
||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* StudySessionController 纯单元测试:直接 new 控制器并 mock 服务层,
|
||||
* 不启动 Spring 容器(与既有 @SpringBootTest 风格的 StudySessionControllerTest 互补)。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StudySessionControllerUnitTest {
|
||||
|
||||
@Mock
|
||||
private StudySessionsServiceImpl studySessionsServiceImpl;
|
||||
|
||||
@Mock
|
||||
private StudyExpectationsService studyExpectationsService;
|
||||
|
||||
@InjectMocks
|
||||
private StudySessionController controller;
|
||||
|
||||
@Test
|
||||
void upsertExpectation_delegatesToService() throws Exception {
|
||||
UpsertExpectationRequest request = new UpsertExpectationRequest();
|
||||
request.setDescription("目标:掌握租户隔离");
|
||||
StudyExpectationsEntity entity = new StudyExpectationsEntity();
|
||||
when(studyExpectationsService.upsertExpectation("S001", "目标:掌握租户隔离")).thenReturn(entity);
|
||||
|
||||
CommonResult<StudyExpectationsEntity> result = controller.upsertExpectation("S001", request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(entity, result.getData());
|
||||
verify(studyExpectationsService).upsertExpectation("S001", "目标:掌握租户隔离");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExpectation_delegatesToService() {
|
||||
StudyExpectationsEntity entity = new StudyExpectationsEntity();
|
||||
when(studyExpectationsService.getBySessionNum("S002")).thenReturn(entity);
|
||||
|
||||
CommonResult<StudyExpectationsEntity> result = controller.getExpectation("S002");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(entity, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStudySessionBySessionNum_delegatesToService() throws Exception {
|
||||
StudySessionResponse response = new StudySessionResponse();
|
||||
when(studySessionsServiceImpl.getStudySessionBySessionNum("S003")).thenReturn(response);
|
||||
|
||||
CommonResult<StudySessionResponse> result = controller.getStudySessionBySessionNum("S003");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(response, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startStudySession_continuesSessionAndReturnsSuccess() throws Exception {
|
||||
CommonResult<Void> result = controller.startStudySession("S004");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertNull(result.getData());
|
||||
verify(studySessionsServiceImpl).continueStudySession("S004");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pauseStudySession_passesEndTimeThrough() throws Exception {
|
||||
LocalDateTime endTime = LocalDateTime.of(2024, 5, 1, 12, 0);
|
||||
|
||||
CommonResult<Void> result = controller.pauseStudySession("S005", endTime);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
verify(studySessionsServiceImpl).pauseStudySession("S005", endTime);
|
||||
}
|
||||
|
||||
@Test
|
||||
void endedStudySession_withReportMessage_returnsMessageInData() throws Exception {
|
||||
EndedStudySessionRequest request = new EndedStudySessionRequest();
|
||||
request.setContent("报告内容");
|
||||
when(studySessionsServiceImpl.endedStudySession("S006", "报告内容")).thenReturn("报告摘要");
|
||||
|
||||
CommonResult<Void> result = controller.endedStudySession("S006", request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
// 注意:CommonResult.success(String) 重载优先匹配 message 参数,
|
||||
// 报告摘要落在 message 字段而非 data(生产行为如此,按实际语义断言)
|
||||
assertEquals("报告摘要", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void endedStudySession_withoutReportMessage_returnsEmptySuccess() throws Exception {
|
||||
EndedStudySessionRequest request = new EndedStudySessionRequest();
|
||||
request.setContent("无报告内容");
|
||||
when(studySessionsServiceImpl.endedStudySession("S007", "无报告内容")).thenReturn(null);
|
||||
|
||||
CommonResult<Void> result = controller.endedStudySession("S007", request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void abortStudySession_delegatesConfirmation() throws Exception {
|
||||
AbortStudySessionRequest request = new AbortStudySessionRequest();
|
||||
request.setConfirmation("确认删除");
|
||||
|
||||
CommonResult<Void> result = controller.abortStudySession("S008", request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
verify(studySessionsServiceImpl).abortStudySession("S008", "确认删除");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllFragments_returnsFragmentList() throws Exception {
|
||||
ArrayList<String> fragments = new ArrayList<>(List.of("片段一", "片段二"));
|
||||
when(studySessionsServiceImpl.getAllFragments("S009")).thenReturn(fragments);
|
||||
|
||||
CommonResult<ArrayList<String>> result = controller.getAllFragments("S009");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(fragments, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReportDraft_returnsGeneratedDraft() throws Exception {
|
||||
when(studySessionsServiceImpl.generateReportDraft("S010")).thenReturn("草稿内容");
|
||||
|
||||
CommonResult<String> result = controller.getReportDraft("S010");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("草稿内容", result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTaskFragments_passesPagingArguments() {
|
||||
Page<StudyReportFragmentsEntity> page = new Page<>();
|
||||
when(studySessionsServiceImpl.getTaskFragments("T001", 2, 5, "关键词")).thenReturn(page);
|
||||
|
||||
CommonResult<Page<StudyReportFragmentsEntity>> result = controller.getTaskFragments("T001", 2, 5, "关键词");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(page, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTaskReports_passesPagingArguments() {
|
||||
Page<StudyReportsEntity> page = new Page<>();
|
||||
when(studySessionsServiceImpl.getTaskReports("T002", 3, 20, null)).thenReturn(page);
|
||||
|
||||
CommonResult<Page<StudyReportsEntity>> result = controller.getTaskReports("T002", 3, 20, null);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(page, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActiveSession_passesExcludeTaskNum() {
|
||||
StudySessionResponse response = new StudySessionResponse();
|
||||
when(studySessionsServiceImpl.getActiveSession("T003")).thenReturn(response);
|
||||
|
||||
CommonResult<StudySessionResponse> result = controller.getActiveSession("T003");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(response, result.getData());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.guo.learningprogresstracker.dto.TaskInfo;
|
||||
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.TaskRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
|
||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||
import com.guo.learningprogresstracker.service.PriorityWeightsService;
|
||||
import com.guo.learningprogresstracker.service.TasksService;
|
||||
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* TaskController 纯单元测试:直接 new 控制器并 mock 三个依赖服务。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskControllerUnitTest {
|
||||
|
||||
@Mock
|
||||
private TasksService tasksService;
|
||||
|
||||
@Mock
|
||||
private StudySessionsServiceImpl studySessionsServiceImpl;
|
||||
|
||||
@Mock
|
||||
private PriorityWeightsService priorityWeightsService;
|
||||
|
||||
@InjectMocks
|
||||
private TaskController controller;
|
||||
|
||||
@Test
|
||||
void getPriorityWeights_delegatesToService() {
|
||||
UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity();
|
||||
when(priorityWeightsService.getWeights()).thenReturn(weights);
|
||||
|
||||
CommonResult<UserPriorityWeightsEntity> result = controller.getPriorityWeights();
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(weights, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePriorityWeights_delegatesToService() throws Exception {
|
||||
UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity();
|
||||
UserPriorityWeightsEntity saved = new UserPriorityWeightsEntity();
|
||||
when(priorityWeightsService.saveWeights(weights)).thenReturn(saved);
|
||||
|
||||
CommonResult<UserPriorityWeightsEntity> result = controller.savePriorityWeights(weights);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(saved, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addTask_returnsGeneratedTaskNum() throws Exception {
|
||||
TaskRequest request = new TaskRequest();
|
||||
when(tasksService.addTask(request)).thenReturn("T-100");
|
||||
|
||||
CommonResult<String> result = controller.addTask(request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("T-100", result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTask_delegatesToService() {
|
||||
TaskInfoResponse response = new TaskInfoResponse();
|
||||
when(tasksService.getTask("1")).thenReturn(response);
|
||||
|
||||
CommonResult<TaskInfoResponse> result = controller.getTask("1");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(response, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateTask_delegatesToService() {
|
||||
TaskRequest request = new TaskRequest();
|
||||
|
||||
CommonResult<Void> result = controller.updateTask("2", request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
verify(tasksService).updateTask("2", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteTask_delegatesToService() throws Exception {
|
||||
CommonResult<Void> result = controller.deleteTask("3");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
verify(tasksService).deleteTask("3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getApplications_returnsApplicationList() throws Exception {
|
||||
List<TaskApplicationEntity> applications = List.of(new TaskApplicationEntity());
|
||||
when(tasksService.getApplications("T-1")).thenReturn(applications);
|
||||
|
||||
CommonResult<List<TaskApplicationEntity>> result = controller.getApplications("T-1");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(applications, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createApplication_fillsTaskNumBeforeDelegation() throws Exception {
|
||||
CreateTaskApplicationRequest request = new CreateTaskApplicationRequest();
|
||||
request.setTitle("背单词");
|
||||
TaskApplicationEntity created = new TaskApplicationEntity();
|
||||
when(tasksService.createApplication(request)).thenReturn(created);
|
||||
|
||||
CommonResult<TaskApplicationEntity> result = controller.createApplication("T-2", request);
|
||||
|
||||
assertEquals("T-2", request.getTaskNum());
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(created, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateApplication_delegatesToService() throws Exception {
|
||||
UpdateTaskApplicationRequest request = new UpdateTaskApplicationRequest();
|
||||
request.setTitle("更新后的标题");
|
||||
TaskApplicationEntity updated = new TaskApplicationEntity();
|
||||
when(tasksService.updateApplication(7, request)).thenReturn(updated);
|
||||
|
||||
CommonResult<TaskApplicationEntity> result = controller.updateApplication(7, request);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(updated, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteApplication_delegatesToService() throws Exception {
|
||||
CommonResult<Void> result = controller.deleteApplication(8);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
verify(tasksService).deleteApplication(8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tasksList_passesPagingArguments() {
|
||||
Page<TaskInfo> page = new Page<>();
|
||||
when(tasksService.taskList(2, 20)).thenReturn(page);
|
||||
|
||||
CommonResult<Page<TaskInfo>> result = controller.tasksList(2, 20);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(page, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startOrContinueStudySession_delegatesToImpl() throws Exception {
|
||||
StudySessionResponse response = new StudySessionResponse();
|
||||
when(studySessionsServiceImpl.startOrContinueStudySession("T-3")).thenReturn(response);
|
||||
|
||||
CommonResult<StudySessionResponse> result = controller.startOrContinueStudySession("T-3");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(response, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNotEndedStudySessionByTaskNum_delegatesToImpl() throws Exception {
|
||||
StudySessionResponse response = new StudySessionResponse();
|
||||
when(studySessionsServiceImpl.getNotEndedStudySessionByTaskNum("T-4")).thenReturn(response);
|
||||
|
||||
CommonResult<StudySessionResponse> result = controller.getNotEndedStudySessionByTaskNum("T-4");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(response, result.getData());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.entity.TestTableEntity;
|
||||
import com.guo.learningprogresstracker.exception.AppException;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||
import com.guo.learningprogresstracker.service.TestTableService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* TestController 纯单元测试:同包直接注入 mock 的 TestTableService 字段,
|
||||
* 覆盖按 id 触发的各业务异常分支。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TestControllerUnitTest {
|
||||
|
||||
@Mock
|
||||
private TestTableService testTableService;
|
||||
|
||||
@Test
|
||||
void getTestEntityById_id1_throwsAppException() {
|
||||
TestController controller = new TestController();
|
||||
controller.testTableService = testTableService;
|
||||
|
||||
AppException ex = assertThrows(AppException.class, () -> controller.getTestEntityById("1"));
|
||||
|
||||
assertEquals("测试AppException", ex.getMessage());
|
||||
verifyNoInteractions(testTableService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTestEntityById_id2_throwsErrorParameterException() {
|
||||
TestController controller = new TestController();
|
||||
controller.testTableService = testTableService;
|
||||
|
||||
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
|
||||
() -> controller.getTestEntityById("2"));
|
||||
|
||||
assertEquals("测试ErrorParameterException", ex.getMessage());
|
||||
verifyNoInteractions(testTableService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTestEntityById_id3_throwsNotFindEntitiesException() {
|
||||
TestController controller = new TestController();
|
||||
controller.testTableService = testTableService;
|
||||
|
||||
NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class,
|
||||
() -> controller.getTestEntityById("3"));
|
||||
|
||||
assertEquals("测试NotFindEntitiesException", ex.getMessage());
|
||||
verifyNoInteractions(testTableService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTestEntityById_id4_throwsOperationFailedException() {
|
||||
TestController controller = new TestController();
|
||||
controller.testTableService = testTableService;
|
||||
|
||||
OperationFailedException ex = assertThrows(OperationFailedException.class,
|
||||
() -> controller.getTestEntityById("4"));
|
||||
|
||||
assertEquals("测试OperationFailedException", ex.getMessage());
|
||||
verifyNoInteractions(testTableService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTestEntityById_otherId_returnsEntityFromService() throws Exception {
|
||||
TestController controller = new TestController();
|
||||
controller.testTableService = testTableService;
|
||||
TestTableEntity entity = new TestTableEntity();
|
||||
when(testTableService.getTestEntityById("5")).thenReturn(entity);
|
||||
|
||||
CommonResult<TestTableEntity> result = controller.getTestEntityById("5");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertSame(entity, result.getData());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.support.TestHttpServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* UtilsController 单元测试:fetchTitle 内部调用 TitleFetcher 静态方法无法 mock,
|
||||
* 用本地 TestHttpServer 提供真实页面验证网络分支,null/空白参数直接断言错误结果。
|
||||
*/
|
||||
class UtilsControllerUnitTest extends TestHttpServer {
|
||||
|
||||
private final UtilsController controller = new UtilsController();
|
||||
|
||||
@Test
|
||||
void fetchTitle_nullUrl_returnsError() {
|
||||
CommonResult<Map<String, String>> result = controller.fetchTitle(null);
|
||||
|
||||
assertEquals(400, result.getCode());
|
||||
assertEquals("url 参数不能为空", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_blankUrl_returnsError() {
|
||||
CommonResult<Map<String, String>> result = controller.fetchTitle(" ");
|
||||
|
||||
assertEquals(400, result.getCode());
|
||||
assertEquals("url 参数不能为空", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_validUrl_returnsPageTitle() {
|
||||
route("GET", "/page", 200, "<html><head><title>单元测试标题</title></head></html>");
|
||||
|
||||
CommonResult<Map<String, String>> result = controller.fetchTitle(baseUrl() + "/page?q=1");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("单元测试标题", result.getData().get("title"));
|
||||
assertEquals("q=1", lastQuery("GET", "/page"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_pageWithoutTitle_fallsBackToUrl() {
|
||||
route("GET", "/plain", 200, "纯文本没有标题",
|
||||
Map.of("Content-Type", "text/plain; charset=utf-8"));
|
||||
|
||||
String url = baseUrl() + "/plain";
|
||||
CommonResult<Map<String, String>> result = controller.fetchTitle(url);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals(url, result.getData().get("title"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.guo.learningprogresstracker.entity;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CommonResult 单元测试:覆盖全部工厂方法与构造重载。
|
||||
*/
|
||||
class CommonResultTest {
|
||||
|
||||
@Test
|
||||
void success_noArgs_shouldUseDefaultMessage() {
|
||||
CommonResult<Void> result = CommonResult.success();
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("请求成功", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_withMessage_shouldKeepMessage() {
|
||||
CommonResult<Void> result = CommonResult.success("操作完成");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("操作完成", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_withData_shouldUseDefaultMessage() {
|
||||
// 注意:CommonResult.success(String) 重载优先匹配 message 参数,
|
||||
// 必须先向上转型为 Object 才能命中 success(T data) 这个工厂方法
|
||||
CommonResult<Object> result = CommonResult.success((Object) "数据");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("请求成功", result.getMessage());
|
||||
assertEquals("数据", result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_withMessageAndData_shouldKeepBoth() {
|
||||
CommonResult<String> result = CommonResult.success("操作完成", "数据");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("操作完成", result.getMessage());
|
||||
assertEquals("数据", result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoArgConstructor_shouldDefaultNullData() {
|
||||
CommonResult<String> result = new CommonResult<>(400, "参数错误");
|
||||
|
||||
assertEquals(400, result.getCode());
|
||||
assertEquals("参数错误", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void error_shouldUseBadRequest() {
|
||||
CommonResult<Void> result = CommonResult.error("请求失败");
|
||||
|
||||
assertEquals(400, result.getCode());
|
||||
assertEquals("请求失败", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notFind_noArgs_shouldUseDefaultMessage() {
|
||||
CommonResult<Void> result = CommonResult.notFind();
|
||||
|
||||
assertEquals(404, result.getCode());
|
||||
assertEquals("请求的资源不存在", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notFind_withMessage_shouldKeepMessage() {
|
||||
CommonResult<Void> result = CommonResult.notFind("找不到任务");
|
||||
|
||||
assertEquals(404, result.getCode());
|
||||
assertEquals("找不到任务", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverError_shouldUse500() {
|
||||
CommonResult<Void> result = CommonResult.serverError("服务器错误");
|
||||
|
||||
assertEquals(500, result.getCode());
|
||||
assertEquals("服务器错误", result.getMessage());
|
||||
assertNull(result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settersAndGetters_shouldWork() {
|
||||
CommonResult<String> result = new CommonResult<>();
|
||||
result.setCode(200);
|
||||
result.setMessage("ok");
|
||||
result.setData("d");
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals("ok", result.getMessage());
|
||||
assertEquals("d", result.getData());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.guo.learningprogresstracker.entity;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* TestTableEntity 单元测试:覆盖手写的 equals/hashCode/toString 全部分支。
|
||||
*/
|
||||
class TestTableEntityTest {
|
||||
|
||||
private TestTableEntity entity(Integer id, String idName) {
|
||||
TestTableEntity entity = new TestTableEntity();
|
||||
entity.setId(id);
|
||||
entity.setIdName(idName);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_sameReference_shouldBeTrue() {
|
||||
TestTableEntity entity = entity(1, "a");
|
||||
assertEquals(entity, entity);
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_null_shouldBeFalse() {
|
||||
assertNotEquals(entity(1, "a"), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_differentClass_shouldBeFalse() {
|
||||
TestTableEntity entity = entity(1, "a");
|
||||
TestTableEntity subclass = new TestTableEntity() {
|
||||
};
|
||||
subclass.setId(1);
|
||||
subclass.setIdName("a");
|
||||
assertNotEquals(entity, subclass);
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_sameFields_shouldBeTrue() {
|
||||
assertEquals(entity(1, "a"), entity(1, "a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_bothFieldsNull_shouldBeTrue() {
|
||||
assertEquals(entity(null, null), entity(null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_differentId_shouldBeFalse() {
|
||||
assertNotEquals(entity(1, "a"), entity(2, "a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_differentIdName_shouldBeFalse() {
|
||||
assertNotEquals(entity(1, "a"), entity(1, "b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_idNullVsSet_shouldBeFalse() {
|
||||
assertNotEquals(entity(null, "a"), entity(1, "a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_idNameNullVsSet_shouldBeFalse() {
|
||||
assertNotEquals(entity(1, null), entity(1, "a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashCode_equalEntities_shouldMatch() {
|
||||
assertEquals(entity(1, "a").hashCode(), entity(1, "a").hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashCode_nullFields_shouldBeStable() {
|
||||
assertEquals(entity(null, null).hashCode(), entity(null, null).hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toString_shouldContainFields() {
|
||||
TestTableEntity entity = entity(1, "测试");
|
||||
String text = entity.toString();
|
||||
|
||||
assertTrue(text.contains("TestTableEntity"));
|
||||
assertTrue(text.contains("id=1"));
|
||||
assertTrue(text.contains("idName=测试"));
|
||||
assertTrue(text.contains("serialVersionUID=1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.guo.learningprogresstracker.enums;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* StudySessionStateEnum 单元测试:覆盖 getDescription 未覆盖行。
|
||||
*/
|
||||
class StudySessionStateEnumTest {
|
||||
|
||||
@Test
|
||||
void descriptions_shouldMatchConstants() {
|
||||
assertEquals("进行中", StudySessionStateEnum.ONGOING.getDescription());
|
||||
assertEquals("暂停", StudySessionStateEnum.PAUSED.getDescription());
|
||||
assertEquals("已结束", StudySessionStateEnum.ENDED.getDescription());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.guo.learningprogresstracker.enums;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* TaskApplicationStatusEnum 单元测试:覆盖 fromCodeOrDefault 的未知码兜底/合法码分支。
|
||||
*/
|
||||
class TaskApplicationStatusEnumTest {
|
||||
|
||||
@Test
|
||||
void fromCodeOrDefault_nullOrBlank_shouldReturnTodo() {
|
||||
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault(null));
|
||||
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault(""));
|
||||
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromCodeOrDefault_validCode_shouldMatchIgnoringCase() {
|
||||
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault("todo"));
|
||||
assertSame(TaskApplicationStatusEnum.DOING, TaskApplicationStatusEnum.fromCodeOrDefault("doing"));
|
||||
assertSame(TaskApplicationStatusEnum.DONE, TaskApplicationStatusEnum.fromCodeOrDefault("DONE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromCodeOrDefault_unknownCode_shouldFallbackTodo() {
|
||||
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault("not-a-status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void codeAndDescription_shouldMatchConstants() {
|
||||
assertEquals("TODO", TaskApplicationStatusEnum.TODO.getCode());
|
||||
assertEquals("待应用", TaskApplicationStatusEnum.TODO.getDescription());
|
||||
assertEquals("DOING", TaskApplicationStatusEnum.DOING.getCode());
|
||||
assertEquals("应用中", TaskApplicationStatusEnum.DOING.getDescription());
|
||||
assertEquals("DONE", TaskApplicationStatusEnum.DONE.getCode());
|
||||
assertEquals("已完成", TaskApplicationStatusEnum.DONE.getDescription());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.guo.learningprogresstracker.exception;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* AppException 构造与消息测试。
|
||||
*/
|
||||
class AppExceptionTest {
|
||||
|
||||
@Test
|
||||
void constructor_shouldKeepMessage() {
|
||||
AppException ex = new AppException("数据库连接失败");
|
||||
|
||||
assertEquals("数据库连接失败", ex.getMessage());
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.guo.learningprogresstracker.exception;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* OperationFailedException 构造与消息测试。
|
||||
*/
|
||||
class OperationFailedExceptionTest {
|
||||
|
||||
@Test
|
||||
void noArgConstructor_messageShouldBeNull() {
|
||||
OperationFailedException ex = new OperationFailedException();
|
||||
|
||||
assertNull(ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void messageConstructor_shouldKeepMessage() {
|
||||
OperationFailedException ex = new OperationFailedException("操作失败");
|
||||
|
||||
assertEquals("操作失败", ex.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.guo.learningprogresstracker.mapStruct;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 仅触发 MAPPER 字段初始化(Mappers.getMapper 加载生成的 Impl),
|
||||
* 覆盖接口内的初始化行;生成实现已被 JaCoCo 排除,不做映射细节断言。
|
||||
*/
|
||||
class FragmentsConvertTest {
|
||||
|
||||
@Test
|
||||
void mapper_shouldBeInitializedAndConvertRequest() {
|
||||
assertNotNull(FragmentsConvert.MAPPER);
|
||||
|
||||
CreateFragmentsRequest request = new CreateFragmentsRequest();
|
||||
request.setSessionNum("SESSION_A");
|
||||
request.setContent("学习内容");
|
||||
|
||||
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
|
||||
|
||||
assertNotNull(entity);
|
||||
assertEquals("SESSION_A", entity.getSessionNum());
|
||||
assertEquals("学习内容", entity.getContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.guo.learningprogresstracker.mapStruct;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 仅触发 MAPPER 字段初始化(Mappers.getMapper 加载生成的 Impl),
|
||||
* 覆盖接口内的初始化行;生成实现已被 JaCoCo 排除,不做映射细节断言。
|
||||
*/
|
||||
class StudySessionConvertTest {
|
||||
|
||||
@Test
|
||||
void mapper_shouldBeInitializedAndConvertEntity() {
|
||||
assertNotNull(StudySessionConvert.MAPPER);
|
||||
|
||||
StudySessionsEntity entity = new StudySessionsEntity();
|
||||
entity.setSessionNum("SESSION_A");
|
||||
entity.setSessionState("ONGOING");
|
||||
|
||||
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(entity);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals("SESSION_A", response.getSessionNum());
|
||||
assertEquals("ONGOING", response.getSessionState());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.guo.learningprogresstracker.support.TestHttpServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* AiServiceClient 单元测试:基于本地 TestHttpServer 覆盖异步 submit + poll 全部分支。
|
||||
*/
|
||||
class AiServiceClientTest extends TestHttpServer {
|
||||
|
||||
private AiServiceClient newClient(String url, int timeoutSeconds) {
|
||||
AiServiceClient client = new AiServiceClient();
|
||||
client.setUrl(url);
|
||||
client.setTimeoutSeconds(timeoutSeconds);
|
||||
client.init();
|
||||
return client;
|
||||
}
|
||||
|
||||
// ============ 配置状态 ============
|
||||
|
||||
@Test
|
||||
void isConfigured_dependsOnUrl() {
|
||||
AiServiceClient client = new AiServiceClient();
|
||||
assertFalse(client.isConfigured());
|
||||
client.setUrl("http://localhost:5199");
|
||||
assertTrue(client.isConfigured());
|
||||
client.setUrl(" ");
|
||||
assertFalse(client.isConfigured());
|
||||
// init 的两条日志分支(未配置 / 已配置)
|
||||
client.init();
|
||||
client.setUrl(baseUrl());
|
||||
client.init();
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicApis_returnEmptyWhenNotConfigured() {
|
||||
AiServiceClient client = new AiServiceClient();
|
||||
client.setTimeoutSeconds(1);
|
||||
client.init();
|
||||
assertTrue(client.aggregateReport("任务", List.of("片段"), null).isEmpty());
|
||||
assertTrue(client.generateMindMap("任务", "描述", List.of("报告")).isEmpty());
|
||||
assertTrue(client.compareRecall("任务", "标准大纲", "回忆大纲").isEmpty());
|
||||
}
|
||||
|
||||
// ============ aggregateReport ============
|
||||
|
||||
@Test
|
||||
void aggregateReport_returnsEmptyWhenFragmentsNullOrEmpty() {
|
||||
AiServiceClient client = newClient(baseUrl(), 1);
|
||||
assertTrue(client.aggregateReport("任务", null, null).isEmpty());
|
||||
assertTrue(client.aggregateReport("任务", List.of(), null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateReport_success_coversExpectationBothBranches() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t1\"}");
|
||||
route("GET", "/ai/tasks/t1", 200, "{\"status\":\"done\",\"result\":{\"report\":\"聚合报告内容\"}}");
|
||||
AiServiceClient client = newClient(baseUrl(), 2);
|
||||
|
||||
// expectation 为 null → 参数不含 expectation
|
||||
Optional<String> noExpectation = client.aggregateReport("任务A", List.of("片段1"), null);
|
||||
assertTrue(noExpectation.isPresent());
|
||||
assertEquals("聚合报告内容", noExpectation.get());
|
||||
|
||||
// expectation 非空 → 参数带 expectation
|
||||
Optional<String> withExpectation = client.aggregateReport("任务A", List.of("片段1"), "期望目标");
|
||||
assertTrue(withExpectation.isPresent());
|
||||
assertEquals("聚合报告内容", withExpectation.get());
|
||||
|
||||
assertEquals("application/json", requestHeader("POST", "/ai/tasks", "Content-Type"));
|
||||
}
|
||||
|
||||
// ============ generateMindMap ============
|
||||
|
||||
@Test
|
||||
void generateMindMap_success_returnsOutline() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t2\"}");
|
||||
route("GET", "/ai/tasks/t2", 200,
|
||||
"{\"status\":\"done\",\"result\":{\"outline\":\"- 分支A\\n- 分支B\"}}");
|
||||
AiServiceClient client = newClient(baseUrl(), 2);
|
||||
|
||||
Optional<String> outline = client.generateMindMap("任务A", null, List.of("报告内容"));
|
||||
assertTrue(outline.isPresent());
|
||||
assertEquals("- 分支A\n- 分支B", outline.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateMindMap_outlineMissing_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t3\"}");
|
||||
route("GET", "/ai/tasks/t3", 200, "{\"status\":\"done\",\"result\":{}}");
|
||||
AiServiceClient client = newClient(baseUrl(), 2);
|
||||
|
||||
assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateMindMap_outlineBlank_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t4\"}");
|
||||
route("GET", "/ai/tasks/t4", 200, "{\"status\":\"done\",\"result\":{\"outline\":\" \"}}");
|
||||
AiServiceClient client = newClient(baseUrl(), 2);
|
||||
|
||||
assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty());
|
||||
}
|
||||
|
||||
// ============ compareRecall ============
|
||||
|
||||
@Test
|
||||
void compareRecall_success_returnsJsonResult() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t5\"}");
|
||||
route("GET", "/ai/tasks/t5", 200,
|
||||
"{\"status\":\"done\",\"result\":{\"matches\":[],\"evaluation\":\"ok\"}}");
|
||||
AiServiceClient client = newClient(baseUrl(), 2);
|
||||
|
||||
Optional<JsonNode> result = client.compareRecall("任务A", "标准大纲", "回忆大纲");
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals("ok", result.get().path("evaluation").asText());
|
||||
}
|
||||
|
||||
// ============ 提交阶段失败 ============
|
||||
|
||||
@Test
|
||||
void submitTask_non201_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 500, "server error body");
|
||||
AiServiceClient client = newClient(baseUrl(), 1);
|
||||
|
||||
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitTask_201WithoutTaskId_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"message\":\"accepted\"}");
|
||||
AiServiceClient client = newClient(baseUrl(), 1);
|
||||
|
||||
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitTask_201BlankTaskId_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\" \"}");
|
||||
AiServiceClient client = newClient(baseUrl(), 1);
|
||||
|
||||
assertTrue(client.aggregateReport("任务A", List.of("片段"), null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitTask_connectionRefused_returnsEmpty() {
|
||||
AiServiceClient client = newClient("http://localhost:1", 1);
|
||||
|
||||
assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty());
|
||||
}
|
||||
|
||||
// ============ 轮询阶段 ============
|
||||
|
||||
@Test
|
||||
void pollTask_failed_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t8\"}");
|
||||
route("GET", "/ai/tasks/t8", 200, "{\"status\":\"failed\",\"error\":\"模型超时\"}");
|
||||
AiServiceClient client = newClient(baseUrl(), 2);
|
||||
|
||||
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollTask_404_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t9\"}");
|
||||
route("GET", "/ai/tasks/t9", 404, "{\"error\":\"not found\"}");
|
||||
AiServiceClient client = newClient(baseUrl(), 2);
|
||||
|
||||
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollTask_unknownStatus_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t10\"}");
|
||||
route("GET", "/ai/tasks/t10", 200, "{\"status\":\"weird\"}");
|
||||
AiServiceClient client = newClient(baseUrl(), 2);
|
||||
|
||||
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollTask_pending_thenDeadlineExpires_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t11\"}");
|
||||
route("GET", "/ai/tasks/t11", 200, "{\"status\":\"pending\"}");
|
||||
AiServiceClient client = newClient(baseUrl(), 1);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
Optional<JsonNode> result = client.compareRecall("任务A", "标准大纲", "回忆大纲");
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
// pending 后退避 2s,超过 1s 的 deadline 后退出
|
||||
assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms");
|
||||
assertTrue(elapsed < 10000, "不应无限轮询,实际 " + elapsed + "ms");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollTask_running_thenDeadlineExpires_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t12\"}");
|
||||
route("GET", "/ai/tasks/t12", 200, "{\"status\":\"running\"}");
|
||||
AiServiceClient client = newClient(baseUrl(), 1);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
Optional<String> result = client.aggregateReport("任务A", List.of("片段"), null);
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollTask_non200Continues_thenDeadlineExpires_returnsEmpty() {
|
||||
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t13\"}");
|
||||
route("GET", "/ai/tasks/t13", 503, "temporarily unavailable");
|
||||
AiServiceClient client = newClient(baseUrl(), 1);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
Optional<JsonNode> result = client.compareRecall("任务A", "标准大纲", "回忆大纲");
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
// 503 → 退避 2s → continue → deadline 过期退出
|
||||
assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms");
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
import com.guo.learningprogresstracker.utils.MindMapNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* BuiltinMindMapGenerator 单元测试:会话分组、标题截断、应用场景、同级去重。
|
||||
*/
|
||||
class BuiltinMindMapGeneratorTest {
|
||||
|
||||
private final BuiltinMindMapGenerator generator = new BuiltinMindMapGenerator();
|
||||
|
||||
private static TaskEntity task() {
|
||||
TaskEntity task = new TaskEntity();
|
||||
task.setTaskNum("T9");
|
||||
task.setTaskName("学习Java");
|
||||
task.setTaskDescription("系统学习Java基础");
|
||||
return task;
|
||||
}
|
||||
|
||||
private static StudyReportsEntity report(String sessionNum, String content, Integer id) {
|
||||
StudyReportsEntity report = new StudyReportsEntity();
|
||||
report.setSessionNum(sessionNum);
|
||||
report.setContent(content);
|
||||
report.setId(id);
|
||||
return report;
|
||||
}
|
||||
|
||||
private static TaskApplicationEntity app(Integer id, String title, String description) {
|
||||
TaskApplicationEntity application = new TaskApplicationEntity();
|
||||
application.setId(id);
|
||||
application.setTitle(title);
|
||||
application.setDescription(description);
|
||||
return application;
|
||||
}
|
||||
|
||||
@Test
|
||||
void isAvailable_alwaysTrue_andNameIsBuiltin() {
|
||||
assertTrue(generator.isAvailable());
|
||||
assertEquals("BUILTIN", generator.generatorName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_nullOrEmptyReports_returnsEmpty() {
|
||||
assertTrue(generator.generate(task(), null, List.of(), null).isEmpty());
|
||||
assertTrue(generator.generate(task(), List.of(), List.of(), null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_groupsBySession_withDatePrefixAndRootFallbackTitle() {
|
||||
TaskEntity task = task();
|
||||
task.setTaskName(null); // → 兜底"学习任务"
|
||||
task.setTaskDescription("任务描述内容");
|
||||
|
||||
StudyReportsEntity withDate = report("S1", "会话一的学习内容", 1);
|
||||
withDate.setCreatedTime(LocalDateTime.of(2024, 5, 6, 10, 30));
|
||||
StudyReportsEntity noDate = report("S2", "会话二的学习内容", 2);
|
||||
noDate.setCreatedTime(null);
|
||||
|
||||
MindMapNode root = generator.generate(task, List.of(withDate, noDate), List.of(), null).get();
|
||||
|
||||
assertEquals("学习任务", root.getTitle());
|
||||
assertEquals("任务描述内容", root.getNotes());
|
||||
assertEquals(2, root.getChildren().size());
|
||||
|
||||
// 日期前缀分支
|
||||
MindMapNode dated = root.getChildren().stream()
|
||||
.filter(n -> n.getTitle().startsWith("05-06"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("05-06 会话一的学习内容", dated.getTitle());
|
||||
|
||||
// createdTime 为 null → 无前缀
|
||||
MindMapNode undated = root.getChildren().stream()
|
||||
.filter(n -> !n.getTitle().startsWith("05-06"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("会话二的学习内容", undated.getTitle());
|
||||
|
||||
// 报告子节点:title / notes / sourceType / sourceId
|
||||
MindMapNode reportNode = dated.getChildren().get(0);
|
||||
assertEquals("会话一的学习内容", reportNode.getTitle());
|
||||
assertEquals("会话一的学习内容", reportNode.getNotes());
|
||||
assertEquals("REPORT", reportNode.getSourceType());
|
||||
assertEquals(1, reportNode.getSourceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_truncatesTitlesAt60CharsWithEllipsis() {
|
||||
String exact60 = "界".repeat(60);
|
||||
String long70 = "长".repeat(70);
|
||||
StudyReportsEntity r1 = report("S1", exact60, 1);
|
||||
StudyReportsEntity r2 = report("S1", long70, 2);
|
||||
|
||||
MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get();
|
||||
|
||||
// 会话标题取第一条报告,恰好 60 字不截断
|
||||
MindMapNode session = root.getChildren().get(0);
|
||||
assertEquals(exact60, session.getTitle());
|
||||
|
||||
assertEquals(exact60, session.getChildren().get(0).getTitle());
|
||||
// 超过 60 字 → 截断 + 省略号
|
||||
MindMapNode truncated = session.getChildren().get(1);
|
||||
assertEquals("长".repeat(60) + "…", truncated.getTitle());
|
||||
assertEquals(61, truncated.getTitle().length());
|
||||
assertEquals(long70, truncated.getNotes());
|
||||
assertEquals("REPORT", truncated.getSourceType());
|
||||
assertEquals(2, truncated.getSourceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_skipsNullOrBlankReportContent() {
|
||||
StudyReportsEntity nullContent = report("S1", null, 1);
|
||||
StudyReportsEntity blankContent = report("S1", " ", 2);
|
||||
StudyReportsEntity valid = report("S1", "有效学习内容", 3);
|
||||
|
||||
MindMapNode root =
|
||||
generator.generate(task(), List.of(nullContent, blankContent, valid), List.of(), null).get();
|
||||
|
||||
MindMapNode session = root.getChildren().get(0);
|
||||
// 会话标题取第一条报告内容,null → truncate 返回 ""
|
||||
assertEquals("", session.getTitle());
|
||||
// null / 空白内容报告被跳过
|
||||
assertEquals(1, session.getChildren().size());
|
||||
assertEquals("有效学习内容", session.getChildren().get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_applications_nullOrEmptyAddsNoBranch() {
|
||||
MindMapNode withNull =
|
||||
generator.generate(task(), List.of(report("S1", "内容", 1)), null, null).get();
|
||||
assertTrue(withNull.getChildren().stream().noneMatch(n -> "应用场景".equals(n.getTitle())));
|
||||
|
||||
MindMapNode withEmpty =
|
||||
generator.generate(task(), List.of(report("S1", "内容", 1)), List.of(), null).get();
|
||||
assertTrue(withEmpty.getChildren().stream().noneMatch(n -> "应用场景".equals(n.getTitle())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_applications_skipsBlankTitle_nullDescriptionToEmptyNotes() {
|
||||
TaskApplicationEntity nullTitle = app(1, null, "描述");
|
||||
TaskApplicationEntity blankTitle = app(2, " ", "描述");
|
||||
TaskApplicationEntity valid = app(3, "缓存设计", null);
|
||||
|
||||
MindMapNode root = generator.generate(task(),
|
||||
List.of(report("S1", "内容", 9)), List.of(nullTitle, blankTitle, valid), null).get();
|
||||
|
||||
MindMapNode appNode = root.getChildren().stream()
|
||||
.filter(n -> "应用场景".equals(n.getTitle()))
|
||||
.findFirst().orElseThrow();
|
||||
// title 为 null / 空白的应用被跳过
|
||||
assertEquals(1, appNode.getChildren().size());
|
||||
MindMapNode child = appNode.getChildren().get(0);
|
||||
assertEquals("缓存设计", child.getTitle());
|
||||
// description 为 null → notes 为空串
|
||||
assertEquals("", child.getNotes());
|
||||
assertEquals("APPLICATION", child.getSourceType());
|
||||
assertEquals(3, child.getSourceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_deduplicatesSameLevelTitles_keepsLongerNotes() {
|
||||
// 两条报告标准化后同名:"重复" 与 "重复。" → 合并,notes 长者胜
|
||||
StudyReportsEntity r1 = report("S1", "重复", 1);
|
||||
StudyReportsEntity r2 = report("S1", "重复。", 2);
|
||||
|
||||
MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get();
|
||||
|
||||
MindMapNode session = root.getChildren().get(0);
|
||||
assertEquals(1, session.getChildren().size());
|
||||
MindMapNode merged = session.getChildren().get(0);
|
||||
assertEquals("重复", merged.getTitle());
|
||||
assertEquals("重复。", merged.getNotes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_deduplicatesSessionNodes_mergesChildren() {
|
||||
// 两个会话首条报告内容相同 → 会话节点同名合并,报告子节点拼接
|
||||
StudyReportsEntity r1 = report("S1", "相同会话内容", 1);
|
||||
StudyReportsEntity r2 = report("S2", "相同会话内容", 2);
|
||||
|
||||
MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get();
|
||||
|
||||
assertEquals(1, root.getChildren().size());
|
||||
MindMapNode session = root.getChildren().get(0);
|
||||
assertEquals("相同会话内容", session.getTitle());
|
||||
// 递归去重:拼接进来的两个同名报告节点再次合并为一个(notes 相同长度保留前者)
|
||||
assertEquals(1, session.getChildren().size());
|
||||
assertEquals("相同会话内容", session.getChildren().get(0).getTitle());
|
||||
assertEquals("相同会话内容", session.getChildren().get(0).getNotes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import cn.dev33.satoken.SaManager;
|
||||
import cn.dev33.satoken.context.SaTokenContext;
|
||||
import cn.dev33.satoken.context.SaTokenContextForThreadLocal;
|
||||
import cn.dev33.satoken.context.SaTokenContextForThreadLocalStorage;
|
||||
import cn.dev33.satoken.context.model.SaRequest;
|
||||
import cn.dev33.satoken.context.model.SaResponse;
|
||||
import cn.dev33.satoken.context.model.SaStorage;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* LoginServiceImpl 单元测试:不启动 Spring,利用 Sa-Token 提供的
|
||||
* ThreadLocal 上下文(SaTokenContextForThreadLocal)伪造 Request/Response/Storage,
|
||||
* 配合默认的内存版 SaTokenDao 覆盖 login/logout 两行逻辑。
|
||||
*/
|
||||
class LoginServiceImplTest {
|
||||
|
||||
private final LoginServiceImpl loginService = new LoginServiceImpl();
|
||||
private final SaTokenContextForThreadLocal threadLocalContext = new SaTokenContextForThreadLocal();
|
||||
private SaTokenContext previousContext;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
previousContext = SaManager.getSaTokenContext();
|
||||
SaManager.setSaTokenContext(threadLocalContext);
|
||||
SaRequest request = Mockito.mock(SaRequest.class);
|
||||
SaResponse response = Mockito.mock(SaResponse.class);
|
||||
SaTokenContextForThreadLocalStorage.setBox(request, response, new InMemoryStorage());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SaTokenContextForThreadLocalStorage.clearBox();
|
||||
SaManager.setSaTokenContext(previousContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于内存 Map 的 SaStorage 实现,供 Sa-Token 存取"本次会话刚创建的 token"标记。
|
||||
*/
|
||||
static class InMemoryStorage implements SaStorage {
|
||||
private final Map<String, Object> data = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public SaStorage set(String key, Object value) {
|
||||
data.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get(String key) {
|
||||
return data.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SaStorage delete(String key) {
|
||||
data.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSource() {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_shouldReturnTokenValue() throws Exception {
|
||||
String token = loginService.login("user-1");
|
||||
|
||||
assertNotNull(token, "登录成功后应返回 token");
|
||||
assertEquals(token, StpUtil.getTokenInfo().getTokenValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_shouldClearLoginState() throws Exception {
|
||||
loginService.login("user-2");
|
||||
|
||||
loginService.logout();
|
||||
|
||||
assertFalse(StpUtil.isLogin(), "登出后不应再处于登录态");
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
||||
import com.guo.learningprogresstracker.mapper.UserPriorityWeightsMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* PriorityWeightsServiceImpl 单元测试:纯 Mockito。
|
||||
* 覆盖 get/update 权重的全部分支:无记录默认、新建、更新并重算、校验失败等。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PriorityWeightsServiceImplTest {
|
||||
|
||||
@InjectMocks
|
||||
private PriorityWeightsServiceImpl priorityWeightsService;
|
||||
|
||||
@Mock
|
||||
private UserPriorityWeightsMapper weightsMapper;
|
||||
|
||||
@Mock
|
||||
private TasksMapper tasksMapper;
|
||||
|
||||
@Test
|
||||
void getWeights_existing_shouldReturnStored() {
|
||||
UserPriorityWeightsEntity stored = UserPriorityWeightsEntity.defaults();
|
||||
when(weightsMapper.selectOne(any())).thenReturn(stored);
|
||||
|
||||
assertSame(stored, priorityWeightsService.getWeights());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWeights_missing_shouldReturnDefaults() {
|
||||
when(weightsMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
UserPriorityWeightsEntity weights = priorityWeightsService.getWeights();
|
||||
|
||||
assertEquals(0.35, weights.getUrgencyWeight(), 0.0001);
|
||||
assertEquals(0.25, weights.getImportanceWeight(), 0.0001);
|
||||
assertEquals(0.20, weights.getContentDifficultyWeight(), 0.0001);
|
||||
assertEquals(0.10, weights.getFutureValueWeight(), 0.0001);
|
||||
assertEquals(0.10, weights.getSubjectivePriorityWeight(), 0.0001);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无既有记录 → insert 新建,任务列表为空时不触发重算循环体
|
||||
*/
|
||||
@Test
|
||||
void saveWeights_noExistingRecord_shouldInsert() throws Exception {
|
||||
UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults();
|
||||
when(weightsMapper.selectOne(any())).thenReturn(null);
|
||||
when(tasksMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
UserPriorityWeightsEntity result = priorityWeightsService.saveWeights(weights);
|
||||
|
||||
assertSame(weights, result);
|
||||
verify(weightsMapper).insert(weights);
|
||||
verify(weightsMapper, never()).updateById(any(UserPriorityWeightsEntity.class));
|
||||
verify(tasksMapper, never()).updateById(any(TaskEntity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 已有记录 → 复用其 id 更新,并用新权重重算全部任务(覆盖维度为 null 与非 null 的 orZero 分支)
|
||||
*/
|
||||
@Test
|
||||
void saveWeights_existingRecord_shouldUpdateAndRecalculateAllTasks() throws Exception {
|
||||
UserPriorityWeightsEntity existing = UserPriorityWeightsEntity.defaults();
|
||||
existing.setId(7);
|
||||
when(weightsMapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
TaskEntity allNullDims = new TaskEntity();
|
||||
allNullDims.setId(1);
|
||||
TaskEntity fullDims = new TaskEntity();
|
||||
fullDims.setId(2);
|
||||
fullDims.setUrgency(2);
|
||||
fullDims.setImportance(3);
|
||||
fullDims.setContentDifficulty(4);
|
||||
fullDims.setFutureValue(5);
|
||||
fullDims.setSubjectivePriority(1);
|
||||
when(tasksMapper.selectList(any())).thenReturn(Arrays.asList(allNullDims, fullDims));
|
||||
|
||||
UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults();
|
||||
UserPriorityWeightsEntity result = priorityWeightsService.saveWeights(weights);
|
||||
|
||||
assertSame(weights, result);
|
||||
assertEquals(Integer.valueOf(7), result.getId());
|
||||
verify(weightsMapper).updateById(weights);
|
||||
verify(weightsMapper, never()).insert(any(UserPriorityWeightsEntity.class));
|
||||
// 全 null 维度按 0 计算优先级
|
||||
assertEquals(0.0, allNullDims.getCalculatedPriority(), 0.0001);
|
||||
// 2*0.35 + 3*0.25 + 4*0.20 + 5*0.10 + 1*0.10 = 2.85
|
||||
assertEquals(2.85, fullDims.getCalculatedPriority(), 0.0001);
|
||||
verify(tasksMapper).updateById(allNullDims);
|
||||
verify(tasksMapper).updateById(fullDims);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权重超过 1 → 校验失败
|
||||
*/
|
||||
@Test
|
||||
void saveWeights_weightAboveOne_shouldThrow() {
|
||||
UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults();
|
||||
weights.setUrgencyWeight(1.5);
|
||||
|
||||
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
|
||||
() -> priorityWeightsService.saveWeights(weights));
|
||||
|
||||
assertEquals("权重必须在 0 到 1 之间", ex.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 负权重 → 校验失败
|
||||
*/
|
||||
@Test
|
||||
void saveWeights_negativeWeight_shouldThrow() {
|
||||
UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults();
|
||||
weights.setImportanceWeight(-0.1);
|
||||
|
||||
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
|
||||
() -> priorityWeightsService.saveWeights(weights));
|
||||
|
||||
assertEquals("权重必须在 0 到 1 之间", ex.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部权重为 null → orZero 兜底为 0,总和为 0 ≠ 1 → 抛出求和校验
|
||||
*/
|
||||
@Test
|
||||
void saveWeights_allNullWeights_shouldThrowSumError() {
|
||||
UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity();
|
||||
|
||||
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
|
||||
() -> priorityWeightsService.saveWeights(weights));
|
||||
|
||||
assertTrue(ex.getMessage().startsWith("五项权重之和必须为 1,当前为 "));
|
||||
verify(weightsMapper, never()).insert(any(UserPriorityWeightsEntity.class));
|
||||
verify(weightsMapper, never()).updateById(any(UserPriorityWeightsEntity.class));
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
import com.guo.learningprogresstracker.utils.MindMapNode;
|
||||
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* RemoteAiMindMapClient 单元测试:mock AiServiceClient,验证降级与大纲解析。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RemoteAiMindMapClientTest {
|
||||
|
||||
@Mock
|
||||
private AiServiceClient aiServiceClient;
|
||||
|
||||
@InjectMocks
|
||||
private RemoteAiMindMapClient client;
|
||||
|
||||
private static TaskEntity task() {
|
||||
TaskEntity task = new TaskEntity();
|
||||
task.setTaskNum("T1");
|
||||
task.setTaskName("学习Java入门");
|
||||
task.setTaskDescription("Java 基础学习描述");
|
||||
return task;
|
||||
}
|
||||
|
||||
private static StudyReportsEntity report(String content) {
|
||||
StudyReportsEntity report = new StudyReportsEntity();
|
||||
report.setSessionNum("S1");
|
||||
report.setContent(content);
|
||||
report.setId(1);
|
||||
return report;
|
||||
}
|
||||
|
||||
@Test
|
||||
void isAvailable_followsAiConfiguration() {
|
||||
when(aiServiceClient.isConfigured()).thenReturn(true);
|
||||
assertTrue(client.isAvailable());
|
||||
when(aiServiceClient.isConfigured()).thenReturn(false);
|
||||
assertFalse(client.isAvailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatorName_isAi() {
|
||||
assertEquals("AI", client.generatorName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_notAvailable_returnsEmpty() {
|
||||
when(aiServiceClient.isConfigured()).thenReturn(false);
|
||||
Optional<MindMapNode> result = client.generate(task(), List.of(report("内容")), List.of(), null);
|
||||
assertTrue(result.isEmpty());
|
||||
verify(aiServiceClient, never()).generateMindMap(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_reportsAllBlank_returnsEmpty() {
|
||||
when(aiServiceClient.isConfigured()).thenReturn(true);
|
||||
StudyReportsEntity nullContent = report(null);
|
||||
StudyReportsEntity blankContent = report(" ");
|
||||
|
||||
Optional<MindMapNode> result =
|
||||
client.generate(task(), List.of(nullContent, blankContent), List.of(), null);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
verify(aiServiceClient, never()).generateMindMap(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_aiReturnsEmpty_returnsEmpty() {
|
||||
when(aiServiceClient.isConfigured()).thenReturn(true);
|
||||
when(aiServiceClient.generateMindMap(any(), any(), any())).thenReturn(Optional.empty());
|
||||
|
||||
Optional<MindMapNode> result = client.generate(task(), List.of(report("内容")), List.of(), null);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_aiReturnsBlankOutline_returnsEmpty() {
|
||||
when(aiServiceClient.isConfigured()).thenReturn(true);
|
||||
when(aiServiceClient.generateMindMap(any(), any(), any())).thenReturn(Optional.of(" "));
|
||||
|
||||
Optional<MindMapNode> result = client.generate(task(), List.of(report("内容")), List.of(), null);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_success_parsesOutlineToTree() {
|
||||
when(aiServiceClient.isConfigured()).thenReturn(true);
|
||||
when(aiServiceClient.generateMindMap(any(), any(), any()))
|
||||
.thenReturn(Optional.of("学习Java\n- 线程基础\n - 线程安全"));
|
||||
|
||||
Optional<MindMapNode> result =
|
||||
client.generate(task(), List.of(report("报告内容")), List.of(), "前端已有大纲");
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
MindMapNode root = result.get();
|
||||
assertEquals("学习Java", root.getTitle());
|
||||
assertEquals(1, root.getChildren().size());
|
||||
assertEquals("线程基础", root.getChildren().get(0).getTitle());
|
||||
assertEquals("线程安全", root.getChildren().get(0).getChildren().get(0).getTitle());
|
||||
assertEquals(3, MindMapTreeTool.countNodes(root));
|
||||
assertEquals(3, MindMapTreeTool.maxDepth(root));
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* StudyExpectationsServiceImpl 单元测试:纯 Mockito,不连数据库。
|
||||
* 该服务为普通类构造注入两个 Mapper,直接 @InjectMocks 即可。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StudyExpectationsServiceImplTest {
|
||||
|
||||
@InjectMocks
|
||||
private StudyExpectationsServiceImpl studyExpectationsService;
|
||||
|
||||
@Mock
|
||||
private StudyExpectationsMapper studyExpectationsMapper;
|
||||
|
||||
@Mock
|
||||
private StudySessionsMapper studySessionsMapper;
|
||||
|
||||
/**
|
||||
* 场景1:会话不存在 → 抛出,不写库
|
||||
*/
|
||||
@Test
|
||||
void upsertExpectation_sessionMissing_shouldThrow() {
|
||||
when(studySessionsMapper.exists(any())).thenReturn(false);
|
||||
|
||||
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
|
||||
() -> studyExpectationsService.upsertExpectation("NOT_EXIST", "预期"));
|
||||
|
||||
assertEquals("这次学习会话不存在或已结束", ex.getMessage());
|
||||
verify(studyExpectationsMapper, never()).insert(any(StudyExpectationsEntity.class));
|
||||
verify(studyExpectationsMapper, never()).updateById(any(StudyExpectationsEntity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景2:尚无预期记录 → 插入新记录并返回
|
||||
*/
|
||||
@Test
|
||||
void upsertExpectation_noExistingRecord_shouldInsert() throws Exception {
|
||||
when(studySessionsMapper.exists(any())).thenReturn(true);
|
||||
when(studyExpectationsMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
StudyExpectationsEntity result = studyExpectationsService.upsertExpectation("SESSION_A", "掌握微积分");
|
||||
|
||||
assertEquals("SESSION_A", result.getSessionNum());
|
||||
assertEquals("掌握微积分", result.getDescription());
|
||||
verify(studyExpectationsMapper).insert(result);
|
||||
verify(studyExpectationsMapper, never()).updateById(any(StudyExpectationsEntity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景3:已有预期记录 → 更新描述并返回原实体
|
||||
*/
|
||||
@Test
|
||||
void upsertExpectation_existingRecord_shouldUpdate() throws Exception {
|
||||
when(studySessionsMapper.exists(any())).thenReturn(true);
|
||||
StudyExpectationsEntity existing = new StudyExpectationsEntity();
|
||||
existing.setExpectationId(3);
|
||||
existing.setSessionNum("SESSION_A");
|
||||
existing.setDescription("旧预期");
|
||||
when(studyExpectationsMapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
StudyExpectationsEntity result = studyExpectationsService.upsertExpectation("SESSION_A", "新预期");
|
||||
|
||||
assertSame(existing, result);
|
||||
assertEquals("新预期", result.getDescription());
|
||||
verify(studyExpectationsMapper).updateById(existing);
|
||||
verify(studyExpectationsMapper, never()).insert(any(StudyExpectationsEntity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景4:按会话编号查询 → 透传 mapper 结果
|
||||
*/
|
||||
@Test
|
||||
void getBySessionNum_shouldReturnMapperResult() {
|
||||
StudyExpectationsEntity entity = new StudyExpectationsEntity();
|
||||
entity.setSessionNum("SESSION_A");
|
||||
entity.setDescription("预期内容");
|
||||
when(studyExpectationsMapper.selectOne(any())).thenReturn(entity);
|
||||
|
||||
StudyExpectationsEntity result = studyExpectationsService.getBySessionNum("SESSION_A");
|
||||
|
||||
assertSame(entity, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景5:按会话编号删除 → 委托 mapper.delete
|
||||
*/
|
||||
@Test
|
||||
void deleteBySessionNum_shouldDelegateToDelete() {
|
||||
studyExpectationsService.deleteBySessionNum("SESSION_A");
|
||||
|
||||
verify(studyExpectationsMapper).delete(any());
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* StudyReportFragmentsServiceImpl 单元测试:继承 ServiceImpl,
|
||||
* 参考 StudySessionsServiceImplTest 的手法对 spy 打桩 save/updateById/getById/list,
|
||||
* 避免 baseMapper 为空引发 NPE。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StudyReportFragmentsServiceImplTest {
|
||||
|
||||
@Spy
|
||||
@InjectMocks
|
||||
private StudyReportFragmentsServiceImpl studyReportFragmentsService;
|
||||
|
||||
@Mock
|
||||
private StudySessionsMapper studySessionsMapper;
|
||||
|
||||
private CreateFragmentsRequest createRequest(String sessionNum, String content) {
|
||||
CreateFragmentsRequest request = new CreateFragmentsRequest();
|
||||
request.setSessionNum(sessionNum);
|
||||
request.setContent(content);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景1:会话不存在 → 抛出,不落库
|
||||
*/
|
||||
@Test
|
||||
void createFragments_sessionMissing_shouldThrow() {
|
||||
// studySessionsMapper.selectOne 未打桩时默认返回 null,即查不到会话
|
||||
|
||||
NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class,
|
||||
() -> studyReportFragmentsService.createFragments(createRequest("NOT_EXIST", "内容")));
|
||||
|
||||
assertEquals("这次学习会话不存在或已结束", ex.getMessage());
|
||||
verify(studyReportFragmentsService, never()).save(any(StudyReportFragmentsEntity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景2:会话存在 → 转换请求并保存残片
|
||||
*/
|
||||
@Test
|
||||
void createFragments_success_shouldSaveEntity() throws Exception {
|
||||
StudySessionsEntity session = new StudySessionsEntity();
|
||||
session.setSessionNum("SESSION_A");
|
||||
when(studySessionsMapper.selectOne(any())).thenReturn(session);
|
||||
doReturn(true).when(studyReportFragmentsService).save(any(StudyReportFragmentsEntity.class));
|
||||
|
||||
studyReportFragmentsService.createFragments(createRequest("SESSION_A", "今天学了导数"));
|
||||
|
||||
verify(studyReportFragmentsService).save(argThat(entity ->
|
||||
"SESSION_A".equals(entity.getSessionNum())
|
||||
&& "今天学了导数".equals(entity.getContent())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景3:残片不存在 → 抛出
|
||||
*/
|
||||
@Test
|
||||
void updateFragments_missing_shouldThrow() {
|
||||
doReturn(null).when(studyReportFragmentsService).getById(any());
|
||||
|
||||
NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class,
|
||||
() -> studyReportFragmentsService.updateFragments(9, new UpdateFragmentsRequest()));
|
||||
|
||||
assertEquals("这条学习残片不存在或已被删除", ex.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景4:残片存在 → 以指定 id 更新内容
|
||||
*/
|
||||
@Test
|
||||
void updateFragments_success_shouldUpdateWithId() throws Exception {
|
||||
StudyReportFragmentsEntity existing = new StudyReportFragmentsEntity();
|
||||
existing.setId(9);
|
||||
existing.setContent("旧内容");
|
||||
doReturn(existing).when(studyReportFragmentsService).getById(any());
|
||||
doReturn(true).when(studyReportFragmentsService).updateById(any(StudyReportFragmentsEntity.class));
|
||||
UpdateFragmentsRequest request = new UpdateFragmentsRequest();
|
||||
request.setContent("更新后的内容");
|
||||
|
||||
studyReportFragmentsService.updateFragments(9, request);
|
||||
|
||||
verify(studyReportFragmentsService).updateById(argThat(entity ->
|
||||
Integer.valueOf(9).equals(entity.getId())
|
||||
&& "更新后的内容".equals(entity.getContent())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景5:按会话查询残片列表 → 透传 list 结果
|
||||
*/
|
||||
@Test
|
||||
void getFragmentsBySession_shouldReturnList() {
|
||||
StudyReportFragmentsEntity f1 = new StudyReportFragmentsEntity();
|
||||
f1.setContent("残片1");
|
||||
StudyReportFragmentsEntity f2 = new StudyReportFragmentsEntity();
|
||||
f2.setContent("残片2");
|
||||
doReturn(List.of(f1, f2)).when(studyReportFragmentsService).list(any(Wrapper.class));
|
||||
|
||||
List<StudyReportFragmentsEntity> result = studyReportFragmentsService.getFragmentsBySession("SESSION_A");
|
||||
|
||||
assertEquals(2, result.size());
|
||||
assertEquals("残片1", result.get(0).getContent());
|
||||
assertEquals("残片2", result.get(1).getContent());
|
||||
}
|
||||
}
|
||||
+103
@@ -4,9 +4,11 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||
import com.guo.learningprogresstracker.enums.StudySessionStateEnum;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -17,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -41,10 +44,15 @@ class StudySessionsServiceImplTest {
|
||||
@Mock
|
||||
private StudySessionsMapper studySessionsMapper;
|
||||
|
||||
@Mock
|
||||
private StudyExpectationsService studyExpectationsService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// stub updateById,避免调用真实的 getBaseMapper()
|
||||
doReturn(true).when(studySessionsService).updateById(any());
|
||||
// stub removeById,避免调用真实的 getBaseMapper()
|
||||
doReturn(true).when(studySessionsService).removeById(any(Serializable.class));
|
||||
}
|
||||
|
||||
private StudySessionsEntity createSession(String state, int minutesAgo) {
|
||||
@@ -210,4 +218,99 @@ class StudySessionsServiceImplTest {
|
||||
|
||||
verify(studySessionsService).updateById(session);
|
||||
}
|
||||
|
||||
// ============ 误操作结束(零数据关闭) ============
|
||||
|
||||
/**
|
||||
* 场景11:确认语正确且未产生残片 → 删除预期与会话,零数据关闭
|
||||
*/
|
||||
@Test
|
||||
void abortStudySession_success_shouldDeleteEverything() throws Exception {
|
||||
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
|
||||
session.setId(1);
|
||||
mockGetOneOpt(session);
|
||||
doReturn(0L).when(studyReportFragmentsMapper).selectCount(any());
|
||||
|
||||
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION);
|
||||
|
||||
verify(studyExpectationsService).deleteBySessionNum("SESSION_TEST");
|
||||
verify(studySessionsService).removeById(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景12:确认语不正确 → 抛异常,不删除任何数据
|
||||
*/
|
||||
@Test
|
||||
void abortStudySession_wrongConfirmation_shouldThrow() {
|
||||
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
|
||||
mockGetOneOpt(session);
|
||||
|
||||
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
|
||||
studySessionsService.abortStudySession("SESSION_TEST", "我不是确认语"));
|
||||
|
||||
assertEquals("确认语输入不正确,请核对后重新输入", ex.getMessage());
|
||||
verify(studyExpectationsService, never()).deleteBySessionNum(any());
|
||||
verify(studySessionsService, never()).removeById(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景13:确认语首尾空白应被忽略,仍视为正确
|
||||
*/
|
||||
@Test
|
||||
void abortStudySession_confirmationWithSpaces_shouldSucceed() throws Exception {
|
||||
StudySessionsEntity session = createSession(StudySessionStateEnum.PAUSED.name(), 5);
|
||||
session.setId(2);
|
||||
mockGetOneOpt(session);
|
||||
doReturn(0L).when(studyReportFragmentsMapper).selectCount(any());
|
||||
|
||||
studySessionsService.abortStudySession("SESSION_TEST",
|
||||
" " + StudySessionsServiceImpl.MISOPERATION_CONFIRMATION + " ");
|
||||
|
||||
verify(studyExpectationsService).deleteBySessionNum("SESSION_TEST");
|
||||
verify(studySessionsService).removeById(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景14:已产生学习残片 → 抛异常,不能误操作结束
|
||||
*/
|
||||
@Test
|
||||
void abortStudySession_hasFragments_shouldThrow() {
|
||||
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
|
||||
mockGetOneOpt(session);
|
||||
doReturn(1L).when(studyReportFragmentsMapper).selectCount(any());
|
||||
|
||||
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
|
||||
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
|
||||
|
||||
assertEquals("这次学习已经产生了学习残片,不能按误操作结束了哦", ex.getMessage());
|
||||
verify(studyExpectationsService, never()).deleteBySessionNum(any());
|
||||
verify(studySessionsService, never()).removeById(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景15:会话已结束 → 抛异常
|
||||
*/
|
||||
@Test
|
||||
void abortStudySession_endedSession_shouldThrow() {
|
||||
StudySessionsEntity session = createSession(StudySessionStateEnum.ENDED.name(), 5);
|
||||
mockGetOneOpt(session);
|
||||
|
||||
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
|
||||
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
|
||||
|
||||
assertEquals("这次学习会话已经结束,不能再误操作结束了", ex.getMessage());
|
||||
verify(studySessionsService, never()).removeById(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景16:会话不存在 → 抛异常
|
||||
*/
|
||||
@Test
|
||||
void abortStudySession_sessionNotFound_shouldThrow() {
|
||||
doReturn(Optional.empty()).when(studySessionsService).getOneOpt(any(Wrapper.class));
|
||||
|
||||
assertThrows(ErrorParameterException.class, () ->
|
||||
studySessionsService.abortStudySession("NOT_EXIST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.TestTableEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
|
||||
/**
|
||||
* TestTableServiceImpl 单元测试:服务仅一个透传查询,覆盖 getById 透传路径。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TestTableServiceImplTest {
|
||||
|
||||
@Spy
|
||||
@InjectMocks
|
||||
private TestTableServiceImpl testTableService;
|
||||
|
||||
@Test
|
||||
void getTestEntityById_shouldReturnMapperResult() {
|
||||
TestTableEntity entity = new TestTableEntity();
|
||||
entity.setId(1);
|
||||
entity.setIdName("测试记录");
|
||||
doReturn(entity).when(testTableService).getById(any());
|
||||
|
||||
TestTableEntity result = testTableService.getTestEntityById("1");
|
||||
|
||||
assertSame(entity, result);
|
||||
assertEquals("测试记录", result.getIdName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.guo.learningprogresstracker.entity.UserEntity;
|
||||
import com.guo.learningprogresstracker.exception.AppException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mindrot.jbcrypt.BCrypt;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
|
||||
/**
|
||||
* UserServiceImpl 单元测试:覆盖认证的用户不存在、密码错误、密码正确三条路径。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceImplTest {
|
||||
|
||||
@Spy
|
||||
@InjectMocks
|
||||
private UserServiceImpl userService;
|
||||
|
||||
private UserEntity userWithPassword(String rawPassword) {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setId("user-1");
|
||||
user.setUserName("admin");
|
||||
user.setUserPassword(BCrypt.hashpw(rawPassword, BCrypt.gensalt()));
|
||||
return user;
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticate_userMissing_shouldThrow() {
|
||||
doReturn(Optional.empty()).when(userService).getOneOpt(any(Wrapper.class));
|
||||
|
||||
AppException ex = assertThrows(AppException.class, () -> userService.authenticate("ghost", "pw"));
|
||||
|
||||
assertEquals("账号或密码错误!", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticate_wrongPassword_shouldThrow() {
|
||||
doReturn(Optional.of(userWithPassword("correct-pw"))).when(userService).getOneOpt(any(Wrapper.class));
|
||||
|
||||
AppException ex = assertThrows(AppException.class, () -> userService.authenticate("admin", "wrong-pw"));
|
||||
|
||||
assertEquals("账号或密码错误!", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticate_correctPassword_shouldReturnUserId() throws Exception {
|
||||
doReturn(Optional.of(userWithPassword("correct-pw"))).when(userService).getOneOpt(any(Wrapper.class));
|
||||
|
||||
assertEquals("user-1", userService.authenticate("admin", "correct-pw"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.guo.learningprogresstracker.support;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
/**
|
||||
* 测试用本地 HTTP 服务器(JDK 内置 HttpServer),用于覆盖 TitleFetcher / AiServiceClient 等网络代码。
|
||||
* 每个测试方法独立启停,端口随机,路由按注册顺序匹配(前缀匹配)。
|
||||
*/
|
||||
public class TestHttpServer {
|
||||
|
||||
private HttpServer server;
|
||||
private final List<Route> routes = new ArrayList<>();
|
||||
/** 请求头按 "METHOD path" 记录(key 小写化),同名后续请求覆盖。 */
|
||||
private final Map<String, Map<String, String>> requestHeadersByPath = new LinkedHashMap<>();
|
||||
private final Map<String, String> lastPaths = new LinkedHashMap<>();
|
||||
private final Map<String, String> lastQueries = new LinkedHashMap<>();
|
||||
private String lastRequestKey;
|
||||
|
||||
private record Route(String method, String pathPrefix, int status, byte[] body,
|
||||
Map<String, String> headers) {
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String method = exchange.getRequestMethod();
|
||||
Map<String, String> lowercaseHeaders = new LinkedHashMap<>();
|
||||
exchange.getRequestHeaders().forEach((k, v) -> lowercaseHeaders.put(k.toLowerCase(Locale.ROOT), String.join(",", v)));
|
||||
lastRequestKey = method + " " + path;
|
||||
requestHeadersByPath.put(lastRequestKey, lowercaseHeaders);
|
||||
lastPaths.put(method + " " + path, path);
|
||||
lastQueries.put(method + " " + path,
|
||||
exchange.getRequestURI().getQuery() == null ? "" : exchange.getRequestURI().getQuery());
|
||||
for (Route route : routes) {
|
||||
if (route.method.equals(method) && path.startsWith(route.pathPrefix)) {
|
||||
if (exchange.getRequestHeaders().getFirst("Content-Length") != null) {
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
}
|
||||
byte[] body = route.body();
|
||||
Map<String, String> headers = new LinkedHashMap<>(route.headers());
|
||||
boolean gzip = "gzip".equals(headers.remove("Content-Encoding-Test"));
|
||||
if (gzip) {
|
||||
headers.put("Content-Encoding", "gzip");
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
try (GZIPOutputStream gz = new GZIPOutputStream(bos)) {
|
||||
gz.write(body);
|
||||
}
|
||||
body = bos.toByteArray();
|
||||
}
|
||||
headers.forEach(exchange.getResponseHeaders()::set);
|
||||
if (route.status() >= 300 && route.status() < 400) {
|
||||
exchange.getResponseHeaders().set("Location", headers.getOrDefault("Location", "/redirected"));
|
||||
}
|
||||
exchange.sendResponseHeaders(route.status(), body.length == 0 ? -1 : body.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册路由;body 为响应体。 */
|
||||
public void route(String method, String pathPrefix, int status, String body) {
|
||||
route(method, pathPrefix, status, body, Map.of());
|
||||
}
|
||||
|
||||
/** 注册路由;headers 里 key=Content-Encoding-Test,value=gzip 表示用 gzip 压缩响应体。 */
|
||||
public void route(String method, String pathPrefix, int status, String body, Map<String, String> headers) {
|
||||
routes.add(new Route(method, pathPrefix, status, body.getBytes(StandardCharsets.UTF_8), headers));
|
||||
}
|
||||
|
||||
/** 服务根地址,如 http://localhost:12345 */
|
||||
public String baseUrl() {
|
||||
return "http://localhost:" + server.getAddress().getPort();
|
||||
}
|
||||
|
||||
/** 最近一次请求的指定请求头值(不区分大小写)。 */
|
||||
public String lastRequestHeader(String name) {
|
||||
Map<String, String> headers = requestHeadersByPath.get(lastRequestKey);
|
||||
return headers == null ? null : headers.get(name.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/** 最近一次请求是否包含指定请求头(不区分大小写)。 */
|
||||
public boolean hasRequestHeader(String name) {
|
||||
return lastRequestHeader(name) != null;
|
||||
}
|
||||
|
||||
/** 指定 "METHOD path" 的请求头值(不区分大小写;该路径尚未请求过返回 null)。 */
|
||||
public String requestHeader(String method, String path, String name) {
|
||||
Map<String, String> headers = requestHeadersByPath.get(method + " " + path);
|
||||
return headers == null ? null : headers.get(name.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/** 最近一次匹配路由的请求查询串。 */
|
||||
public String lastQuery(String method, String path) {
|
||||
return lastQueries.get(method + " " + path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.guo.learningprogresstracker.support;
|
||||
|
||||
import com.sun.net.httpserver.HttpsConfigurator;
|
||||
import com.sun.net.httpserver.HttpsServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 测试用本地 HTTPS 服务器,使用 src/test/resources/test-keystore.p12 中的自签名证书。
|
||||
* 客户端(TitleFetcher 的信任所有证书逻辑)与之完成真实 TLS 握手,可覆盖信任管理器代码路径。
|
||||
*/
|
||||
public class TestHttpsServer {
|
||||
|
||||
private HttpsServer server;
|
||||
private final List<Route> routes = new ArrayList<>();
|
||||
|
||||
private record Route(String pathPrefix, int status, byte[] body, String contentType) {
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws Exception {
|
||||
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||
try (InputStream in = getClass().getResourceAsStream("/test-keystore.p12")) {
|
||||
ks.load(in, "changeit".toCharArray());
|
||||
}
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
kmf.init(ks, "changeit".toCharArray());
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(kmf.getKeyManagers(), null, null);
|
||||
|
||||
server = HttpsServer.create(new InetSocketAddress("localhost", 0), 0);
|
||||
server.setHttpsConfigurator(new HttpsConfigurator(sslContext));
|
||||
server.createContext("/", exchange -> {
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
for (Route route : routes) {
|
||||
if (path.startsWith(route.pathPrefix())) {
|
||||
exchange.getResponseHeaders().set("Content-Type", route.contentType());
|
||||
exchange.sendResponseHeaders(route.status(), route.body().length == 0 ? -1 : route.body().length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(route.body());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
public void route(String pathPrefix, int status, String body, String contentType) {
|
||||
routes.add(new Route(pathPrefix, status, body.getBytes(StandardCharsets.UTF_8), contentType));
|
||||
}
|
||||
|
||||
public void html(String pathPrefix, String title) {
|
||||
route(pathPrefix, 200, "<html><head><title>" + title + "</title></head><body>hi</body></html>", "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
/** 服务 HTTPS 根地址,如 https://localhost:12345 */
|
||||
public String baseUrl() {
|
||||
return "https://localhost:" + server.getAddress().getPort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.guo.learningprogresstracker.utils;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.PriorityDto;
|
||||
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class CalculatedPriorityToolTest {
|
||||
|
||||
private PriorityDto dto(int urgency, int importance, int difficulty, int future, int subjective) {
|
||||
PriorityDto dto = new PriorityDto();
|
||||
dto.setUrgency(urgency);
|
||||
dto.setImportance(importance);
|
||||
dto.setContentDifficulty(difficulty);
|
||||
dto.setFutureValue(future);
|
||||
dto.setSubjectivePriority(subjective);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatedPriority_withDefaultWeights() {
|
||||
// 默认权重:10*0.35 + 8*0.25 + 5*0.20 + 7*0.10 + 9*0.10 = 8.1
|
||||
assertEquals(8.1, CalculatedPriorityTool.calculatedPriority(dto(10, 8, 5, 7, 9)), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatedPriority_withCustomWeights() {
|
||||
UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity();
|
||||
weights.setUrgencyWeight(1.0);
|
||||
weights.setImportanceWeight(1.0);
|
||||
weights.setContentDifficultyWeight(1.0);
|
||||
weights.setFutureValueWeight(1.0);
|
||||
weights.setSubjectivePriorityWeight(1.0);
|
||||
// 权重全为 1 时等于各维度求和:10+8+5+7+9 = 39
|
||||
assertEquals(39.0, CalculatedPriorityTool.calculatedPriority(dto(10, 8, 5, 7, 9), weights), 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.guo.learningprogresstracker.utils;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class GenerateNumToolTest {
|
||||
|
||||
@Test
|
||||
void generateNum_defaultSeparator_prefixDateSequence() {
|
||||
String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
String code = GenerateNumTool.generateNum("ORD");
|
||||
assertTrue(code.startsWith("ORD-" + today + "-"), "实际值: " + code);
|
||||
// 序列为毫秒时间戳对 1_000_000 取模,最多 6 位数字
|
||||
assertTrue(code.matches("ORD-" + today + "-\\d{1,6}"), "实际值: " + code);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateNum_customSeparator_prefixDateSequence() {
|
||||
String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
String code = GenerateNumTool.generateNum("BIZ", "_");
|
||||
assertTrue(code.matches("BIZ_" + today + "_\\d{1,6}"), "实际值: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.guo.learningprogresstracker.utils;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MindMapNodeTest {
|
||||
|
||||
@Test
|
||||
void noArgConstructor_leavesFieldsNull() {
|
||||
MindMapNode n = new MindMapNode();
|
||||
assertNull(n.getTitle());
|
||||
assertNull(n.getNotes());
|
||||
assertNull(n.getSourceType());
|
||||
assertNull(n.getSourceId());
|
||||
assertNull(n.getChildren());
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleConstructor_setsEmptyNotesAndMutableChildren() {
|
||||
MindMapNode n = new MindMapNode("标题");
|
||||
assertEquals("标题", n.getTitle());
|
||||
assertEquals("", n.getNotes());
|
||||
assertNotNull(n.getChildren());
|
||||
assertTrue(n.getChildren().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toMap_nullScalarsBecomeEmptyString_andOptionalKeysOmitted() {
|
||||
Map<String, Object> map = new MindMapNode().toMap();
|
||||
assertEquals("", map.get("title"));
|
||||
assertEquals("", map.get("notes"));
|
||||
assertFalse(map.containsKey("sourceType"));
|
||||
assertFalse(map.containsKey("sourceId"));
|
||||
assertEquals(List.of(), map.get("children"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void toMap_withScalarsAndNestedChildren() {
|
||||
MindMapNode n = new MindMapNode("父");
|
||||
n.setNotes("备注");
|
||||
n.setSourceType("REPORT");
|
||||
n.setSourceId(9);
|
||||
n.setChildren(new ArrayList<>(List.of(new MindMapNode("子"))));
|
||||
|
||||
Map<String, Object> map = n.toMap();
|
||||
assertEquals("父", map.get("title"));
|
||||
assertEquals("备注", map.get("notes"));
|
||||
assertEquals("REPORT", map.get("sourceType"));
|
||||
assertEquals(Integer.valueOf(9), map.get("sourceId"));
|
||||
|
||||
List<Map<String, Object>> children = (List<Map<String, Object>>) map.get("children");
|
||||
assertEquals(1, children.size());
|
||||
assertEquals("子", children.get(0).get("title"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromMap_supportsBothChildrenAndNodesKeys() {
|
||||
Map<String, Object> viaChildren = Map.of(
|
||||
"title", "父",
|
||||
"children", List.of(Map.of("title", "子1")));
|
||||
MindMapNode a = MindMapNode.fromMap(viaChildren);
|
||||
assertEquals("父", a.getTitle());
|
||||
assertEquals("子1", a.getChildren().get(0).getTitle());
|
||||
|
||||
Map<String, Object> viaNodes = Map.of(
|
||||
"title", "父",
|
||||
"nodes", List.of(Map.of("title", "子2")));
|
||||
MindMapNode b = MindMapNode.fromMap(viaNodes);
|
||||
assertEquals("子2", b.getChildren().get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromMap_convertsNumericSourceId_andDefaults() {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("title", "t");
|
||||
map.put("notes", "笔记");
|
||||
map.put("sourceType", "TASK");
|
||||
map.put("sourceId", 42L); // Number 统一转 Integer
|
||||
|
||||
MindMapNode n = MindMapNode.fromMap(map);
|
||||
assertEquals("t", n.getTitle());
|
||||
assertEquals("笔记", n.getNotes());
|
||||
assertEquals("TASK", n.getSourceType());
|
||||
assertEquals(Integer.valueOf(42), n.getSourceId());
|
||||
assertTrue(n.getChildren().isEmpty()); // 无 children/nodes key
|
||||
|
||||
map.put("sourceId", null); // 显式 null 不写入
|
||||
assertNull(MindMapNode.fromMap(map).getSourceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromMap_skipsNonMapEntriesInChildList() {
|
||||
Map<String, Object> map = Map.of(
|
||||
"title", "父",
|
||||
"children", List.of("文本", Map.of("title", "有效"), 42));
|
||||
MindMapNode n = MindMapNode.fromMap(map);
|
||||
assertEquals(1, n.getChildren().size());
|
||||
assertEquals("有效", n.getChildren().get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toMapFromMapRoundTrip_keepsFields() {
|
||||
MindMapNode child = new MindMapNode("子");
|
||||
child.setNotes("cn");
|
||||
MindMapNode root = new MindMapNode("根");
|
||||
root.setNotes("rn");
|
||||
root.setSourceType("REPORT");
|
||||
root.setSourceId(3);
|
||||
root.setChildren(new ArrayList<>(List.of(child)));
|
||||
|
||||
MindMapNode rebuilt = MindMapNode.fromMap(root.toMap());
|
||||
assertEquals("根", rebuilt.getTitle());
|
||||
assertEquals("rn", rebuilt.getNotes());
|
||||
assertEquals("REPORT", rebuilt.getSourceType());
|
||||
assertEquals(Integer.valueOf(3), rebuilt.getSourceId());
|
||||
assertEquals("子", rebuilt.getChildren().get(0).getTitle());
|
||||
assertEquals("cn", rebuilt.getChildren().get(0).getNotes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromJson_parsesValidJson_andFallsBackOnInvalid() {
|
||||
MindMapNode ok = MindMapNode.fromJson(
|
||||
"{\"title\":\"根\",\"notes\":\"n\",\"children\":[{\"title\":\"子\"}]}",
|
||||
new ObjectMapper());
|
||||
assertEquals("根", ok.getTitle());
|
||||
assertEquals("n", ok.getNotes());
|
||||
assertEquals("子", ok.getChildren().get(0).getTitle());
|
||||
|
||||
assertEquals("解析失败", MindMapNode.fromJson("not json", new ObjectMapper()).getTitle());
|
||||
assertEquals("解析失败", MindMapNode.fromJson(null, new ObjectMapper()).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void chainedSetters_returnSameInstance() {
|
||||
MindMapNode n = new MindMapNode();
|
||||
assertSame(n, n.setTitle("t"));
|
||||
assertSame(n, n.setNotes("n"));
|
||||
assertSame(n, n.setSourceType("s"));
|
||||
assertSame(n, n.setSourceId(1));
|
||||
assertSame(n, n.setChildren(new ArrayList<>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package com.guo.learningprogresstracker.utils;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MindMapTreeToolTest {
|
||||
|
||||
/** 构建带可变 children 列表的节点 */
|
||||
private static MindMapNode node(String title, MindMapNode... children) {
|
||||
MindMapNode n = new MindMapNode(title);
|
||||
List<MindMapNode> list = new ArrayList<>();
|
||||
Collections.addAll(list, children);
|
||||
n.setChildren(list);
|
||||
return n;
|
||||
}
|
||||
|
||||
private static List<String> titles(List<MindMapNode> nodes) {
|
||||
return nodes.stream().map(MindMapNode::getTitle).toList();
|
||||
}
|
||||
|
||||
// ============ 私有构造器 ============
|
||||
|
||||
@Test
|
||||
void privateConstructor_onlyForUtilityUsage() throws Exception {
|
||||
Constructor<MindMapTreeTool> ctor = MindMapTreeTool.class.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
assertNotNull(ctor.newInstance());
|
||||
}
|
||||
|
||||
// ============ parseOutline ============
|
||||
|
||||
@Test
|
||||
void parseOutline_nullBlankOrWhitespaceOnly_returnsUnnamed() {
|
||||
assertEquals("未命名", MindMapTreeTool.parseOutline(null).getTitle());
|
||||
assertEquals("未命名", MindMapTreeTool.parseOutline("").getTitle());
|
||||
assertEquals("未命名", MindMapTreeTool.parseOutline(" \n\t ").getTitle());
|
||||
// 全部是空白行时过滤后为空,同样返回"未命名"
|
||||
assertEquals("未命名", MindMapTreeTool.parseOutline(" \n\t\t\n").getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseOutline_indentAndMarkers_buildHierarchyWithBacktracking() {
|
||||
String outline = String.join("\n",
|
||||
"项目导图",
|
||||
"- 分支A",
|
||||
" * 子A1",
|
||||
" + 孙A1a",
|
||||
"- 分支B",
|
||||
"1. 分支C",
|
||||
"* 1. 组合标记");
|
||||
|
||||
MindMapNode root = MindMapTreeTool.parseOutline(outline);
|
||||
assertEquals("项目导图", root.getTitle());
|
||||
assertEquals(List.of("分支A", "分支B", "分支C", "组合标记"), titles(root.getChildren()));
|
||||
assertEquals(List.of("子A1"), titles(root.getChildren().get(0).getChildren()));
|
||||
assertEquals(List.of("孙A1a"), titles(root.getChildren().get(0).getChildren().get(0).getChildren()));
|
||||
// 深层节点被解析时均被显式挂上 children 列表
|
||||
assertNotNull(root.getChildren().get(0).getChildren().get(0).getChildren());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseOutline_tabIndent_levels() {
|
||||
String outline = String.join("\n",
|
||||
"根",
|
||||
"\t一级A",
|
||||
"\t\t二级A1",
|
||||
"\t一级B");
|
||||
MindMapNode root = MindMapTreeTool.parseOutline(outline);
|
||||
assertEquals(List.of("一级A", "一级B"), titles(root.getChildren()));
|
||||
assertEquals(List.of("二级A1"), titles(root.getChildren().get(0).getChildren()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseOutline_hashLevels_andPopBack() {
|
||||
String outline = String.join("\n",
|
||||
"根",
|
||||
"# 一级",
|
||||
"## 二级",
|
||||
"### 三级",
|
||||
"## 二级B");
|
||||
MindMapNode root = MindMapTreeTool.parseOutline(outline);
|
||||
// 实际弹栈语义:## 二级B 出栈到 level<2 时父级是一级
|
||||
assertEquals(List.of("一级"), titles(root.getChildren()));
|
||||
assertEquals(List.of("二级", "二级B"), titles(root.getChildren().get(0).getChildren()));
|
||||
assertEquals(List.of("三级"), titles(root.getChildren().get(0).getChildren().get(0).getChildren()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseOutline_stripsTrailingWhitespace_andRootStrip() {
|
||||
String outline = " 根标题 \r\n - 节点A \t \r\n";
|
||||
MindMapNode root = MindMapTreeTool.parseOutline(outline);
|
||||
assertEquals("根标题", root.getTitle());
|
||||
assertEquals(List.of("节点A"), titles(root.getChildren()));
|
||||
}
|
||||
|
||||
// ============ toOutline / toFullOutline ============
|
||||
|
||||
@Test
|
||||
void toOutline_singleLevelChildren() {
|
||||
MindMapNode root = node("Root", node("A"), node("B"));
|
||||
assertEquals(" - A\n - B\n", MindMapTreeTool.toOutline(root));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toOutline_nullTitleAndNoChildren() {
|
||||
MindMapNode leaf = new MindMapNode(); // children 为 null
|
||||
assertEquals("", MindMapTreeTool.toOutline(leaf));
|
||||
|
||||
MindMapNode root = new MindMapNode();
|
||||
root.setChildren(new ArrayList<>(List.of(new MindMapNode())));
|
||||
assertEquals(" - \n", MindMapTreeTool.toOutline(root));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toOutline_multiLevel_documentsCurrentDuplicationBehavior() {
|
||||
// 现状记录(生产缺陷,见测试报告):level 0 的二次遍历会把孙子层重复输出一次
|
||||
MindMapNode root = node("Root", node("A", node("A1")));
|
||||
assertEquals(" - A\n - A1\n - A1\n", MindMapTreeTool.toOutline(root));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toFullOutline_rootTitleThenIndentedChildren() {
|
||||
MindMapNode root = node("Root", node("A", node("A1")), node("B"));
|
||||
assertEquals("Root\n - A\n - A1\n - B\n", MindMapTreeTool.toFullOutline(root));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toFullOutline_nullTitleRoot() {
|
||||
assertEquals("\n", MindMapTreeTool.toFullOutline(new MindMapNode()));
|
||||
}
|
||||
|
||||
// ============ flatten / countNodes / maxDepth ============
|
||||
|
||||
@Test
|
||||
void flatten_countNodes_maxDepth() {
|
||||
MindMapNode leaf = new MindMapNode();
|
||||
leaf.setChildren(null); // 叶子:children 为 null
|
||||
MindMapNode empty = new MindMapNode("e"); // children 为空列表
|
||||
|
||||
MindMapNode root = node("r", node("a", node("a1")), node("b"));
|
||||
|
||||
assertEquals(1, MindMapTreeTool.maxDepth(leaf));
|
||||
assertEquals(1, MindMapTreeTool.maxDepth(empty));
|
||||
assertEquals(3, MindMapTreeTool.maxDepth(root));
|
||||
|
||||
assertEquals(List.of("r", "a", "a1", "b"), titles(MindMapTreeTool.flatten(root)));
|
||||
assertEquals(4, MindMapTreeTool.countNodes(root));
|
||||
assertEquals(1, MindMapTreeTool.countNodes(leaf));
|
||||
}
|
||||
|
||||
// ============ extractSubtree ============
|
||||
|
||||
@Test
|
||||
void extractSubtree_byFullPath_returnsDeepCopy() {
|
||||
MindMapNode a1 = node("A1");
|
||||
a1.setNotes("笔记");
|
||||
a1.setSourceType("REPORT");
|
||||
a1.setSourceId(7);
|
||||
a1.setChildren(new ArrayList<>(List.of(node("A1子"))));
|
||||
MindMapNode root = node("Root", node("A", a1, node("A2")), node("B"));
|
||||
|
||||
MindMapNode sub = MindMapTreeTool.extractSubtree(root, "Root / A / A1");
|
||||
assertNotNull(sub);
|
||||
assertEquals("A1", sub.getTitle());
|
||||
assertEquals("笔记", sub.getNotes());
|
||||
assertEquals("REPORT", sub.getSourceType());
|
||||
assertEquals(Integer.valueOf(7), sub.getSourceId());
|
||||
assertEquals(List.of("A1子"), titles(sub.getChildren()));
|
||||
assertNotSame(a1, sub);
|
||||
assertNotSame(a1.getChildren().get(0), sub.getChildren().get(0));
|
||||
// 修改副本不影响原树
|
||||
sub.getChildren().clear();
|
||||
assertEquals(1, a1.getChildren().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractSubtree_withoutRootSegment_andNormalizedMatching() {
|
||||
MindMapNode root = node("复习计划", node("Java 核心(进阶)", node("并发编程")));
|
||||
|
||||
// 大小写与标点差异通过 normalizeForMerge 匹配
|
||||
MindMapNode sub = MindMapTreeTool.extractSubtree(root, "java核心进阶 / 并发编程");
|
||||
assertNotNull(sub);
|
||||
assertEquals("并发编程", sub.getTitle());
|
||||
|
||||
// 不含根标题:第一段直接在根 children 中查找
|
||||
MindMapNode sub2 = MindMapTreeTool.extractSubtree(root, "Java 核心(进阶)");
|
||||
assertNotNull(sub2);
|
||||
assertEquals("Java 核心(进阶)", sub2.getTitle());
|
||||
|
||||
// 空路径段被跳过
|
||||
MindMapNode sub3 = MindMapTreeTool.extractSubtree(root, "复习计划 // Java 核心(进阶)");
|
||||
assertNotNull(sub3);
|
||||
assertEquals("Java 核心(进阶)", sub3.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractSubtree_nullOrMismatchedPathReturnsNull() {
|
||||
MindMapNode root = node("Root", node("A", node("A1")), new MindMapNode());
|
||||
assertNull(MindMapTreeTool.extractSubtree(null, "Root"));
|
||||
assertNull(MindMapTreeTool.extractSubtree(root, null));
|
||||
assertNull(MindMapTreeTool.extractSubtree(root, " "));
|
||||
assertNull(MindMapTreeTool.extractSubtree(root, "Root / X")); // 中途失配
|
||||
assertNull(MindMapTreeTool.extractSubtree(root, "Root / A / A1 / 更深")); // 末端失配
|
||||
}
|
||||
|
||||
// ============ findClosestNode ============
|
||||
|
||||
@Test
|
||||
void findClosestNode_titleMatch() {
|
||||
MindMapNode root = node("Root", node("Java 并发"), node("完全无关"));
|
||||
MindMapNode best = MindMapTreeTool.findClosestNode(root, "java并发");
|
||||
assertNotNull(best);
|
||||
assertEquals("Java 并发", best.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findClosestNode_notesCountWithHalfWeight() {
|
||||
MindMapNode hit = node("完全无关");
|
||||
hit.setNotes("数据库索引优化详解");
|
||||
MindMapNode root = node("Root", hit, node("另一个"));
|
||||
|
||||
MindMapNode best = MindMapTreeTool.findClosestNode(root, "数据库索引优化");
|
||||
assertNotNull(best);
|
||||
assertEquals("完全无关", best.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findClosestNode_lowScoresOrInvalidInputsReturnNull() {
|
||||
MindMapNode root = node("Root", new MindMapNode(), node(" "), node("abcd"));
|
||||
assertNull(MindMapTreeTool.findClosestNode(root, "zzzz")); // 全部低于阈值
|
||||
assertNull(MindMapTreeTool.findClosestNode(null, "x"));
|
||||
assertNull(MindMapTreeTool.findClosestNode(root, null));
|
||||
assertNull(MindMapTreeTool.findClosestNode(root, " "));
|
||||
}
|
||||
|
||||
// ============ getPath ============
|
||||
|
||||
@Test
|
||||
void getPath_hitAndMissAndNulls() {
|
||||
MindMapNode root = node("Root", node("A"), node("B", node("C")));
|
||||
assertEquals("Root / B / C", MindMapTreeTool.getPath(root, "C"));
|
||||
assertEquals("Root", MindMapTreeTool.getPath(root, "ROOT")); // normalize 后命中根
|
||||
assertEquals("", MindMapTreeTool.getPath(root, "不存在"));
|
||||
assertEquals("", MindMapTreeTool.getPath(null, "x"));
|
||||
assertEquals("", MindMapTreeTool.getPath(root, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPath_handlesNullTitlesAlongPath() {
|
||||
MindMapNode root = node("Root", new MindMapNode());
|
||||
assertEquals("Root", MindMapTreeTool.getPath(root, "Root"));
|
||||
assertEquals("", MindMapTreeTool.getPath(root, "不在树中"));
|
||||
}
|
||||
|
||||
// ============ similarityScore ============
|
||||
|
||||
@Test
|
||||
void similarityScore_variousCases() {
|
||||
assertEquals(1.0, MindMapTreeTool.similarityScore("Java 并发", "java并发"), 1e-9);
|
||||
assertEquals(0.9, MindMapTreeTool.similarityScore("java concurrency guide", "concurrency"), 1e-9);
|
||||
// bigram Jaccard:{ab,bc,cd} 与 {ab,bc,ce} 交 2 / 并 4
|
||||
assertEquals(0.5, MindMapTreeTool.similarityScore("abcd", "abce"), 1e-9);
|
||||
assertEquals(0.0, MindMapTreeTool.similarityScore("", "x"), 1e-9);
|
||||
assertEquals(0.0, MindMapTreeTool.similarityScore(null, "x"), 1e-9);
|
||||
assertEquals(0.0, MindMapTreeTool.similarityScore("x", null), 1e-9);
|
||||
assertEquals(0.0, MindMapTreeTool.similarityScore("a", "b"), 1e-9); // 双方 bigram 均为空
|
||||
assertEquals(0.9, MindMapTreeTool.similarityScore("a", "ab"), 1e-9); // 子串匹配优先于 bigram
|
||||
}
|
||||
|
||||
// ============ mergeTrees ============
|
||||
|
||||
@Test
|
||||
void mergeTrees_nullSideReturnsOther() {
|
||||
MindMapNode oldTree = node("旧");
|
||||
MindMapNode newTree = node("新");
|
||||
assertSame(newTree, MindMapTreeTool.mergeTrees(null, newTree));
|
||||
assertSame(oldTree, MindMapTreeTool.mergeTrees(oldTree, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeTrees_keepsOldTitlesAndAppendsOldOnlyNodes() {
|
||||
MindMapNode oldChild = node("任务,一");
|
||||
oldChild.setNotes("旧备注");
|
||||
oldChild.setChildren(new ArrayList<>(List.of(node("旧子"))));
|
||||
MindMapNode oldTree = node("根", oldChild, node("旧独有"));
|
||||
|
||||
MindMapNode newChild = node("任务一。");
|
||||
newChild.setChildren(new ArrayList<>(List.of(node("新子"))));
|
||||
MindMapNode newTree = node("新根", newChild, node("新增"));
|
||||
|
||||
MindMapNode merged = MindMapTreeTool.mergeTrees(oldTree, newTree);
|
||||
|
||||
assertEquals("根", merged.getTitle()); // 保留旧树根标题
|
||||
assertEquals(List.of("任务,一", "新增", "旧独有"), titles(merged.getChildren()));
|
||||
|
||||
MindMapNode kept = merged.getChildren().get(0);
|
||||
assertEquals("旧备注", kept.getNotes());
|
||||
assertEquals(List.of("新子", "旧子"), titles(kept.getChildren()));
|
||||
|
||||
assertNotSame(oldChild, kept);
|
||||
assertSame(newTree.getChildren().get(1), merged.getChildren().get(1)); // 新节点直接复用
|
||||
assertSame(oldTree.getChildren().get(1), merged.getChildren().get(2)); // 旧独有节点直接复用
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeTrees_nullChildrenLists() {
|
||||
// 旧 children 为 null:新 children 全部保留
|
||||
MindMapNode oldNoKids = new MindMapNode();
|
||||
oldNoKids.setChildren(null);
|
||||
MindMapNode newWithKids = node("新", node("k1"));
|
||||
MindMapNode merged = MindMapTreeTool.mergeTrees(oldNoKids, newWithKids);
|
||||
assertNull(merged.getTitle());
|
||||
assertEquals(List.of("k1"), titles(merged.getChildren()));
|
||||
|
||||
// 新 children 为 null:旧 children 原样保留
|
||||
MindMapNode oldWithKids = node("旧", node("k2"));
|
||||
MindMapNode newNoKids = new MindMapNode("新2");
|
||||
newNoKids.setChildren(null);
|
||||
MindMapNode merged2 = MindMapTreeTool.mergeTrees(oldWithKids, newNoKids);
|
||||
assertEquals(List.of("k2"), titles(merged2.getChildren()));
|
||||
assertSame(oldWithKids.getChildren().get(0), merged2.getChildren().get(0));
|
||||
}
|
||||
|
||||
// ============ toJson / fromJson ============
|
||||
|
||||
@Test
|
||||
void toJson_serializesNodeMapJson() {
|
||||
MindMapNode n = node("根", node("子"));
|
||||
n.setNotes("备注");
|
||||
String json = MindMapTreeTool.toJson(n, new ObjectMapper());
|
||||
assertTrue(json.contains("\"title\":\"根\""));
|
||||
assertTrue(json.contains("\"notes\":\"备注\""));
|
||||
assertTrue(json.contains("\"title\":\"子\""));
|
||||
assertTrue(json.contains("\"children\":[]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toJson_serializationFailureReturnsFallback() throws Exception {
|
||||
ObjectMapper failing = mock(ObjectMapper.class);
|
||||
when(failing.writeValueAsString(any())).thenThrow(new RuntimeException("boom"));
|
||||
assertEquals("{\"title\":\"序列化失败\"}", MindMapTreeTool.toJson(node("x"), failing));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromJson_parsesValidJson_andFallsBackOnInvalid() {
|
||||
MindMapNode ok = MindMapTreeTool.fromJson(
|
||||
"{\"title\":\"根\",\"children\":[{\"title\":\"子\",\"nodes\":[]}]}",
|
||||
new ObjectMapper());
|
||||
assertEquals("根", ok.getTitle());
|
||||
assertEquals("子", ok.getChildren().get(0).getTitle());
|
||||
|
||||
assertEquals("解析失败", MindMapTreeTool.fromJson("not json", new ObjectMapper()).getTitle());
|
||||
assertEquals("解析失败", MindMapTreeTool.fromJson(null, new ObjectMapper()).getTitle());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package com.guo.learningprogresstracker.utils;
|
||||
|
||||
import com.guo.learningprogresstracker.support.TestHttpServer;
|
||||
import com.guo.learningprogresstracker.support.TestHttpsServer;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TitleFetcherTest {
|
||||
|
||||
// ============ HTTP 组:基于本地 TestHttpServer ============
|
||||
@Nested
|
||||
class HttpGroup extends TestHttpServer {
|
||||
|
||||
@Test
|
||||
void fetchTitle_basicHtml_withQueryAndPrefixRoute() {
|
||||
route("GET", "/basic", 200, "<html><head><title>基础标题</title></head></html>");
|
||||
assertEquals("基础标题", TitleFetcher.fetchTitle(baseUrl() + "/basic?src=unit"));
|
||||
assertEquals("src=unit", lastQuery("GET", "/basic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_decodesHtmlEntities_andCollapsesWhitespace() {
|
||||
route("GET", "/entities", 200,
|
||||
"<html><title>A&B <tag> "引" 'x' A B 多 空格</title></html>");
|
||||
assertEquals("A&B <tag> \"引\" 'x' A B 多 空格",
|
||||
TitleFetcher.fetchTitle(baseUrl() + "/entities"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_whitespaceOnlyTitleReturnsNull() {
|
||||
route("GET", "/emptytitle", 200, "<html><title> </title></html>");
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/emptytitle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_gzipResponseBody() {
|
||||
route("GET", "/gz", 200, "<html><title>压缩标题</title></html>",
|
||||
Map.of("Content-Encoding-Test", "gzip"));
|
||||
assertEquals("压缩标题", TitleFetcher.fetchTitle(baseUrl() + "/gz"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_gzipHeaderButRawBody_fallsBackAndReturnsNull() {
|
||||
// 声明 gzip 但响应体未压缩:GZIPInputStream 构造抛异常 → 走 catch 回退读原始流
|
||||
route("GET", "/badgz", 200, "<html><body>not gzip</body></html>",
|
||||
Map.of("Content-Encoding", "gzip"));
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/badgz"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_deflateHeaderWithRawBody_returnsNull() {
|
||||
// InflaterInputStream 包装成功但读取原始字节时抛错 → doFetch 捕获返回 null
|
||||
route("GET", "/deflate", 200, "<html><body>not deflate</body></html>",
|
||||
Map.of("Content-Encoding", "deflate"));
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/deflate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_gbkCharsetDecoding() {
|
||||
String rawTitle = "中文标题";
|
||||
route("GET", "/gbk", 200,
|
||||
"<html><title>" + rawTitle + "</title></html>",
|
||||
Map.of("Content-Type", "text/html; charset=GBK"));
|
||||
// 服务端实际发送 UTF-8 字节,客户端按 GBK 解码:两者结果必然不同
|
||||
String expected = new String(rawTitle.getBytes(StandardCharsets.UTF_8), Charset.forName("GBK"));
|
||||
assertFalse(expected.isBlank());
|
||||
String got = TitleFetcher.fetchTitle(baseUrl() + "/gbk");
|
||||
assertNotEquals(rawTitle, got);
|
||||
assertEquals(expected, got);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_unknownCharsetFallsBackToUtf8() {
|
||||
route("GET", "/badcharset", 200, "<html><title>Ascii 标题</title></html>",
|
||||
Map.of("Content-Type", "text/html; charset=NOT-A-REAL-CHARSET"));
|
||||
assertEquals("Ascii 标题", TitleFetcher.fetchTitle(baseUrl() + "/badcharset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_textHtmlWithoutCharsetDefaultsToUtf8() {
|
||||
route("GET", "/nocharset", 200, "<html><title>无charset</title></html>",
|
||||
Map.of("Content-Type", "text/html"));
|
||||
assertEquals("无charset", TitleFetcher.fetchTitle(baseUrl() + "/nocharset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_nonTextContentTypeReturnsNull() {
|
||||
route("GET", "/pdf", 200, "%PDF-1.4 not html",
|
||||
Map.of("Content-Type", "application/pdf"));
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_textPlainWithoutTitleReturnsNull() {
|
||||
route("GET", "/txt", 200, "纯文本没有标题",
|
||||
Map.of("Content-Type", "text/plain; charset=utf-8"));
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_xhtmlAccepted() {
|
||||
route("GET", "/xhtml", 200, "<html><title>XHTML 标题</title></html>",
|
||||
Map.of("Content-Type", "application/xhtml+xml; charset=utf-8"));
|
||||
assertEquals("XHTML 标题", TitleFetcher.fetchTitle(baseUrl() + "/xhtml"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_errorStatusReturnsNull() {
|
||||
route("GET", "/err", 500, "oops");
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/err"));
|
||||
}
|
||||
|
||||
// ---- 重定向链 ----
|
||||
|
||||
@Test
|
||||
void fetchTitle_followsRedirectWithRelativeLocation() {
|
||||
route("GET", "/start", 200, "<html><body>没有标题的页面</body></html>");
|
||||
route("HEAD", "/start", 302, "", Map.of("Location", "/final"));
|
||||
route("GET", "/final", 200, "<html><title>最终标题</title></html>");
|
||||
assertEquals("最终标题", TitleFetcher.fetchTitle(baseUrl() + "/start"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_followsAbsoluteRedirectLocation() {
|
||||
route("GET", "/absc", 200, "<html><body>没有标题</body></html>");
|
||||
route("HEAD", "/absc", 301, "", Map.of("Location", baseUrl() + "/absfinal"));
|
||||
route("GET", "/absfinal", 200, "<html><title>绝对跳转</title></html>");
|
||||
assertEquals("绝对跳转", TitleFetcher.fetchTitle(baseUrl() + "/absc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_selfRedirectReturnsNull() {
|
||||
route("GET", "/loop", 200, "<html><body>loop</body></html>");
|
||||
route("HEAD", "/loop", 302, "", Map.of("Location", "/loop"));
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/loop"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_tooManyRedirectsReturnsNull() {
|
||||
for (int i = 0; i <= 5; i++) {
|
||||
route("GET", "/r" + i, 200, "<html><body>跳转" + i + "</body></html>");
|
||||
route("HEAD", "/r" + i, 302, "", Map.of("Location", "/r" + (i + 1)));
|
||||
}
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/r0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_headWithoutRedirectStatusReturnsNull() {
|
||||
route("GET", "/stay", 200, "<html><body>没有标题</body></html>");
|
||||
route("HEAD", "/stay", 200, "");
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/stay"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_blankRedirectLocationReturnsNull() {
|
||||
route("GET", "/blank", 200, "<html><body>没有标题</body></html>");
|
||||
route("HEAD", "/blank", 302, "", Map.of("Location", " "));
|
||||
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/blank"));
|
||||
}
|
||||
|
||||
// ---- 非法输入 / 连接失败 ----
|
||||
|
||||
@Test
|
||||
void fetchTitle_invalidInputsReturnNull() {
|
||||
assertNull(TitleFetcher.fetchTitle(null));
|
||||
assertNull(TitleFetcher.fetchTitle(""));
|
||||
assertNull(TitleFetcher.fetchTitle(" "));
|
||||
assertNull(TitleFetcher.fetchTitle("ftp://example.com/file"));
|
||||
assertNull(TitleFetcher.fetchTitle("example.com/page"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTitle_connectionRefusedReturnsNull() {
|
||||
assertNull(TitleFetcher.fetchTitle("http://localhost:1/"));
|
||||
}
|
||||
|
||||
// ---- embedTitles ----
|
||||
|
||||
@Test
|
||||
void embedTitles_nullBlankOrLinkOnly_returnsAsIs() {
|
||||
assertNull(TitleFetcher.embedTitles(null));
|
||||
assertEquals(" ", TitleFetcher.embedTitles(" "));
|
||||
String linkOnly = "已有 [手册](" + baseUrl() + "/keep) 说明";
|
||||
assertEquals(linkOnly, TitleFetcher.embedTitles(linkOnly));
|
||||
}
|
||||
|
||||
@Test
|
||||
void embedTitles_replacesBareUrl_andStripsTrailingPunctuation() {
|
||||
route("GET", "/doc", 200, "<html><title>接口文档</title></html>");
|
||||
String url = baseUrl() + "/doc";
|
||||
assertEquals("参见 [接口文档](" + url + ")。", TitleFetcher.embedTitles("参见 " + url + "。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void embedTitles_protectsExistingMarkdownLinks_thenRestores() {
|
||||
route("GET", "/fresh", 200, "<html><title>新标题</title></html>");
|
||||
String out = TitleFetcher.embedTitles(
|
||||
"看 [手册](" + baseUrl() + "/keep) 与 " + baseUrl() + "/fresh");
|
||||
assertEquals("看 [手册](" + baseUrl() + "/keep) 与 [新标题](" + baseUrl() + "/fresh)", out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void embedTitles_deduplicatesUrls() {
|
||||
route("GET", "/dup", 200, "<html><title>重复标题</title></html>");
|
||||
String url = baseUrl() + "/dup";
|
||||
assertEquals("[重复标题](" + url + ") 和 [重复标题](" + url + ")",
|
||||
TitleFetcher.embedTitles(url + " 和 " + url));
|
||||
}
|
||||
|
||||
@Test
|
||||
void embedTitles_failedFetchKeepsBareUrl() {
|
||||
String text = "文档在 http://localhost:1/missing 页面";
|
||||
assertEquals(text, TitleFetcher.embedTitles(text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void embedTitles_multipleUrlsAllReplaced() {
|
||||
route("GET", "/a", 200, "<html><title>甲</title></html>");
|
||||
route("GET", "/b", 200, "<html><title>乙</title></html>");
|
||||
String out = TitleFetcher.embedTitles(baseUrl() + "/a 中间 " + baseUrl() + "/b");
|
||||
assertEquals("[甲](" + baseUrl() + "/a) 中间 [乙](" + baseUrl() + "/b)", out);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ HTTPS 组:自签名证书触发信任所有证书路径 ============
|
||||
@Nested
|
||||
class HttpsGroup extends TestHttpsServer {
|
||||
|
||||
@Test
|
||||
void fetchTitle_selfSignedCertificateTrusted() {
|
||||
html("/secure", "加密标题");
|
||||
assertEquals("加密标题", TitleFetcher.fetchTitle(baseUrl() + "/secure"));
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 静态块与内部 TrustManager ============
|
||||
|
||||
@Test
|
||||
void trustAllManager_isInstalledAndAcceptsEverything() throws Exception {
|
||||
// 静态块应成功安装宽松 SSL(sslRelaxed = true 分支)
|
||||
Field relaxed = TitleFetcher.class.getDeclaredField("sslRelaxed");
|
||||
relaxed.setAccessible(true);
|
||||
assertTrue(relaxed.getBoolean(null), "静态块应成功安装信任所有证书的 SSLContext");
|
||||
|
||||
// 握手之外直接调用三个回调,确保内部类全部行被执行
|
||||
X509TrustManager tm = newTrustAllManagerInstance();
|
||||
assertArrayEquals(new X509Certificate[0], tm.getAcceptedIssuers());
|
||||
assertDoesNotThrow(() -> tm.checkClientTrusted(null, "RSA"));
|
||||
assertDoesNotThrow(() -> tm.checkServerTrusted(null, "RSA"));
|
||||
}
|
||||
|
||||
private static X509TrustManager newTrustAllManagerInstance() throws Exception {
|
||||
try {
|
||||
for (Class<?> c : TitleFetcher.class.getDeclaredClasses()) {
|
||||
if (X509TrustManager.class.isAssignableFrom(c)) {
|
||||
return (X509TrustManager) newInstanceAllowingAll(c);
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
// 个别实现不通过 getDeclaredClasses 暴露匿名类,退回按名称查找
|
||||
}
|
||||
Class<?> anon = Class.forName(TitleFetcher.class.getName() + "$1");
|
||||
assertTrue(X509TrustManager.class.isAssignableFrom(anon), "TitleFetcher$1 应为 X509TrustManager");
|
||||
return (X509TrustManager) newInstanceAllowingAll(anon);
|
||||
}
|
||||
|
||||
private static Object newInstanceAllowingAll(Class<?> type) throws Exception {
|
||||
for (Constructor<?> ctor : type.getDeclaredConstructors()) {
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(new Object[ctor.getParameterCount()]);
|
||||
}
|
||||
throw new IllegalStateException("无可访问构造器: " + type.getName());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
||||
|
||||
# 测试环境不激活 profile,而 src/test/resources/application.yml 会遮蔽 src/main/resources/application.yml,
|
||||
# 因此这里必须显式声明 cors 配置,否则 WebMvcConfig 会因 allowedOrigins 为 null 抛 NPE
|
||||
cors:
|
||||
allowCredentials: true
|
||||
allowed-origins:
|
||||
- '*'
|
||||
|
||||
sa-token:
|
||||
token-name: satoken
|
||||
timeout: 2592000
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user