| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- import { Component, signal } from '@angular/core';
- import { TestBed } from '@angular/core/testing';
- import { provideRouter, Router } from '@angular/router';
- import { RouterTestingHarness } from '@angular/router/testing';
- import { By } from '@angular/platform-browser';
- import { of, throwError, Subject } from 'rxjs';
- import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
- import { CourseCenterPageComponent } from '../features/course-center/course-center-page.component';
- import { ApiService, ApiError } from './api.service';
- import { SessionService } from './session.service';
- import { PlatformService } from './platform.service';
- import { appointmentDate, beijingDate } from './appointment-time';
- @Component({standalone:true,template:'课堂内容'}) class ClassroomStub {}
- describe('appointment workflow screens',()=>{
- 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>}>;
- const user=signal({userId:30,groupId:3}),student=signal<unknown>(null),groupId=signal(3);
- const response=(result:unknown)=>of({retcode:0,result});
- beforeEach(()=>{
- localStorage.clear();sessionStorage.clear();calls=[];failDetail=false;failCourses=false;courses=null;delayedUnits=null;user.set({userId:30,groupId:3});groupId.set(3);
- 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'};
- const invoke=(action:string,params:Record<string,unknown>={})=>{
- calls.push({action,params});
- if(action==='e_order_detail')return failDetail?throwError(()=>new ApiError('详情不可用')):response([{...order}]);
- if(action==='app_live_versions')return response({version:'appointments=1',scopeVersions:{appointments:'1'},syncStatus:{state:'current'}});
- 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==='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});}
- if(action==='app_lesson_review'){order={...order,allowedActions:[],reviewStatus:'submitted',studentReview:{score:params['score'],comment:params['comment']}};return response({status:30});}
- return response([]);
- };
- TestBed.configureTestingModule({providers:[
- provideRouter([
- {path:'pages/home/shop',component:CourseCenterPageComponent,data:{meta:{path:'pages/home/shop'}}},
- {path:'pages/shop/get_order',component:AppointmentPageComponent,data:{meta:{path:'pages/shop/get_order'}}},
- {path:'pages/shop/stu_get_order',component:AppointmentPageComponent,data:{meta:{path:'pages/shop/stu_get_order'}}},
- {path:'pages/stu/stu_abc',component:ClassroomStub},
- ]),
- {provide:ApiService,useValue:{get:invoke,post:invoke}},
- {provide:SessionService,useValue:{user,student,groupId,token:signal('r:test'),isLoggedIn:()=>true,setStudent:(s:unknown)=>student.set(s)}},
- {provide:PlatformService,useValue:{loading:signal(false),toast:()=>{},confirm:async()=>true}},
- ]});
- });
- const screen=async(path='/pages/shop/get_order?id=901')=>{
- const harness=await RouterTestingHarness.create();await harness.navigateByUrl(path);await harness.fixture.whenStable();harness.fixture.detectChanges();
- const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent))?.componentInstance as AppointmentPageComponent;
- return{harness,component};
- };
- it('opens a future appointment and keeps the teacher-wide refresh scope independent of student selection',async()=>{
- const {harness}=await screen('/pages/home/shop');
- const center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
- center.showAllSchedules();harness.fixture.detectChanges();
- (harness.routeNativeElement?.querySelector('article[data-schedule-id]') as HTMLElement).click();
- await harness.fixture.whenStable();expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
- expect(calls.find(c=>c.action==='app_live_versions')?.params['studentId']).toBeUndefined();
- expect(calls.find(c=>c.action==='app_companion_overview')?.params['source']).toBe('database');
- });
- it('starts a writing lesson without requiring vocabulary units',async()=>{
- order['courseCategoryKey']='primary_writing';
- const {harness,component}=await screen();expect(component.isWriting).toBeTrue();
- (harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement).click();
- harness.fixture.detectChanges();expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);expect(component.status()).toBe(10);
- });
- for (const time of ['00:01', '23:59']) {
- it(`starts a same-day lesson scheduled at ${time} without new server capabilities`,async()=>{
- order={...order,bxrq:beijingDate(),sdsd:time};delete order['allowedActions'];
- const {harness,component}=await screen();
- expect(component.can('start')).toBeTrue();expect(component.can('complete')).toBeFalse();
- expect(harness.routeNativeElement?.textContent).toContain('可提前开始');
- component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
- expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);
- expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
- expect(student()).toEqual(jasmine.objectContaining({userId:20}));
- });
- }
- it('keeps all of today’s unfinished lessons out of the overdue filter and opens details immediately',async()=>{
- order={...order,bxrq:beijingDate(),sdsd:'00:00'};
- const {harness}=await screen('/pages/home/shop');
- const center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
- expect(center.statusText(order)).toBe('未开始');
- expect(center.statusText({...order,bxrq:'2000-01-01'})).toBe('计划未完成');
- expect(center.statusText({...order,dszt:10})).toBe('已开始');
- center.open(order);await harness.fixture.whenStable();
- expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
- expect(calls.some(c=>c.action==='user_get')).toBeFalse();
- });
- it('allows an already started legacy lesson to continue across midnight',async()=>{
- order={...order,dszt:10,bxrq:'2000-01-01'};delete order['allowedActions'];
- const {component}=await screen();expect(component.can('continue')).toBeTrue();expect(component.can('start')).toBeFalse();
- });
- for (const changes of [{bxrq:'2000-01-01'}, {bxrq:'2099-10-11'}, {pl:31}, {cancelled:true}, {GeneralID:902}]) {
- it(`does not infer start permission for an ineligible legacy detail ${JSON.stringify(changes)}`,async()=>{
- order={...order,bxrq:beijingDate(),...changes};delete order['allowedActions'];
- const {component}=await screen();component.startOrContinue();
- expect(component.can('start')).toBeFalse();expect(calls.some(c=>c.action==='e_order_update_v2')).toBeFalse();
- });
- }
- it('does not override an explicit server denial on today’s lesson',async()=>{
- order={...order,bxrq:beijingDate(),allowedActions:[],blockedReason:'课时不足'};
- const {component}=await screen();expect(component.can('start')).toBeFalse();
- });
- it('disables legacy start again when a background detail refresh fails',async()=>{
- order={...order,bxrq:beijingDate()};delete order['allowedActions'];
- const {component}=await screen();expect(component.can('start')).toBeTrue();
- failDetail=true;component.load(true);expect(component.can('start')).toBeFalse();
- });
- it('uses Beijing midnight boundaries and accepts the legacy compact business date',()=>{
- expect(beijingDate(Date.parse('2026-10-10T15:59:59Z'))).toBe('2026-10-10');
- expect(beijingDate(Date.parse('2026-10-10T16:00:00Z'))).toBe('2026-10-11');
- expect(beijingDate(Date.parse('2026-10-11T15:59:59Z'))).toBe('2026-10-11');
- expect(beijingDate(Date.parse('2026-10-11T16:00:00Z'))).toBe('2026-10-12');
- expect(appointmentDate({bxrq:'20261011'})).toBe('2026-10-11');
- });
- it('shows a persistent reason when vocabulary has not been selected, then enters the bound classroom',async()=>{
- const {harness,component}=await screen();component.startOrContinue();harness.fixture.detectChanges();
- expect(harness.routeNativeElement?.querySelector('[role="alert"]')?.textContent).toContain('选择');
- component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
- expect(TestBed.inject(Router).url).toContain('orderId=901');expect(TestBed.inject(Router).url).toContain('bindingId=binding-1');
- });
- it('lists every opened enrollment and switches units, progress and the classroom binding together',async()=>{
- const {harness,component}=await screen();
- const select=harness.routeNativeElement?.querySelector('select[aria-label="本次学习课本或词库"]') as HTMLSelectElement;
- expect(select.options.length).toBe(3);expect(select.value).toBe('binding-1');
- component.toggleUnit({NodeID:811});
- select.value='binding-2';select.dispatchEvent(new Event('change'));harness.fixture.detectChanges();
- expect(component.selectedCourse).toBe('802');expect(component.selectedUnits().size).toBe(0);expect(component.completedUnits().has('822')).toBeTrue();
- expect(calls.filter(c=>c.action==='node_list').at(-1)?.params).toEqual(jasmine.objectContaining({pid:'802',bindingId:'binding-2',userId:20}));
- component.toggleUnit({NodeID:822});component.startOrContinue();await harness.fixture.whenStable();
- expect(calls.find(c=>c.action==='e_order_update_v2')?.params).toEqual(jasmine.objectContaining({learningBindingId:'binding-2',expectedUpdatedAt:order['updatedAt']}));
- const url=TestBed.inject(Router).url;expect(url).toContain('pnid=802');expect(url).toContain('bindingId=binding-2');
- expect(order['bindingId']).toBe('binding-1');expect(order['kcid']).toBe(801);
- });
- it('ignores an old unit response after selecting another book and marks the selected enrollment only',async()=>{
- delayedUnits=new Subject();const old=delayedUnits;
- const {component}=await screen();delayedUnits=null;component.selectedBindingId='binding-2';component.chooseCourse();
- 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);
- });
- for(const mode of ['empty','failure'])it(`blocks start when the course list is ${mode}`,async()=>{
- if(mode==='empty')courses=[];else failCourses=true;
- const {component}=await screen();component.toggleUnit({NodeID:811});component.startOrContinue();
- expect(calls.some(c=>c.action==='e_order_update_v2')).toBeFalse();
- });
- it('does not request teacher course selection in the student view',async()=>{
- user.set({userId:20,groupId:1});groupId.set(1);order['allowedActions']=[];
- const {harness}=await screen('/pages/shop/stu_get_order?id=901');
- expect(calls.some(c=>c.action==='app_lesson_courses')).toBeFalse();
- expect(harness.routeNativeElement?.querySelector('select[aria-label="本次学习课本或词库"]')).toBeNull();
- });
- it('does not allow a cached appointment to write when the detail endpoint fails',async()=>{
- failDetail=true;
- const harness=await RouterTestingHarness.create();
- await TestBed.inject(Router).navigate(['/pages/shop/get_order'],{queryParams:{id:901},state:{appointmentSnapshot:order}});
- await harness.fixture.whenStable();harness.fixture.detectChanges();
- const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
- expect(component.detailVerified()).toBeFalse();component.startOrContinue();
- expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(0);
- });
- it('completes with feedback and exposes the report without waiting for student confirmation',async()=>{
- order={...order,dszt:10,allowedActions:['continue','complete']};
- const {harness,component}=await screen();component.openCompletionFeedback();
- component.contentSummary='Unit 1';component.reviewStatus='完成';component.trainingStatus='正常进行';component.trainingSummary='完成练习';component.noHomework=true;
- component.finishCoachLesson();harness.fixture.detectChanges();
- const command=calls.find(c=>c.action==='app_lesson_complete');expect(command?.params['expectedUpdatedAt']).toBe(order['updatedAt']);
- expect(harness.routeNativeElement?.textContent).toContain('查看学习报告');expect(harness.routeNativeElement?.textContent).toContain('待学生评价');
- expect(calls.some(c=>c.action==='content_update')).toBeFalse();
- });
- for (const statusText of ['', ' 复习到第十页,易错词下次继续 ']) {
- it(`accepts optional feedback text ${statusText ? 'with custom wording' : 'left blank'} from the dialog`,async()=>{
- order={...order,dszt:10,allowedActions:['continue','complete']};
- const {harness,component}=await screen();component.openCompletionFeedback();harness.fixture.detectChanges();
- for (const title of ['复习 / 生词本情况','训练进程和状态']) {
- const label=Array.from(harness.routeNativeElement!.querySelectorAll('.feedback-fields label')).find(el=>el.querySelector('span')?.textContent?.trim()===title)!;
- expect(label).toBeDefined();expect(label.querySelector('select,b')).toBeNull();
- const input=label.querySelector('input')!;
- expect(input.required).toBeFalse();expect(input.getAttribute('aria-required')).toBeNull();
- input.value=statusText;input.dispatchEvent(new Event('input'));
- }
- component.contentSummary='Unit 1';component.trainingSummary='下次从第十页开始';component.noHomework=true;
- harness.fixture.detectChanges();
- (harness.routeNativeElement!.querySelector('.feedback-actions .primary-button') as HTMLButtonElement).click();
- await harness.fixture.whenStable();harness.fixture.detectChanges();
- const command=calls.find(c=>c.action==='app_lesson_complete');expect(command).toBeDefined();
- const feedback=JSON.parse(command!.params['feedback'] as string);
- expect(feedback.reviewStatus).toBe(statusText.trim());expect(feedback.trainingStatus).toBe(statusText.trim());
- expect(component.status()).toBe(30);
- });
- }
- it('still requires the progress description when the optional status fields are blank',async()=>{
- order={...order,dszt:10,allowedActions:['continue','complete']};
- const {component}=await screen();component.openCompletionFeedback();component.contentSummary='Unit 1';component.noHomework=true;
- component.finishCoachLesson();expect(component.feedbackError()).toBe('请填写进度说明');
- expect(calls.some(c=>c.action==='app_lesson_complete')).toBeFalse();
- });
- it('submits a student review on an already completed course without changing its status',async()=>{
- user.set({userId:20,groupId:1});groupId.set(1);order={...order,dszt:30,allowedActions:['review'],reviewStatus:'pending'};
- const {harness,component}=await screen('/pages/shop/stu_get_order?id=901');component.studentScore=4;component.studentComment='有帮助';component.finishStudentLesson();harness.fixture.detectChanges();
- expect(calls.find(c=>c.action==='app_lesson_review')?.params).toEqual({orderId:'901',score:4,comment:'有帮助'});expect(component.status()).toBe(30);
- expect(harness.routeNativeElement?.textContent).toContain('学生已评价');expect(harness.routeNativeElement?.textContent).toContain('有帮助');
- });
- it('keeps a non-current-day course readable and shows the server reason without a start button',async()=>{
- order={...order,allowedActions:[],blockedReason:'课程仅在预约当天可开始'};const {harness}=await screen();
- expect(harness.routeNativeElement?.textContent).toContain('预约当天');expect(harness.routeNativeElement?.querySelector('.appointment-actions .primary-button')).toBeNull();
- });
- });
|