Browse Source

Fix incomplete reading question sets before practice and printing

彭峰 3 weeks ago
parent
commit
c22974ab45

+ 105 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.spec.ts

@@ -1,7 +1,7 @@
 import {EnglishReadingService} from './english-reading.service';
 import {ReadingCloudService} from './reading-cloud.service';
 import {FmodeAiService} from './fmode-ai.service';
-import {ReadingArticle,ReadingRouteParams}from './reading.models';
+import {ReadingArticle,ReadingQuestion,ReadingRouteParams}from './reading.models';
 describe('reading article reuse',()=>{
  const params={sourceStudyId:'201',level:'初一',type:'记叙文',theme:'校园',wordList:['apple','river'],renew:false} as ReadingRouteParams;
  function setup(){const cloud={userPointer:{__type:'Pointer',className:'_User',objectId:'student'},query:jasmine.createSpy('query').and.resolveTo([]),create:jasmine.createSpy('create').and.callFake(async(_name:string,data:object)=>({...data,objectId:'new'}))};const s=new EnglishReadingService(cloud as unknown as ReadingCloudService,{} as FmodeAiService);spyOn(s as any,'formatPrompt').and.resolveTo('prompt');spyOn(s as any,'collectStream').and.resolveTo('A new story\napple river');return{s,cloud};}
@@ -9,3 +9,107 @@ describe('reading article reuse',()=>{
  it('does not reuse an article that only overlaps part of the requested vocabulary',async()=>{const{s,cloud}=setup();cloud.query.and.callFake(async(_n:string,w:Record<string,unknown>)=>w['createOptions.params.wordList']?[{objectId:'old-apple-tree'}]:[]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('new');expect(cloud.create).toHaveBeenCalled();});
  it('regenerates on explicit request without querying a reusable article',async()=>{const{s,cloud}=setup();const article=await s.createArticle({...params,renew:true},()=>{});expect(article.objectId).toBe('new');expect(cloud.query).not.toHaveBeenCalled();});
 });
+
+describe('reading question persistence', () => {
+ const article:ReadingArticle={objectId:'article-1',type:'reading',title:'A story',content:'Apple grows by the river.',createOptions:{tpl:'article',params:{wordList:['apple'],count:120,stage:'小学',level:'三年级',type:'记叙文',theme:'校园'}}};
+ const options=()=>[
+  {check:true,value:'A',answer:'正确'},
+  {check:false,value:'B',answer:'错误'},
+  {check:false,value:'C',answer:'错误'},
+  {check:false,value:'D',answer:'错误'},
+ ];
+ const generated=(count:number)=>Array.from({length:count},(_,index)=>({title:`New question ${index+1}?`,type:'细节题',options:options(),answer:'依据原文'}));
+ const saved=(index:number):ReadingQuestion=>({objectId:`old-${index}`,type:'reading-item',parent:{__type:'Pointer',className:'SurveyItem',objectId:article.objectId},index,title:`Old question ${index}?`,options:options(),createdAt:'2026-09-01T00:00:00Z'});
+
+ function setup(initial:ReadingQuestion[]=[]){
+  const rows=[...initial];
+  const cloud={
+   userPointer:{__type:'Pointer',className:'_User',objectId:'student'},
+   pointer:(className:string,objectId:string)=>({__type:'Pointer',className,objectId}),
+   query:jasmine.createSpy('query').and.callFake(async()=>[...rows]),
+   create:jasmine.createSpy('create').and.callFake(async(_name:string,value:ReadingQuestion)=>{
+    const created={...value,objectId:`new-${rows.length}`,createdAt:'2026-09-27T00:00:00Z'};
+    rows.push(created);
+    return created;
+   }),
+  };
+  const ai={json:jasmine.createSpy('json').and.callFake(async(_prompt:string,schema:string)=>({questions:generated(JSON.parse(schema).count)}))};
+  const service=new EnglishReadingService(cloud as unknown as ReadingCloudService,ai as unknown as FmodeAiService);
+  spyOn(service as any,'formatPrompt').and.resolveTo('prompt');
+  return{service,cloud,ai,rows};
+ }
+
+ it('rejects one, two and four AI questions before saving, then persists five',async()=>{
+  for(const shortCount of [1,2,4]){
+   const {service,cloud,ai}=setup();
+   let requests=0;
+   ai.json.and.callFake(async(_prompt:string,schema:string)=>({questions:generated(requests++===0?shortCount:JSON.parse(schema).count)}));
+   const questions=await service.createQuestions(article,()=>{});
+   expect(questions.length).toBe(5);
+   expect(ai.json).toHaveBeenCalledTimes(2);
+   expect(cloud.create).toHaveBeenCalledTimes(5);
+  }
+ });
+
+ it('keeps existing question IDs and fills only missing indexes',async()=>{
+  for(const count of [1,2,4]){
+   const originals=Array.from({length:count},(_,index)=>saved(index));
+   const {service,cloud,ai}=setup(originals);
+   const questions=await service.createQuestions(article,()=>{});
+   expect(questions.map((question)=>question.index)).toEqual([0,1,2,3,4]);
+   expect(questions.slice(0,count).map((question)=>question.objectId)).toEqual(originals.map((question)=>question.objectId));
+   expect(cloud.create).toHaveBeenCalledTimes(5-count);
+   expect(JSON.parse(ai.json.calls.first().args[1]).count).toBe(5-count);
+  }
+ });
+
+ it('reuses an already saved five-question set without another AI request or write',async()=>{
+  const originals=Array.from({length:5},(_,index)=>saved(index));
+  const {service,cloud,ai}=setup(originals);
+  const questions=await service.createQuestions(article,()=>{});
+  expect(questions.map((question)=>question.objectId)).toEqual(originals.map((question)=>question.objectId));
+  expect(ai.json).not.toHaveBeenCalled();
+  expect(cloud.create).not.toHaveBeenCalled();
+ });
+
+ it('retries invalid options before writing any question',async()=>{
+  const {service,cloud,ai}=setup();
+  let attempts=0;
+  ai.json.and.callFake(async()=>{
+   const questions=generated(5);
+   if(attempts++===0) questions[0].options[1].check=true;
+   expect(cloud.create).not.toHaveBeenCalled();
+   return{questions};
+  });
+  expect((await service.createQuestions(article,()=>{})).length).toBe(5);
+  expect(ai.json).toHaveBeenCalledTimes(2);
+  expect(cloud.create).toHaveBeenCalledTimes(5);
+ });
+
+ it('resumes from the saved gap after a write failure without replacing the first question',async()=>{
+  const {service,cloud,ai,rows}=setup();
+  ai.json.and.callFake(async(_prompt:string,schema:string)=>({questions:generated(JSON.parse(schema).count).map((question,index)=>({...question,title:`New question ${index+rows.length+1}?`}))}));
+  let fail=true;
+  cloud.create.and.callFake(async(name:string,value:ReadingQuestion)=>{
+   if(fail && value.index===1){fail=false;throw new Error('网络连接失败');}
+   const created={...value,objectId:`new-${rows.length}`,createdAt:'2026-09-27T00:00:00Z'};
+   rows.push(created);
+   return created;
+  });
+  await expectAsync(service.createQuestions(article,()=>{})).toBeRejectedWithError('网络连接失败');
+  expect(rows.map((question)=>question.index)).toEqual([0]);
+  const firstId=rows[0].objectId;
+  const completed=await service.createQuestions(article,()=>{});
+  expect(completed.length).toBe(5);
+  expect(completed[0].objectId).toBe(firstId);
+  expect(rows.map((question)=>question.index)).toEqual([0,1,2,3,4]);
+ });
+
+ it('does not persist an invalid generated set after all retries',async()=>{
+  const {service,cloud,ai}=setup();
+  ai.json.and.resolveTo({questions:generated(1)});
+  await expectAsync(service.createQuestions(article,()=>{})).toBeRejectedWithError(/题目生成失败/);
+  expect(ai.json).toHaveBeenCalledTimes(3);
+  expect(cloud.create).not.toHaveBeenCalled();
+ });
+});

+ 52 - 19
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts

@@ -8,7 +8,7 @@ import {
   ReadingArticle, ReadingHistoryEntry, ReadingLog, ReadingOption, ReadingProgress,
   ReadingQuestion, ReadingRouteParams,
 } from './reading.models';
-import { normalizeReadingQuestions, readingStage, readingWordLimit, stageQuestionLimit } from './reading.utils';
+import { isCompleteReadingQuestionSet, isValidReadingQuestion, normalizeReadingQuestions, READING_QUESTION_COUNT, readingStage, readingWordLimit, stageQuestionLimit } from './reading.utils';
 
 export const ARTICLE_TEMPLATE = 'english-reading-article-tpl';
 export const SUPPLY_TEMPLATE = 'english-reading-article-supply-tpl';
@@ -118,38 +118,71 @@ export class EnglishReadingService {
 
   async createQuestions(article: ReadingArticle, onProgress: (progress: ReadingProgress) => void): Promise<ReadingQuestion[]> {
     const existing = await this.getQuestions(article);
-    if (existing.length) return existing;
+    if (isCompleteReadingQuestionSet(existing)) return existing;
+    const retained = new Map(existing.filter((question) => Number(question.index) >= 0 && Number(question.index) < READING_QUESTION_COUNT && isValidReadingQuestion(question)).map((question) => [Number(question.index), question]));
+    const missingIndexes = Array.from({ length: READING_QUESTION_COUNT }, (_, index) => index).filter((index) => !retained.has(index));
     const params = article.createOptions.params;
     const prompt = await this.formatPrompt(QUESTION_TEMPLATE, {
       title: article.title,
       content: article.content,
       ...params,
-      count: 5,
+      count: missingIndexes.length,
       stageQuestionLimit: stageQuestionLimit(params.level),
     });
-    onProgress({ task: 'questions', message: '正在设计 5 道选择题' });
-    const schema = '{"count":5,"stage":"年级阶段","questions":[{"title":"英文题目","type":"题型","difficulty":"easy|normal|difficult","wordList":[],"options":[{"check":true,"value":"英文选项","answer":"中文解析"}],"answer":"中文题目解析"}]}';
-    const generated = await this.ai.json(prompt, schema, 2, (content) => {
-      onProgress({ task: 'questions', message: `正在设计选择题 ${content.length} 字符` });
+    const count = missingIndexes.length;
+    const previousTitles = existing.map((question) => question.title).filter(Boolean);
+    const basePrompt = `${prompt}\n最终 questions 数组必须恰好包含 ${count} 道不同的单选题,每题恰好四个不同选项且只有一个 check 为 true。${previousTitles.length ? `不要重复已有题目:${previousTitles.join(';')}` : ''}`;
+    const schema = JSON.stringify({
+      count, stage: params.stage,
+      questions: Array.from({ length: count }, (_, index) => ({
+        title: `英文题目${index + 1}`, type: '题型', difficulty: 'easy|normal|difficult', wordList: [],
+        options: Array.from({ length: 4 }, (_, optionIndex) => ({ check: optionIndex === 0, value: `英文选项${optionIndex + 1}`, answer: '中文解析' })),
+        answer: '中文题目解析',
+      })),
     });
-    const list = generated['questions'];
-    if (!Array.isArray(list) || !list.length) throw new Error('AI 返回的题目列表为空');
-    const questions: ReadingQuestion[] = [];
-    for (let index = 0; index < list.length; index += 1) {
-      const item = list[index] as GeneratedQuestion;
-      if (!item.title || !Array.isArray(item.options) || item.options.length < 2) {
-        throw new Error(`第 ${index + 1} 题格式不完整`);
+    let list: GeneratedQuestion[] | null = null;
+    let failure = `AI 未返回完整的 ${count} 道题`;
+    for (let attempt = 0; attempt < 3; attempt += 1) {
+      onProgress({ task: 'questions', message: `正在设计 ${count} 道选择题${attempt ? `(重试 ${attempt}/2)` : ''}` });
+      try {
+        const generated = await this.ai.json(`${basePrompt}${attempt ? `\n上次生成未通过校验:${failure}。请重新生成完整题组。` : ''}`, schema, 0, (content) => {
+          onProgress({ task: 'questions', message: `正在设计选择题 ${content.length} 字符` });
+        });
+        const candidate = generated['questions'];
+        const titles = new Set(previousTitles.map((title) => title.trim().toLowerCase()));
+        if (Array.isArray(candidate) && candidate.length === count && candidate.every((item) => {
+          if (!isValidReadingQuestion(item)) return false;
+          const title = item.title.trim().toLowerCase();
+          if (titles.has(title)) return false;
+          titles.add(title);
+          return true;
+        })) {
+          list = candidate as GeneratedQuestion[];
+          break;
+        }
+        failure = `题目数量应为 ${count},且每题需要四个不同选项和唯一正确答案`;
+      } catch (error) {
+        failure = error instanceof Error ? error.message : '题目生成服务失败';
       }
-      questions.push(await this.cloud.create<ReadingQuestion>('SurveyItem', {
+    }
+    if (!list) throw new Error(`题目生成失败:${failure}`);
+
+    for (let position = 0; position < list.length; position += 1) {
+      const item = list[position];
+      onProgress({ task: 'questions', message: `正在保存第 ${retained.size + position + 1}/${READING_QUESTION_COUNT} 道题` });
+      await this.cloud.create<ReadingQuestion>('SurveyItem', {
         parent: this.cloud.pointer('SurveyItem', article.objectId),
-        type: 'reading-item', index, title: item.title,
+        type: 'reading-item', index: missingIndexes[position], title: item.title!,
         difficulty: item.difficulty, answer: item.answer,
-        options: item.options,
+        options: item.options!,
         createOptions: { tpl: QUESTION_TEMPLATE, params: { type: item.type, wordList: item.wordList } },
         user: this.cloud.userPointer,
-      }));
+      });
     }
-    return questions;
+    const saved = await this.getQuestions(article);
+    if (!isCompleteReadingQuestionSet(saved)) throw new Error('题目尚未完整保存,请重试补齐后再答题或打印');
+    onProgress({ task: 'questions', message: '5 道题已保存' });
+    return saved;
   }
 
   async latestLog(articleId: string): Promise<ReadingLog | null> {

+ 4 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.html

@@ -1,8 +1,8 @@
 <section class="exercise-page">
   <header class="exercise-toolbar">
     <button type="button" class="toolbar-back" (click)="returnToOrigin()" aria-label="返回原页面"><lucide-icon [img]="icons.ArrowLeft" [size]="19" /><span>返回</span></button>
-    <div class="toolbar-title"><strong>阅读理解</strong><small>{{ article()?.objectId ? (article()?.content?.split(' ')?.length || 0) + ' words · 内容已自动保存' : '正在准备内容' }}</small></div>
-    <button type="button" class="toolbar-print" (click)="print()" [disabled]="!article()?.objectId || saving()"><lucide-icon [img]="icons.FileDown" [size]="17" />打印</button>
+    <div class="toolbar-title"><strong>阅读理解</strong><small>{{ article()?.objectId ? (article()?.content?.split(' ')?.length || 0) + ' words · ' + (canPrint ? '5 道题已保存' : legacyIncomplete() ? '历史题组不完整' : error() ? '题目保存失败' : '文章已保存,题目准备中') : '正在准备内容' }}</small></div>
+    <button type="button" class="toolbar-print" (click)="print()" [disabled]="!canPrint || saving()"><lucide-icon [img]="icons.FileDown" [size]="17" />打印</button>
   </header>
 
   @if (error()) {
@@ -17,6 +17,7 @@
     @if (loading()) {
       <div class="generation-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="25" /><div><strong>{{ progress().message }}</strong><p>{{ progress().task === 'article' ? '1' : progress().task === 'supply' ? '2' : '3' }}/3 · {{ loadingTip() }}</p></div></div>
     } @else {
+      @if (legacyIncomplete()) { <div class="generation-state"><div><strong>这份历史练习的题目不完整</strong><p>原有答题记录和成绩已保留。需要完整的五题练习,请生成新文章。</p><button type="button" class="secondary-button" (click)="createNewArticle()">生成新文章</button></div></div> }
       <section class="questions-section">
         @for (question of questions(); track question.objectId; let questionIndex = $index) {
           <article class="question-block">
@@ -36,7 +37,7 @@
           <lucide-icon [img]="saving() ? icons.LoaderCircle : icons.CheckCircle2" [class.spin]="saving()" [size]="18" />
           <span>{{ primaryButtonLabel }}({{ answeredCount }}/{{ questions().length }})</span>
         </button>
-        @if (submitted()) { <button type="button" class="footer-tool retry-button" (click)="reset()"><lucide-icon [img]="icons.RotateCcw" [size]="19" /><span>再做一次</span></button> }
+        @if (submitted() && !legacyIncomplete()) { <button type="button" class="footer-tool retry-button" (click)="reset()"><lucide-icon [img]="icons.RotateCcw" [size]="19" /><span>再做一次</span></button> }
         <button type="button" class="footer-tool" [disabled]="saving()" (click)="openHistory()"><lucide-icon [img]="icons.History" [size]="19" /><span>历史 {{ 3 - remainingArticles() }}/3</span></button>
         @if (submitted() && score() === questions().length && remainingArticles() > 0) { <button type="button" class="footer-tool new-button" (click)="createNewArticle()"><lucide-icon [img]="icons.CirclePlus" [size]="19" /><span>新文章</span></button> }
       </div>

+ 83 - 10
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.spec.ts

@@ -21,23 +21,30 @@ describe('ReadingExerciseComponent', () => {
     content:'apple river',
     createOptions:{ tpl:'template', params:{ wordList:['apple'], count:120, stage:'小学', level:'三年级', type:'记叙文', theme:'校园' } },
   };
-  const questions: ReadingQuestion[] = [
-    { objectId:'question-1', type:'reading-item', parent:{ __type:'Pointer', className:'SurveyItem', objectId:'article-1' }, index:0, title:'Question 1', options:[{ value:'A', check:true }, { value:'B', check:false }] },
-    { objectId:'question-2', type:'reading-item', parent:{ __type:'Pointer', className:'SurveyItem', objectId:'article-1' }, index:1, title:'Question 2', options:[{ value:'C', check:true }, { value:'D', check:false }] },
-  ];
+  const questions: ReadingQuestion[] = Array.from({ length: 5 }, (_, index) => ({
+    objectId:`question-${index + 1}`, type:'reading-item', parent:{ __type:'Pointer', className:'SurveyItem', objectId:'article-1' },
+    index, title:`Question ${index + 1}`, options:[
+      { value:'A', check:index===0 }, { value:'B', check:false }, { value:'C', check:index!==0 }, { value:'D', check:false },
+    ],
+  }));
   const savedLog: ReadingLog = {
     objectId:'log-1', surveyItem:{ __type:'Pointer', className:'SurveyItem', objectId:'article-1' },
-    answer:{ 'question-1':'A', 'question-2':'D' }, right:1, wrong:1, grade:50, type:'exam',
+    answer:{ 'question-1':'A', 'question-2':'D' }, right:1, wrong:4, grade:20, type:'exam',
   };
 
   beforeEach(async () => {
-    reading = jasmine.createSpyObj<EnglishReadingService>('EnglishReadingService', ['saveLog']);
+    reading = jasmine.createSpyObj<EnglishReadingService>('EnglishReadingService', ['saveLog', 'findArticles', 'createArticle', 'latestLog', 'getQuestions', 'createQuestions', 'supplyMissingWords']);
     router = jasmine.createSpyObj<Router>('Router', ['navigate', 'navigateByUrl']);
     router.navigate.and.resolveTo(true);
     router.navigateByUrl.and.resolveTo(true);
     platform = jasmine.createSpyObj<PlatformService>('PlatformService', ['toast', 'confirm']);
     platform.confirm.and.resolveTo(true);
-    const cloud = jasmine.createSpyObj<ReadingCloudService>('ReadingCloudService', ['ensureUser']);
+    const cloud = jasmine.createSpyObj<ReadingCloudService>('ReadingCloudService', ['ensureUser', 'get']);
+    reading.findArticles.and.resolveTo([]);
+    reading.latestLog.and.resolveTo(null);
+    reading.getQuestions.and.resolveTo(questions);
+    reading.createQuestions.and.resolveTo(questions);
+    cloud.get.and.resolveTo(article);
 
     await TestBed.configureTestingModule({
       imports:[ReadingExerciseComponent],
@@ -62,7 +69,7 @@ describe('ReadingExerciseComponent', () => {
     };
     component.article.set(article);
     component.questions.set(questions);
-    component.answers = ['A', 'D'];
+    component.answers = ['A', 'D', 'D', 'D', 'D'];
   });
 
   it('waits for the answer log to save before showing completion and blocks duplicate clicks', async () => {
@@ -91,7 +98,7 @@ describe('ReadingExerciseComponent', () => {
 
     await component.submit();
 
-    expect(component.answers).toEqual(['A', 'D']);
+    expect(component.answers).toEqual(['A', 'D', 'D', 'D', 'D']);
     expect(component.submitted()).toBeFalse();
     expect(component.saveFailed()).toBeTrue();
     expect(component.primaryButtonLabel).toBe('保存失败,重新保存');
@@ -104,7 +111,7 @@ describe('ReadingExerciseComponent', () => {
 
     await component.primaryAction();
 
-    expect(component.answers).toEqual(['A', 'D']);
+    expect(component.answers).toEqual(['A', 'D', 'D', 'D', 'D']);
     expect(router.navigateByUrl).toHaveBeenCalledWith('/pages/his/his?date=2026-09-10', { replaceUrl:true });
     expect(reading.saveLog).not.toHaveBeenCalled();
   });
@@ -120,4 +127,70 @@ describe('ReadingExerciseComponent', () => {
     expect(await component.canDeactivate()).toBeTrue();
     expect(platform.confirm).not.toHaveBeenCalled();
   });
+
+  it('blocks printing a partial set and prints only after five saved questions are available', async () => {
+    component.loading.set(false);
+    component.questions.set(questions.slice(0, 1));
+    await component.print();
+    expect(component.canPrint).toBeFalse();
+    expect(router.navigate).not.toHaveBeenCalled();
+
+    component.questions.set(questions);
+    await component.print();
+    expect(component.canPrint).toBeTrue();
+    expect(router.navigate).toHaveBeenCalledWith(['/words/reading/pdf', article.objectId], jasmine.any(Object));
+  });
+
+  it('fills an unsubmitted historical set before exposing it for printing', async () => {
+    component.params.sid = article.objectId;
+    reading.getQuestions.and.resolveTo(questions.slice(0, 1));
+    await component.load();
+    expect(reading.createQuestions).toHaveBeenCalled();
+    expect(reading.supplyMissingWords).not.toHaveBeenCalled();
+    expect(component.questions().length).toBe(5);
+    expect(component.canPrint).toBeTrue();
+  });
+
+  it('preserves a submitted incomplete historical set and prevents another submission or print', async () => {
+    component.params.sid = article.objectId;
+    reading.latestLog.and.resolveTo(savedLog);
+    reading.getQuestions.and.resolveTo(questions.slice(0, 1));
+    await component.load();
+    expect(component.legacyIncomplete()).toBeTrue();
+    expect(component.submitted()).toBeTrue();
+    expect(component.canPrint).toBeFalse();
+    expect(reading.createQuestions).not.toHaveBeenCalled();
+    await component.print();
+    expect(router.navigate).not.toHaveBeenCalled();
+  });
+
+  it('retries a failed new article against the saved article instead of generating another', async () => {
+    reading.createArticle.and.resolveTo(article);
+    reading.getQuestions.and.resolveTo([]);
+    reading.supplyMissingWords.and.resolveTo(article);
+    reading.createQuestions.and.rejectWith(new Error('题目保存失败'));
+    await component.load();
+    expect(component.params.sid).toBe(article.objectId);
+    expect(component.canPrint).toBeFalse();
+    expect(router.navigate).toHaveBeenCalledWith(['/words/reading'], jasmine.objectContaining({
+      queryParams: jasmine.objectContaining({ sid: article.objectId, renew: false }), replaceUrl: true,
+    }));
+
+    reading.createQuestions.and.resolveTo(questions);
+    await component.load();
+    expect(reading.createArticle).toHaveBeenCalledTimes(1);
+    expect(component.questions().length).toBe(5);
+    expect(component.canPrint).toBeTrue();
+  });
+
+  it('offers a fresh article for a submitted incomplete legacy set even when the normal quota is full', async () => {
+    component.legacyIncomplete.set(true);
+    component.remainingArticles.set(0);
+    const reload = spyOn(component, 'load').and.resolveTo();
+    await component.createNewArticle();
+    expect(router.navigate).toHaveBeenCalledWith(['/words/reading'], jasmine.objectContaining({
+      queryParams: jasmine.objectContaining({ renew: true, sid: undefined }), replaceUrl: true,
+    }));
+    expect(reload).toHaveBeenCalled();
+  });
 });

+ 36 - 10
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.ts

@@ -7,7 +7,7 @@ import { SessionService } from '../../core/session.service';
 import { EnglishReadingService } from './english-reading.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { ReadingArticle, ReadingHistoryEntry, ReadingProgress, ReadingQuestion, ReadingRouteParams } from './reading.models';
-import { highlightWords, normalizeReadingParams, normalizeReadingReturnUrl, shuffle } from './reading.utils';
+import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl, shuffle } from './reading.utils';
 
 interface ReadingDialog {
   title: string;
@@ -44,6 +44,7 @@ export class ReadingExerciseComponent implements OnInit {
   readonly historyItems = signal<ReadingHistoryEntry[]>([]);
   readonly remainingArticles = signal(3);
   readonly error = signal('');
+  readonly legacyIncomplete = signal(false);
   readonly dialog = signal<ReadingDialog | null>(null);
   readonly loadingTip = signal('');
   answers: string[] = [];
@@ -71,6 +72,7 @@ export class ReadingExerciseComponent implements OnInit {
   private readonly platform = inject(PlatformService);
 
   get answeredCount(): number { return this.answers.filter(Boolean).length; }
+  get canPrint(): boolean { return !this.loading() && !this.error() && isCompleteReadingQuestionSet(this.questions()); }
   get hasUnsavedAnswers(): boolean { return this.answeredCount > 0 && !this.submitted(); }
   get primaryButtonLabel(): string {
     if (this.submitted()) return '完成并返回';
@@ -111,6 +113,7 @@ export class ReadingExerciseComponent implements OnInit {
 
   async submit(): Promise<void> {
     if (this.submitted() || this.saving()) return;
+    if (!isCompleteReadingQuestionSet(this.questions())) return;
     if (this.answeredCount < this.questions().length) return;
     const article = this.article();
     if (!article) return;
@@ -138,7 +141,7 @@ export class ReadingExerciseComponent implements OnInit {
   }
 
   reset(): void {
-    if (this.saving()) return;
+    if (this.saving() || this.legacyIncomplete()) return;
     this.questions.set(shuffle(this.questions()).map((question) => ({ ...question, options: shuffle(question.options) })));
     this.answers = new Array(this.questions().length).fill('');
     this.score.set(0);
@@ -149,12 +152,21 @@ export class ReadingExerciseComponent implements OnInit {
 
   async print(): Promise<void> {
     const id = this.article()?.objectId;
-    if (id) await this.router.navigate(['/words/reading/pdf', id], {
+    if (id && this.canPrint) await this.router.navigate(['/words/reading/pdf', id], {
       queryParams: { ...this.params, wordList:this.params.wordList.join(',') },
     });
   }
 
   async createNewArticle(): Promise<void> {
+    if (this.legacyIncomplete()) {
+      const params = { ...this.params, sid: undefined, renew: true, wordList: this.params.wordList.join(','), t: Date.now() };
+      const navigated = await this.router.navigate(['/words/reading'], { queryParams: params, replaceUrl: true });
+      if (navigated) {
+        this.params = { ...this.params, sid: undefined, renew: true };
+        await this.load();
+      }
+      return;
+    }
     if (!this.remainingArticles()) return;
     await this.router.navigate(['/words/go-reading'], {
       queryParams: { ...this.params, wordList: this.params.wordList.join(','), type: '随机', theme: '随机', renew: true, t: Date.now() },
@@ -232,6 +244,7 @@ export class ReadingExerciseComponent implements OnInit {
     this.submitted.set(false);
     this.saving.set(false);
     this.saveFailed.set(false);
+    this.legacyIncomplete.set(false);
     this.loadingTip.set(this.tips[Math.floor(Math.random() * this.tips.length)]);
     try {
       await this.cloud.ensureUser(this.readingLearner(), this.params.phone);
@@ -241,20 +254,33 @@ export class ReadingExerciseComponent implements OnInit {
         120000, '短文生成',
       );
       this.article.set(article);
+      const attachArticleToRoute = !this.params.sid || this.params.renew;
+      this.params = { ...this.params, sid: article.objectId, renew: false };
+      if (attachArticleToRoute) {
+        await this.router.navigate(['/words/reading'], {
+          queryParams: { ...this.params, wordList: this.params.wordList.join(','), t: Date.now() },
+          replaceUrl: true,
+        });
+      }
 
       const latest = await this.reading.latestLog(article.objectId);
       let questions = await this.reading.getQuestions(article);
       if (!latest) {
-        const missing = this.params.wordList.filter((word) => !article.content.toLowerCase().includes(word.toLowerCase()));
-        article = await this.withTimeout(
-          this.reading.supplyMissingWords(article, missing, (value) => this.updateArticleProgress(value)),
-          120000, '词汇补全',
-        );
-        this.article.set(article);
-        questions = questions.length ? questions : await this.withTimeout(
+        if (!questions.length) {
+          const missing = this.params.wordList.filter((word) => !article.content.toLowerCase().includes(word.toLowerCase()));
+          article = await this.withTimeout(
+            this.reading.supplyMissingWords(article, missing, (value) => this.updateArticleProgress(value)),
+            120000, '词汇补全',
+          );
+          this.article.set(article);
+        }
+        questions = await this.withTimeout(
           this.reading.createQuestions(article, (value) => this.progress.set(value)),
           180000, '题目生成',
         );
+        if (!isCompleteReadingQuestionSet(questions)) throw new Error('题目尚未完整保存,请重试补齐后再答题或打印');
+      } else if (!isCompleteReadingQuestionSet(questions)) {
+        this.legacyIncomplete.set(true);
       }
       this.questions.set(shuffle(questions).map((question) => ({ ...question, options: shuffle(question.options) })));
       this.answers = new Array(questions.length).fill('');

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.html

@@ -2,7 +2,7 @@
   @if (wechatHint()) { <div class="wechat-backdrop" (click)="wechatHint.set(false)"><section class="wechat-dialog" (click)="$event.stopPropagation()"><h2>提示</h2><p>微信不支持直接下载 PDF,请点击右上角菜单选择“在浏览器中打开”</p><small>点击右上角 ⋯ → 在浏览器中打开</small><button type="button" class="primary-button" (click)="wechatHint.set(false)">知道了</button></section></div> }
   <header class="pdf-toolbar"><button type="button" (click)="back()"><lucide-icon [img]="icons.ArrowLeft" [size]="19" />返回</button><strong>PDF 导出预览</strong><span>{{ article()?.content?.split(' ')?.length || 0 }} words</span></header>
   @if (loading()) { <div class="pdf-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载打印内容</div> }
-  @else if (error()) { <div class="pdf-state">{{ error() }}</div> }
+  @else if (error()) { <div class="pdf-state">{{ error() }}<button type="button" class="primary-button" (click)="back()">返回练习页</button></div> }
   @else {
     <main #pdfContent class="pdf-document">
       <section class="reading-section"><h1>{{ article()?.title }}</h1><div class="document-meta"><span>{{ article()?.createOptions?.params?.level }}</span><span>{{ article()?.createOptions?.params?.type }}</span><span>{{ article()?.createOptions?.params?.theme }}</span><span>{{ questions().length }} 道题</span></div><div class="reading-content" [innerHTML]="articleHtml"></div></section>

+ 57 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.spec.ts

@@ -0,0 +1,57 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+import { PlatformService } from '../../core/platform.service';
+import { SessionService } from '../../core/session.service';
+import { EnglishReadingService } from './english-reading.service';
+import { ReadingCloudService } from './reading-cloud.service';
+import { ReadingArticle, ReadingQuestion } from './reading.models';
+import { ReadingPdfComponent } from './reading-pdf.component';
+
+describe('ReadingPdfComponent', () => {
+  const article: ReadingArticle = {
+    objectId: 'article-1', type: 'reading', title: 'A story', content: 'Apple grows by the river.',
+    createOptions: { tpl: 'article', params: { wordList: ['apple'], count: 120, stage: '小学', level: '三年级', type: '记叙文', theme: '校园' } },
+  };
+  const questions: ReadingQuestion[] = Array.from({ length: 5 }, (_, index) => ({
+    objectId: `question-${index}`, type: 'reading-item', parent: { __type: 'Pointer', className: 'SurveyItem', objectId: article.objectId },
+    index, title: `Question ${index}`, options: [
+      { value: 'A', check: true }, { value: 'B', check: false }, { value: 'C', check: false }, { value: 'D', check: false },
+    ],
+  }));
+
+  async function setup(savedQuestions: ReadingQuestion[]) {
+    const cloud = jasmine.createSpyObj<ReadingCloudService>('ReadingCloudService', ['ensureUser', 'get']);
+    const reading = jasmine.createSpyObj<EnglishReadingService>('EnglishReadingService', ['getQuestions']);
+    cloud.get.and.resolveTo(article);
+    reading.getQuestions.and.resolveTo(savedQuestions);
+    await TestBed.configureTestingModule({
+      imports: [ReadingPdfComponent],
+      providers: [
+        { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ sid: article.objectId }), queryParamMap: convertToParamMap({}) } } },
+        { provide: Router, useValue: jasmine.createSpyObj<Router>('Router', ['navigate', 'navigateByUrl']) },
+        { provide: SessionService, useValue: { groupId: () => 1, user: () => ({ userId: 7 }), student: () => null } },
+        { provide: PlatformService, useValue: jasmine.createSpyObj<PlatformService>('PlatformService', ['toast']) },
+      ],
+    }).overrideComponent(ReadingPdfComponent, {
+      set: { providers: [
+        { provide: ReadingCloudService, useValue: cloud },
+        { provide: EnglishReadingService, useValue: reading },
+      ] },
+    }).compileComponents();
+    const component = TestBed.createComponent(ReadingPdfComponent).componentInstance;
+    await component.ngOnInit();
+    return component;
+  }
+
+  it('refuses direct PDF access while the cloud has only one question', async () => {
+    const component = await setup(questions.slice(0, 1));
+    expect(component.questions()).toEqual([]);
+    expect(component.error()).toContain('题目尚未完整保存');
+  });
+
+  it('loads five saved questions without requiring an online answer log', async () => {
+    const component = await setup(questions);
+    expect(component.error()).toBe('');
+    expect(component.questions().length).toBe(5);
+  });
+});

+ 3 - 2
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts

@@ -7,7 +7,7 @@ import { SessionService } from '../../core/session.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { EnglishReadingService } from './english-reading.service';
 import { ReadingArticle, ReadingQuestion, ReadingRouteParams } from './reading.models';
-import { highlightWords, normalizeReadingParams, normalizeReadingReturnUrl } from './reading.utils';
+import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl } from './reading.utils';
 
 @Component({
   selector: 'app-reading-pdf',
@@ -59,6 +59,7 @@ export class ReadingPdfComponent implements OnInit {
       await this.cloud.ensureUser(this.readingLearner());
       const article = await this.cloud.get<ReadingArticle>('SurveyItem', this.sid);
       const questions = await this.reading.getQuestions(article);
+      if (!isCompleteReadingQuestionSet(questions)) throw new Error('题目尚未完整保存,请返回练习页补齐后再打印');
       this.article.set(article);
       this.questions.set(questions);
     } catch (error) {
@@ -89,7 +90,7 @@ export class ReadingPdfComponent implements OnInit {
   async exportPdf(): Promise<void> {
     const element = this.pdfContent?.nativeElement;
     const article = this.article();
-    if (!element || !article) return;
+    if (!element || !article || !isCompleteReadingQuestionSet(this.questions())) return;
     if (navigator.userAgent.toLowerCase().includes('micromessenger')) {
       this.wechatHint.set(true);
       return;

+ 33 - 2
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.ts

@@ -1,4 +1,4 @@
-import { ReadingQuestion, ReadingRouteParams } from './reading.models';
+import { ReadingOption, ReadingQuestion, ReadingRouteParams } from './reading.models';
 
 export const READING_LEVELS = [
   '一年级', '二年级', '三年级', '四年级', '五年级', '六年级',
@@ -13,6 +13,8 @@ export const READING_THEMES = [
   '文学故事', '人际沟通', '历史发展', '古代科技',
 ] as const;
 
+export const READING_QUESTION_COUNT = 5;
+
 export function normalizeReadingReturnUrl(value: unknown, fallback = '/pages/his/his'): string {
   const url = String(value ?? '').trim();
   return url.startsWith('/') && !/^\/[\\/]/.test(url) ? url : fallback;
@@ -71,7 +73,36 @@ export function shuffle<T>(values: T[]): T[] {
   return result;
 }
 
-export function normalizeReadingQuestions(values: ReadingQuestion[], limit = 5): ReadingQuestion[] {
+export function isValidReadingQuestion(value: unknown): value is { title: string; options: ReadingOption[] } {
+  if (!value || typeof value !== 'object') return false;
+  const question = value as Record<string, unknown>;
+  if (typeof question['title'] !== 'string' || !question['title'].trim()) return false;
+  if (!Array.isArray(question['options']) || question['options'].length !== 4) return false;
+  const options = question['options'] as unknown[];
+  const values = new Set<string>();
+  let correct = 0;
+  for (const option of options) {
+    if (!option || typeof option !== 'object') return false;
+    const item = option as Record<string, unknown>;
+    if (typeof item['value'] !== 'string' || !item['value'].trim() || typeof item['check'] !== 'boolean') return false;
+    values.add(item['value'].trim().toLowerCase());
+    if (item['check']) correct += 1;
+  }
+  return values.size === 4 && correct === 1;
+}
+
+export function isCompleteReadingQuestionSet(questions: ReadingQuestion[]): boolean {
+  if (questions.length !== READING_QUESTION_COUNT) return false;
+  const indexes = new Set<number>();
+  for (const question of questions) {
+    const index = Number(question.index);
+    if (!Number.isInteger(index) || index < 0 || index >= READING_QUESTION_COUNT || !isValidReadingQuestion(question)) return false;
+    indexes.add(index);
+  }
+  return indexes.size === READING_QUESTION_COUNT;
+}
+
+export function normalizeReadingQuestions(values: ReadingQuestion[], limit = READING_QUESTION_COUNT): ReadingQuestion[] {
   const newest = [...values].sort((left, right) => {
     const dateDifference = Date.parse(right.createdAt || '') - Date.parse(left.createdAt || '');
     return (Number.isFinite(dateDifference) ? dateDifference : 0) || right.objectId.localeCompare(left.objectId);