Quellcode durchsuchen

fix: persist complete unit progress and improve learning interactions

彭峰 vor 6 Stunden
Ursprung
Commit
73a4b26419
24 geänderte Dateien mit 392 neuen und 55 gelöschten Zeilen
  1. 21 0
      docs/qa/learning-experience-2026-10-13/README.md
  2. 44 0
      docs/qa/learning-experience-2026-10-13/browser-check.cjs
  3. 28 0
      docs/qa/learning-experience-2026-10-13/browser-result.json
  4. BIN
      docs/qa/learning-experience-2026-10-13/listening-375.png
  5. 8 0
      docs/qa/learning-experience-2026-10-13/online-result.json
  6. BIN
      docs/qa/learning-experience-2026-10-13/unit-375.png
  7. BIN
      docs/qa/learning-experience-2026-10-13/unit-812.png
  8. 15 8
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  9. 26 0
      projects/xiaoshu-mobile/src/app/core/learning-random.spec.ts
  10. 32 0
      projects/xiaoshu-mobile/src/app/core/learning-random.ts
  11. 7 2
      projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts
  12. 15 0
      projects/xiaoshu-mobile/src/app/core/platform.service.spec.ts
  13. 12 1
      projects/xiaoshu-mobile/src/app/core/platform.service.ts
  14. 1 1
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html
  15. 21 15
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  16. 6 1
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html
  17. 35 27
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  18. BIN
      public/assets/audio/learning-error.wav
  19. BIN
      public/assets/audio/learning-success.wav
  20. 39 0
      scripts/cloud/lesson-unit-progress.js
  21. 2 0
      scripts/deploy-admin-functions.mjs
  22. 20 0
      scripts/generate-learning-sounds.py
  23. 17 0
      scripts/smoke-mobile-teaching-flow.mjs
  24. 43 0
      scripts/tests/lesson-unit-progress.test.mjs

+ 21 - 0
docs/qa/learning-experience-2026-10-13/README.md

@@ -0,0 +1,21 @@
+# 教师带学四项修复验收(2026-10-13)
+
+## 修改与边界
+
+- “标记已学完”原来只写 CourseBinding.syjd,进度条却读取 PracticeRecord,因此数字不会变化。新增 app_lesson_unit_complete,由服务端核验预约老师、学员开课与单元归属,事务内补齐全部词条的已学状态,再保存标记。已有学习次数和生词标志保留;失败整笔回滚。页面等待服务端成功后显示 100%,失败不改变进度。
+- 第二轮原来调用 reverse();改为 Fisher–Yates 随机洗牌,三个以上词避免与当前顺序相同或刚好完全倒序,两个词只能交换位置。
+- 听音四选一每题优先两个本轮之外的中文释义,再选一个本轮干扰项。词库随机查询在服务器排除本轮所有词条,前端按词条和释义去重、随机摆放正确答案;复用随机池并拒绝迟到响应。加载期间不能抢答;失败可使用已有词汇组成完整四选一,仍不足四项时提供重新加载。
+- 成功音改为轻柔双音,错误音改为短低音,均为自制 0.26 秒本地 WAV、播放音量 35%。提示音独立于单词发音,避免截断。资源生成脚本为 scripts/generate-learning-sounds.py。
+- 整单元手动标记只影响词汇进度,不创建课堂学习批次、抗遗忘或工资,不扣课时。正式课堂成果仍按逐轮确认机制保存。
+
+## 验证
+
+- 移动端 218 项测试通过;后端 181 项测试通过。
+- SQL 测试使用真实 PostgreSQL 兼容执行器,覆盖 205 词跨批事务、重复确认、不覆盖已有次数和生词状态、越权拒绝、后续更新失败时全部回滚。
+- 学生/老师端和管理后台生产构建成功;云函数 11 个定义校验通过;120 个前端动作审计无缺失。
+- 本地 Chrome 页面:保存失败保持 52%,成功变成 100%,刷新仍为 100%;375/812 像素无横向溢出。四个听音选项包含两个外部释义;两种 WAV 均可读取和解码。截图和 browser-result.json 位于本目录。
+- 已发布配套 app gateway,版本 2026-10-13-learning-experience-v9。线上源码与本地构建定义核对一致,其他云函数未改;本次复用现有表和事务函数,无数据库结构迁移。
+- 隔离账号真实链路 74 项通过:刷新后真实单元计数为 100%、重复点击幂等、不改变余额/流水、不虚构课堂成果;随机接口确实排除本轮词条。回归 9+5+3=17 词、多连接保存/结课、10 次复习、报告修改、评价、扣费及权限。测试账号和业务数据已清理。此轮没有调用付费 AI 生成。
+- 云端备份、结构探测及原始验收日志保存在本机权限受限目录 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-13-learning-experience-v9/`,不进入仓库。
+
+前端仍由用户上线。浏览器验收为本机 Chrome,尚未验证真机微信 WebView;音效听感可按实际使用反馈微调。

+ 44 - 0
docs/qa/learning-experience-2026-10-13/browser-check.cjs

@@ -0,0 +1,44 @@
+const {chromium}=require('playwright');const fs=require('fs');
+(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true}),page=await browser.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
+await page.addInitScript(()=>{
+ if(location.pathname.includes('/stu/')) {
+  localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:fixture',user:{userId:100,groupId:1,honeyName:'隔离学员'}}));
+  const prefix='xiaoshu.learning.100.100.practice.',words=[{GeneralID:1,Title:'apple',sy:'苹果'},{GeneralID:2,Title:'banana',sy:'香蕉'},{GeneralID:3,Title:'orange',sy:'橙子'},{GeneralID:4,Title:'pear',sy:'梨'}];
+  for(const [key,value]of Object.entries({learn_mode:'newword',choose_newword_learn:words,_page_word_orilist:[words],_page_current_learn:1}))localStorage.setItem(prefix+key,JSON.stringify(value));
+  return;
+ }
+ localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:fixture',user:{userId:30,groupId:3,honeyName:'测试老师'},student:{userId:20,groupId:1,honeyName:'测试学员'}}));
+});
+let learned=52,fail=true;const order={GeneralID:901,szyh:20,pl:30,dszt:10,HoneyName:'测试学员',bindingId:'binding',kcid:161,allowedActions:['continue','complete'],updatedAt:new Date().toISOString()};
+await page.route('**/*',r=>['127.0.0.1','localhost'].includes(new URL(r.request().url()).hostname)?r.continue():r.abort());
+await page.route('**/cloud-functions/**',async r=>{const p=r.request().postDataJSON()?.params||{};let result=[];
+if(p.action==='e_order_detail')result=[order];
+if(p.action==='app_lesson_study')result={record:null,batches:[],pending:0};
+if(p.action==='app_lesson_courses')result={items:[{bindingId:'binding',kcid:161,Title:'高中词库',categoryKey:'word',syjd:learned===100?'{"172":"172@必修一全册@1"}':'{}'}]};
+if(p.action==='node_list')result=[{NodeID:172,NodeName:'必修一全册',total:100,yx_word:learned}];
+if(p.action==='app_live_versions')result={version:'fixture',scopeVersions:{},syncStatus:{state:'current'}};
+if(p.action==='app_lesson_unit_complete'){
+ if(fail){fail=false;return r.fulfill({status:503,json:{retcode:-1,retmsg:'保存失败'}});}
+ learned=100;result={nodeId:172,total:100,learned:100,percent:100,syjd:'{"172":"172@必修一全册@1"}'};
+}
+if(p.action==='content_list')result=Array.from({length:20},(_,i)=>({GeneralID:300+i,Title:'extra'+i,sy:'拓展释义'+i}));
+await r.fulfill({json:{retcode:0,result}});
+});
+const out='docs/qa/learning-experience-2026-10-13';
+await page.setViewportSize({width:375,height:812});await page.goto('http://127.0.0.1:4200/pages/shop/get_order?id=901');
+await page.getByText('已学 52%',{exact:true}).waitFor();await page.getByRole('button',{name:'标记已学完'}).click();
+await page.getByRole('button',{name:'标记已学完'}).waitFor();await page.waitForFunction(()=>!document.querySelector('.mark-complete')?.disabled);
+if(!(await page.getByText('已学 52%',{exact:true}).count()))throw Error('failed save changed progress');
+await page.getByRole('button',{name:'标记已学完'}).click();await page.getByText('已学 100%',{exact:true}).waitFor();await page.reload();await page.getByText('已学 100%',{exact:true}).waitFor();
+for(const width of [375,812]){await page.setViewportSize({width,height:812});await page.locator('.unit-grid').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/unit-'+width+'.png'});if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('overflow');}
+await page.setViewportSize({width:375,height:812});await page.goto('http://127.0.0.1:4200/pages/stu/stu4');
+await page.locator('.option-list button').first().waitFor();
+const options=await page.locator('.option-list button').allTextContents();
+if(options.length!==4||options.filter(x=>x.startsWith('拓展释义')).length<2)throw Error('missing external choices');
+await page.screenshot({path:out+'/listening-375.png',fullPage:true});
+const audio=await page.evaluate(async()=>{const results=[];for(const kind of ['success','error']){const response=await fetch('/assets/audio/learning-'+kind+'.wav');const data=await response.arrayBuffer();const context=new AudioContext();const buffer=await context.decodeAudioData(data);results.push({kind,ok:response.ok,duration:buffer.duration});await context.close();}return results;});
+if(audio.some(a=>!a.ok||a.duration<.1||a.duration>.5))throw Error('invalid audio asset');
+await browser.close();
+fs.writeFileSync(out+'/browser-result.json',JSON.stringify({viewports:[375,812],failedSavePreserved:true,savedProgress:100,refreshProgress:100,listeningOptions:options,audio,pageErrors:errors},null,2));
+if(errors.length)throw Error(errors.join('\n'));console.log('Unit progress failure, success, reload and responsive browser checks passed');
+})().catch(e=>{console.error(e);process.exit(1)});

+ 28 - 0
docs/qa/learning-experience-2026-10-13/browser-result.json

@@ -0,0 +1,28 @@
+{
+  "viewports": [
+    375,
+    812
+  ],
+  "failedSavePreserved": true,
+  "savedProgress": 100,
+  "refreshProgress": 100,
+  "listeningOptions": [
+    "拓展释义16",
+    "梨",
+    "香蕉",
+    "拓展释义9"
+  ],
+  "audio": [
+    {
+      "kind": "success",
+      "ok": true,
+      "duration": 0.26
+    },
+    {
+      "kind": "error",
+      "ok": true,
+      "duration": 0.26
+    }
+  ],
+  "pageErrors": []
+}

BIN
docs/qa/learning-experience-2026-10-13/listening-375.png


+ 8 - 0
docs/qa/learning-experience-2026-10-13/online-result.json

@@ -0,0 +1,8 @@
+{
+  "version": "2026-10-13-learning-experience-v9",
+  "gatewaySHA256": "590eef3dfdecbd971fc774a1b5a50eb49ec3472151a3dddc396246880285be9e",
+  "unrelatedFunctionsUnchanged": true,
+  "checks": 74,
+  "passed": true,
+  "fixturesCleaned": true
+}

BIN
docs/qa/learning-experience-2026-10-13/unit-375.png


BIN
docs/qa/learning-experience-2026-10-13/unit-812.png


+ 15 - 8
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -39,6 +39,9 @@ class LearningModulesApiService {
   ];
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (action === 'content_list' && params['orders'] === 'NEWID()') {
+      return this.result<T>(Array.from({length: 8}, (_, i) => ({GeneralID: 9000 + i, Title: `external-${i}`, sy: `外部释义 ${i}`})));
+    }
     if (action === 'content_list' && Number(params['ModelID'] ?? params['modelId']) === 56 && String(params['myfield2']).includes('dsid=')) {
       this.lastAppointmentRecordParams = params;
       if (!String(params['myfield2']).includes('|pl=300')) return throwError(() => new Error('无权查看该学员的内容记录'));
@@ -474,18 +477,22 @@ describe('home learning modules', () => {
     const firstWord = learning.current()!;
     const firstResponse = responses[0];
     expect(requestCount).toBe(1);
-    expect(learning.choiceOptions().some((option) => option.id === firstWord.id)).toBeTrue();
+    expect(learning.choicesLoading()).toBeTrue();
     learning.chooseMeaning(firstWord);
-    const secondWord = learning.current()!;
-    expect(secondWord.id).not.toBe(firstWord.id);
-    const secondResponse = responses[1];
-    firstResponse?.next({ retcode: 1, result: [{ GeneralID: 201, Title: 'meeting', sy: '集会' }] });
-    expect(learning.choiceOptions().some((option) => option.id === secondWord.id)).toBeTrue();
-    secondResponse?.next({ retcode: 1, result: Array.from({ length: 8 }, (_, index) => ({
+    expect(learning.current()).toBe(firstWord); // Cannot answer while options are loading.
+    firstResponse?.next({ retcode: 1, result: Array.from({ length: 8 }, (_, index) => ({
       GeneralID: 301 + index, Title: `extra-${index}`, sy: `干扰项 ${index}`,
     })) });
     expect(learning.choiceOptions().length).toBe(4);
-    expect(learning.choiceOptions().some((option) => option.id === secondWord.id)).toBeTrue();
+    expect(learning.choiceOptions().filter(option => Number(option.id) >= 301).length).toBeGreaterThanOrEqual(2);
+    learning.chooseMeaning(firstWord);
+    const secondWord = learning.current()!;
+    expect(secondWord.id).not.toBe(firstWord.id);
+    expect(requestCount).toBe(1); // Reuse the pool instead of requesting each question.
+    firstResponse?.next({ retcode: 1, result: [{ GeneralID: 999, Title: 'stale', sy: '迟到结果' }] });
+    expect(learning.choiceOptions().length).toBe(4);
+    expect(learning.choiceOptions().some(option => option.id === '999')).toBeFalse();
+    expect(learning.choiceOptions().some(option => option.id === secondWord.id)).toBeTrue();
     localStorage.removeItem(prefix + 'learn_mode');
     localStorage.removeItem(prefix + 'choose_newword_learn');
     localStorage.removeItem(prefix + '_page_word_orilist');

+ 26 - 0
projects/xiaoshu-mobile/src/app/core/learning-random.spec.ts

@@ -0,0 +1,26 @@
+import { listeningChoices, shuffledStudy } from './learning-random';
+describe('课堂乱序与听音干扰项', () => {
+  it('真正打乱,不固定倒序;保持完整词表且不修改原数组', () => {
+    const source = [1,2,3,4,5,6];
+    const seen = new Set<string>();
+    for (let i = 0; i < 60; i++) {
+      const result = shuffledStudy(source);
+      expect(result).not.toEqual(source); expect(result).not.toEqual([...source].reverse());
+      expect([...result].sort()).toEqual(source); seen.add(result.join(','));
+    }
+    expect(seen.size).toBeGreaterThan(5); expect(source).toEqual([1,2,3,4,5,6]);
+    expect(shuffledStudy(source, () => .999)).not.toEqual(source);
+    expect(shuffledStudy([1,2], () => .999)).toEqual([2,1]);
+    expect(shuffledStudy([])).toEqual([]);
+  });
+  const w = (id: string, meaning: string) => ({id,meaning});
+  const round = [w('1','一'),w('2','二'),w('3','三'),w('4','四')];
+  it('足够本轮词汇时仍加入两个外部释义,选项不重名', () => {
+    const choices = listeningChoices(round[0], round, [round[2],w('5','一'),w('6','外部甲'),w('7','外部甲'),w('8','外部乙')], x => x);
+    expect(choices.length).toBe(4); expect(choices.map(x=>x.id)).toEqual(['1','6','8','2']);
+  });
+  it('只有一个学习词也可补齐四选一,失败时保留可用本轮选项', () => {
+    expect(listeningChoices(round[0], [round[0]], [w('6','甲'),w('7','乙'),w('8','丙')], x=>x).length).toBe(4);
+    expect(listeningChoices(round[0], round, [], x=>x)).toEqual(round);
+  });
+});

+ 32 - 0
projects/xiaoshu-mobile/src/app/core/learning-random.ts

@@ -0,0 +1,32 @@
+/** Fisher–Yates, with a bounded retry so a teaching shuffle visibly changes order. */
+export function shuffledStudy<T>(items: readonly T[], random = Math.random): T[] {
+  const shuffle = () => {
+    const out = [...items];
+    for (let i = out.length - 1; i > 0; i--) {const j = Math.floor(random() * (i + 1)); [out[i], out[j]] = [out[j], out[i]];}
+    return out;
+  };
+  if (items.length < 2) return [...items];
+  for (let attempt = 0; attempt < 12; attempt++) {
+    const out = shuffle();
+    if (!out.every((item, i) => item === items[i]) && (items.length === 2 || !out.every((item, i) => item === items[items.length - 1 - i]))) return out;
+  }
+  // Also terminates with a deterministic/mock RNG; two words can only swap.
+  return [...items.slice(1), items[0]];
+}
+
+export function listeningChoices<T extends {id: string; meaning: string}>(current: T, round: T[], external: T[], shuffle: (items: T[]) => T[]): T[] {
+  const ids = new Set(round.map(w => w.id));
+  const meanings = new Set([current.meaning.trim()]);
+  const selected: T[] = [];
+  const take = (pool: T[], limit: number) => {
+    for (const w of shuffle(pool)) {
+      if (selected.length >= limit) break;
+      const meaning = w.meaning.trim();
+      if (!w.id || w.id === current.id || !meaning || meanings.has(meaning) || selected.some(s => s.id === w.id)) continue;
+      selected.push(w); meanings.add(meaning);
+    }
+  };
+  const outside = external.filter(w => !ids.has(w.id));
+  take(outside, 2); take(round, 3); take(outside, 3);
+  return shuffle([current, ...selected]);
+}

+ 7 - 2
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -28,6 +28,7 @@ describe('appointment workflow screens',()=>{
    if(action==='app_companion_overview')return response({courses:[],schedules:[{...order}],dates:[{date:'2099-10-11',finished:0,planned:1}],studentOptions:[],page:{itemCount:1,pageCount:1},syncStatus:{state:'current'}});
    if(action==='user_get')return response({userId:20,honeyName:'测试学员',groupId:1});
    if(action==='app_lesson_courses')return failCourses?throwError(()=>new ApiError('课程读取失败')):response({items:courses||[{bindingId:'binding-1',GeneralID:5801,kcid:801,Title:'词表',categoryKey:order['courseCategoryKey'],scheduled:true,syjd:'{}'},{bindingId:'binding-2',GeneralID:5802,kcid:802,Title:'另一词库',categoryKey:'word',syjd:'{"822":"822@Unit 2@1"}'}]});
+   if(action==='app_lesson_unit_complete')return response({nodeId:params['nodeId'],total:300,learned:300,percent:100,syjd:JSON.stringify({[String(params['nodeId'])]:params['nodeId']+'@Unit@1'})});
    if(action==='node_list')return delayedUnits||response([{NodeID:params['pid']==='802'?822:811,NodeName:'Unit 1'}]);
    if(action==='e_order_update_v2'){order={...order,dszt:10,allowedActions:['continue','complete']};return response({status:10});}
    if(action==='app_lesson_complete'){order={...order,dszt:30,allowedActions:[],reviewStatus:'pending'};return response({status:30});}
@@ -142,8 +143,12 @@ describe('appointment workflow screens',()=>{
   old.next({result:[{NodeID:811,NodeName:'Old'}]});
   expect(component.units()[0]['NodeID']).toBe(822);
   await component.markUnitComplete({NodeID:822,NodeName:'Unit 2'});
-  const payload=calls.find(c=>c.action==='content_update')?.params;
-  expect(JSON.parse(String(payload?.['content'])).GeneralID).toBe(5802);
+  const payload=calls.find(c=>c.action==='app_lesson_unit_complete')?.params;
+  expect(payload?.['bindingId']).toBe('binding-2');
+  expect(payload?.['nodeId']).toBe('822');
+  expect(component.unitProgress(component.units()[0])?.percent).toBe(100);
+  expect(component.unitProgress(component.units()[0])?.learned).toBe(300);
+  expect(component.completingUnits().size).toBe(0);
  });
  for(const mode of ['empty','failure'])it(`blocks start when the course list is ${mode}`,async()=>{
   if(mode==='empty')courses=[];else failCourses=true;

+ 15 - 0
projects/xiaoshu-mobile/src/app/core/platform.service.spec.ts

@@ -0,0 +1,15 @@
+import { PlatformService } from './platform.service';
+describe('学习提示音', () => {
+  it('新音效使用本地资源,轻音量,播放单词不会打断音效', () => {
+    const audios: any[] = [];
+    spyOn(window, 'Audio').and.callFake((function(url: string) {
+      const audio = {url,volume:1,play:jasmine.createSpy().and.returnValue(Promise.resolve()),pause:jasmine.createSpy()};
+      audios.push(audio); return audio;
+    }) as any);
+    const service = new PlatformService();
+    service.play('/word.mp3'); service.playEffect('success'); service.play('/next.mp3');
+    expect(audios[1].url).toBe('assets/audio/learning-success.wav'); expect(audios[1].volume).toBe(.35);
+    expect(audios[1].pause).not.toHaveBeenCalled(); expect(audios[0].pause).toHaveBeenCalled();
+    service.stopAudio(); expect(audios[1].pause).toHaveBeenCalled(); expect(audios[2].pause).toHaveBeenCalled();
+  });
+});

+ 12 - 1
projects/xiaoshu-mobile/src/app/core/platform.service.ts

@@ -11,6 +11,7 @@ export class PlatformService {
   readonly toastState = signal<ToastState | null>(null);
   private toastTimer: ReturnType<typeof setTimeout> | null = null;
   private audio: HTMLAudioElement | null = null;
+  private effectAudio: HTMLAudioElement | null = null;
 
   toast(message: string, tone: ToastState['tone'] = 'info'): void {
     if (this.toastTimer) clearTimeout(this.toastTimer);
@@ -36,12 +37,22 @@ export class PlatformService {
   }
 
   play(url: string): void {
-    this.stopAudio();
+    this.audio?.pause();
     this.audio = new Audio(url);
     void this.audio.play().catch(() => this.toast('音频播放失败', 'error'));
   }
 
+  playEffect(kind: 'success' | 'error'): void {
+    this.effectAudio?.pause();
+    this.effectAudio = new Audio(`assets/audio/learning-${kind}.wav`);
+    this.effectAudio.volume = 0.35;
+    // A blocked or unavailable decorative sound must not interrupt teaching.
+    void this.effectAudio.play().catch(() => undefined);
+  }
+
   stopAudio(): void {
+    this.effectAudio?.pause();
+    this.effectAudio = null;
     this.audio?.pause();
     this.audio = null;
   }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -73,7 +73,7 @@
                   @if (completedUnits().has(unitId(unit))) { <span class="unit-completion-note">已标记学完</span> }
                 </span>
               </button>
-              <button type="button" class="mark-complete" [disabled]="!can('start') && !can('continue')" title="标记已学完" aria-label="标记已学完" (click)="markUnitComplete(unit)"><lucide-icon [img]="icons.BookCheck" [size]="17" /></button>
+              <button type="button" class="mark-complete" [disabled]="completingUnits().has(selectedBindingId + ':' + unitId(unit)) || (!can('start') && !can('continue'))" title="标记已学完" aria-label="标记已学完" (click)="markUnitComplete(unit)"><lucide-icon [img]="icons.BookCheck" [size]="17" /></button>
             </article>
           } @empty { <div class="empty-state"><p>{{unitsLoading() ? '正在加载学习单元…' : selectedBindingId ? '暂无可用学习单元' : '请选择课程后加载单元'}}</p></div> }
         </div>

+ 21 - 15
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -37,6 +37,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   private unitRequest?: Subscription;
   readonly units = signal<SourceItem[]>([]);
   readonly selectedUnits = signal<Set<string>>(new Set());
+  readonly completingUnits = signal<Set<string>>(new Set());
   readonly completedUnits = signal<Set<string>>(new Set());
   readonly bindingRecord = signal<SourceItem>({});
   readonly loading = signal(true);
@@ -221,7 +222,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     this.selectedCourse = String(course?.['kcid'] || '');
     if (!course) return;
     this.bindingRecord.set(course);
-    this.completedUnits.set(new Set(Object.keys(this.parseRecord(course['syjd']))));
+    this.completedUnits.set(new Set(Object.entries(this.parseRecord(course['syjd'])).filter(([, value]) => String(value).endsWith('@1')).map(([id]) => id)));
     if (!this.isWriting) this.loadUnits();
   }
 
@@ -237,22 +238,27 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   async markUnitComplete(unit: SourceItem): Promise<void> {
     if (!this.detailVerified() || (!this.can('start') && !this.can('continue'))) return;
     const selectedBindingId = this.selectedBindingId;
-    if (!(await this.platform.confirm(`确认将“${this.unitTitle(unit)}”标记为已学完?`))) return;
-    if (selectedBindingId !== this.selectedBindingId) return;
-    const id = String(unit['NodeID'] ?? unit['ID'] ?? '');
-    const binding = this.bindingRecord();
-    if (!binding['GeneralID']) {
-      this.platform.toast('未找到课程进度记录', 'error');
+    const id = this.unitId(unit), pendingKey = `${selectedBindingId}:${id}`;
+    if (this.completingUnits().has(pendingKey)) return;
+    this.completingUnits.update(set => new Set(set).add(pendingKey));
+    if (!(await this.platform.confirm(`确认将“${this.unitTitle(unit)}”的全部单词标记为已学完?`)) || selectedBindingId !== this.selectedBindingId) {
+      this.completingUnits.update(set => {const next = new Set(set); next.delete(pendingKey); return next;});
       return;
     }
-    const progress = this.parseRecord(binding['syjd']);
-    progress[id] = `${id}@${this.unitTitle(unit)}@1`;
-    this.api.post('content_update', {
-      content: JSON.stringify({ GeneralID: binding['GeneralID'], ModelID: 58 }),
-      addon: JSON.stringify({ syjd: JSON.stringify(progress) }),
-    }).subscribe({
-      next: () => { this.courses.update(rows => rows.map(row => String(row['bindingId']) === selectedBindingId ? {...row,syjd:JSON.stringify(progress)} : row)); if (selectedBindingId === this.selectedBindingId) { this.bindingRecord.set({...binding,syjd:JSON.stringify(progress)}); this.completedUnits.update(current => new Set(current).add(id)); } },
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
+    this.api.post<any>('app_lesson_unit_complete', {
+      orderId: this.orderId, bindingId: selectedBindingId, nodeId: id,
+      requestKey: `unit:${this.orderId}:${selectedBindingId}:${id}:${Date.now()}`,
+    }).pipe(finalize(() => this.completingUnits.update(set => {const next = new Set(set); next.delete(pendingKey); return next;}))).subscribe({
+      next: ({result}) => {
+        this.courses.update(rows => rows.map(row => String(row['bindingId']) === selectedBindingId ? {...row, syjd: result.syjd} : row));
+        if (selectedBindingId === this.selectedBindingId) {
+          this.bindingRecord.update(binding => ({...binding, syjd: result.syjd}));
+          this.completedUnits.update(current => new Set(current).add(id));
+          this.units.update(rows => rows.map(row => this.unitId(row) === id ? {...row, total: result.total, yx_word: result.learned} : row));
+        }
+        this.platform.toast('该单元已全部学完,进度已保存', 'success');
+      },
+      error: (error: ApiError) => this.platform.toast(error.message || '保存失败,请重试', 'error'),
     });
   }
 

+ 6 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -297,7 +297,12 @@
           <p>点击喇叭,选择正确释义</p>
         </div>
         <div class="option-list">
-          @for (option of choiceOptions(); track option.id) {
+          @if (choicesLoading()) { <p role="status">正在准备选项…</p> }
+          @if (!choicesLoading() && choiceOptions().length !== 4) {
+            <p role="status">选项暂未准备完整,请重新加载。</p>
+            <button type="button" (click)="retryListeningChoices()">重新加载选项</button>
+          }
+          @for (option of choiceOptions().length === 4 ? choiceOptions() : []; track option.id) {
             <button type="button" (click)="chooseMeaning(option)">{{ option.meaning || option.word }}</button>
           }
         </div>

+ 35 - 27
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -28,7 +28,7 @@ import {
   X,
 } from 'lucide-angular';
 import { catchError, finalize, forkJoin, map, Observable, of, Subscription, switchMap, tap, throwError } from 'rxjs';
-import { GAME_AUDIO } from '../../core/app.constants';
+import { listeningChoices, shuffledStudy } from '../../core/learning-random';
 import { ApiError, ApiService } from '../../core/api.service';
 import {
   AntiForgetCalendarDay,
@@ -149,6 +149,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly matchRight = signal<Word[]>([]);
   readonly leftChoice = signal('');
   readonly rightChoice = signal('');
+  readonly choicesLoading = signal(false);
   readonly choiceOptions = signal<Word[]>([]);
   readonly letters = signal<LetterChoice[]>([]);
   readonly assembled = signal('');
@@ -287,6 +288,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private calendarRequest?: Subscription;
   private choiceOptionsRequest?: Subscription;
   private choiceOptionsVersion = 0;
+  private outsideChoicePool: Word[] | null = null;
 
   ngOnInit(): void {
     if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.isReadOnlySelectionPage && !this.canStartPointReading && !this.isNewWordSelfStudyStage) {
@@ -733,7 +735,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   shuffleStudyWords(): void {
-    this.words.set(this.shuffle(this.words()));
+    this.words.set(shuffledStudy(this.words()));
     this.repetitions.set({});
   }
 
@@ -757,7 +759,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       this.rightChoice.set('');
       return;
     }
-    this.platform.play(this.api.assetUrl(GAME_AUDIO.success));
+    this.platform.playEffect('success');
     this.matchLeft.update((items) => items.filter((item) => item.id !== left));
     this.matchRight.update((items) => items.filter((item) => item.id !== right));
     this.leftChoice.set('');
@@ -766,16 +768,17 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   chooseMeaning(option: Word): void {
+    if (this.choicesLoading() || this.choiceOptions().length !== 4 || this.completed() || !this.choiceOptions().some(w => w.id === option.id)) return;
     const word = this.current();
     if (!word) return;
     if (option.id !== word.id) {
-      this.platform.play(this.api.assetUrl(GAME_AUDIO.error));
+      this.platform.playEffect('error');
       this.platform.toast('答错了,请重新听一遍', 'error');
       this.play(word);
       return;
     }
     this.correct.update((value) => value + 1);
-    this.platform.play(this.api.assetUrl(GAME_AUDIO.success));
+    this.platform.playEffect('success');
     if (this.currentIndex() >= this.words().length - 1) {
       this.completed.set(true);
       return;
@@ -795,11 +798,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     if (next.toLowerCase() === word.word.toLowerCase()) {
       this.spellingCorrect.set(true);
       this.correct.update((value) => value + 1);
-      this.platform.play(this.api.assetUrl(GAME_AUDIO.success));
+      this.platform.playEffect('success');
       this.platform.toast('拼写正确', 'success');
       return;
     }
-    this.platform.play(this.api.assetUrl(GAME_AUDIO.error));
+    this.platform.playEffect('error');
     this.platform.toast('拼写错了,再试一次', 'error');
     this.prepareSpelling();
   }
@@ -1393,7 +1396,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     if (this.studyStep() < 3) {
       const next = (this.studyStep() + 1) as 2 | 3;
       this.studyStep.set(next);
-      this.words.set(next === 2 ? [...this.words()].reverse() : this.shuffle(this.words()));
+      this.words.set(shuffledStudy(this.words()));
       this.repetitions.set({});
       this.platform.toast(next === 2 ? '进入乱序学习' : '进入中文挑战', 'success');
       return;
@@ -1401,6 +1404,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.navigateStage('/pages/stu/stu3');
   }
 
+  retryListeningChoices(): void { this.outsideChoicePool = null; this.prepareChoiceOptions(); }
+
   private prepareChoiceOptions(): void {
     this.choiceOptionsRequest?.unsubscribe();
     const version = ++this.choiceOptionsVersion;
@@ -1409,25 +1414,26 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       this.choiceOptions.set([]);
       return;
     }
-    const distractors = this.shuffle(this.words().filter((word) => word.id !== current.id && word.meaning !== current.meaning)).slice(0, 3);
-    this.choiceOptions.set(this.shuffle([current, ...distractors]));
-    if (distractors.length < 3) {
-      this.choiceOptionsRequest = this.api.get<unknown>('content_list', { modelId: 52, orders: 'NEWID()', cpage: 1, psize: 8 }).subscribe({
-        next: ({ result }) => {
-          if (version !== this.choiceOptionsVersion || this.current() !== current || this.completed()) return;
-          const usedIds = new Set([current.id, ...distractors.map((word) => word.id)]);
-          const usedMeanings = new Set([current.meaning, ...distractors.map((word) => word.meaning)]);
-          const extras = this.shuffle(this.findList(result).map((item) => this.toWord(item))).filter((word) => {
-            if (!word.id || !word.meaning || usedIds.has(word.id) || usedMeanings.has(word.meaning)) return false;
-            usedIds.add(word.id);
-            usedMeanings.add(word.meaning);
-            return true;
-          }).slice(0, 3 - distractors.length);
-          this.choiceOptions.set(this.shuffle([current, ...distractors, ...extras]));
-        },
-        error: () => undefined,
-      });
-    }
+    const round = [...this.readAllLearningWords(), ...this.words()];
+    const apply = (pool: Word[]) => {
+      if (version !== this.choiceOptionsVersion || this.current() !== current || this.completed()) return;
+      this.choiceOptions.set(listeningChoices(current, round, pool, items => this.shuffle(items)));
+      this.choicesLoading.set(false);
+    };
+    if (this.outsideChoicePool !== null) { apply(this.outsideChoicePool); return; }
+    this.choiceOptions.set([]);
+    this.choicesLoading.set(true);
+    this.choiceOptionsRequest = this.api.get<unknown>('content_list', { modelId: 52, orders: 'NEWID()', cpage: 1, psize: 60, excludeWordIds: [...new Set(round.map(w => w.id))].join(',') }).subscribe({
+      next: ({ result }) => {
+        if (version !== this.choiceOptionsVersion || this.current() !== current || this.completed()) return;
+        this.outsideChoicePool = this.findList(result).map(item => this.toWord(item));
+        apply(this.outsideChoicePool);
+      },
+      error: () => {
+        apply([]);
+        this.platform.toast('拓展选项暂未加载,本题使用已选词汇', 'info');
+      },
+    });
   }
 
   private prepareSpelling(): void {
@@ -2039,6 +2045,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private clearLearningStorage(): void { this.draft().clear(); }
 
   private resetPageState(): void {
+    this.outsideChoicePool = null;
+    this.choicesLoading.set(false);
     this.choiceOptionsVersion++;
     this.choiceOptionsRequest?.unsubscribe();
     this.stopExamTimer();

BIN
public/assets/audio/learning-error.wav


BIN
public/assets/audio/learning-success.wav


+ 39 - 0
scripts/cloud/lesson-unit-progress.js

@@ -0,0 +1,39 @@
+// Explicit teacher acknowledgement of an entire unit. This is progress only,
+// never a classroom study batch, review plan, credit deduction or pay event.
+async function completeLessonUnit(input,current) {
+ assertLessonTenant(current);
+ const gid=String(input.orderId||''),bindingId=String(input.bindingId||''),nodeId=String(input.nodeId||'');
+ if(number(current.get('legacyGroupId'))!==3)fail(403,'仅预约老师可以标记学完');
+ if(!gid||!bindingId||!/^\d+$/.test(nodeId))fail(400,'缺少预约、开课或单元');
+ const key=mobileCommandKey(input);if(!key)fail(400,'缺少操作标识,请刷新重试');
+ try{return await Psql.tx(async t=>{
+  await t.one('SELECT pg_advisory_xact_lock(hashtext($1))',['xs-credit:'+DEFAULT_COMPANY_ID]);
+  const a=await t.oneOrNone('SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=$1 AND c."generalId"::text=$2 AND c.status<>-2 FOR UPDATE OF a',[DEFAULT_COMPANY_ID,gid]);
+  if(!a||number(a.pl)!==ownLegacyId(current))fail(403,'仅预约老师可以标记该学员进度');
+  if(![0,10].includes(number(a.dszt)))fail(409,'预约已结束,请刷新详情');
+  const b=await t.oneOrNone('SELECT b.* FROM "CourseBinding" b JOIN "CommonModel" c ON c.company=b.company AND c."modelId"=58 AND c."itemId"=b.id JOIN "Node" n ON n.company=b.company AND n."nodeId"::text=b.kcid WHERE b.company=$1 AND b."objectId"=$2 AND b.yhid=$3 AND b."enrollmentStatus"=\'active\' AND c.status=99 AND COALESCE(n.zstatus,99)=99 FOR UPDATE OF b',[DEFAULT_COMPANY_ID,bindingId,String(a.szyh)]);
+  if(!b)fail(403,'该课程尚未分配给当前学员');
+  const nodes=await t.any('SELECT "nodeId"::text id,"nodeName" name FROM "Node" WHERE company=$1 AND "parentId"::text=$2',[DEFAULT_COMPANY_ID,String(b.kcid)]);
+  const unit=(nodes.length?nodes:courseAssignedUnits(b.syjd)).find(n=>n.id===nodeId);
+  if(!unit)fail(403,'单元不属于所选课程');
+  const words=await t.any('SELECT "generalId" FROM "CommonModel" WHERE company=$1 AND "modelId"=52 AND "nodeId"::text=$2 ORDER BY "generalId"',[DEFAULT_COMPANY_ID,nodeId]);
+  if(!words.length)fail(409,'该单元暂无单词');
+  if(words.length>10000)fail(409,'单元词量异常,请联系管理员');
+  // Same learner lock as e_add_words: do not overwrite a concurrent practice count.
+  await t.one('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',[DEFAULT_COMPANY_ID+':'+a.szyh]);
+  const rows=await t.any('SELECT scid,xxcs FROM "PracticeRecord" WHERE company=$1 AND yhid::text=$2 AND scid::text=ANY($3::text[])',[DEFAULT_COMPANY_ID,String(a.szyh),words.map(w=>String(w.generalId))]);
+  const learned=new Set(rows.filter(r=>number(r.xxcs)>0).map(r=>String(r.scid)));
+  const missing=words.filter(w=>!learned.has(String(w.generalId)));
+  for(let offset=0;offset<missing.length;offset+=100){
+   const operations=missing.slice(offset,offset+100).map(w=>({table:'PracticeRecord',match:{company:DEFAULT_COMPANY_ID,yhid:number(a.szyh),scid:number(w.generalId)},data:{objectId:randomObjectId(),createdAt:new Date().toISOString(),company:DEFAULT_COMPANY_ID,sourceKey:'teacher:unit:'+a.szyh+':'+w.generalId,yhid:number(a.szyh),scid:number(w.generalId),kcid:String(b.kcid),xxcs:1}}));
+   await t.one('SELECT xs_mobile_teaching_write($1,$2,$3,md5($4),$5::jsonb) result',[DEFAULT_COMPANY_ID,String(a.szyh),current.id+':unit:'+key+':'+offset,JSON.stringify({gid,bindingId,nodeId,words:operations.map(o=>o.data.scid)}),JSON.stringify(operations)]);
+  }
+  let progress;try{progress=JSON.parse(b.syjd||'{}');}catch(_){progress={};}
+  if(!progress||typeof progress!=='object'||Array.isArray(progress))progress={};
+  progress[nodeId]=nodeId+'@'+unit.name+'@1';
+  await t.none('UPDATE "CourseBinding" SET syjd=$3,"updatedAt"=now() WHERE company=$1 AND "objectId"=$2',[DEFAULT_COMPANY_ID,bindingId,JSON.stringify(progress)]);
+  // Stable audit key makes repeated clicks visible without manufacturing learning events.
+  await t.none('INSERT INTO "MobileTeachingCommand" (company,"actorId","requestKey",fingerprint,result) VALUES($1,$2,$3,$4,$5::jsonb) ON CONFLICT DO NOTHING',[DEFAULT_COMPANY_ID,current.id,'unit-complete:'+key,JSON.stringify({gid,bindingId,nodeId}),JSON.stringify({orderId:gid,bindingId,nodeId,teacherId:ownLegacyId(current),studentId:number(a.szyh),total:words.length,changed:missing.length})]);
+  return {nodeId,total:words.length,learned:words.length,percent:100,syjd:JSON.stringify(progress)};
+ });}catch(e){if(e.status||e.statusCode)throw e;fail(409,String(e.message||e));}
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 2 - 0
scripts/deploy-admin-functions.mjs


+ 20 - 0
scripts/generate-learning-sounds.py

@@ -0,0 +1,20 @@
+"""Original short, softly enveloped cues; reproducible, no external audio license."""
+import math, struct, wave
+from pathlib import Path
+root = Path(__file__).resolve().parent.parent / 'public/assets/audio'
+root.mkdir(parents=True, exist_ok=True)
+for kind, notes in [('success', [(660, 0, .14), (880, .075, .16)]), ('error', [(330, 0, .17)])]:
+    rate, duration = 22050, .26
+    frames = []
+    for i in range(int(rate * duration)):
+        t = i / rate
+        value = 0
+        for freq, start, length in notes:
+            x = t - start
+            if 0 <= x < length:
+                envelope = min(1, x / .012) * (1 - x / length) ** 2
+                value += .32 * envelope * math.sin(2 * math.pi * freq * x)
+        frames.append(struct.pack('<h', round(value * 32767)))
+    with wave.open(str(root / f'learning-{kind}.wav'), 'wb') as output:
+        output.setparams((1, 2, rate, 0, 'NONE', 'not compressed'))
+        output.writeframes(b''.join(frames))

+ 17 - 0
scripts/smoke-mobile-teaching-flow.mjs

@@ -57,7 +57,22 @@ try{
  expect(Array.isArray(classroom.allowedActions)&&typeof classroom.blockedReason==='string'&&typeof classroom.validToday==='boolean'&&Number.isFinite(Date.parse(classroom.serverNow)),'详情返回 allowedActions、blockedReason、validToday 和 serverNow');
  expect(classroom.allowedActions.includes('complete'),'当天可提前开始并允许结课');
  const unit=(Array.isArray(units[0].data)?units[0].data:[])[0];expect(!!unit?.NodeID,'测试课程有可学习单元');
+ const progressBefore=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;
+ const unitRequest={action:'app_lesson_unit_complete',orderId:String(generalId),bindingId:word.bindingIds[0],nodeId:String(unit.NodeID),requestKey:randomUUID()};
+ const deniedUnit=await call('xiaoshu/app/gateway',empty.sessionToken,unitRequest,true);expect(deniedUnit.status===403,'非预约老师不能标记单元');
+ const wrongUnit=await call('xiaoshu/app/gateway',coach.sessionToken,{...unitRequest,nodeId:'999999999999'},true);expect(wrongUnit.status===403,'其他课程单元不能标记');
+ const completedUnit=(await call('xiaoshu/app/gateway',coach.sessionToken,unitRequest)).data;
+ const retriedUnit=(await call('xiaoshu/app/gateway',coach.sessionToken,unitRequest)).data;
+ expect(completedUnit.percent===100&&completedUnit.total>0&&completedUnit.learned===completedUnit.total&&retriedUnit.learned===completedUnit.learned,'整单元完成保存且重复点击幂等');
+ const freshUnits=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'node_list',ifunit:1,pid:course.courseId,bindingId:word.bindingIds[0],userId:student.legacyUserId})).data;
+ const freshUnit=freshUnits.find(u=>String(u.NodeID)===String(unit.NodeID));expect(freshUnit.yx_word===freshUnit.total&&freshUnit.process===100,'刷新读取真实词条进度仍为100%');
+ const progressAfter=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;
+ expect(JSON.stringify(progressBefore.accounts)===JSON.stringify(progressAfter.accounts)&&progressBefore.transactions.length===progressAfter.transactions.length,'整单元标记不扣课时不增加流水');
+ const beforeLesson=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_lesson_report',id:generalId})).data;
+ expect(beforeLesson.records.length===0&&beforeLesson.antiForget.total===0&&!beforeLesson.feedback,'整单元标记不伪造课堂记录及抗遗忘');
  const vocabulary=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:52,nodeid:unit.NodeID,psize:30})).data;
+ const external=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:52,orders:'NEWID()',psize:20,excludeWordIds:vocabulary.map(w=>w.GeneralID).join(',')})).data;
+ expect(external.length>=3&&external.every(w=>!vocabulary.some(v=>v.GeneralID===w.GeneralID)),'随机干扰词接口排除本轮完整词表');
  const learnedWord=vocabulary[0];expect(Number(learnedWord?.GeneralID)>0,'读取真实词库内容用于隔离课堂');
  const lessonWords=vocabulary.slice(0,17);expect(lessonWords.length===17,'真实词库提供17个隔离学习词');
  let studyId,lastStudyRequest;for(const [index,range] of [[0,[0,9]],[1,[9,14]],[2,[14,17]]]){
@@ -136,6 +151,8 @@ finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.ad
 function helperCode(state){
 const ids=state.users.map(u=>u.legacyUserId),objects=state.users.map(u=>u.objectId),texts=ids.map(String),co=state.co,admin=state.admin.objectId;
 const statements=[
+['DELETE FROM "PracticeRecord" WHERE company=$1 AND yhid=ANY($2::numeric[])',[co,ids]],
+['DELETE FROM "MobileTeachingCommand" WHERE company=$1 AND "actorId"=ANY($2::text[])',[co,objects]],
 ['DELETE FROM \"LessonStudyBatch\" WHERE company=$1 AND \"studentId\"=ANY($2::numeric[])',[co,ids]],
 ['DELETE FROM \"LessonStudyCommand\" WHERE company=$1 AND actor=ANY($2::text[])',[co,objects]],
 ['DELETE FROM \"AccountDeletionRequest\" WHERE company=$1 AND \"userId\"=ANY($2::text[])',[co,objects]],

+ 43 - 0
scripts/tests/lesson-unit-progress.test.mjs

@@ -0,0 +1,43 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+import {randomUUID} from 'node:crypto';
+const code=readFileSync(new URL('../cloud/lesson-unit-progress.js',import.meta.url),'utf8');
+test('whole-unit acknowledgement is atomic, durable, authorized and idempotent without financial or lesson writes',async()=>{
+ const db=new PGlite();try{
+ await db.exec(`
+ CREATE TABLE "CourseAppointment"("objectId" text,company text,id numeric,szyh numeric,pl numeric,dszt text);
+ CREATE TABLE "CourseBinding"("objectId" text,company text,id numeric,yhid text,kcid text,"enrollmentStatus" text,syjd text,"updatedAt" timestamptz);
+ CREATE TABLE "CommonModel"(company text,"generalId" numeric,"modelId" numeric,"itemId" numeric,"nodeId" numeric,status int);
+ CREATE TABLE "Node"(company text,"nodeId" numeric,"parentId" numeric,"nodeName" text,zstatus int);
+ CREATE TABLE "PracticeRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"sourceKey" text,kcid text,yhid numeric,scid numeric,xxcs numeric,jrscb text);
+ INSERT INTO "CourseAppointment" VALUES('a','co',1,110,69,'10');
+ INSERT INTO "CourseBinding" VALUES('b','co',2,'110','100','active','{"202":"202@Other@0"}',now());
+ INSERT INTO "CommonModel" VALUES('co',900,54,1,0,99),('co',901,58,2,0,99);
+ INSERT INTO "Node" VALUES('co',100,0,'Book',99),('co',201,100,'Unit',99),('co',202,100,'Other',99);
+ INSERT INTO "CommonModel" SELECT 'co',n,52,n,201,99 FROM generate_series(1,205) n;
+ INSERT INTO "PracticeRecord" VALUES('old',now(),now(),'co','old','100',110,1,7,'1');
+ `);
+ await db.exec(readFileSync(new URL('../sql/mobile-teaching.sql',import.meta.url),'utf8'));
+ const adapter=d=>({one:async(s,p)=>(await d.query(s,p)).rows[0],oneOrNone:async(s,p)=>(await d.query(s,p)).rows[0]||null,any:async(s,p)=>(await d.query(s,p)).rows,none:async(s,p)=>{await d.query(s,p);}});
+ const context={DEFAULT_COMPANY_ID:'co',number:x=>Number(x)||0,ownLegacyId:c=>c.uid,assertLessonTenant:()=>{},mobileCommandKey:i=>i.requestKey,randomObjectId:randomUUID,courseAssignedUnits:()=>[],fail:(status,message)=>{throw Object.assign(Error(message),{status});},Psql:{tx:fn=>db.transaction(t=>fn(adapter(t)))}};
+ vm.runInNewContext(code+';this.complete=completeLessonUnit;',context);
+ const teacher={id:'teacher',uid:69,get:()=>3},input={orderId:'900',bindingId:'b',nodeId:'201',requestKey:'request-unit-0001'};
+ await assert.rejects(context.complete(input,{...teacher,uid:70}),/仅预约老师/);
+ await assert.rejects(context.complete({...input,nodeId:'999'},teacher),/不属于/);
+ const out=await context.complete(input,teacher);assert.equal(out.learned,205);assert.equal(out.percent,100);
+ assert.equal((await db.query('SELECT count(*) n FROM "PracticeRecord" WHERE xxcs>0')).rows[0].n,205);
+ assert.equal((await db.query('SELECT xxcs,jrscb FROM "PracticeRecord" WHERE scid=1')).rows[0].xxcs,'7');
+ assert.equal(JSON.parse(out.syjd)['202'],'202@Other@0');
+ await context.complete(input,teacher);await context.complete({...input,requestKey:'request-unit-0002'},teacher);
+ assert.equal((await db.query('SELECT count(*) n FROM "PracticeRecord"')).rows[0].n,205);
+ assert.equal((await db.query('SELECT xxcs FROM "PracticeRecord" WHERE scid=2')).rows[0].xxcs,'1');
+ await db.exec('UPDATE "CourseAppointment" SET dszt=\'30\'');await assert.rejects(context.complete(input,teacher),/已结束/);
+ // A downstream failure must roll back every chunk of word progress.
+ await db.exec('UPDATE "CourseAppointment" SET dszt=\'10\'; DELETE FROM "PracticeRecord" WHERE scid>1; ALTER TABLE "CourseBinding" ADD CONSTRAINT no_completion CHECK (syjd NOT LIKE \'%Blocked%\'); UPDATE "Node" SET "nodeName"=\'Blocked\' WHERE "nodeId"=201;');
+ await assert.rejects(context.complete({...input,requestKey:'rollback-unit-0003'},teacher),/no_completion/);
+ assert.equal((await db.query('SELECT count(*) n FROM "PracticeRecord"')).rows[0].n,1);
+ }finally{await db.close();}
+});

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.