灯光模拟HarmonyOS应用实战-71-科一种子定义了十组却没有进入生成链:用GenerationPlan说明启用、替代与退役

题库文件里已经写了科一的十组生成种子:近光、会车、跟车、远近交替、雾天、故障处理等场景一应俱全。维护者自然会认为这些种子正在扩充科一题库。可继续追到 buildBuiltinQuestions(),实际生成调用只有科二和科四,科一仍只追加手工灯光题。

这类问题比“少写了一行函数调用”更难治理。也许科一种子本来准备启用,也许已被 42 道手工题替代,也许只是旧方案退役后忘了删除。源码没有记录决策,任何人都只能猜。GenerationPlan 的作用就是把每个科目的来源、状态、数量策略、替代关系和退役原因集中写清,让配置存在与运行启用成为两件可审查的事实。

题库生成计划封面

接下来先还原当前链路,再把“启用、替代、退役”转成可执行计划和生成报告,并设计阻止孤立种子再次出现的验证门禁。

一、科一种子目录确实定义了十组业务场景

QuestionBank.ets 中的 SUBJECT_ONE_SEEDS 从“夜间在照明良好的城市道路上行驶”开始,到“仪表盘蓝色前照灯图标亮起”结束,共十个 GeneratedQuestionSeed。每项包含分类、场景、正确做法、三个干扰项和解析。

const SUBJECT_ONE_SEEDS: GeneratedQuestionSeed[] = [
  {
    category: '使用规则',
    scenario: '夜间在照明良好的城市道路上行驶',
    correct: '使用近光灯',
    wrongOne: '持续使用远光灯',
    wrongTwo: '只开启危险报警闪光灯',
    wrongThree: '关闭全部灯光',
    explanation: '照明良好的道路应使用近光灯,远光灯容易影响其他车辆。'
  },
  // 其余场景:会车、跟车、远近交替、路口、恶劣天气、
  // 故障处理、临时停车、转向提示、灯光标志
];

同文件的 buildGeneratedTitle() 还专门处理 subject === SUBJECT_ONEgetGeneratedSeed() 也会为科一查询 SUBJECT_ONE_SEEDS。这些代码说明生成器具备理解科一种子的分支,却不能证明内置题库实际创建了科一自动题。

代码事实能说明什么不能说明什么
定义 10 颗科一种子配置内容存在配置已经启用
标题函数有科一分支生成器可处理该科目构建入口一定调用
规范化可查询科一种子若收到对应自动题可尝试反查内置题库已经产生自动题
页面能筛选科一科一题可展示与练习题目来自哪条生成路径

二、buildBuiltinQuestions只启动科二和科四生成

种子定义到内置题库的真实链路

当前内置题库的构建顺序很明确:先追加 SUBJECT_ONE_LIGHT_QUESTIONS,再追加 BASE_QUESTIONS 中非科一题,最后分别为科二和科四生成 1000 条目标数量。

function buildBuiltinQuestions(): QuestionItem[] {
  const questions: QuestionItem[] = [];
  for (let index = 0;
    index < SUBJECT_ONE_LIGHT_QUESTIONS.length; index++) {
    appendUniqueQuestion(
      questions,
      SUBJECT_ONE_LIGHT_QUESTIONS[index]
    );
  }
  for (let index = 0; index < BASE_QUESTIONS.length; index++) {
    if (BASE_QUESTIONS[index].subject !== SUBJECT_ONE) {
      appendUniqueQuestion(questions, BASE_QUESTIONS[index]);
    }
  }
  appendGeneratedSubjectQuestions(
    questions, SUBJECT_TWO, 's2_auto',
    SUBJECT_TWO_SEEDS, GENERATED_SUBJECT_COUNT
  );
  appendGeneratedSubjectQuestions(
    questions, SUBJECT_FOUR, 's4_auto',
    SUBJECT_FOUR_SEEDS, GENERATED_SUBJECT_COUNT
  );
  return questions;
}

这里没有以 SUBJECT_ONE 调用 appendGeneratedSubjectQuestions()。因此当前内置构建链中,科一来源是 SubjectOneLightBank.ets 的 42 道手工题;十组生成种子没有进入该链。不能仅凭变量被其他函数引用就把它们统计为“已生成题库”。

三、缺一行调用不等于应该直接补一行

最直接的改法似乎是追加:

appendGeneratedSubjectQuestions(
  questions,
  SUBJECT_ONE,
  's1_auto',
  SUBJECT_ONE_SEEDS,
  GENERATED_SUBJECT_COUNT
);

但在明确产品决策前,这一行可能引入新的问题:科一现有手工题与生成场景重复;搜索排序和抽样池突然扩大;自动题 ID 成为新的历史引用;此前只审核过手工题内容,生成变体尚未逐场景确认。

正确问题不是“能不能调用”,而是“这些种子处于什么生命周期”。至少存在三种合理解释:

状态业务含义生成器行为
启用 ENABLED该目录是当前正式来源按批准策略生成并报告
替代 REPLACED内容由另一目录接管不生成,指向替代来源
退役 RETIRED旧能力停止维护不生成,保留原因和最后版本

如果十组种子只是未来草案,可以再加 DRAFT。草案不应混在生产构建文件里让人误判;它应位于明确的试验目录,或在计划中声明不可进入发布题库。

四、GenerationPlan集中记录来源与生命周期

GenerationPlan、目录与报告结构

计划条目要引用稳定目录 ID,而不是直接散落数组变量。状态、数量、前缀和替代关系都在一处表达。

export enum GenerationLifecycle {
  ENABLED = 'enabled',
  REPLACED = 'replaced',
  RETIRED = 'retired',
  DRAFT = 'draft'
}

export interface GenerationPlanEntry {
  subject: string;
  catalogId: string;
  lifecycle: GenerationLifecycle;
  targetCount: number;
  idPrefix: string;
  replacedByCatalogId: string;
  reasonCode: string;
  planVersion: number;
}

targetCount 只在 ENABLED 时有效。REPLACED 必须提供 replacedByCatalogIdRETIRED 必须提供原因码,DRAFT 则不能被正式构建入口消费。planVersion 在来源或生成策略改变时递增,不能直接借用应用版本代替。

计划不决定每颗题目的稳定身份和规则覆盖,那些仍由独立协议负责。它只回答“哪个目录被哪条构建链以什么状态消费”。

五、先把当前行为编码成计划,再讨论是否启用科一

迁移第一步应忠实描述现状,而不是顺手改变产物。当前计划可以把科一手工目录设为启用,把科一种子目录设为 DRAFTREPLACEDRETIRED,具体状态必须由维护者确认。

const GENERATION_PLAN_V1: GenerationPlanEntry[] = [
  {
    subject: SUBJECT_ONE,
    catalogId: 'subject1.manual.light.v1',
    lifecycle: GenerationLifecycle.ENABLED,
    targetCount: 42,
    idPrefix: 'doc_s1_light',
    replacedByCatalogId: '',
    reasonCode: 'CURRENT_MANUAL_CATALOG',
    planVersion: 1
  },
  {
    subject: SUBJECT_ONE,
    catalogId: 'subject1.generated.seed.v1',
    lifecycle: GenerationLifecycle.DRAFT,
    targetCount: 0,
    idPrefix: 's1_auto',
    replacedByCatalogId: '',
    reasonCode: 'AWAITING_CONTENT_REVIEW',
    planVersion: 1
  }
];

第二条的 DRAFT 和原因只是示例,不能冒充当前团队已经作出的决定。若实际结论是“42 道手工题替代十组生成种子”,就改为 REPLACED 并指向手工目录;若准备启用,则必须先完成内容审查、重复分析、ID 与迁移方案,再改为 ENABLED

科二和科四也要进入同一计划,避免它只为修补科一特例而存在。

六、构建器只消费ENABLED条目,并返回生成报告

正式入口遍历计划,而不是手写两次函数调用。任何未启用目录都不会生成,同时报告其生命周期和原因。

export interface GenerationReportItem {
  catalogId: string;
  lifecycle: GenerationLifecycle;
  requestedCount: number;
  appendedCount: number;
  duplicateSkippedCount: number;
  reasonCode: string;
}

function executeGenerationPlan(
  plan: GenerationPlanEntry[],
  registry: QuestionCatalogRegistry
): GenerationReportItem[] {
  const report: GenerationReportItem[] = [];
  for (let index: number = 0; index < plan.length; index++) {
    const entry: GenerationPlanEntry = plan[index];
    if (entry.lifecycle !== GenerationLifecycle.ENABLED) {
      report.push(skippedReport(entry));
      continue;
    }
    report.push(appendEnabledCatalog(entry, registry));
  }
  return report;
}

当前 appendUniqueQuestion() 按科目、分类和标题跳过重复项,所以“请求生成 1000 条”不必然等于“新增 1000 条”。报告必须同时保存 requestedCountappendedCountduplicateSkippedCount,不能把目标数量当最终产量。

ENABLED 目录在注册表中不存在、种子数组为空或前缀冲突,构建应明确失败;若 DRAFT 被正式入口请求,也应拒绝,而不是自动升级为启用。

七、替代与退役必须携带迁移影响

目录从启用变为替代或退役时,旧题 ID、错题历史和进行中会话仍可能引用它。计划需要配套影响清单,而不是从构建入口直接删除来源。

export interface CatalogTransition {
  catalogId: string;
  from: GenerationLifecycle;
  to: GenerationLifecycle;
  effectivePlanVersion: number;
  existingReferencePolicy: string;
  replacementCatalogId: string;
}

const transition: CatalogTransition = {
  catalogId: 'subject1.generated.seed.v1',
  from: GenerationLifecycle.ENABLED,
  to: GenerationLifecycle.REPLACED,
  effectivePlanVersion: 3,
  existingReferencePolicy: 'READ_ONLY_SNAPSHOT',
  replacementCatalogId: 'subject1.manual.light.v2'
};

READ_ONLY_SNAPSHOT 表示旧记录保留当时题目快照,不能自动映射到语义相近的新题。若团队已有稳定的一对一映射,可以采用显式映射表;无法确认时就记录 UNRESOLVED,不要按标题相似度猜替代项。

转换新生成旧引用必需说明
DRAFT → ENABLED开始建立新身份内容与容量评审结果
ENABLED → REPLACED停止旧目录映射或只读快照替代目录及生效版本
ENABLED → RETIRED停止保留旧读取路径退役原因与保留期限
REPLACED → ENABLED谨慎恢复核对两套引用恢复理由和冲突处理

八、孤立目录应在构建前被主动发现

配置存在但没有计划条目,是这次问题的根源之一。注册表中的每个正式目录都必须被计划引用一次;计划也不能引用不存在的目录。

function validatePlanCoverage(plan: GenerationPlanEntry[],
  registryIds: string[]): string[] {
  const errors: string[] = [];
  for (let index: number = 0;
    index < registryIds.length; index++) {
    if (!hasPlanEntry(plan, registryIds[index])) {
      errors.push('UNPLANNED_CATALOG:' + registryIds[index]);
    }
  }
  for (let index: number = 0; index < plan.length; index++) {
    if (registryIds.indexOf(plan[index].catalogId) < 0) {
      errors.push('MISSING_CATALOG:' + plan[index].catalogId);
    }
  }
  return errors;
}

还应校验同一 catalogId 只出现一次、启用条目的 targetCount 为正、非启用条目不会携带可执行数量、替代目录不能指向自己、替代链不能成环、ID 前缀在同一题库范围内唯一。

检查结果要在题库构建前返回,避免页面运行后才发现某个科目数量异常。

九、验证矩阵要覆盖计划状态与实际产物

测试不能只断言常量数组长度。它要读取生成报告和最终题库,证明每个计划状态产生了预期行为。

用例计划状态关键断言
G01科一种子 DRAFT最终题库没有 s1_auto,报告写明跳过原因
G02科一种子 ENABLED只生成批准数量,前缀唯一
G03科一种子 REPLACED不生成,替代目录存在且启用
G04科一种子 RETIRED不生成,旧读取策略存在
G05注册表新增目录无计划构建前返回 UNPLANNED_CATALOG
G06计划引用缺失目录返回 MISSING_CATALOG
G07两目录使用同一前缀拒绝生成
G08重复题被跳过报告的请求数、追加数、跳过数守恒
G09替代链成环计划校验失败
G10同一 V1 计划执行两次生成身份和报告保持一致

最终还要按科目统计手工、生成、替代和退役数量。总题数只说明容量,不能替代目录生命周期、内容审查和规则覆盖结论。

十、验证清单、排障表与事实边界

  • 每个题目目录都有唯一 catalogId
  • 每个正式目录恰好出现在一个计划条目中。
  • ENABLEDREPLACEDRETIREDDRAFT 语义固定。
  • 非启用目录不会进入正式生成链。
  • 替代条目指向存在且经过确认的目录。
  • 退役条目说明旧引用读取策略。
  • 启用条目的目标数量与 ID 前缀合法且无冲突。
  • 生成报告区分请求、追加与重复跳过数量。
  • 计划变更带版本和迁移影响清单。
  • 科目总数按来源拆分,不把种子存在当作已生成。
  • 旧错题与历史引用不会按相似标题自动迁移。
  • 计划和注册表覆盖在题库构建前核对。
现象先核对常见原因修复方向
种子写了却搜不到题生成报告目录没有启用条目明确生命周期后再决定接入
加一行后科一题暴增计划数量与重复项直接启用未评审目录先做影子报告与内容审查
目标 1000 条实际更少重复跳过数把请求数当追加数输出三项守恒报告
删除目录后旧错题打不开退役引用策略直接移除读取能力保留快照或显式映射
两套自动题 ID 冲突idPrefix前缀散落在调用点在计划中集中校验
替代目录互相指向替代链没有环校验构建前遍历并拒绝环
维护者误判配置已启用计划覆盖只阅读种子常量以生成报告为运行事实

当前源码能够确认:SUBJECT_ONE_SEEDS 定义了十组种子;buildGeneratedTitle()getGeneratedSeed() 有科一分支;buildBuiltinQuestions() 追加 42 道 SUBJECT_ONE_LIGHT_QUESTIONS,过滤掉 BASE_QUESTIONS 中的科一项,并且只为科二、科四调用自动生成函数。由此可以确认科一种子没有进入当前内置生成链。

GenerationPlan、目录注册表、生命周期状态、生成报告、替代转换和覆盖校验都是建议方案,尚未写入 The_kemusan。科一种子究竟应启用、被手工题替代,还是正式退役,需要内容与产品决策;文章不会替源码作者猜结论。这次没有运行构建,没有生成新的 HAP,也没有在模拟器或真机核对题库数量、搜索结果或旧引用迁移。

Logo

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

更多推荐