lesson-workflow.integration.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { Component, signal } from '@angular/core';
  2. import { TestBed } from '@angular/core/testing';
  3. import { provideRouter, Router } from '@angular/router';
  4. import { RouterTestingHarness } from '@angular/router/testing';
  5. import { By } from '@angular/platform-browser';
  6. import { of, throwError, Subject } from 'rxjs';
  7. import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
  8. import { CourseCenterPageComponent } from '../features/course-center/course-center-page.component';
  9. import { ApiService, ApiError } from './api.service';
  10. import { SessionService } from './session.service';
  11. import { PlatformService } from './platform.service';
  12. import { appointmentDate, beijingDate } from './appointment-time';
  13. @Component({standalone:true,template:'课堂内容'}) class ClassroomStub {}
  14. describe('appointment workflow screens',()=>{
  15. let failFeedback:boolean;
  16. let order:Record<string,unknown>,failDetail:boolean,failCourses:boolean,courses:Record<string,unknown>[]|null,delayedUnits:Subject<unknown>|null,calls:Array<{action:string;params:Record<string,unknown>}>;
  17. const user=signal({userId:30,groupId:3}),student=signal<unknown>(null),groupId=signal(3);
  18. const response=(result:unknown)=>of({retcode:0,result});
  19. beforeEach(()=>{
  20. localStorage.clear();sessionStorage.clear();calls=[];failFeedback=false;failDetail=false;failCourses=false;courses=null;delayedUnits=null;user.set({userId:30,groupId:3});groupId.set(3);
  21. order={GeneralID:901,szyh:20,pl:30,HoneyName:'测试学员',kcid:801,bindingId:'binding-1',courseCategoryKey:'word',yykcmc:'词表',dszt:0,bxrq:'2099-10-11',sdsd:'19:00',allowedActions:['start'],updatedAt:'2026-10-11T00:00:00Z'};
  22. const invoke=(action:string,params:Record<string,unknown>={})=>{
  23. calls.push({action,params});
  24. if(action==='e_order_detail')return failDetail?throwError(()=>new ApiError('详情不可用')):response([{...order}]);
  25. if(action==='app_live_versions')return response({version:'appointments=1',scopeVersions:{appointments:'1'},syncStatus:{state:'current'}});
  26. 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'}});
  27. if(action==='user_get')return response({userId:20,honeyName:'测试学员',groupId:1});
  28. 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"}'}]});
  29. if(action==='node_list')return delayedUnits||response([{NodeID:params['pid']==='802'?822:811,NodeName:'Unit 1'}]);
  30. if(action==='e_order_update_v2'){order={...order,dszt:10,allowedActions:['continue','complete']};return response({status:10});}
  31. if(action==='app_lesson_complete'){order={...order,dszt:30,allowedActions:[],reviewStatus:'pending'};return response({status:30});}
  32. if(action==='app_lesson_feedback_update'){if(failFeedback)return throwError(()=>new ApiError('报告已被更新,请刷新详情后重试'));order={...order,feedback:{...JSON.parse(String(params['feedback'])),revision:'2026-10-13T00:00:00Z'}};return response({saved:true});}
  33. if(action==='app_lesson_review'){order={...order,allowedActions:[],reviewStatus:'submitted',studentReview:{score:params['score'],comment:params['comment']}};return response({status:30});}
  34. return response([]);
  35. };
  36. TestBed.configureTestingModule({providers:[
  37. provideRouter([
  38. {path:'pages/home/shop',component:CourseCenterPageComponent,data:{meta:{path:'pages/home/shop'}}},
  39. {path:'pages/shop/get_order',component:AppointmentPageComponent,data:{meta:{path:'pages/shop/get_order'}}},
  40. {path:'pages/shop/stu_get_order',component:AppointmentPageComponent,data:{meta:{path:'pages/shop/stu_get_order'}}},
  41. {path:'pages/stu/stu_abc',component:ClassroomStub},
  42. ]),
  43. {provide:ApiService,useValue:{get:invoke,post:invoke}},
  44. {provide:SessionService,useValue:{user,student,groupId,token:signal('r:test'),isLoggedIn:()=>true,setStudent:(s:unknown)=>student.set(s)}},
  45. {provide:PlatformService,useValue:{loading:signal(false),toast:()=>{},confirm:async()=>true}},
  46. ]});
  47. });
  48. const screen=async(path='/pages/shop/get_order?id=901')=>{
  49. const harness=await RouterTestingHarness.create();await harness.navigateByUrl(path);await harness.fixture.whenStable();harness.fixture.detectChanges();
  50. const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent))?.componentInstance as AppointmentPageComponent;
  51. return{harness,component};
  52. };
  53. it('opens a future appointment and keeps the teacher-wide refresh scope independent of student selection',async()=>{
  54. const {harness}=await screen('/pages/home/shop');
  55. const center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
  56. center.showAllSchedules();harness.fixture.detectChanges();
  57. (harness.routeNativeElement?.querySelector('article[data-schedule-id]') as HTMLElement).click();
  58. await harness.fixture.whenStable();expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
  59. expect(calls.find(c=>c.action==='app_live_versions')?.params['studentId']).toBeUndefined();
  60. expect(calls.find(c=>c.action==='app_companion_overview')?.params['source']).toBe('database');
  61. });
  62. it('starts a writing lesson without requiring vocabulary units',async()=>{
  63. order['courseCategoryKey']='primary_writing';
  64. const {harness,component}=await screen();expect(component.isWriting).toBeTrue();
  65. (harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement).click();
  66. harness.fixture.detectChanges();expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);expect(component.status()).toBe(10);
  67. });
  68. for (const time of ['00:01', '23:59']) {
  69. it(`starts a same-day lesson scheduled at ${time} without new server capabilities`,async()=>{
  70. order={...order,bxrq:beijingDate(),sdsd:time};delete order['allowedActions'];
  71. const {harness,component}=await screen();
  72. expect(component.can('start')).toBeTrue();expect(component.can('complete')).toBeFalse();
  73. expect(harness.routeNativeElement?.textContent).toContain('可提前开始');
  74. component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
  75. expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);
  76. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
  77. expect(student()).toEqual(jasmine.objectContaining({userId:20}));
  78. });
  79. }
  80. it('keeps all of today’s unfinished lessons out of the overdue filter and opens details immediately',async()=>{
  81. order={...order,bxrq:beijingDate(),sdsd:'00:00'};
  82. const {harness}=await screen('/pages/home/shop');
  83. const center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
  84. expect(center.statusText(order)).toBe('未开始');
  85. expect(center.statusText({...order,bxrq:'2000-01-01'})).toBe('计划未完成');
  86. expect(center.statusText({...order,dszt:10})).toBe('已开始');
  87. center.open(order);await harness.fixture.whenStable();
  88. expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
  89. expect(calls.some(c=>c.action==='user_get')).toBeFalse();
  90. });
  91. it('allows an already started legacy lesson to continue across midnight',async()=>{
  92. order={...order,dszt:10,bxrq:'2000-01-01'};delete order['allowedActions'];
  93. const {component}=await screen();expect(component.can('continue')).toBeTrue();expect(component.can('start')).toBeFalse();
  94. });
  95. for (const changes of [{bxrq:'2000-01-01'}, {bxrq:'2099-10-11'}, {pl:31}, {cancelled:true}, {GeneralID:902}]) {
  96. it(`does not infer start permission for an ineligible legacy detail ${JSON.stringify(changes)}`,async()=>{
  97. order={...order,bxrq:beijingDate(),...changes};delete order['allowedActions'];
  98. const {component}=await screen();component.startOrContinue();
  99. expect(component.can('start')).toBeFalse();expect(calls.some(c=>c.action==='e_order_update_v2')).toBeFalse();
  100. });
  101. }
  102. it('does not override an explicit server denial on today’s lesson',async()=>{
  103. order={...order,bxrq:beijingDate(),allowedActions:[],blockedReason:'课时不足'};
  104. const {component}=await screen();expect(component.can('start')).toBeFalse();
  105. });
  106. it('disables legacy start again when a background detail refresh fails',async()=>{
  107. order={...order,bxrq:beijingDate()};delete order['allowedActions'];
  108. const {component}=await screen();expect(component.can('start')).toBeTrue();
  109. failDetail=true;component.load(true);expect(component.can('start')).toBeFalse();
  110. });
  111. it('uses Beijing midnight boundaries and accepts the legacy compact business date',()=>{
  112. expect(beijingDate(Date.parse('2026-10-10T15:59:59Z'))).toBe('2026-10-10');
  113. expect(beijingDate(Date.parse('2026-10-10T16:00:00Z'))).toBe('2026-10-11');
  114. expect(beijingDate(Date.parse('2026-10-11T15:59:59Z'))).toBe('2026-10-11');
  115. expect(beijingDate(Date.parse('2026-10-11T16:00:00Z'))).toBe('2026-10-12');
  116. expect(appointmentDate({bxrq:'20261011'})).toBe('2026-10-11');
  117. });
  118. it('shows a persistent reason when vocabulary has not been selected, then enters the bound classroom',async()=>{
  119. const {harness,component}=await screen();component.startOrContinue();harness.fixture.detectChanges();
  120. expect(harness.routeNativeElement?.querySelector('[role="alert"]')?.textContent).toContain('选择');
  121. component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
  122. expect(TestBed.inject(Router).url).toContain('orderId=901');expect(TestBed.inject(Router).url).toContain('bindingId=binding-1');
  123. });
  124. it('lists every opened enrollment and switches units, progress and the classroom binding together',async()=>{
  125. const {harness,component}=await screen();
  126. const select=harness.routeNativeElement?.querySelector('select[aria-label="本次学习课本或词库"]') as HTMLSelectElement;
  127. expect(select.options.length).toBe(3);expect(select.value).toBe('binding-1');
  128. component.toggleUnit({NodeID:811});
  129. select.value='binding-2';select.dispatchEvent(new Event('change'));harness.fixture.detectChanges();
  130. expect(component.selectedCourse).toBe('802');expect(component.selectedUnits().size).toBe(0);expect(component.completedUnits().has('822')).toBeTrue();
  131. expect(calls.filter(c=>c.action==='node_list').at(-1)?.params).toEqual(jasmine.objectContaining({pid:'802',bindingId:'binding-2',userId:20}));
  132. component.toggleUnit({NodeID:822});component.startOrContinue();await harness.fixture.whenStable();
  133. expect(calls.find(c=>c.action==='e_order_update_v2')?.params).toEqual(jasmine.objectContaining({learningBindingId:'binding-2',expectedUpdatedAt:order['updatedAt']}));
  134. const url=TestBed.inject(Router).url;expect(url).toContain('pnid=802');expect(url).toContain('bindingId=binding-2');
  135. expect(order['bindingId']).toBe('binding-1');expect(order['kcid']).toBe(801);
  136. });
  137. it('ignores an old unit response after selecting another book and marks the selected enrollment only',async()=>{
  138. delayedUnits=new Subject();const old=delayedUnits;
  139. const {component}=await screen();delayedUnits=null;component.selectedBindingId='binding-2';component.chooseCourse();
  140. old.next({result:[{NodeID:811,NodeName:'Old'}]});
  141. expect(component.units()[0]['NodeID']).toBe(822);
  142. await component.markUnitComplete({NodeID:822,NodeName:'Unit 2'});
  143. const payload=calls.find(c=>c.action==='content_update')?.params;
  144. expect(JSON.parse(String(payload?.['content'])).GeneralID).toBe(5802);
  145. });
  146. for(const mode of ['empty','failure'])it(`blocks start when the course list is ${mode}`,async()=>{
  147. if(mode==='empty')courses=[];else failCourses=true;
  148. const {component}=await screen();component.toggleUnit({NodeID:811});component.startOrContinue();
  149. expect(calls.some(c=>c.action==='e_order_update_v2')).toBeFalse();
  150. });
  151. it('does not request teacher course selection in the student view',async()=>{
  152. user.set({userId:20,groupId:1});groupId.set(1);order['allowedActions']=[];
  153. const {harness}=await screen('/pages/shop/stu_get_order?id=901');
  154. expect(calls.some(c=>c.action==='app_lesson_courses')).toBeFalse();
  155. expect(harness.routeNativeElement?.querySelector('select[aria-label="本次学习课本或词库"]')).toBeNull();
  156. });
  157. it('does not allow a cached appointment to write when the detail endpoint fails',async()=>{
  158. failDetail=true;
  159. const harness=await RouterTestingHarness.create();
  160. await TestBed.inject(Router).navigate(['/pages/shop/get_order'],{queryParams:{id:901},state:{appointmentSnapshot:order}});
  161. await harness.fixture.whenStable();harness.fixture.detectChanges();
  162. const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
  163. expect(component.detailVerified()).toBeFalse();component.startOrContinue();
  164. expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(0);
  165. });
  166. it('completes with feedback and exposes the report without waiting for student confirmation',async()=>{
  167. order={...order,dszt:10,allowedActions:['continue','complete']};
  168. const {harness,component}=await screen();component.openCompletionFeedback();
  169. component.contentSummary='Unit 1';component.reviewStatus='完成';component.trainingStatus='正常进行';component.trainingSummary='完成练习';component.noHomework=true;
  170. component.finishCoachLesson();harness.fixture.detectChanges();
  171. const command=calls.find(c=>c.action==='app_lesson_complete');expect(command?.params['expectedUpdatedAt']).toBe(order['updatedAt']);
  172. expect(harness.routeNativeElement?.textContent).toContain('查看学习报告');expect(harness.routeNativeElement?.textContent).toContain('待学生评价');
  173. expect(calls.some(c=>c.action==='content_update')).toBeFalse();
  174. });
  175. for (const statusText of ['', ' 复习到第十页,易错词下次继续 ']) {
  176. it(`accepts optional feedback text ${statusText ? 'with custom wording' : 'left blank'} from the dialog`,async()=>{
  177. order={...order,dszt:10,allowedActions:['continue','complete']};
  178. const {harness,component}=await screen();component.openCompletionFeedback();harness.fixture.detectChanges();
  179. for (const title of ['复习 / 生词本情况','训练进程和状态']) {
  180. const label=Array.from(harness.routeNativeElement!.querySelectorAll('.feedback-fields label')).find(el=>el.querySelector('span')?.textContent?.trim()===title)!;
  181. expect(label).toBeDefined();expect(label.querySelector('select,b')).toBeNull();
  182. const input=label.querySelector('input')!;
  183. expect(input.required).toBeFalse();expect(input.getAttribute('aria-required')).toBeNull();
  184. input.value=statusText;input.dispatchEvent(new Event('input'));
  185. }
  186. component.contentSummary='Unit 1';component.trainingSummary='下次从第十页开始';component.noHomework=true;
  187. harness.fixture.detectChanges();
  188. (harness.routeNativeElement!.querySelector('.feedback-actions .primary-button') as HTMLButtonElement).click();
  189. await harness.fixture.whenStable();harness.fixture.detectChanges();
  190. const command=calls.find(c=>c.action==='app_lesson_complete');expect(command).toBeDefined();
  191. const feedback=JSON.parse(command!.params['feedback'] as string);
  192. expect(feedback.reviewStatus).toBe(statusText.trim());expect(feedback.trainingStatus).toBe(statusText.trim());
  193. expect(component.status()).toBe(30);
  194. });
  195. }
  196. it('still requires the progress description when the optional status fields are blank',async()=>{
  197. order={...order,dszt:10,allowedActions:['continue','complete']};
  198. const {component}=await screen();component.openCompletionFeedback();component.contentSummary='Unit 1';component.noHomework=true;
  199. component.finishCoachLesson();expect(component.feedbackError()).toBe('请填写进度说明');
  200. expect(calls.some(c=>c.action==='app_lesson_complete')).toBeFalse();
  201. });
  202. it('submits a student review on an already completed course without changing its status',async()=>{
  203. user.set({userId:20,groupId:1});groupId.set(1);order={...order,dszt:30,allowedActions:['review'],reviewStatus:'pending'};
  204. const {harness,component}=await screen('/pages/shop/stu_get_order?id=901');component.studentScore=4;component.studentComment='有帮助';component.finishStudentLesson();harness.fixture.detectChanges();
  205. expect(calls.find(c=>c.action==='app_lesson_review')?.params).toEqual({orderId:'901',score:4,comment:'有帮助'});expect(component.status()).toBe(30);
  206. expect(harness.routeNativeElement?.textContent).toContain('学生已评价');expect(harness.routeNativeElement?.textContent).toContain('有帮助');
  207. });
  208. it('keeps a non-current-day course readable and shows the server reason without a start button',async()=>{
  209. order={...order,allowedActions:[],blockedReason:'课程仅在预约当天可开始'};const {harness}=await screen();
  210. expect(harness.routeNativeElement?.textContent).toContain('预约当天');expect(harness.routeNativeElement?.querySelector('.appointment-actions .primary-button')).toBeNull();
  211. });
  212. it('edits a completed report with its saved version without completing the lesson again',async()=>{
  213. order={...order,dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'原学习内容',reviewStatus:'原复习',reviewSummary:'复习说明',trainingStatus:'原状态',trainingSummary:'原进度',coachComment:'原点评',noHomework:true,homework:'无作业',notes:'原备注',revision:'2026-10-13 10:20:00.123456+08'}};
  214. const {harness,component}=await screen();component.openFeedbackEditor();harness.fixture.detectChanges();
  215. expect(component.contentSummary).toBe('原学习内容');expect(component.notes).toBe('原备注');
  216. expect(harness.routeNativeElement?.querySelector('dialog')?.textContent).toContain('保存修改');
  217. component.contentSummary='新的学习内容';component.finishCoachLesson();harness.fixture.detectChanges();
  218. const write=calls.find(c=>c.action==='app_lesson_feedback_update');
  219. expect(write?.params['expectedUpdatedAt']).toBe('2026-10-13 10:20:00.123456+08');
  220. expect(calls.some(c=>c.action==='app_lesson_complete')).toBeFalse();
  221. expect(component.feedback()?.['contentSummary']).toBe('新的学习内容');expect(component.feedbackSaved()).toBeTrue();expect(component.status()).toBe(30);
  222. });
  223. it('cancels editing without writing and restores saved fields on reopen',async()=>{
  224. order={...order,dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'已保存内容',trainingSummary:'原进度'}};
  225. const {component}=await screen();component.openFeedbackEditor();component.contentSummary='未保存内容';component.closeCompletionFeedback();component.openFeedbackEditor();
  226. expect(component.contentSummary).toBe('已保存内容');expect(calls.some(c=>c.action==='app_lesson_feedback_update')).toBeFalse();
  227. });
  228. it('does not show a teacher edit action for the student or an unverified detail',async()=>{
  229. user.set({userId:20,groupId:1});groupId.set(1);order={...order,dszt:30,allowedActions:[],feedback:{contentSummary:'内容'}};
  230. const {harness,component}=await screen('/pages/shop/stu_get_order?id=901');component.openFeedbackEditor();
  231. expect(harness.routeNativeElement?.textContent).not.toContain('修改报告');expect(component.completionDialog()).toBeUndefined();
  232. });
  233. it('keeps a failed save open with the teacher draft ',async()=>{
  234. order={...order,dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'原内容',trainingSummary:'进度',noHomework:true,revision:'2026-10-13T00:00:00Z'}};
  235. const {harness,component}=await screen();component.openFeedbackEditor();component.contentSummary='未丢失的修改';failFeedback=true;component.finishCoachLesson();harness.fixture.detectChanges();
  236. expect(component.completionDialog()?.nativeElement.open).toBeTrue();expect(component.contentSummary).toBe('未丢失的修改');
  237. expect(component.feedbackError()).toContain('已被更新');expect(component.submitting()).toBeFalse();expect(component.feedbackSaved()).toBeFalse();
  238. });
  239. });