R1 先行批:question-center 后端(SB 2.7.18,23 单测全绿)+ 建表脚本 + 仓库说明
build / build-push (push) Successful in 51s

This commit is contained in:
chenxing
2026-10-22 10:13:00 +08:00
parent 380df08271
commit 907063a0b6
30 changed files with 2693 additions and 2 deletions
@@ -0,0 +1,138 @@
package com.yunhai.question.fc;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.exception.DException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* FormCenterClient 单元测试(mock YschOpenCall):
* ① 业务拒绝(DException=FC 信封 code!=0)不重试直接转 Reject;
* ② 网络/信封异常重试 2 次后转 Unavailable;③ answers 分页解析(rows/nextCursor/after 透传)。
*/
class FormCenterClientTest {
private YschOpenCall openCall;
private FormCenterClient client;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
client = new FormCenterClient(openCall, new ObjectMapper());
}
@Test
void fetchSchemaReturnsData() {
Map<String, Object> data = new HashMap<>();
data.put("formUuid", "U1");
data.put("version", 2);
data.put("groups", Arrays.asList(
group("base", field("q1", "你的性别", "radio",
option("男", "m"), option("女", "f")))));
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
Map<String, Object> got = client.fetchSchema(27L, "U1");
assertEquals("U1", got.get("formUuid"));
assertEquals(1, client.parseFields(got).size());
assertEquals("radio", client.parseFields(got).get(0).get("type"));
}
@Test
void fcBusinessRejectIsNotRetried() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new DException("表单尚未发布:q-001"));
FormCenterClient.FormCenterRejectException e = assertThrows(
FormCenterClient.FormCenterRejectException.class,
() -> client.fetchSchema(27L, "U1"));
assertTrue(e.getMessage().contains("表单尚未发布"));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@Test
void networkFailureRetriesTwiceThenUnavailable() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new RuntimeException("connect refused"));
assertThrows(FormCenterClient.FormCenterUnavailableException.class,
() -> client.fetchSchema(27L, "U1"));
verify(openCall, times(FormCenterClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@Test
@SuppressWarnings("unchecked")
void pullAnswersParsesRowsAndCursor() {
Map<String, Object> page = new HashMap<>();
page.put("rows", Arrays.asList(row("a1"), row("a2")));
page.put("nextCursor", "a2");
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(page);
FormCenterClient.AnswersPage got = client.pullAnswers(27L, "U1", null, 500);
assertEquals(2, got.getRows().size());
assertEquals("a2", got.getNextCursor());
assertEquals("a1", got.getRows().get(0).get("answer_uuid"));
}
@Test
void pullAnswersEndOfWindowHasNullCursor() {
Map<String, Object> page = new HashMap<>();
page.put("rows", Arrays.asList());
page.put("nextCursor", null);
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(page);
FormCenterClient.AnswersPage got = client.pullAnswers(27L, "U1", "a2", 500);
assertEquals(null, got.getNextCursor());
assertTrue(got.getRows().isEmpty());
}
private static Map<String, Object> row(String uuid) {
Map<String, Object> r = new HashMap<>();
r.put("answer_uuid", uuid);
r.put("is_deleted", 0);
return r;
}
static Map<String, Object> group(String key, Map<String, Object>... fields) {
Map<String, Object> g = new HashMap<>();
g.put("key", key);
g.put("fields", Arrays.asList(fields));
return g;
}
static Map<String, Object> field(String key, String label, String type, Map<String, Object>... options) {
Map<String, Object> f = new HashMap<>();
f.put("key", key);
f.put("label", label);
f.put("type", type);
f.put("options", Arrays.asList(options));
return f;
}
static Map<String, Object> option(String label, String value) {
Map<String, Object> o = new HashMap<>();
o.put("label", label);
o.put("value", value);
return o;
}
}
@@ -0,0 +1,208 @@
package com.yunhai.question.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.question.entity.PullCursor;
import com.yunhai.question.entity.Questionnaire;
import com.yunhai.question.fc.FormCenterClient;
import com.yunhai.question.repository.AnswerProjectionRepository;
import com.yunhai.question.repository.PullCursorRepository;
import com.yunhai.question.repository.QuestionnaireRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 投影服务单测(H2 真库 + mock FormCenterClient)——任务书点名的四件之二/三:
* 游标推进、投影幂等 upsert(重放同窗口不产生重复行、不重复累计 seen_count)、墓碑翻位。
*/
@DataJpaTest
class ProjectionServiceTest {
private static final Long SCH_ID = 990001L;
private static final String FORM_UUID = "f0123456789abcdef0123456789abcde";
@Autowired
private QuestionnaireRepository questionnaireRepository;
@Autowired
private PullCursorRepository pullCursorRepository;
@Autowired
private AnswerProjectionRepository answerProjectionRepository;
@Autowired
private PlatformTransactionManager txManager;
private FormCenterClient fc;
private ProjectionService service;
@BeforeEach
void setUp() {
fc = mock(FormCenterClient.class);
service = new ProjectionService(questionnaireRepository, pullCursorRepository,
answerProjectionRepository, fc, new ObjectMapper(),
new TransactionTemplate(txManager), 10, 100);
}
private Questionnaire questionnaire(String formUuid) {
Questionnaire q = new Questionnaire();
q.setSchId(SCH_ID);
q.setFormUuid(formUuid);
q.setFormCode("q-001");
q.setTitleSnapshot("测试问卷");
q.setStatus(Questionnaire.STATUS_COLLECTING);
q.setAnonymous(false);
return questionnaireRepository.save(q);
}
/** FC 行(snake_case 闭集) */
private static Map<String, Object> fcRow(String uuid, boolean deleted, Object... valuePairs) {
Map<String, Object> values = new LinkedHashMap<>();
for (int i = 0; i + 1 < valuePairs.length; i += 2) {
values.put(String.valueOf(valuePairs[i]), valuePairs[i + 1]);
}
Map<String, Object> row = new HashMap<>();
row.put("answer_uuid", uuid);
row.put("form_uuid", FORM_UUID);
row.put("submitter_id", 101);
row.put("subject_id", null);
row.put("status", "待处理");
row.put("values", values);
row.put("create_time", "2026-10-21 10:00:00");
row.put("update_time", "2026-10-21 10:00:00");
row.put("is_deleted", deleted ? 1 : 0);
return row;
}
/** after=afterArg 的页(rows 可空) */
private void stubPage(String afterArg, String nextCursor, Map<String, Object>... rows) {
when(fc.pullAnswers(any(), anyString(), eq(afterArg), anyInt()))
.thenReturn(new FormCenterClient.AnswersPage(Arrays.asList(rows), nextCursor));
}
@Test
void advancesCursorAcrossPages() {
questionnaire(FORM_UUID);
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
stubPage("a1", "a2", fcRow("a2", false, "q1", "f"));
stubPage("a2", null);
service.pullOnce(SCH_ID, FORM_UUID);
assertEquals("a2", pullCursorRepository.findById(FORM_UUID).get().getCursor());
assertEquals(2, answerProjectionRepository.countByFormUuid(FORM_UUID));
// 第二轮:游标从 a2 起,只拉增量
stubPage("a2", "a3", fcRow("a3", false, "q1", "m"));
stubPage("a3", null);
int fresh = service.pullOnce(SCH_ID, FORM_UUID);
assertEquals(1, fresh);
assertEquals("a3", pullCursorRepository.findById(FORM_UUID).get().getCursor());
assertEquals(3, answerProjectionRepository.countByFormUuid(FORM_UUID));
// 增量轮从 a2 起——验证游标确已推进(a2 之前的窗口未再请求)
verify(fc, times(1)).pullAnswers(any(), anyString(), eq((String) null), anyInt());
verify(fc, times(1)).pullAnswers(any(), anyString(), eq("a1"), anyInt());
verify(fc, times(2)).pullAnswers(any(), anyString(), eq("a2"), anyInt());
}
@Test
void replaySameWindowIsIdempotent() {
questionnaire(FORM_UUID);
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
stubPage("a1", null);
service.pullOnce(SCH_ID, FORM_UUID);
// 模拟游标回拨(运维/异常后重放):从头再拉同窗口 + 新行 a2——a1 重放不算新行
PullCursor rollback = pullCursorRepository.findById(FORM_UUID).orElseThrow(AssertionError::new);
rollback.setCursor(null);
pullCursorRepository.saveAndFlush(rollback);
stubPage(null, "a2", fcRow("a1", false, "q1", "m"), fcRow("a2", false, "q1", "f"));
stubPage("a2", null);
int fresh = service.pullOnce(SCH_ID, FORM_UUID);
assertEquals(1, fresh);
assertEquals(2, answerProjectionRepository.countByFormUuid(FORM_UUID));
PullCursor cursor = pullCursorRepository.findById(FORM_UUID).get();
assertEquals(2L, cursor.getSeenCount(), "重放行不重复累计 seen_count");
assertEquals("a2", cursor.getCursor());
}
@Test
void tombstoneFlipsIsDeletedWithoutDroppingRow() {
questionnaire(FORM_UUID);
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
stubPage("a1", null);
service.pullOnce(SCH_ID, FORM_UUID);
assertEquals(1, answerProjectionRepository.countByFormUuidAndIsDeletedFalse(FORM_UUID));
// FC 侧软删 → 墓碑行:同一 answer_uuid,is_deleted=1
stubPage("a1", null, fcRow("a1", true, "q1", "m"));
service.pullOnce(SCH_ID, FORM_UUID);
assertEquals(0, answerProjectionRepository.countByFormUuidAndIsDeletedFalse(FORM_UUID));
assertEquals(1, answerProjectionRepository.countByFormUuid(FORM_UUID), "墓碑不删行");
assertTrue(answerProjectionRepository.findByAnswerUuid("a1").getIsDeleted());
assertEquals(1L, pullCursorRepository.findById(FORM_UUID).get().getSeenCount(),
"墓碑是已见行的翻位,不计新行");
}
@Test
void updatedRowKeepsSingleRow() {
questionnaire(FORM_UUID);
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
stubPage("a1", null);
service.pullOnce(SCH_ID, FORM_UUID);
// 状态被 FC 侧更新后重放:同 uuid 更新而非插入
Map<String, Object> updated = fcRow("a1", false, "q1", "f");
updated.put("status", "已处理");
stubPage("a1", null, updated);
service.pullOnce(SCH_ID, FORM_UUID);
assertEquals(1, answerProjectionRepository.countByFormUuid(FORM_UUID));
assertEquals("已处理", answerProjectionRepository.findByAnswerUuid("a1").getStatus());
assertEquals(1L, pullCursorRepository.findById(FORM_UUID).get().getSeenCount());
}
@Test
void firstPullWithEmptyWindowInitializesCursor() {
questionnaire(FORM_UUID);
stubPage(null, null);
int fresh = service.pullOnce(SCH_ID, FORM_UUID);
assertEquals(0, fresh);
PullCursor cursor = pullCursorRepository.findById(FORM_UUID).get();
assertNull(cursor.getCursor(), "空窗口不推进游标(null=从未有行)");
assertEquals("OK", cursor.getLastStatus());
}
@Test
void reconciliationWarnsOnSeenCountDrift() {
questionnaire(FORM_UUID);
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
stubPage("a1", null);
service.pullOnce(SCH_ID, FORM_UUID);
// 模拟投影表被外部删行:seen_count=1 但投影总量=0 → 漂移告警路径(不抛错)
answerProjectionRepository.deleteAll();
service.checkReconciliation(FORM_UUID, pullCursorRepository.findById(FORM_UUID).get());
assertEquals(0, answerProjectionRepository.countByFormUuid(FORM_UUID));
}
}
@@ -0,0 +1,82 @@
package com.yunhai.question.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.question.entity.Questionnaire;
import com.yunhai.question.fc.FormCenterClient;
import com.yunhai.question.repository.QuestionnaireRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.Arrays;
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.mock;
/**
* 绑定登记幂等单测(H2 真库——uk form_uuid 是数据库级约束,mock 测不出真实行为):
* 重复提交第三步不产生重复行;发布被 FC 拒(未发布)时拦截。
*/
@DataJpaTest
class QuestionnaireServiceTest {
private static final Long SCH_ID = 990001L;
@Autowired
private QuestionnaireRepository questionnaireRepository;
private FormCenterClient fc;
private QuestionnaireService service;
@BeforeEach
void setUp() {
fc = mock(FormCenterClient.class);
service = new QuestionnaireService(questionnaireRepository, fc, new ObjectMapper());
}
@Test
void registerIsIdempotentOnFormUuid() {
Questionnaire first = service.register(SCH_ID,
"u0123456789abcdef0123456789abcde", "q-001", "食堂满意度调查", false, null);
Questionnaire second = service.register(SCH_ID,
"u0123456789abcdef0123456789abcde", "q-001", "食堂满意度调查", false, null);
assertSame(first, second, "重复登记返回既有行");
assertEquals(1, questionnaireRepository.count());
assertEquals(Questionnaire.STATUS_DRAFT, second.getStatus());
}
@Test
void registerKeepsSchoolsIsolated() {
service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "本校问卷", false, null);
service.register(990002L, "u1123456789abcdef0123456789abcde", "q-002", "他校问卷", false, null);
List<Questionnaire> mine = service.list(SCH_ID);
assertEquals(1, mine.size());
assertEquals("u0123456789abcdef0123456789abcde", mine.get(0).getFormUuid());
}
@Test
void myFillableIntersectsByFormCode() {
Questionnaire draft1 = service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "问卷一", false, null);
Questionnaire draft2 = service.register(SCH_ID, "u1123456789abcdef0123456789abcde", "q-002", "问卷二", false, null);
draft1.setStatus(Questionnaire.STATUS_COLLECTING);
draft2.setStatus(Questionnaire.STATUS_COLLECTING);
questionnaireRepository.save(draft1);
questionnaireRepository.save(draft2);
// FC fillable 只回了 q-002/q-999:交集后仅问卷二可见
List<Questionnaire> got = service.myFillable(SCH_ID, Arrays.asList("q-002", "q-999"));
assertEquals(1, got.size());
assertEquals("q-002", got.get(0).getFormCode());
}
@Test
void myFillableWithNoCodesReturnsEmpty() {
service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "问卷一", false, null);
assertEquals(0, service.myFillable(SCH_ID, java.util.Collections.<String>emptyList()).size());
}
}
@@ -0,0 +1,52 @@
package com.yunhai.question.service;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.question.common.BizException;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** 校管理员守卫单测:超管(type=1)/校领导(2)/管理员(7) 放行,普通教师/学生/未登录拒绝 */
class SchoolAdminGuardTest {
private final SchoolAdminGuard guard = new SchoolAdminGuard();
private BoaUser user(Integer id, Integer type, Integer schId, Integer... roleIds) {
BoaUser user = mock(BoaUser.class, RETURNS_DEEP_STUBS);
when(user.getId()).thenReturn(id);
when(user.getType()).thenReturn(type);
when(user.getRoleIds()).thenReturn(roleIds.length == 0 ? null
: new java.util.ArrayList<>(Arrays.asList(roleIds)));
when(user.getCurrentRole().getSchId()).thenReturn(schId);
return user;
}
@Test
void superAdminPasses() {
assertEquals(990001L, guard.requireSchool(user(1, 1, 990001)));
}
@Test
void schoolLeaderAndAdminRolePass() {
assertEquals(990001L, guard.requireSchool(user(9, 2, 990001, 2)));
assertEquals(990001L, guard.requireSchool(user(10, 2, 990001, 1, 7)));
}
@Test
void plainTeacherAndStudentRejected() {
assertThrows(BizException.class, () -> guard.requireSchool(user(11, 2, 990001, 1)));
assertThrows(BizException.class, () -> guard.requireSchool(user(12, 2, 990001, 3)));
}
@Test
void anonymousAndNoSchoolContextRejected() {
assertThrows(BizException.class, () -> guard.requireSchool(user(null, null, null)));
assertThrows(BizException.class, () -> guard.requireSchool(user(13, 1, null, 2)));
}
}
@@ -0,0 +1,175 @@
package com.yunhai.question.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.question.entity.AnswerProjection;
import com.yunhai.question.entity.Questionnaire;
import com.yunhai.question.fc.FormCenterClient;
import com.yunhai.question.repository.AnswerProjectionRepository;
import com.yunhai.question.repository.QuestionnaireRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
/**
* 基础统计单测(H2 真库)——任务书点名的"人数口径统计":
* 一份答卷对一题的一个选项最多计 1,多选按答卷去重后逐选项计数(与选次口径可区分)。
*/
@DataJpaTest
class StatsServiceTest {
private static final String FORM_UUID = "s0123456789abcdef0123456789abcde";
@Autowired
private QuestionnaireRepository questionnaireRepository;
@Autowired
private AnswerProjectionRepository answerProjectionRepository;
private StatsService statsService;
@BeforeEach
void setUp() {
QuestionnaireService questionnaireService = new QuestionnaireService(
questionnaireRepository, mock(FormCenterClient.class), new ObjectMapper());
statsService = new StatsService(answerProjectionRepository, questionnaireService, new ObjectMapper());
}
private Questionnaire published() {
Questionnaire q = new Questionnaire();
q.setSchId(990001L);
q.setFormUuid(FORM_UUID);
q.setFormCode("q-001");
q.setTitleSnapshot("统计测试卷");
q.setStatus(Questionnaire.STATUS_COLLECTING);
q.setAnonymous(false);
q.setFormVersion(3);
// schema 快照:单选 q1(radio) + 多选 q2(checkbox) + 文本 q3(text)
q.setSchemaSnapshot("{\"formUuid\":\"" + FORM_UUID + "\",\"version\":3,\"groups\":[{\"key\":\"g1\",\"fields\":["
+ "{\"key\":\"q1\",\"label\":\"年级\",\"type\":\"radio\",\"required\":true,\"options\":["
+ "{\"label\":\"高一\",\"value\":\"g1v1\"},{\"label\":\"高二\",\"value\":\"g1v2\"}]},"
+ "{\"key\":\"q2\",\"label\":\"喜欢的社团(多选)\",\"type\":\"checkbox\",\"options\":["
+ "{\"label\":\"篮球\",\"value\":\"shetuan_basketball\"},{\"label\":\"合唱\",\"value\":\"shetuan_chorus\"},{\"label\":\"编程\",\"value\":\"shetuan_coding\"}]},"
+ "{\"key\":\"q3\",\"label\":\"建议\",\"type\":\"textarea\"}"
+ "]}]}");
return questionnaireRepository.save(q);
}
private void answer(String uuid, Object q1, Object q2, String q3) {
AnswerProjection p = new AnswerProjection();
p.setAnswerUuid(uuid);
p.setFormUuid(FORM_UUID);
Map<String, Object> values = new HashMap<>();
values.put("q1", q1);
values.put("q2", q2);
values.put("q3", q3);
try {
p.setValuesJson(new ObjectMapper().writeValueAsString(values));
} catch (Exception e) {
throw new IllegalStateException(e);
}
p.setSubmitterId(101);
p.setStatus("待处理");
p.setIsDeleted(false);
p.setFcCreateTime("2026-10-21 10:00:00");
answerProjectionRepository.save(p);
}
@Test
@SuppressWarnings("unchecked")
void multiSelectCountsDistinctRespondentsPerOption() {
published();
// 卷A:q2 选 [篮球,合唱];卷B:q2 选 [合唱];卷C:q2 未答
answer("a1", "g1v1", Arrays.asList("shetuan_basketball", "shetuan_chorus"), "很好");
answer("a2", "g1v2", Arrays.asList("shetuan_chorus"), null);
answer("a3", "g1v1", null, null);
Map<String, Object> stats = statsService.stats(questionnaireRepository
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
assertEquals(3L, stats.get("total"));
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
Map<String, Object> q2 = byField.stream()
.filter(f -> "q2".equals(f.get("key"))).findFirst().get();
// 人数口径:篮球=1 人、合唱=2 人、编程=0 人;answeredSheets=2(a3 未答不计分母)
assertEquals(2L, q2.get("answeredSheets"));
Map<String, Long> counts = new HashMap<>();
for (Map<String, Object> item : (List<Map<String, Object>>) q2.get("items")) {
counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue());
}
assertEquals(1L, counts.get("shetuan_basketball"), "篮球:仅卷A计 1 人");
assertEquals(2L, counts.get("shetuan_chorus"), "合唱:卷A+卷B 各计 1 人(人数口径,非选次——选次也是 2)");
assertEquals(0L, counts.get("shetuan_coding"));
}
@Test
@SuppressWarnings("unchecked")
void multiSelectSameOptionTwiceInOneSheetCountsOnce() {
published();
// 一份答卷的 q2 里同选项出现两次(异常数据防御):人数口径仍计 1
answer("a1", "g1v1", Arrays.asList("shetuan_basketball", "shetuan_basketball"), null);
Map<String, Object> stats = statsService.stats(questionnaireRepository
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
Map<String, Object> q2 = byField.stream()
.filter(f -> "q2".equals(f.get("key"))).findFirst().get();
Map<String, Long> counts = new HashMap<>();
for (Map<String, Object> item : (List<Map<String, Object>>) q2.get("items")) {
counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue());
}
assertEquals(1L, counts.get("shetuan_basketball"), "同卷同选项去重后计 1");
}
@Test
@SuppressWarnings("unchecked")
void radioCountsByScalarAndTextDetailListed() {
published();
answer("a1", "g1v1", null, "建议一");
answer("a2", "g1v2", null, "建议二");
Map<String, Object> stats = statsService.stats(questionnaireRepository
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
Map<String, Object> q1 = byField.stream()
.filter(f -> "q1".equals(f.get("key"))).findFirst().get();
Map<String, Long> counts = new HashMap<>();
for (Map<String, Object> item : (List<Map<String, Object>>) q1.get("items")) {
counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue());
}
assertEquals(1L, counts.get("g1v1"));
assertEquals(1L, counts.get("g1v2"));
List<Map<String, Object>> textDetail = (List<Map<String, Object>>) stats.get("textDetail");
Map<String, Object> q3 = textDetail.stream()
.filter(f -> "q3".equals(f.get("key"))).findFirst().get();
assertEquals(Arrays.asList("建议一", "建议二"), q3.get("items"));
}
@Test
@SuppressWarnings("unchecked")
void tombstoneRowsAreExcluded() {
published();
answer("a1", "g1v1", null, null);
AnswerProjection tombstone = new AnswerProjection();
tombstone.setAnswerUuid("a2");
tombstone.setFormUuid(FORM_UUID);
tombstone.setValuesJson("{\"q1\":\"g1v2\"}");
tombstone.setSubmitterId(102);
tombstone.setIsDeleted(true);
answerProjectionRepository.save(tombstone);
Map<String, Object> stats = statsService.stats(questionnaireRepository
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
assertEquals(1L, stats.get("total"), "墓碑行不进统计");
}
}