两条题共用 ID,列表可能串卡片;选项写了两次 B,内容与判题键不再对应;答案填成 D,题目却只有前三个选项,用户怎么选都不会正确。界面异常的根因,可能早在题库入口就存在。

题库入口完整性校验封面

灯光模拟工程 The_kemusan 已有多题源合并与内容去重。本文补充一份只读校验报告,在练习开始前判断题库能否使用,并指出需要修改的字段。

本文实现 ID、选项、答案校验和练习入口报告。示例针对工程内置 QuestionItem[],不代替外部 JSON 解析。新增代码尚未写入原工程,没有构建或真机结论。

一、内容去重与结构校验解决不同问题

当前 entry/src/main/ets/models/QuestionBank.etshasDuplicateQuestion(),比较的是科目、分类和题干:

function hasDuplicateQuestion(target: QuestionItem[], question: QuestionItem): boolean {
  for (let index = 0; index < target.length; index++) {
    const current = target[index];
    if (current.subject === question.subject &&
      current.category === question.category &&
      current.title === question.title) {
      return true;
    }
  }
  return false;
}

这段源码可以避免同一科目、同一分类中完全相同的题干重复加入,但它没有承诺 ID 唯一,也没有检查选项。假设两条题的标题不同、ID 相同,内容去重会保留它们;反过来,标题相同却答案不同的两条题,后加入的题会被跳过,潜在冲突可能因此被隐藏。

约束当前合并逻辑的作用本文入口校验的作用
同科目、分类、题干重复跳过后加入的数据不重复承担内容去重
全部题目 ID 唯一不是比较条件必须验证,重复即阻止启动
每道题选项键唯一未在这里检查必须验证,报告具体选项位置
答案属于当前选项未在这里检查必须验证,禁止无法答对的题
知识结论正确无法证明仍需内容来源和人工复核

生产题库时应在去重前观察来源冲突,准备练习时校验最终数据。本文实现后者;最终数组有效,不能证明被去重的上游数据没有冲突。

合并题库到练习放行的校验流程

二、先固定报告协议,避免页面拼接错误字符串

校验应汇总全部问题及其位置,而非遇到首错就返回false。以下类型放在建议新增的 models/QuestionValidation.ets

export interface QuestionIssue {
  code: string;
  index: number;
  questionId: string;
  field: string;
  message: string;
}

export interface QuestionValidationReport {
  total: number;
  invalidCount: number;
  issueCount: number;
  issues: QuestionIssue[];
  canStart: boolean;
}

index 保留输入数组中的零基位置,即使 ID 为空也能定位。invalidCount 表示至少有一个问题的题目数,issueCount 表示问题总数,两者不能混为一谈。一道题同时有重复选项和答案不存在,会产生两项问题,但只增加一次无效题计数。

程序按 code 分类,页面展示消息;canStart 要求题库非空且问题数为零。可以限制报告展示条数,但必须完成全部校验。

三、用一次遍历建立 ID 索引,重复项必须指向首次出现位置

建议新增 services/QuestionBankValidator.ets。下面是完整校验器的主体;第四、五节给出它依赖的函数,放在同一个文件即可。模型继续复用工程已有的 QuestionItem 和科目、题型常量。

import {
  QuestionItem, SUBJECT_ONE, SUBJECT_TWO, SUBJECT_FOUR,
  TYPE_CHOICE, TYPE_JUDGE, TYPE_CHECK
} from '../models/DrivingLightModels';
import {
  QuestionIssue, QuestionValidationReport
} from '../models/QuestionValidation';

function addIssue(issues: QuestionIssue[], code: string, index: number,
  question: QuestionItem, field: string, message: string): void {
  const issue: QuestionIssue = {
    code, index, questionId: question.id, field, message
  };
  issues.push(issue);
}

export function validateQuestionBank(questions: QuestionItem[]): QuestionValidationReport {
  const issues: QuestionIssue[] = [];
  const idFirstIndex: Map<string, number> = new Map<string, number>();
  let invalidCount: number = 0;

  for (let index = 0; index < questions.length; index++) {
    const question: QuestionItem = questions[index];
    const countBefore: number = issues.length;
    if (question.id.trim().length === 0) {
      addIssue(issues, 'ID_EMPTY', index, question, 'id', '题目ID不能为空');
    } else if (question.id !== question.id.trim()) {
      addIssue(issues, 'ID_SPACE', index, question, 'id', '题目ID不能带首尾空格');
    }

    if (question.id.trim().length > 0) {
      const first: number | undefined = idFirstIndex.get(question.id);
      if (first !== undefined) {
        addIssue(issues, 'ID_DUPLICATE', index, question, 'id',
          `题目ID重复,首次出现在第${first + 1}题`);
      } else {
        idFirstIndex.set(question.id, index);
      }
    }

    validateQuestionFields(question, index, issues);
    validateQuestionOptions(question, index, issues);
    if (issues.length > countBefore) {
      invalidCount++;
    }
  }

  const report: QuestionValidationReport = {
    total: questions.length,
    invalidCount,
    issueCount: issues.length,
    issues,
    canStart: questions.length > 0 && issues.length === 0
  };
  return report;
}

Map 保留首次出现位置。必须判断 first !== undefined;第一题索引为0,不能用真值判断。

这里不会自动给 ID 添加后缀,也不把带空格的 ID 悄悄裁剪后写回。工程的历史记录使用 wrongQuestionId 关联题目;校验器擅自改 ID,可能让旧错题失去关联。报告只负责指出问题,真正修复应发生在题源,必要时同时制定历史映射策略。

空数组的报告是 total=0issueCount=0canStart=false,这没有矛盾:没有逐题错误,不等于具备可以练习的题目。入口展示应把“空题库”和“存在坏题”分开。

四、字段规则必须匹配当前业务,不把任意字符串都当作有效值

源码的科目和题型都是string,拼错仍能通过类型检查,因此还需业务白名单。

function validateQuestionFields(question: QuestionItem, index: number,
  issues: QuestionIssue[]): void {
  const subjects: string[] = [SUBJECT_ONE, SUBJECT_TWO, SUBJECT_FOUR];
  const types: string[] = [TYPE_CHOICE, TYPE_JUDGE, TYPE_CHECK];
  if (subjects.indexOf(question.subject) < 0) {
    addIssue(issues, 'SUBJECT_UNKNOWN', index, question, 'subject',
      '理论题库只接受已登记的科一、科二、科四科目');
  }
  if (types.indexOf(question.type) < 0) {
    addIssue(issues, 'TYPE_UNKNOWN', index, question, 'type', '题型未登记');
  }
  if (question.title.trim().length === 0) {
    addIssue(issues, 'TITLE_EMPTY', index, question, 'title', '题干不能为空');
  }
  if (question.explanation.trim().length === 0) {
    addIssue(issues, 'EXPLANATION_EMPTY', index, question, 'explanation',
      '练习题需要提供答案解析');
  }
  if (question.category.trim().length === 0) {
    addIssue(issues, 'CATEGORY_EMPTY', index, question, 'category',
      '题目分类不能为空');
  }
}

这些约束属于当前理论题业务。科三使用其他指令模型,不进入本校验器;新增科目时同步更新白名单、入口、筛选和用例。

TYPE_CHECK 在当前工程中也使用单个 answer: string 进行选项比较。不能看到名字带 check 就假设支持多选。如果业务未来加入多选,必须更新模型为答案集合,说明顺序、重复项与部分得分规则,再另写对应校验分支。直接接受 A,C 会把现有单答案判题链路变成隐蔽故障。

五、选项键、文本和答案必须形成一对一关系

选项的关键约束不是“看起来有四行”。题内每个键只能出现一次;每个选项应有可阅读文本;答案必须精确对应当前键。下面函数继续放在同一个校验器文件中。

function validateQuestionOptions(question: QuestionItem, index: number,
  issues: QuestionIssue[]): void {
  const keys: Set<string> = new Set<string>();
  const allowedKeys: string[] = ['A', 'B', 'C', 'D'];

  if (question.options.length < 2 || question.options.length > 4) {
    addIssue(issues, 'OPTION_COUNT', index, question, 'options',
      '当前练习页面要求每题有2至4个选项');
  }
  if (question.type === TYPE_JUDGE && question.options.length !== 2) {
    addIssue(issues, 'JUDGE_OPTION_COUNT', index, question, 'options',
      '判断题必须有两个选项');
  }

  for (let optionIndex = 0; optionIndex < question.options.length; optionIndex++) {
    const option = question.options[optionIndex];
    const field: string = `options[${optionIndex}]`;
    if (allowedKeys.indexOf(option.key) < 0) {
      addIssue(issues, 'OPTION_KEY_UNKNOWN', index, question, `${field}.key`,
        '选项键必须是A、B、C、D之一');
    }
    if (keys.has(option.key)) {
      addIssue(issues, 'OPTION_KEY_DUPLICATE', index, question, `${field}.key`,
        `选项键${option.key}重复`);
    }
    keys.add(option.key);

    const text: string = option.text.trim();
    if (text.length === 0) {
      addIssue(issues, 'OPTION_TEXT_EMPTY', index, question, `${field}.text`,
        '选项内容不能为空');
    }
  }

  if (!keys.has(question.answer)) {
    addIssue(issues, 'ANSWER_NOT_IN_OPTIONS', index, question, 'answer',
      '答案必须精确对应本题的一个选项键');
  }
}

trim() 只识别空文本,不修改题目。答案 bB 不等于 B。本文只要求键在白名单内且唯一,因此 A/C 可以成立;若产品还要求连续编号,应另加规则和用例。

六、校验的是实际发给页面的快照,避免前后两套答案

当前源码对 ID 含 _auto 的生成题提供 normalizeQuestion(),并由 getQuestionAnswer() 读取规范化答案。入口若校验原数组,而页面又重新生成选项,校验对象与渲染对象就可能不一致。更稳妥的接入方式是先按既有规则生成最终快照,再校验和使用这同一份快照。

import { QuestionItem } from '../models/DrivingLightModels';
import { BUILTIN_QUESTIONS, normalizeQuestion } from '../models/QuestionBank';
import { QuestionValidationReport } from '../models/QuestionValidation';
import { validateQuestionBank } from './QuestionBankValidator';

export interface PreparedQuestionBank {
  questions: QuestionItem[];
  report: QuestionValidationReport;
}

export function prepareQuestionBank(): PreparedQuestionBank {
  const questions: QuestionItem[] = [];
  for (let index = 0; index < BUILTIN_QUESTIONS.length; index++) {
    questions.push(normalizeQuestion(BUILTIN_QUESTIONS[index]));
  }
  const report: QuestionValidationReport = validateQuestionBank(questions);
  const prepared: PreparedQuestionBank = { questions, report };
  return prepared;
}

准备层规范化,校验器只读,页面使用同一数组。题库在进程内不变时,初始化一次即可,不必随搜索输入重复校验。

同时要守住一个限制:规范化可能修正旧格式,却也可能掩盖原始数据错误。开发阶段可以分别校验题源和最终快照,前者用于定位生产问题,后者决定用户能否练习;它们应标出不同阶段,不能把两份报告合并后误算无效题数量。

题库生产、只读校验与练习入口的责任划分

七、失败时关掉启动入口,报告保留给维护者

页面应使用 canStart 判断能否进入练习,不能只看 questions.length。下面的报告摘要函数没有框架依赖,可放在校验器旁边;页面把结果绑定到状态文本,把 canStart 绑定到开始按钮即可。

export function describeQuestionReport(report: QuestionValidationReport): string {
  if (report.total === 0) {
    return '当前没有可练习的题目';
  }
  if (report.canStart) {
    return `已准备${report.total}道题`;
  }
  const lines: string[] = [
    `题库暂不可用:${report.invalidCount}道题存在${report.issueCount}处问题`
  ];
  const limit: number = Math.min(report.issues.length, 6);
  for (let index = 0; index < limit; index++) {
    const issue: QuestionIssue = report.issues[index];
    lines.push(`第${issue.index + 1}题 ${issue.field}:${issue.message}`);
  }
  if (report.issues.length > limit) {
    lines.push(`另有${report.issues.length - limit}处问题,请查看完整报告`);
  }
  return lines.join('\n');
}

详细字段留给维护者,普通用户看到“题库准备失败”的提示即可。本例整批阻断;若要过滤坏题继续练习,还需明确最小题数、科目分布和固定题规则。

八、用小样本验证规则,再对真实题库跑完整报告

先用小样本验证规则。下面验证重复 ID,不涉及交通知识正确性。

const first: QuestionItem = {
  id: 'validation_001', subject: SUBJECT_TWO, type: TYPE_CHOICE,
  title: '校验用题目一',
  options: [{ key: 'A', text: '候选一' }, { key: 'B', text: '候选二' }],
  answer: 'A', explanation: '仅用于验证数据结构', category: '校验样本'
};
const second: QuestionItem = {
  id: 'validation_001', subject: SUBJECT_TWO, type: TYPE_CHOICE,
  title: '校验用题目二',
  options: [{ key: 'A', text: '候选三' }, { key: 'B', text: '候选四' }],
  answer: 'B', explanation: '仅用于验证重复ID', category: '校验样本'
};
const report: QuestionValidationReport = validateQuestionBank([first, second]);
console.info(`canStart=${report.canStart}, issues=${report.issueCount}`);

该样本的预期是 canStart=falseinvalidCount=1issueCount=1,错误项指向第二题,消息指出首次位于第一题。这里无效题计数按“被报告问题的题目”计算;第一次出现的题作为参照,不再追加第二份重复错误。

输入变更应有结果需要额外确认
空数组不允许开始,题目总数为0页面显示空态而非数据损坏
单条完整题允许开始校验不改变原对象
第二题复用第一题IDID_DUPLICATE首次位置为0时仍能命中
同题两个选项键都为BOPTION_KEY_DUPLICATE不因答案也是B而忽略
答案D但没有D选项ANSWER_NOT_IN_OPTIONS不能在判题时回退到第一项
选项内容仅空格OPTION_TEXT_EMPTY输入原文仍然保留
判断题四个选项JUDGE_OPTION_COUNT不影响普通选择题四选项
同题同时两个问题问题数2,无效题数1摘要与完整报告一致
最终快照完整但原始题源冲突练习报告可通过生产阶段报告仍需人工处理

小样本通过后再验证整库,并随题库版本记录结果。

九、入口通过以后,仍然保留内容和界面验收

结构校验能证明“键和答案连得上”,不能证明答案在现实交通规则中正确。生成题也不能因为换了地点、天气词语就自动获得知识有效性。题源应保留出处,结论与正式考试要求需要另外核对;不要在应用和文章中把训练材料包装成官方题库。

验收至少分开三层:

  1. 数据层:完整报告无问题,题目总数、各科分布符合发布计划,校验前后输入未被修改。
  2. 页面层:长题干、两选项、四选项、空搜索结果都能正常展示;ID 用作列表键时,滚动和筛选没有串项。
  3. 判题层:显示的选项键、点击提交键、正确答案三者一致;答错后保存的 wrongQuestionId 能重新定位原题。

落地时在分支接入并运行项目测试,再执行 hvigorw assembleHap --no-daemon,最后完成页面和判题回归。本文尚未执行这些接入验收。

十、常见故障与落地取舍

现象优先检查修复方向
校验通过,点击仍一直判错页面是否用了另一份规范化结果校验和发题共享同一快照
重复ID恰好漏掉第一题是否把索引0当成false使用 first !== undefined
加题后旧错题打不开是否自动重命名了已有ID保持稳定ID,必要时提供迁移映射
几千条题只报告第一条错误是否遇错就throw或return累积问题后统一返回
清理空白以后历史关联出错是否在校验时修改输入校验只读,题源修复单独评审
允许选择新科目却没有题科目白名单与入口是否同步同时更新筛选规则和用例
导入JSON后在 .trim() 崩溃是否直接把外部对象断言为模型先做外部格式解析,再进入本文校验器

先在准备入口校验,修复题源,再接入题库维护流程。核心契约是:每道题身份稳定,选项键唯一,答案属于实际选项,失败能够定位题源。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐