Compare commits
3
Commits
11a7c8aaea
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16149e83ee | ||
|
|
5e0b922b3f | ||
|
|
3e4c0645ad |
@@ -0,0 +1,2 @@
|
|||||||
|
# 让 Lombok 生成的方法带 @Generated 注解,JaCoCo 自动排除生成代码,覆盖率只统计手写逻辑
|
||||||
|
lombok.addLombokGeneratedAnnotation = true
|
||||||
@@ -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) {
|
public static Double calculatedPriority(PriorityDto priorityDto) {
|
||||||
return calculatedPriority(priorityDto, UserPriorityWeightsEntity.defaults());
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+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:
|
configuration:
|
||||||
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
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:
|
sa-token:
|
||||||
token-name: satoken
|
token-name: satoken
|
||||||
timeout: 2592000
|
timeout: 2592000
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user