import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { fakeAsync, tick, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { provideRouter, Router } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; import { Observable, of, Subject, throwError } from 'rxjs'; import { CourseCenterPageComponent } from '../features/course-center/course-center-page.component'; import { AppointmentPageComponent } from '../features/appointment/appointment-page.component'; import { LearningPageComponent } from '../features/learning/learning-page.component'; import { StudyRecordsPageComponent } from '../features/study-records/study-records-page.component'; import { HomePageComponent } from '../features/home/home-page.component'; import { routes } from '../app.routes'; import { ApiService } from './api.service'; import { ApiEnvelope, AppUser } from './models'; import { LiveDataRefreshService } from './live-data-refresh.service'; import { PlatformService } from './platform.service'; import { SessionService } from './session.service'; class LearningModulesApiService { lastScheduleParams: Record = {}; lastNodeParams: Record = {}; lastAppointmentCourseParams: Record = {}; lastWordParams: Record = {}; lastLearningWordUpdate: Record = {}; lastMemoryParams: Record = {}; lastStandaloneLearningRecord: Record = {}; appointmentStatus = 0; historyWordAudio = ''; completionRequests: Record[] = []; completionResponse = new Subject>(); lastAppointmentRecordParams: Record = {}; studyUpdates: Record[] = []; studyBatches: any[] = []; lastStudyQuery: Record = {}; reviewPageResponse: Subject> | null = null; courseWords: Record[] | null = null; courseIncrements = new Map(); courseBookCounts: number[] | null = null; unitRows: Record[] = [ { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 }, { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 }, ]; get(action: string, params: Record = {}): Observable> { if (action === 'app_account_overview') return this.post(action, params); if (action === 'content_list' && params['orders'] === 'NEWID()') { return this.result(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('无权查看该学员的内容记录')); return this.result([{ GeneralID: 9901 }]); } if (action === 'app_lesson_study') { this.lastStudyQuery = params; return this.result({record: {learned: this.studyBatches.filter(b => b.status === 'confirmed').reduce((n, b) => n + b.words.length, 0)}, batches: this.studyBatches, pending: this.studyBatches.filter(b => b.status === 'draft').length}); } if (action === 'e_record_detail') { return this.result([{ GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果', check: 0 }]); } if (action === 'app_lesson_courses') { this.lastAppointmentCourseParams=params; return this.result([{bindingId:'binding-801',kcid:801,Title:'七年级上册',courseName:'七年级上册',syjd:'{}'},{bindingId:'binding-802',kcid:802,Title:'阅读训练',courseName:'阅读训练'}]); } if (action === 'e_order_detail') { return this.result([{ GeneralID: 901, szyh: 100, HoneyName: '张雅茜', kcid: 801, bindingId:'binding-801', yykcmc: '七年级上册', dszt: this.appointmentStatus, updatedAt: "2026-10-11T00:00:00Z", allowedActions: this.appointmentStatus===0?["start"]:this.appointmentStatus===10?["continue","complete"]:this.appointmentStatus===30?[]:["complete"], reviewStatus: "pending", dslx: 2, bxrq: '2026-08-10', sdsd: '19:00', jffs: '线上', }]); } if (action === 'content_list' && Number(params['nid']) === 291) { return this.result([ { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) }, { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', con: '高中英语@Unit 2' }, ]); } if (action === 'content_list' && Number(params['nid']) === 388) { return this.result([ { GeneralID: 100, Title: '张雅茜@历史任务', kywsj: '2026-05-15 09:00', fxzt: 1, yhid: 100, xxjlid: 900 }, { GeneralID: 101, Title: '张雅茜@任务', kywsj: '2026-08-01 09:00', fxzt: 1, yhid: 100, xxjlid: 900 }, { GeneralID: 102, Title: '张雅茜@任务', kywsj: '2026-08-02 09:00', fxzt: 0, yhid: 100, xxjlid: 900 }, ]); } if (action === 'content_list' && Number(params['nid']) === 3) { return this.result([{ GeneralID: 301, Title: '阅读理解专项' }]); } if (action === 'content_list' && Number(params['nid']) === 389) { return this.result([{ GeneralID: 401, df: 18, totalScore: 48, dontKnow: 20, wrong: 10 }]); } if (action === 'content_list' && Number(params['nid']) === 28) { this.lastAppointmentCourseParams = params; return this.result([ { GeneralID: 801, kcid: 801, Title: '初中英语|七年级上册@同步词汇' }, { GeneralID: 802, kcid: 801, Title2: '【初中英语】阅读训练' }, ]); } if (action === 'content_list' && Number(params['ModelID']) === 58) { return this.result([{ GeneralID: 701, yhid: 100, kcid: 801, syjd: '{}' }]); } if (action === 'content_list' && Number(params['nid']) === 29) { this.lastScheduleParams = params; return this.result([ { GeneralID: 901, bxrq: '2026-08-10 00:00:00', sdsd: '19:00', Title: '同步伴学', plxm: '张老师', dszt: 10 }, { GeneralID: 902, bxrq: '2026-08-03 00:00:00', sdsd: '18:00', Title: '历史伴学', plxm: '李老师', dszt: 30 }, ]); } if (action === 'node_list' && Number(params['pid']) === 8) { return this.result([{ NodeID: 801, NodeName: '小学人教版词库' }]); } if (action === 'node_list' && Number(params['pid']) === 801) { this.lastNodeParams = params; return this.result(this.unitRows); } if (action === 'e_ck_list') { this.lastWordParams = params; const allWords = (this.courseWords ?? (Number(params['nids']) === 813 ? Array.from({ length: 65 }, (_, index) => ({ GeneralID: 2001 + index, NodeID: 813, Title: `word-${index + 1}`, sy: `释义 ${index + 1}` })) : [ { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: 'n. 苹果', yp: 'apple.mp3', w_learned: 1 }, { GeneralID: 1002, NodeID: 812, Title: 'banana', sy: 'n. 香蕉', yp: 'banana.mp3', w_learned: 0 }, ])).map((word: Record) => ({...word,w_learned:(Number(word['w_learned'])||0)+(this.courseIncrements.get(String(word['GeneralID']))||0)})); const pageIndex = Math.max(1, Number(params['cpage'] ?? 1)); const pageSize = Math.max(1, Number(params['psize'] ?? 30)); const passCounts = [0,1,2,3].map(pass => allWords.filter(word => Math.min(3,Number(word['w_learned']) || 0) === pass).length); const filtered = params['learningPass'] == null ? allWords : allWords.filter(word => Math.min(3,Number(word['w_learned']) || 0) === Number(params['learningPass'])); const result = filtered.slice((pageIndex - 1) * pageSize, pageIndex * pageSize); return of({ retcode: 1, result: result as T, page: { itemCount: filtered.length, pageCount: Math.ceil(filtered.length / pageSize), pageIndex, pageSize, passCounts, bookPassCounts: this.courseBookCounts ?? passCounts }, }); } if (action === 'node_get') return this.result([{ NodeID: 3, NodeName: '专项训练' }]); if (action === 'app_exam_history') return this.result({items:[{GeneralID:'answer:401',df:18,totalScore:48,dontKnow:20,wrong:10,resultAvailable:true,askid:1,testedAt:'2026-10-01T08:00:00Z'}],hasMore:false}); if (action === 'vote_ask') return this.result([{ ID: 1, Title: '第一组问题' }]); if (action === 'vote_question') { return this.result([{ ID: 501, AskID: 1, QType: 1, QTitle: 'apple', QContent: '苹果', QOption: JSON.stringify([{ text: '苹果', value: 1 }]) }]); } if (action === 'e_words_list') { return this.result([{ GeneralID: 601, Title: 'tend@张雅茜', detail: [{ GeneralID: 701, Title: 'tend', sy: 'vt.照顾;照料', yp: 'tend.mp3' }], }]); } return this.result([]); } post(action: string, params: Record = {}): Observable> { if (action === 'stu_record_update_v2') { this.studyUpdates.push(params); const batchId=String(params['batchId']), words=JSON.parse(String(params['addon'])).xxqs; const version=Number(params['expectedVersion']||0)+1; this.studyBatches=this.studyBatches.filter(b=>b.batchId!==batchId).concat({batchId,words,status:params['saveMode']==='confirm'?'confirmed':'draft',version,bindingId:'binding-801'}); return this.result({ GeneralID: 9901, batchVersion:version, learned:new Set(this.studyBatches.filter(b=>b.status==='confirmed').flatMap(b=>b.words.map((w:any)=>w.GeneralID))).size }); } if (action === 'app_lesson_complete') { this.completionRequests.push(params); return this.completionResponse as Observable>; } if (action === 'e_add_words') { this.lastLearningWordUpdate = params; if (params['save'] === 1) for (const id of String(params['wordsId']).split(',')) this.courseIncrements.set(id,(this.courseIncrements.get(id)||0)+1); return this.result({ affected: String(params['wordsId'] ?? '').split(',').filter(Boolean).length }); } if (action === 'content_add_zt') { this.lastStandaloneLearningRecord = params; return this.result({ GeneralID: 9901 }); } if (action === 'e_order_tongji') { return this.result({ t30: 4, t60: 6, t_tiyan: 1, t_total: 11, t_shichang: 9, t_yongji: 320 }); } if (action === 'e_user_list') { return this.result([{ szyh: 100, honeyname: '张雅茜' }]); } if (action === 'app_learning_overview') { const records = Number(params['studentId']) === 777 ? Array.from({ length: 45 }, (_, index) => ({ GeneralID: 5000 + index, CreateTime: `2026-08-${String(31 - (index % 20)).padStart(2, '0')} 09:30:00`, dqrq: '20260801', con: `分页学习记录 ${index + 1}`, })) : [ { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', dqrq: '20260801', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]), readingArticleId: 'readingarticle201', readingCompletedAt: '2026-08-01T03:00:00.000Z' }, { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', dqrq: '20260804', con: '高中英语@Unit 2', xxqs: JSON.stringify([{ Title: 'banana' }]) }, ]; const selected = String(params['date'] ?? ''); return this.result({ records: (selected ? records.filter((item) => item.CreateTime.startsWith(selected)) : records).slice((Number(params['page']||1)-1)*20,Number(params['page']||1)*20), page:{itemCount:selected?records.filter(item=>item.CreateTime.startsWith(selected)).length:records.length,pageCount:Math.ceil(records.length/20)}, latestReadingRecord: records[0], recordDateCounts: { '2026-08-01': 1, '2026-08-04': 1 }, scheduleDateCounts: {}, schedules: [], courses: [ { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' }, { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' }, ], refreshedAt: '2026-08-26T00:00:00.000Z', }); } if (action === 'app_companion_overview') { this.lastScheduleParams = params; const schedules = [ { GeneralID: 901, bxrq: '2026-08-10 00:00:00', sdsd: '19:00', Title: '同步伴学', plxm: '张老师', dszt: 10 }, { GeneralID: 902, bxrq: '2026-08-03 00:00:00', sdsd: '18:00', Title: '历史伴学', plxm: '李老师', dszt: 30 }, { GeneralID: 903, bxrq: '2099-01-01 00:00:00', sdsd: '18:00', Title: '未来计划', plxm: '王老师', dszt: 0 }, ]; const historySchedules = [ { GeneralID: 911, szyh:100, bxrq: '2026-07-14 00:00:00', sdsd: '22:00', Title: '谢晨晨(60分钟练习)', plxm: '张老师', dszt: 30 }, { GeneralID: 912, szyh:101, bxrq: '2026-07-14 00:00:00', sdsd: '17:00', Title: '谢谢(60分钟练习)', plxm: '张老师', dszt: 30 }, { GeneralID: 913, szyh:102, bxrq: '2026-03-09 00:00:00', sdsd: '10:00', Title: '花花(60分钟练习)', plxm: '张老师', dszt: 30 }, { GeneralID: 914, szyh:103, bxrq: '2026-01-18 00:00:00', sdsd: '16:30', Title: '小赵(60分钟练习)', plxm: '张老师', dszt: 30 }, { GeneralID: 915, szyh:104, bxrq: '2026-01-18 00:00:00', sdsd: '16:00', Title: '小黄(60分钟练习)', plxm: '张老师', dszt: 30 }, ]; const rangeStart = String(params['rangeStart'] ?? ''); const rangeEnd = String(params['rangeEnd'] ?? ''); return this.result({ studentOptions: params['history'] ? [{id:'100',name:'谢晨晨',count:1},{id:'101',name:'谢谢',count:1},{id:'102',name:'花花',count:1},{id:'103',name:'小赵',count:1},{id:'104',name:'小黄',count:1}] : [], courses: [ { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' }, { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' }, ], dates:(params['history']?historySchedules:schedules).map(item=>({date:item.bxrq.slice(0,10),finished:item.dszt>=30?1:0,planned:item.dszt>=30?0:1})), schedules: params['history'] ? historySchedules : rangeStart && rangeEnd ? schedules.filter((item) => item.bxrq.slice(0, 10) >= rangeStart && item.bxrq.slice(0, 10) <= rangeEnd) : schedules, refreshedAt: '2026-08-26T00:00:00.000Z', }); } if (action === 'app_account_overview') { if (Number(params['uid']) === 300) { return this.result({ profile: { userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }, coachPanel: { todaySchedules: 2, completedLessons: 8, reviewIncome: 36, reviewRecords: 3, totalLessons: 11, totalHours: 9, course30: 4, course60: 6, trialLessons: 1 }, refreshedAt: '2026-08-26T00:00:00.000Z', }); } return this.result({ profile: { userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 }, companionSummary: { completedSessions: 1, completedMinutes: 60 }, antiForgetSummary: { total: 3, completed: 2, progress: 67, todayTotal: 0, todayCompleted: 0, duePending: 1, scheduled: 0, scheduledDays: 3 }, refreshedAt: '2026-08-26T00:00:00.000Z', }); } if (action === 'app_review_page') { this.lastMemoryParams=params; if (this.reviewPageResponse) return this.reviewPageResponse as Observable>; const items=params['scope']==='today' ? [{ GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: String(params['date'])+' 09:00', fxzt: 0, yhid: 100, xxjlid: 900 }] : [ { GeneralID: 100, Title: '张雅茜@历史任务', kc_title: '历史复习', kywsj: '2026-05-15 09:00', learned_at: '2026-05-01T01:15:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 }, { GeneralID: 101, Title: '张雅茜@任务', kc_title: '初中英语复习', kywsj: '2026-08-01 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 }, { GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: '2026-08-02 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 0, yhid: 100, xxjlid: 900 }, ...(Number(params['shouru']) === 1 ? [{ GeneralID: 103, Title: '李同学@任务', kc_title: '小学英语复习', kywsj: '2026-08-02 10:00', learned_at: '2026-07-21T02:20:00.000Z', fxzt: 0, yhid: 101, xxjlid: 901 }] : []), ];return this.result({...(params['view']==='home'?{view:'home',legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}}:{}),items,page:{itemCount:items.length,pageCount:1},summary:{total:items.length,completed:items.filter(i=>i.fxzt===1).length,due:items.filter(i=>i.fxzt!==1).length,today:0,income:0},dates:items.map(i=>({date:i.kywsj.slice(0,10),total:1,completed:i.fxzt===1?1:0}))}); } if (action === 'e_get_21list') { this.lastMemoryParams = params; return this.result([ { GeneralID: 100, Title: '张雅茜@历史任务', kc_title: '历史复习', kywsj: '2026-05-15 09:00', learned_at: '2026-05-01T01:15:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 }, { GeneralID: 101, Title: '张雅茜@任务', kc_title: '初中英语复习', kywsj: '2026-08-01 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 }, { GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: '2026-08-02 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 0, yhid: 100, xxjlid: 900 }, ...(Number(params['shouru']) === 1 ? [{ GeneralID: 103, Title: '李同学@任务', kc_title: '小学英语复习', kywsj: '2026-08-02 10:00', learned_at: '2026-07-21T02:20:00.000Z', fxzt: 0, yhid: 101, xxjlid: 901 }] : []), ]); } if (action === 'e_record_detail') { return of({ retcode: 1, result: [{ GeneralID: 701, Title: 'apple', detail: [{ GeneralID: 701, Title: 'apple', sy: '苹果', yp: this.historyWordAudio }] }], addon: [{ if_writing: '0', if_writing_tips: '先回忆单词,再查看释义' }], } as unknown as ApiEnvelope); } return this.result([]); } assetUrl(path: unknown): string { return String(path ?? ''); } private result(result: unknown): Observable> { return of({ retcode: 1, result: result as T }); } } describe('home learning modules', () => { const user = signal({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 }); const selectedStudent = signal(null); const session = { user, token: signal('test-token'), student: selectedStudent, isLoggedIn: signal(true), groupId: signal(1), setStudent: (student: AppUser | null) => selectedStudent.set(student), patchUser: () => undefined, logout: () => undefined, }; beforeEach(() => { sessionStorage.removeItem('xiaoshu.learning.teacherTodayReview'); localStorage.removeItem('xiaoshu.learning.homeDaily.v1'); spyOn(window, 'confirm').and.returnValue(true); // Route tests do not depend on remote audio availability or its error-toast timer. spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve()); localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.skip_spelling_stage'); user.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 }); session.groupId.set(1); session.student.set(null); TestBed.configureTestingModule({ providers: [ provideRouter(routes), provideHttpClient(), { provide: ApiService, useClass: LearningModulesApiService }, { provide: SessionService, useValue: session }, ], }); }); for (const role of [1,3]) { it(`numbers learning records in reverse across pages and date filters for role ${role}`, async () => { user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role); const api=TestBed.inject(ApiService), original=api.post.bind(api); spyOn(TestBed.inject(LiveDataRefreshService),'watch').and.returnValue(()=>{}); spyOn(api,'post').and.callFake(((action:string,params:Record={})=>{ if(action!=='app_learning_overview')return original(action,params); const total=params['date']?2:26,offset=(Number(params['page'])-1)*20; return of({retcode:0,result:{records:Array.from({length:Math.min(20,total-offset)},(_,i)=>({GeneralID:1000+offset+i,CreateTime:params['date']||'2026-11-12 12:00',con:'课程 '+(total-offset-i)})),courses:[],schedules:[],recordDateCounts:{},scheduleDateCounts:{},page:{itemCount:total,pageCount:Math.ceil(total/20)}}}); }) as typeof api.post); const harness=await RouterTestingHarness.create('/pages/his/his');await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent; const numbers=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.learning-item .record-number'),node=>node.textContent!.trim()); expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i))); component.goToRecordPage(2);await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(numbers()).toEqual(['6','5','4','3','2','1']); component.selectDate('2026-11-12');await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(numbers()).toEqual(['2','1']); }); } for (const role of [1,3]) { it(`numbers training records across date groups and pages with newest first for role ${role}`, async () => { user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role); const api=TestBed.inject(ApiService), original=api.post.bind(api); spyOn(TestBed.inject(LiveDataRefreshService),'watch').and.returnValue(()=>{}); const calls:Record[]=[]; const delayed = new Subject>();let retryPage = false; spyOn(api,'post').and.callFake(((action:string,params:Record={})=>{ if(action!=='app_companion_overview')return original(action,params); calls.push(params);if(Number(params['page'])===2 && !retryPage)return delayed; const total=26,offset=(Number(params['page'])-1)*20; return of({retcode:0,result:{schedules:Array.from({length:Math.min(20,total-offset)},(_,i)=>({GeneralID:1000+offset+i,bxrq:'2026-11-'+String(26-offset-i).padStart(2,'0'),sdsd:'19:00',Title:'训练 '+(26-offset-i),dszt:30})),courses:[],studentOptions:[],dates:[],earningsSummary:{amount:0,pendingCount:0},page:{itemCount:26,pageCount:2}}}); }) as typeof api.post); const harness=await RouterTestingHarness.create(role===3?'/pages/member/ke_xiao':'/pages/home/cart?csStatus=finished&csRange=all'); await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent; const numbers=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.schedule-list .record-number'),node=>node.textContent!.trim()); expect(calls[0]['history']).toBe(role===3); expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i))); expect(harness.routeNativeElement!.querySelector('.schedule-copy strong')?.textContent).toContain('训练 26'); component.turnPage(1);harness.fixture.detectChanges(); expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i))); delayed.error(new Error('翻页失败'));await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i))); retryPage=true;component.load(true,true);await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(numbers()).toEqual(['6','5','4','3','2','1']); }); } it('filters teacher history students before restoring a stale selection and uses server pagination', async () => { user.set({userId:300, groupId:3}); session.groupId.set(3); const watch = spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {}); const api = TestBed.inject(ApiService), original = api.post.bind(api); const students = new Subject>(); const post = spyOn(api, 'post').and.callFake(((action: string, params: Record = {}) => { if (action === 'e_user_list') return students; return original(action, params); }) as typeof api.post); const harness = await RouterTestingHarness.create('/pages/his/his?uid=999'); expect(post.calls.allArgs().find(([action]) => action === 'e_user_list')?.[1]).toEqual(jasmine.objectContaining({scope:'current-scheduled',selectedStudentId:999})); expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse(); expect(watch).not.toHaveBeenCalled(); students.next({retcode:0,result:[{szyh:100,honeyname:'在学学员'}],page:{itemCount:1,pageCount:1}}); students.complete(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const component = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent; expect(component.studentId).toBe(100); expect(watch).toHaveBeenCalledTimes(1); expect(session.student()?.userId).toBe(100); expect(TestBed.inject(Router).url).toContain('uid=100'); expect(component.studentsHaveMore()).toBeFalse(); expect(post.calls.allArgs().filter(([action]) => action === 'app_learning_overview').map(([,params]) => params?.['studentId'])).toEqual([100]); expect(harness.routeNativeElement?.querySelector('.student-filter select')?.textContent).toContain('在学学员'); }); it('clears teacher history and the old student context when no eligible scheduled students remain', async () => { user.set({userId:300, groupId:3}); session.groupId.set(3); session.student.set({userId:999,groupId:1,honeyName:'旧学员'}); spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {}); const api = TestBed.inject(ApiService), original = api.post.bind(api); const post = spyOn(api, 'post').and.callFake(((action: string, params: Record = {}) => action === 'e_user_list' ? of({retcode:0,result:[],page:{itemCount:0,pageCount:0}}) : original(action,params)) as typeof api.post); const harness = await RouterTestingHarness.create('/pages/his/his?uid=999'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(session.student()).toBeNull(); expect(TestBed.inject(Router).url).toBe('/pages/his/his'); expect(harness.routeNativeElement?.textContent).toContain('暂无在排课的正常学员'); expect(harness.routeNativeElement?.querySelector('.learning-record-card')).toBeNull(); expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse(); }); it('keeps an eligible restored student and a stable student order while loading more pages', async () => { user.set({userId:300, groupId:3}); session.groupId.set(3); spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {}); const api = TestBed.inject(ApiService), original = api.post.bind(api), requests: Record[] = []; spyOn(api, 'post').and.callFake(((action: string, params: Record = {}) => { if (action !== 'e_user_list') return original(action,params); requests.push(params); return of({retcode:0,result:params['cpage']===1?[{szyh:777,honeyname:'原学员'},{szyh:100,honeyname:'另一学员'}]:[{szyh:101,honeyname:'下一页学员'}],page:{itemCount:3,pageCount:2}}); }) as typeof api.post); const harness = await RouterTestingHarness.create('/pages/his/his?uid=777'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const component = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent; expect(component.studentId).toBe(777); expect(component.studentsHaveMore()).toBeTrue(); component.studentId=100; component.onFilterChange(); component.loadStudents(true); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(requests.map(params => params['selectedStudentId'])).toEqual([777,777]); expect(component.students().map(row => component.studentValue(row))).toEqual([777,100,101]); expect(component.studentId).toBe(100); expect(component.studentsHaveMore()).toBeFalse(); }); it('shows a retry state instead of loading a stale student when the filtered student request fails', async () => { user.set({userId:300, groupId:3}); session.groupId.set(3); session.student.set({userId:999,groupId:1,honeyName:'旧学员'}); const watch = spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {}); const api = TestBed.inject(ApiService), original = api.post.bind(api); let fail = true; const post = spyOn(api, 'post').and.callFake(((action: string, params: Record = {}) => { if (action !== 'e_user_list') return original(action,params); return fail ? throwError(() => new Error('学员列表暂时无法加载')) : of({retcode:0,result:[{szyh:100,honeyname:'在学学员'}],page:{itemCount:1,pageCount:1}}); }) as typeof api.post); const harness = await RouterTestingHarness.create('/pages/his/his?uid=999'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(session.student()).toBeNull(); expect(harness.routeNativeElement?.textContent).toContain('学员列表加载失败'); expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse(); expect(watch).not.toHaveBeenCalled(); fail = false; (harness.routeNativeElement?.querySelector('.teacher-student-state button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(session.student()?.userId).toBe(100); expect(harness.routeNativeElement?.querySelector('.teacher-student-state')).toBeNull(); }); it('reconciles monthly review fees from every matching page when an old gateway always returns 29', async () => { user.set({userId:300, groupId:3, honeyName:'钱老师'}); session.groupId.set(3); const api = TestBed.inject(ApiService), original = api.post.bind(api), requests: Record[] = []; spyOn(api, 'post').and.callFake(((action:string, params:Record = {}) => { if (action !== 'app_review_page') return original(action,params); requests.push(params); const rows = Array.from({length:params['month']==='2026-09'?6:28}, (_,i) => ({GeneralID:i+1,money:params['month']==='2026-09'?1:i<21?1:0,fxzt:1,kywrq:params['month']+'-15'})); const size=Number(params['pageSize']), index=Number(params['page']); return of({result:{items:rows.slice((index-1)*size,index*size),page:{itemCount:rows.length,pageCount:Math.ceil(rows.length/size)},summary:{income:29},dates:[{date:'2026-09-15',total:6,completed:6},{date:'2026-10-15',total:28,completed:28}]}}); }) as typeof api.post); const harness=await RouterTestingHarness.create('/pages/member/ke_fuxi'); const component=harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent; component.setIncomeMonth('2026-10'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(component.totalIncome()).toBe(21); expect(component.items().length).toBe(20); expect(requests.some(p=>p['month']==='2026-10'&&p['pageSize']===100&&p['includeMetadata']===false)).toBeTrue(); expect(harness.routeNativeElement!.querySelector('.fee-summary-amount')!.textContent).toContain('21.00'); component.setIncomeMonth('2026-09'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(component.totalIncome()).toBe(6); expect(component.serverTotal()).toBe(6); expect(harness.routeNativeElement!.querySelector('.fee-summary-top')!.textContent).toContain('2026年09月'); }); it('shows lesson fee total with older gateways that omit earningsSummary', async () => { spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {}); user.set({userId:300, groupId:3, honeyName:'钱老师'}); session.groupId.set(3); const api=TestBed.inject(ApiService), original=api.post.bind(api); spyOn(api,'post').and.callFake(((action:string,params:Record={}) => { if(action!=='app_companion_overview')return original(action,params); return of({result:{courses:[],schedules:Array.from({length:6},(_,i)=>({GeneralID:i+1,bxrq:'2026-09-15',dszt:30,teacherPay:30,payStatus:'earned'})),page:{itemCount:6,pageCount:1},dates:[{date:'2026-09-15',finished:6,planned:0}],studentOptions:[],refreshedAt:'2026-10-28'}}); }) as typeof api.post); const harness=await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/member/ke_xiao'); const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent; component.setHistoryMonth('2026-09'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(component.earningsSummary()?.amount).toBe(180); expect(harness.routeNativeElement!.querySelector('.fee-summary-amount')!.textContent).toContain('180.00'); }); it('renders scheduled duration for new lessons without duplicating legacy duration labels', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '钱老师', groupId: 3 }); session.groupId.set(3); const harness = await RouterTestingHarness.create('/pages/home/shop'); const center = harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent; center.scheduleRange.set('all'); center.schedules.set([ { GeneralID: 1, Title: '可爱同学(单词课)', durationMinutes: 30, bxrq: '2026-10-19', sdsd: '15:00', dszt: 0 }, { GeneralID: 2, Title: '小黄(单词课)', durationMinutes: 60, bxrq: '2026-10-19', sdsd: '19:00', dszt: 0 }, { GeneralID: 3, Title: '熊小俊(60分钟练习)', durationMinutes: 60, bxrq: '2026-10-19', sdsd: '14:00', dszt: 0 }, ]); harness.fixture.detectChanges(); const titles = Array.from(harness.routeNativeElement!.querySelectorAll('.schedule-copy strong')).map(element => element.textContent?.trim()); expect(titles).toEqual(['熊小俊(60分钟练习)', '可爱同学(单词课) · 30分钟', '小黄(单词课) · 60分钟']); expect(center.scheduleTitle({ Title: '旧课(60分钟练习)', durationMinutes: 30 })).toBe('旧课(30分钟练习)'); for (const durationMinutes of [undefined, null, 0, -30, 'invalid']) { expect(center.scheduleTitle({ Title: '旧课(60分钟练习)', durationMinutes })).toBe('旧课(60分钟练习)'); } }); it('keeps all today tasks cached across navigation and visibility', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const originalPost = api.post.bind(api); const post = spyOn(api, 'post').and.callFake((action, params) => { if (action !== 'app_review_page') return originalPost(action, params); return of({ retcode: 1, result: { items: Array.from({length: 7}, (_, i) => ({ GeneralID: 100+i, yhid: 100, Title: '今日学员', kywsj: params?.['date'], fxzt: i === 6 ? 1 : 0 })), page: { itemCount: 7, pageCount: 1 }, summary: { total: 7, completed: 1, due: 6, today: 7, income: 0 }, dates: [], } }) as never; }); const count = () => post.calls.allArgs().filter(([name]) => name === 'app_review_page').length; const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/home/index'); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.coach-memory-list article').length).toBe(7); expect(harness.routeNativeElement?.querySelectorAll('.coach-memory-footer').length).toBe(1); expect(harness.routeNativeElement?.textContent).not.toContain('查看今日全部任务'); document.dispatchEvent(new Event('visibilitychange')); await harness.navigateByUrl('/pages/member/starshop'); await harness.navigateByUrl('/pages/home/index'); harness.fixture.detectChanges(); expect(count()).toBe(1); expect(harness.routeNativeElement?.querySelector('.coach-memory-state')).toBeNull(); (harness.routeNativeElement?.querySelector('.coach-memory-refresh') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(count()).toBe(2); }); it('upgrades a warm legacy home review while preserving existing balance and stats snapshots', async () => { user.set({userId:100,groupId:1});session.groupId.set(1); const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date()),at=new Date().toISOString(); localStorage.setItem('xiaoshu.learning.homeDaily.v1',JSON.stringify({identity:`100|1|${today}`,values:{ review:{value:{items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:99,completed:0,due:99,today:99,income:0},legacyReviewSync:{state:'current'}},updatedAt:at}, balances:{value:{UserPoint:12.5,Purse:8,SilverCoin:7},updatedAt:at}, stats:{value:{t_total:18,t_shichang:9},updatedAt:at} }})); const api=TestBed.inject(ApiService),original=api.post.bind(api); const post=spyOn(api,'post').and.callFake(((action:string,params:Record={})=>action==='e_order_tongji'?of({retcode:0,result:{t_total:18,t_shichang:19}}):action==='app_review_page'?of({retcode:0,result:{view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:2,completed:0,due:2,today:2,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}}}):original(action,params)) as typeof api.post); const harness=await RouterTestingHarness.create('/pages/home/index');harness.fixture.detectChanges(); expect(post.calls.allArgs().filter(([action])=>action==='app_review_page').length).toBe(1); expect(post.calls.allArgs().filter(([action])=>action==='e_order_tongji').length).toBe(1); expect(harness.routeNativeElement?.querySelector('.review-sync-notice')).not.toBeNull(); expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).map(e=>e.textContent?.trim())).toEqual(['18','12.5','8','7']); expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).not.toContain('99'); }); it('ages the sync check on a mounted or restored home without reloading balances or synchronizing tasks', fakeAsync(() => { user.set({userId:100,groupId:1});session.groupId.set(1); const api=TestBed.inject(ApiService),original=api.post.bind(api); const post=spyOn(api,'post').and.callFake(((action:string,params:Record={})=>action==='app_review_page'?of({retcode:0,result:{view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:2,completed:0,due:2,today:2,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}}}):original(action,params)) as typeof api.post); let fixture=TestBed.createComponent(HomePageComponent);fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('上次检查'); tick(61_000);fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('已过期'); const requests=post.calls.count();fixture.destroy(); fixture=TestBed.createComponent(HomePageComponent);fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('已过期'); expect(post.calls.count()).toBe(requests); fixture.destroy(); })); it('student home requests a read-only summary and exposes stale sync without blocking learning or altering balances', async () => { user.set({userId:100,groupId:1});session.groupId.set(1); const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService; const original=api.post.bind(api); spyOn(api,'post').and.callFake((action,params={})=>{ if(action==='app_account_overview')return of({retcode:0,result:{profile:{UserPoint:12.5,Purse:8,SilverCoin:7}}}) as any; if(action!=='app_review_page')return original(action,params); api.lastMemoryParams=params; return of({retcode:0,result:{view:'home',items:[],page:{itemCount:0,pageCount:0},summary:{total:50,completed:1,due:2,today:2,income:0},dates:[],legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}}}) as any; }); const harness=await RouterTestingHarness.create('/pages/home/index'); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.student-stats')?.textContent).not.toContain('累计陪练时长'); expect(api.lastMemoryParams).toEqual(jasmine.objectContaining({view:'home',source:'database'})); expect(harness.routeNativeElement?.querySelector('.review-sync-notice')?.textContent).toContain('已同步'); expect(harness.routeNativeElement?.querySelector('.review-sync-notice')?.textContent).toContain('暂不可用'); expect(harness.routeNativeElement?.querySelector('.continue-button')).not.toBeNull(); expect((harness.routeNativeElement?.querySelector('.student-home-refresh') as HTMLButtonElement).disabled).toBeFalse(); expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).toContain('2'); expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).slice(1).map(e=>e.textContent?.trim())).toEqual(['12.5','8','7']); }); for (const groupId of [1, 3]) { it(`caches homepage overview for role ${groupId} until an explicit refresh`, async () => { user.set({ userId: groupId === 3 ? 300 : 100, groupId }); session.groupId.set(groupId); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const post = spyOn(api, 'post').and.callThrough(); const get = spyOn(api, 'get').and.callThrough(); const count = (action: string) => (action === 'app_account_overview' ? get.calls.allArgs() : post.calls.allArgs()).filter(([name]) => name === action).length; const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/home/index'); const first = { stats: count('e_order_tongji'), review: count('app_review_page'), balances: count('app_account_overview') }; if (groupId === 1) expect(get.calls.allArgs().find(([name]) => name === 'app_account_overview')?.[1]).toEqual(jasmine.objectContaining({ profileOnly: true, refresh: true })); document.dispatchEvent(new Event('visibilitychange')); await harness.navigateByUrl('/pages/member/starshop'); await harness.navigateByUrl('/pages/home/index'); harness.fixture.detectChanges(); expect(count('e_order_tongji')).toBe(first.stats); expect(count('app_review_page')).toBe(first.review); expect(count('app_account_overview')).toBe(first.balances); expect(harness.routeNativeElement?.textContent).not.toContain('正在同步学习计划'); const button = harness.routeNativeElement?.querySelector(groupId === 3 ? '.coach-hero .coach-refresh' : '.student-home-refresh') as HTMLButtonElement; button.click(); harness.fixture.detectChanges(); expect(count('e_order_tongji')).toBe(first.stats + 1); expect(count('app_review_page')).toBe(first.review + (groupId === 1 ? 1 : 0)); expect(count('app_account_overview')).toBe(first.balances + (groupId === 1 ? 1 : 0)); }); } for (const groupId of [1, 3]) { it(`caches companion and account tabs for role ${groupId} and refreshes only on request`, async () => { user.set({ userId: groupId === 3 ? 300 : 100, groupId }); session.groupId.set(groupId); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const post = spyOn(api, 'post').and.callThrough(); const count = (action: string) => post.calls.allArgs().filter(([name]) => name === action).length; const harness = await RouterTestingHarness.create(); const route = groupId === 3 ? '/pages/home/shop' : '/pages/home/cart'; await harness.navigateByUrl(route); await harness.navigateByUrl('/pages/home/ucenter'); const before = { companion: count('app_companion_overview'), account: count('app_account_overview'), versions: count('app_live_versions') }; document.dispatchEvent(new Event('visibilitychange')); await harness.navigateByUrl(route); harness.fixture.detectChanges(); expect(count('app_companion_overview')).toBe(before.companion); expect(harness.routeNativeElement?.querySelector('.center-sync')).toBeNull(); const center = harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent; center.showTomorrow(); expect(count('app_companion_overview')).toBe(before.companion + 1); center.showToday(); expect(count('app_companion_overview')).toBe(before.companion + 1); center.load(true); expect(count('app_companion_overview')).toBe(before.companion + 2); await harness.navigateByUrl('/pages/home/ucenter'); harness.fixture.detectChanges(); expect(count('app_account_overview')).toBe(before.account); expect(harness.routeNativeElement?.textContent).not.toContain('正在同步...'); expect(harness.routeNativeElement?.querySelector('.account-live-status')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.account-refresh')).toBeNull(); expect(count('app_account_overview')).toBe(before.account); expect(count('app_live_versions')).toBe(before.versions); }); } it('shows only review states to students and keeps pay labels on teacher calendars', async () => { const harness = await RouterTestingHarness.create('/pages/stu/stu_calendar?scope=overdue'); const calendar = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const today = new Date().toLocaleDateString('sv-SE', {timeZone:'Asia/Shanghai'}); calendar.calendarItems.set([ {GeneralID:1,kywsj:'2000-01-01',kywrq:'2000-01-01',fxzt:0}, {GeneralID:2,kywsj:today,kywrq:today,fxzt:0}, {GeneralID:3,kywsj:'2999-01-01',kywrq:'2999-01-01',fxzt:0}, {GeneralID:4,kywsj:'2000-01-01',kywrq:'2000-01-01',fxzt:1}, ]); const states = () => Array.from(harness.routeNativeElement!.querySelectorAll('.task-state')).map(node => node.textContent!.trim()); harness.fixture.detectChanges(); expect(states()).toEqual(['逾期补做','待复习','未到期','已完成']); expect(harness.routeNativeElement!.querySelector('.memory-date-tasks h2')?.textContent).toBe('逾期补做'); expect(harness.routeNativeElement!.textContent).not.toMatch(/计薪|记薪|费用|薪酬|收入/); user.set({userId:300,groupId:3});session.groupId.set(3);harness.fixture.detectChanges(); expect(states()).toEqual(['逾期补做 · 不计薪','待复习 · 当天完成计薪','未到期','已完成']); expect(harness.routeNativeElement!.querySelector('.memory-date-tasks h2')?.textContent).toBe('逾期补做 · 不计薪'); user.set({userId:100,groupId:1});session.groupId.set(1);harness.fixture.detectChanges(); expect(harness.routeNativeElement!.textContent).not.toMatch(/计薪|记薪|费用|薪酬|收入/); }); it('loads the review calendar once per login and refreshes only on an explicit request', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const originalPost = api.post.bind(api); const post = spyOn(api, 'post').and.callFake((action, params) => originalPost(action, params)); const count = (action: string) => post.calls.allArgs().filter(([name]) => name === action).length; const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/stu_calendar'); harness.fixture.detectChanges(); expect(count('app_review_page')).toBe(1); expect(count('app_live_versions')).toBe(0); await harness.navigateByUrl('/pages/member/starshop'); await harness.navigateByUrl('/pages/stu/stu_calendar'); harness.fixture.detectChanges(); expect(count('app_review_page')).toBe(1); const calendar = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; calendar.retryCalendar(); harness.fixture.detectChanges(); expect(count('app_review_page')).toBe(2); await harness.navigateByUrl('/pages/member/starshop'); session.token.set('next-login-token'); await harness.navigateByUrl('/pages/stu/stu_calendar'); harness.fixture.detectChanges(); expect(count('app_review_page')).toBe(3); session.token.set('test-token'); }); it('silently reveals pending and completed words opened from student learning content', async () => { const play = spyOn(TestBed.inject(PlatformService), 'play'); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/his/his'); (harness.routeNativeElement?.querySelector('.current-learning-list button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); (harness.routeNativeElement?.querySelector('.node-list button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('readonly=1'); const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; for (const [index, meaning] of ['n. 香蕉', 'n. 苹果'].entries()) { (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[index] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.audio')).toBeNull(); expect(harness.routeNativeElement?.textContent).not.toContain('读音'); const word = harness.routeNativeElement?.querySelector('.word-reveal') as HTMLButtonElement; word.click(); harness.fixture.detectChanges(); expect(word.textContent).toContain(meaning); expect(word.getAttribute('aria-expanded')).toBe('true'); word.click(); harness.fixture.detectChanges(); expect(word.getAttribute('aria-expanded')).toBe('false'); } learning.words().forEach((word) => learning.play(word)); expect(play).not.toHaveBeenCalled(); }); it('pages through all words in a history record without requesting a course unit', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const originalPost = api.post.bind(api); const post = spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail' ? of({ retcode: 1, result: Array.from({ length: 31 }, (_, index) => ({ GeneralID: 700 + index, Title: `history-${index + 1}`, detail: [{ GeneralID: 700 + index, Title: `history-${index + 1}`, sy: `释义 ${index + 1}` }], })), addon: [] } as ApiEnvelope) : originalPost(action, params)); const get = spyOn(api, 'get').and.callThrough(); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/his/his_words?id=201'); harness.fixture.detectChanges(); const list = () => harness.routeNativeElement?.querySelectorAll('.readonly-word-list article') ?? []; expect(list().length).toBe(30); expect(harness.routeNativeElement?.textContent).toContain('共 31 个单词'); (harness.routeNativeElement?.querySelector('.word-preview-pagination .page-direction:last-of-type') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(list().length).toBe(1); expect(list()[0].textContent).toContain('history-31'); expect(harness.routeNativeElement?.textContent).toContain('2 / 2 页'); expect(get.calls.allArgs().some(([action]) => action === 'e_ck_list')).toBeFalse(); expect(post.calls.allArgs().filter(([action]) => action === 'e_record_detail').length).toBe(1); (harness.routeNativeElement?.querySelector('.word-preview-pagination .page-direction:first-child') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(list().length).toBe(30); expect(list()[0].textContent).toContain('history-1'); }); it('plays student history words on click without changing learning records or enabling unit preview audio', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; api.historyWordAudio = 'apple.mp3'; const post = spyOn(api, 'post').and.callThrough(); const platform = TestBed.inject(PlatformService); const play = spyOn(platform, 'play').and.resolveTo('played'); const stopAudio = spyOn(platform, 'stopAudio'); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/his/his'); (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201'); expect(play).not.toHaveBeenCalled(); post.calls.reset(); const button = harness.routeNativeElement?.querySelector('.history-word-audio') as HTMLButtonElement; expect(button.disabled).toBeFalse(); expect(button.getAttribute('aria-label')).toBe('播放单词读音:apple'); expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).not.toContain('苹果'); button.querySelector('strong')!.click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).not.toContain('苹果'); play.calls.mostRecent().args[2]!.onEnded(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('苹果'); (button.querySelector('lucide-icon') as HTMLElement).click(); expect(play).toHaveBeenCalledTimes(2); expect(play.calls.mostRecent().args[0]).toContain('apple.mp3'); expect(post).not.toHaveBeenCalled(); const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; learning.words.update((words) => words.map((word) => ({ ...word, audio: '' }))); harness.fixture.detectChanges(); expect(button.disabled).toBeFalse(); expect(harness.routeNativeElement?.textContent).toContain('点击生成读音'); button.click(); expect(play).toHaveBeenCalledTimes(2); stopAudio.calls.reset(); await harness.navigateByUrl('/pages/home/index'); expect(stopAudio).toHaveBeenCalled(); await harness.navigateByUrl('/pages/stu/stu_con?nid=811&readonly=1'); expect(harness.routeNativeElement?.querySelector('.history-word-audio')).toBeNull(); const preview = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; preview.words().forEach((word) => preview.play(word)); expect(play).toHaveBeenCalledTimes(2); }); it('prepares listening choices during matching and reuses them when advancing', async () => { const prefix = 'xiaoshu.learning.100.100.practice.'; const words = [{GeneralID:1001,Title:'apple',sy:'苹果'},{GeneralID:1002,Title:'banana',sy:'香蕉'}]; localStorage.setItem(prefix+'learn_mode','newword'); localStorage.setItem(prefix+'choose_newword_learn',JSON.stringify(words)); localStorage.setItem(prefix+'_page_word_orilist',JSON.stringify([words])); localStorage.setItem(prefix+'_page_current_learn','1'); const response = new Subject>(); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); let count = 0; spyOn(api,'get').and.callFake((action,params) => { if (action==='content_list' && params?.['orders']==='NEWID()') {count++;return response;} return get(action,params); }); spyOn(TestBed.inject(PlatformService),'play'); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/stu3'); expect(harness.routeNativeElement?.querySelector('.match-grid')).not.toBeNull(); expect(count).toBe(1); response.next({retcode:1,result:[{GeneralID:301,Title:'cat',sy:'猫'},{GeneralID:302,Title:'dog',sy:'狗'},{GeneralID:303,Title:'bird',sy:'鸟'}]}); response.complete(); await harness.navigateByUrl('/pages/stu/stu4'); expect(count).toBe(1); const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(learning.choicesLoading()).toBeFalse(); expect(harness.routeNativeElement?.querySelectorAll('.option-list button').length).toBe(4); }); it('keeps the spoken word among listening choices and discards answers from an earlier question', async () => { const prefix = 'xiaoshu.learning.100.100.practice.'; const selectedWords = [ { GeneralID: 1001, Title: 'apple', sy: '苹果', yp: 'apple.mp3' }, { GeneralID: 1002, Title: 'banana', sy: '香蕉', yp: 'banana.mp3' }, ]; localStorage.setItem(prefix + 'learn_mode', 'newword'); localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify(selectedWords)); localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([selectedWords])); localStorage.setItem(prefix + '_page_current_learn', '1'); const responses = [new Subject>(), new Subject>()]; let requestCount = 0; const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); spyOn(api, 'get').and.callFake((action, params) => action === 'content_list' && params?.['orders'] === 'NEWID()' ? responses[requestCount++] ?? of({ retcode: 1, result: [] } as ApiEnvelope) : get(action, params)); spyOn(TestBed.inject(PlatformService), 'play'); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/stu4'); const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const firstWord = learning.current()!; const firstResponse = responses[0]; expect(requestCount).toBe(1); expect(learning.choicesLoading()).toBeTrue(); learning.chooseMeaning(firstWord); 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().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'); localStorage.removeItem(prefix + '_page_current_learn'); }); it('lets a student study selected words from their wordbook while keeping course stages teacher-led', async () => { localStorage.removeItem('xiaoshu.learning.100.100.practice.learn_mode'); localStorage.removeItem('xiaoshu.learning.100.100.practice.choose_newword_learn'); const harness = await RouterTestingHarness.create(); const router = TestBed.inject(Router); await harness.navigateByUrl('/pages/member/starshop'); (harness.routeNativeElement?.querySelector('.word-select-list > button') as HTMLButtonElement).click(); (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(router.url).toContain('/pages/stu/stu_con'); expect(learning().words().map((word) => word.word)).toEqual(['tend']); learning().skipStudyList(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu3'); expect(learning().words().map((word) => word.word)).toEqual(['tend']); learning().chooseMatch('left', learning().words()[0]); learning().chooseMatch('right', learning().words()[0]); learning().continueAfterStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu4'); expect(learning().words().map((word) => word.word)).toEqual(['tend']); learning().chooseMeaning(learning().words()[0]); learning().continueAfterStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu5'); for (const letter of learning().current()!.word) { const index = learning().letters().findIndex((choice) => !choice.used && choice.value === letter); expect(index).toBeGreaterThanOrEqual(0); learning().spellLetter(index); } learning().nextSpellingWord(); learning().continueAfterStage(); await harness.fixture.whenStable(); expect(router.url).toBe('/pages/member/starshop'); expect(localStorage.getItem('xiaoshu.learning.100.100.practice.learn_mode')).toBeNull(); await harness.navigateByUrl('/pages/stu/stu_con?nids=811&pnid=801'); await harness.fixture.whenStable(); expect(router.url).toBe('/pages/home/cart'); }); it('lets a teacher open a student wordbook, switch learners and save practice for the selected learner', async () => { user.set({userId:300, userName:'coach', honeyName:'老师', groupId:3}); session.groupId.set(3); selectedStudent.set({userId:888, honeyName:'正在排课的学员', groupId:1}); spyOn(TestBed.inject(PlatformService), 'play').and.resolveTo('played'); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); const pendingFirstLearner = new Subject>(); const words = (uid: number, completed = false) => ({ retcode:1, result:[{GeneralID:uid, xxcs:completed ? 2 : 1, detail:[{GeneralID:uid+700, Title:uid===100 ? 'apple' : 'banana', sy:uid===100 ? '苹果' : '香蕉', yp:'/UploadFiles/test.mp3'}]}], page:{itemCount:1, pendingCount:completed ? 0 : 1, completedCount:completed ? 1 : 0}, }); const read = spyOn(api, 'get').and.callFake((action, params): Observable> => { if (action === 'e_user_list') return of({retcode:1, result:[{userId:100, honeyName:'学员甲'}, {userId:101, honeyName:'学员乙'}]}); if (action !== 'e_words_list') return get(action, params); const uid = Number(params?.['uid']); const completed = params?.['learningStatus'] === 'completed'; if (uid === 100 && completed) return pendingFirstLearner; const saved = uid === 101 && api.lastLearningWordUpdate['save'] === 1; if (saved !== completed) return of({retcode:1, result:[], page:{itemCount:0, pendingCount:saved ? 0 : 1, completedCount:saved ? 1 : 0}}); return of(words(uid, saved)); }); const harness = await RouterTestingHarness.create(); const router = TestBed.inject(Router); const page = () => harness.routeNativeElement!; const stage = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const settled = async () => { await harness.fixture.whenStable(); harness.fixture.detectChanges(); }; await harness.navigateByUrl('/pages/home/index'); (page().querySelector('.coach-wordbook-entry') as HTMLAnchorElement).click(); await settled(); expect(router.url).toBe('/pages/member/starshop'); expect(read).toHaveBeenCalledWith('e_user_list', jasmine.objectContaining({scope:'wordbook'})); expect(page().querySelector('.word-select-list')?.textContent).toContain('apple'); expect(page().querySelector('.danger-button')).toBeNull(); (page().querySelector('.word-select-list button') as HTMLButtonElement).click(); (page().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click(); const picker = page().querySelector('#wordbook-student')!; picker.value = '101'; picker.dispatchEvent(new Event('change')); pendingFirstLearner.next(words(100, true)); await settled(); expect(page().querySelector('.word-select-list')?.textContent).toContain('banana'); expect(page().querySelector('.word-select-list')?.textContent).not.toContain('apple'); expect(stage().selected().size).toBe(0); expect(selectedStudent()?.userId).toBe(888); (page().querySelector('.word-select-list button') as HTMLButtonElement).click(); await settled(); (page().querySelector('.selection-header .primary-button') as HTMLButtonElement).click(); await settled(); expect(router.url).toBe('/pages/stu/stu_con?wordbookStudentId=101'); expect(stage().words().map(word=>word.word)).toEqual(['banana']); stage().skipStudyList(); await settled(); expect(router.url).toBe('/pages/stu/stu3?wordbookStudentId=101'); stage().chooseMatch('left', stage().words()[0]); stage().chooseMatch('right', stage().words()[0]); stage().continueAfterStage(); await settled(); stage().chooseMeaning(stage().words()[0]); stage().continueAfterStage(); await settled(); expect(router.url).toBe('/pages/stu/pl_stu5?wordbookStudentId=101'); stage().skipSpellingStage(); await settled(); expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({uid:101, scope:'wordbook', wordsId:'801', save:1})); expect(router.url).toBe('/pages/member/starshop?wordbookStudentId=101'); expect(page().querySelector('#wordbook-student')?.value).toBe('101'); expect(selectedStudent()?.userId).toBe(888); (page().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click(); await settled(); expect(page().querySelector('.completed-column')?.textContent).toContain('banana'); }, 15000); it('separates unlearned and learned wordbook words while keeping selections across tabs', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list' ? of({ retcode: 1, result: params?.['learningStatus'] === 'completed' ? [{ GeneralID: 602, Title: 'prepare@张雅茜', xxcs: 2, detail: [{ GeneralID: 702, Title: 'prepare', sy: '准备' }] }] : [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 1, detail: [{ GeneralID: 701, Title: 'tend', sy: '照顾' }] }], page: { itemCount: 1, pendingCount: 1, completedCount: 1, pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope) : get(action, params)); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/member/starshop'); harness.fixture.detectChanges(); const tabs = harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button') ?? []; expect(tabs[0].textContent).toContain('未学习'); expect(tabs[0].textContent).toContain('1'); expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('tend'); (harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button') as HTMLButtonElement).click(); tabs[1].click(); harness.fixture.detectChanges(); expect(tabs[1].textContent).toContain('已学习'); expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('prepare'); (harness.routeNativeElement?.querySelector('.completed-column .word-select-list > button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.selection-header strong')?.textContent).toContain('已选 2 个单词'); tabs[0].click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button.selected')).not.toBeNull(); }); for (const legacy of [false, true]) { it(`selects wordbook words independently when practice IDs repeat (${legacy ? 'legacy' : 'paged'} response)`, async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); const pending = Array.from({ length: 35 }, (_, index) => ({ GeneralID: 601, scid: 701 + index, xxcs: 1, detail: [{ GeneralID: 701 + index, Title: `word-${index + 1}`, yp: 'word.mp3' }], })); const completed = [{ GeneralID: 601, scid: 801, xxcs: 2, detail: [{ GeneralID: 801, Title: 'done' }] }]; spyOn(api, 'get').and.callFake((action, params) => { if (action !== 'e_words_list') return get(action, params); const rows = legacy ? [...pending, ...completed] : params?.['learningStatus'] === 'completed' ? completed : pending; const page = Number(params?.['cpage']); const size = Number(params?.['psize']); return of({ retcode: 1, result: rows.slice((page - 1) * size, page * size), page: { itemCount: rows.length, pageCount: Math.ceil(rows.length / size), pageIndex: page, pageSize: size, ...(!legacy ? { pendingCount: pending.length, completedCount: completed.length } : {}), } } as ApiEnvelope); }); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/member/starshop'); const view = () => harness.routeNativeElement!; const rows = () => view().querySelectorAll('.word-select-list > button'); const selected = () => Array.from(view().querySelectorAll('.word-select-list > button.selected strong'), node => node.textContent); const click = (element: HTMLElement) => { element.click(); harness.fixture.detectChanges(); }; const expectCount = (count: number) => expect(view().querySelector('.selection-header strong')?.textContent).toContain(`已选 ${count} 个单词`); click(rows()[3]); expect(selected()).toEqual(['word-4']); expectCount(1); click(rows()[1]); expect(selected()).toEqual(['word-2', 'word-4']); click(rows()[3]); expect(selected()).toEqual(['word-2']); click(rows()[1].querySelector('.audio') as HTMLElement); expect(selected()).toEqual(['word-2']); click(view().querySelectorAll('.mobile-pagination button')[1]); click(rows()[0]); expect(selected()).toEqual(['word-31']); expectCount(2); click(view().querySelectorAll('.word-selection-tabs button')[1]); expect(selected()).toEqual([]); click(rows()[0]); expectCount(3); click(view().querySelectorAll('.word-selection-tabs button')[0]); expect(selected()).toEqual(['word-2']); click(view().querySelector('.selection-header .primary-button') as HTMLButtonElement); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(learning.words().map(word => word.word)).toEqual(['word-2', 'word-31', 'done']); expect(new Set(learning.words().map(word => word.id)).size).toBe(3); learning.leave(); await harness.fixture.whenStable(); }); } it('deletes only the selected vocabulary ID when a wordbook detail uses scid as its identity', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list' ? of({ retcode: 1, result: [ { GeneralID: 601, scid: 701, xxcs: 1, detail: [{ GeneralID: 701, Title: 'first' }] }, { GeneralID: 601, scid: 702, xxcs: 1, detail: { Title: 'second' } }, ], page: { itemCount: 2, pendingCount: 2, completedCount: 0, pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope) : get(action, params)); const post = spyOn(api, 'post').and.callThrough(); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/member/starshop'); const view = harness.routeNativeElement!; view.querySelectorAll('.word-select-list > button')[1].click(); harness.fixture.detectChanges(); expect(view.querySelectorAll('.word-select-list > button.selected').length).toBe(1); (view.querySelector('.danger-button') as HTMLButtonElement).click(); expect(post).toHaveBeenCalledWith('e_add_words', jasmine.objectContaining({ uid: 100, wordsId: '702', ifnew: 0 })); }); for (const role of [1,3]) { it(`reveals a wordbook meaning only after completed pronunciation for role ${role}`,async()=>{ user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role); const api=TestBed.inject(ApiService),original=api.get.bind(api); spyOn(api,'get').and.callFake(((action:string,params:Record={})=>{ if(action==='e_user_list')return of({retcode:0,result:[{userId:100,honeyName:'学员甲'},{userId:101,honeyName:'学员乙'}]}); if(action==='e_words_list')return of({retcode:0,result:[{GeneralID:601,xxcs:params['learningStatus']==='completed'?2:1,detail:[{GeneralID:701,Title:'apple',sy:'苹果',yp:'apple.mp3'}]},{GeneralID:602,xxcs:params['learningStatus']==='completed'?2:1,detail:[{GeneralID:702,Title:'banana',sy:'香蕉',yp:'banana.mp3'}]}],...(role===3?{page:{itemCount:2,pendingCount:2,completedCount:2}}:{})}); return original(action,params); }) as typeof api.get); const platform=TestBed.inject(PlatformService),play=spyOn(platform,'play').and.resolveTo('played');spyOn(platform,'toast'); const harness=await RouterTestingHarness.create('/pages/member/starshop');await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const rows=()=>harness.routeNativeElement!.querySelectorAll('.word-select-list > button'); const meanings=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.word-select-list small'),node=>node.textContent); expect(meanings()).toEqual([]); rows()[0].click();await harness.fixture.whenStable();harness.fixture.detectChanges();expect(meanings()).toEqual([]); const old=play.calls.mostRecent().args[2]!;expect(old).toBeDefined();if(!old)return; rows()[1].click();await harness.fixture.whenStable();harness.fixture.detectChanges(); old.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual([]); play.calls.mostRecent().args[2]!.onError?.();harness.fixture.detectChanges();expect(meanings()).toEqual([]); rows()[0].querySelector('.audio')!.click();await harness.fixture.whenStable(); play.calls.mostRecent().args[2]!.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual(['苹果']); expect(component.selected().size).toBe(2); component.load();await harness.fixture.whenStable();harness.fixture.detectChanges();expect(meanings()).toEqual([]); rows()[0].click();await harness.fixture.whenStable();const late=play.calls.mostRecent().args[2]!; if(role===3)component.selectWordbookStudent(101);else {component.selectNewWordBucket('completed');component.selectNewWordBucket('pending');} await harness.fixture.whenStable();late.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual([]); }); } it('pronounces a wordbook word on row click and only once on speaker click', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list' ? of({ retcode: 1, result: [{ GeneralID: 601, xxcs: 1, detail: [{ GeneralID: 701, Title: 'tend', yp: 'UploadFiles/tend.mp3' }] }], page: { itemCount: 1, pageCount: 1, pageIndex: 1, pageSize: Number(params?.['psize']) } } as ApiEnvelope) : get(action, params)); const play = spyOn(TestBed.inject(PlatformService), 'play'); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/member/starshop'); harness.fixture.detectChanges(); const row = harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button') as HTMLButtonElement; row.querySelector('strong')!.click(); harness.fixture.detectChanges(); expect(row.classList.contains('selected')).toBeTrue(); expect(play).toHaveBeenCalledTimes(1); expect(play.calls.mostRecent().args[0]).toBe('UploadFiles/tend.mp3'); expect(play.calls.mostRecent().args[0]).not.toContain('UploadFiles/UploadFiles'); (row.querySelector('.audio') as HTMLElement).click(); harness.fixture.detectChanges(); expect(play).toHaveBeenCalledTimes(2); expect(row.classList.contains('selected')).toBeTrue(); }); it('shows 30 wordbook words per page while counting every learning status', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); const pending = Array.from({ length: 65 }, (_, index) => ({ GeneralID: 601 + index, xxcs: 1, detail: [{ GeneralID: 701 + index, Title: `pending-${index + 1}` }] })); const completed = Array.from({ length: 7 }, (_, index) => ({ GeneralID: 801 + index, xxcs: 2, detail: [{ GeneralID: 901 + index, Title: `completed-${index + 1}` }] })); const requests: Record[] = []; spyOn(api, 'get').and.callFake((action, params) => { if (action !== 'e_words_list') return get(action, params); requests.push(params ?? {}); const rows = params?.['learningStatus'] === 'completed' ? completed : pending; const page = Number(params?.['cpage']); const size = Number(params?.['psize']); return of({ retcode: 1, result: rows.slice((page - 1) * size, page * size), page: { itemCount: rows.length, pendingCount: pending.length, completedCount: completed.length, pageCount: Math.ceil(rows.length / size), pageIndex: page, pageSize: size } } as ApiEnvelope); }); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/member/starshop'); harness.fixture.detectChanges(); const view = () => harness.routeNativeElement!; const rows = () => view().querySelectorAll('.pending-column .word-select-list > button'); expect(requests[0]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', cpage: 1, psize: 30 })); expect(rows().length).toBe(30); expect(view().querySelectorAll('.word-selection-tabs b')[0].textContent).toContain('65'); expect(view().querySelectorAll('.word-selection-tabs b')[1].textContent).toContain('7'); (rows()[0] as HTMLButtonElement).click(); (view().querySelectorAll('.mobile-pagination button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(requests[1]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', cpage: 2, psize: 30 })); expect(rows().length).toBe(30); expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 2 / 3 页'); (view().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(requests[2]).toEqual(jasmine.objectContaining({ learningStatus: 'completed', cpage: 1, psize: 30 })); expect(view().querySelectorAll('.completed-column .word-select-list > button').length).toBe(7); expect(view().querySelector('.selection-header strong')?.textContent).toContain('已选 1 个单词'); }); it('keeps wordbook counts and rows correct when the server only returns the old mixed list', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); const all = Array.from({ length: 139 }, (_, index) => ({ GeneralID: 601 + index, xxcs: 1, detail: [{ GeneralID: 701 + index, Title: `word-${index + 1}` }], })); const requests: Record[] = []; spyOn(api, 'get').and.callFake((action, params) => { if (action !== 'e_words_list') return get(action, params); requests.push(params ?? {}); const page = Number(params?.['cpage']); const size = Number(params?.['psize']); return of({ retcode: 1, result: all.slice((page - 1) * size, page * size), page: { itemCount: all.length, pageCount: Math.ceil(all.length / size), pageIndex: page, pageSize: size } } as ApiEnvelope); }); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/member/starshop'); harness.fixture.detectChanges(); const view = () => harness.routeNativeElement!; expect(requests.length).toBe(2); expect(requests[0]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', psize: 30 })); expect(requests[1]).toEqual(jasmine.objectContaining({ cpage: 1, psize: 500 })); expect(view().querySelectorAll('.word-selection-tabs b')[0].textContent).toContain('139'); expect(view().querySelectorAll('.word-selection-tabs b')[1].textContent).toContain('0'); expect(view().querySelectorAll('.pending-column .word-select-list > button').length).toBe(30); expect(view().querySelector('.pending-column .word-select-list > button')?.textContent).toContain('word-1'); expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 1 / 5 页 · 共 139 个单词'); (view().querySelectorAll('.mobile-pagination button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 2 / 5 页'); (view().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(view().querySelectorAll('.completed-column .word-select-list > button').length).toBe(0); expect(requests.length).toBe(2); }); it('saves completed wordbook study before showing the word in the learned tab', async () => { const prefix = 'xiaoshu.learning.100.100.practice.'; const word = { GeneralID: 701, Title: 'tend', sy: '照顾' }; localStorage.setItem(prefix + 'learn_mode', JSON.stringify('newword')); localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify([word])); localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([[word]])); localStorage.setItem(prefix + '_page_current_learn', JSON.stringify(1)); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list' ? of({ retcode: 1, result: api.lastLearningWordUpdate['save'] === 1 && params?.['learningStatus'] === 'completed' ? [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 2, detail: [word] }] : api.lastLearningWordUpdate['save'] === 1 ? [] : [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 1, detail: [word] }], page: { itemCount: params?.['learningStatus'] === 'completed' ? 1 : api.lastLearningWordUpdate['save'] === 1 ? 0 : 1, pendingCount: api.lastLearningWordUpdate['save'] === 1 ? 0 : 1, completedCount: api.lastLearningWordUpdate['save'] === 1 ? 1 : 0, pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope) : get(action, params)); const harness = await RouterTestingHarness.create(); const router = TestBed.inject(Router); await harness.navigateByUrl('/pages/stu/stu5'); const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; learning.skipSpellingStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({ uid: 100, wordsId: '701', save: 1 })); expect(router.url).toBe('/pages/member/starshop'); const tabs = harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button') ?? []; expect(tabs[0].textContent).toContain('0'); tabs[1].click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('tend'); }, 15000); it('skips the entire spelling stage for student wordbook self-study in one click', async () => { const prefix = 'xiaoshu.learning.100.100.practice.'; const words = [ { GeneralID: 701, Title: 'tend', sy: '照顾' }, { GeneralID: 702, Title: 'work on', sy: '从事' }, { GeneralID: 703, Title: 'prepare', sy: '准备' }, ]; localStorage.setItem(prefix + 'learn_mode', JSON.stringify('newword')); localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify(words.slice(0, 2))); localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([words.slice(0, 2), words.slice(2)])); localStorage.setItem(prefix + '_page_current_learn', JSON.stringify(1)); const harness = await RouterTestingHarness.create(); const router = TestBed.inject(Router); await harness.navigateByUrl('/pages/stu/stu5'); harness.fixture.detectChanges(); const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(learning().words().length).toBe(2); expect(learning().currentIndex()).toBe(0); const skip = Array.from(harness.routeNativeElement?.querySelectorAll('.skippable-spelling-actions button') ?? []) .find((button) => button.textContent?.includes('跳过这一关'))!; skip.click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu_con'); expect(learning().words().map((word) => word.word)).toEqual(['prepare']); learning().skipStudyList(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); learning().chooseMatch('left', learning().words()[0]); learning().chooseMatch('right', learning().words()[0]); learning().continueAfterStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu4'); learning().chooseMeaning(learning().words()[0]); learning().continueAfterStage(); await harness.fixture.whenStable(); expect(router.url).toBe('/pages/member/starshop'); expect(localStorage.getItem(prefix + 'learn_mode')).toBeNull(); }, 15000); for(const pass of [0,1,2,3]) { it(`waits for course word audio before revealing meanings in pass ${pass}`,async()=>{ const {harness,component}=await teacherSelection(2,pass,'901'); component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'}))); const platform=TestBed.inject(PlatformService),play=spyOn(platform,'play').and.resolveTo('played');spyOn(platform,'toast'); harness.fixture.detectChanges();const view=()=>harness.routeNativeElement!; const meanings=()=>view().querySelectorAll('.word-reveal small.revealed').length; expect(meanings()).toBe(0); (view().querySelector('.word-reveal') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(meanings()).toBe(0);const old=play.calls.mostRecent().args[2]!;expect(old).toBeDefined();if(!old)return; old.onEnded();harness.fixture.detectChanges();expect(meanings()).toBe(1); component.load();await harness.fixture.whenStable();if(pass>0){component.selectCoursePass(pass);await harness.fixture.whenStable();}component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'})));harness.fixture.detectChanges();expect(meanings()).toBe(0); (view().querySelector('.word-reveal') as HTMLButtonElement).click();await harness.fixture.whenStable();const late=play.calls.mostRecent().args[2]!; (view().querySelectorAll('.word-reveal')[1] as HTMLButtonElement).click();await harness.fixture.whenStable();late.onEnded();harness.fixture.detectChanges();expect(meanings()).toBe(0); play.calls.mostRecent().args[2]!.onError?.();harness.fixture.detectChanges();expect(meanings()).toBe(0); }); } it('still pronounces pending and completed words when a coach reveals them', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); session.student.set({ userId: 100, userName: 'student', groupId: 1 }); const play = spyOn(TestBed.inject(PlatformService), 'play'); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/stu_abc?nids=811,812&pnid=801'); for (const index of [0, 1]) { (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[index] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.audio')).not.toBeNull(); (harness.routeNativeElement?.querySelector('.word-reveal') as HTMLButtonElement).click(); } expect(play).toHaveBeenCalledTimes(2); expect(play.calls.argsFor(0)[0]).toContain('banana.mp3'); expect(play.calls.argsFor(1)[0]).toContain('apple.mp3'); }); it('allows second and third passes per word before the whole book is finished and stops selection after three passes', async () => { user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1}); const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService; api.courseWords=[{GeneralID:1001,NodeID:811,Title:'apple',w_learned:1},{GeneralID:1002,NodeID:811,Title:'banana',w_learned:2},{GeneralID:1003,NodeID:811,Title:'pear',w_learned:3}]; api.courseBookCounts=[1,1,1,1]; // An unselected unit still has a first-pass word. const harness=await RouterTestingHarness.create('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701'); const learning=()=>harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const tab=(n:number)=>(harness.routeNativeElement!.querySelectorAll('.course-pass-tabs button')[n] as HTMLButtonElement); harness.fixture.detectChanges(); expect(harness.routeNativeElement!.querySelectorAll('.course-pass-tabs button').length).toBe(4); tab(1).click();harness.fixture.detectChanges(); expect(harness.routeNativeElement!.querySelector('.word-select-check')).not.toBeNull(); expect(harness.routeNativeElement!.textContent).toContain('选择单词开始第二遍学习'); expect(learning().firstPassProgress()).toBe(75); const select=harness.routeNativeElement!.querySelector('.word-select-check') as HTMLButtonElement; select.click();harness.fixture.detectChanges(); expect(learning().selected().size).toBe(1); tab(2).click();harness.fixture.detectChanges(); const secondPass=harness.routeNativeElement!.querySelector('.word-select-check') as HTMLButtonElement; expect(secondPass).not.toBeNull(); secondPass.click();harness.fixture.detectChanges(); expect(learning().selected().size).toBe(2); secondPass.click();harness.fixture.detectChanges(); expect(learning().selected().size).toBe(1); tab(3).click();harness.fixture.detectChanges(); expect(harness.routeNativeElement!.textContent).toContain('已完成三遍学习'); expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull(); expect(learning().firstPassProgress()).toBe(75); // Selecting a learned word must actually enter the regular learning flow. tab(1).click();harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.selection-header .primary-button') as HTMLButtonElement).click(); await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con'); expect(learning().words().map(word=>word.word)).toEqual(['apple']); const finishPass=async()=>{ for(let stage=0;stage<4;stage++){ const skip=Array.from(harness.routeNativeElement!.querySelectorAll('.sticky-actions button')).find(button=>button.textContent?.includes('跳过这一关'))!; skip.click();await harness.fixture.whenStable();harness.fixture.detectChanges(); } expect(TestBed.inject(Router).url).toBe('/pages/his/his'); }; await finishPass(); expect(api.courseIncrements.get('1001')).toBe(1); await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701');harness.fixture.detectChanges(); tab(2).click();harness.fixture.detectChanges(); expect(learning().words().find(word=>word.word==='apple')?.['w_learned']).toBe(2); (harness.routeNativeElement!.querySelector('[aria-label="加入学习:apple"]') as HTMLButtonElement).click(); (harness.routeNativeElement!.querySelector('.selection-header .primary-button') as HTMLButtonElement).click(); await harness.fixture.whenStable();harness.fixture.detectChanges(); await finishPass(); expect(api.courseIncrements.get('1001')).toBe(2); await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701');harness.fixture.detectChanges(); tab(3).click();harness.fixture.detectChanges(); expect(learning().words().find(word=>word.word==='apple')?.['w_learned']).toBe(3); expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull(); expect(learning().firstPassProgress()).toBe(75); api.courseBookCounts=[0,0,1,3];tab(0).click();tab(3).click();harness.fixture.detectChanges(); expect(learning().firstPassProgress()).toBe(100); },30000); it('shows named schedule students below the trigger and closes after selection or Escape', async () => { user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3}); const harness=await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/home/shop'); const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent; component.serverStudents.set([{id:'100',name:'张雅茜',count:15},{id:'101',name:'花花',count:9},{id:'102',name:' ',count:1}]); spyOn(component,'load').and.stub(); harness.fixture.detectChanges(); await new Promise(resolve => setTimeout(resolve, 0)); const trigger=harness.routeNativeElement!.querySelector('.student-filter .xs-dropdown-trigger')!; expect(trigger.textContent).toContain('全部学员(25)'); trigger.click();harness.fixture.detectChanges(); const menu=document.querySelector('.xs-dropdown-menu')!; expect(menu.textContent).toContain('张雅茜(15)'); expect(menu.textContent).toContain('学员 102(1)'); expect(menu.getBoundingClientRect().top).toBeGreaterThan(trigger.getBoundingClientRect().bottom); menu.querySelectorAll('button')[2].click();harness.fixture.detectChanges(); await new Promise(resolve => setTimeout(resolve, 0)); expect(component.selectedStudent()).toBe('101'); expect(trigger.textContent).toContain('花花(9)'); expect(document.querySelector('.xs-dropdown-menu')).toBeNull(); trigger.click();harness.fixture.detectChanges(); document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));harness.fixture.detectChanges(); expect(document.querySelector('.xs-dropdown-menu')).toBeNull(); }); it('shows the planned review day and original learning time for migrated August tasks', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const post = api.post.bind(api); spyOn(api, 'post').and.callFake((action, params) => action === 'app_review_page' ? of({ retcode: 1, result: { items: [{ GeneralID: 102, yhid: 100, xxjlid: 900, kcid: 801, Title: '张雅茜@任务', kywrq: params?.['date'], kywsj: '2026-09-01 09:00', CreateTime: '2026-09-01T02:25:17Z', learned_at: '2026-07-31T01:20:00Z', learned_date: '20260731', fxzt: 0 }], page: { itemCount: 1, pageCount: 1 }, summary: { total: 1, completed: 0, due: 1, today: 0, income: 0 }, dates: [{ date: '2026-08-01', total: 1, completed: 0 }, { date: '2026-08-15', total: 1, completed: 0 }], } } as ApiEnvelope) : post(action, params)); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/stu_calendar?date=2026-08-01'); await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.task-context')?.textContent).toContain('复习日期 2026-08-01'); expect(harness.routeNativeElement?.querySelector('.first-learned-at')?.textContent).toContain('2026-07-31 09:20'); (harness.routeNativeElement?.querySelector('[aria-label^="2026-08-15"]') as HTMLButtonElement).click(); await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.task-context')?.textContent).toContain('复习日期 2026-08-15'); const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const task = component.calendarItems()[0]; expect(component.calendarFirstLearnedAt({ learned_date: '20260731' })).toContain('2026-07-31(时间未记录)'); component.openCalendar(task); await harness.fixture.whenStable(); expect(TestBed.inject(Router).url).toContain('plannedDate=2026-08-15'); }); it('keeps the anti-forget page visible while a selected date loads locally', async () => { const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/stu_calendar'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; component.memoryMonth.set(new Date(2026, 8, 1, 12)); component.memorySelectedDate.set('2026-09-11'); harness.fixture.detectChanges(); const response = new Subject>(); api.reviewPageResponse = response; (harness.routeNativeElement?.querySelector('[aria-label^="2026-09-12"]') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(api.lastMemoryParams['date']).toBe('2026-09-12'); expect(harness.routeNativeElement?.querySelector('.memory-overview')).not.toBeNull(); expect(harness.routeNativeElement?.querySelector('.memory-calendar')).not.toBeNull(); expect(harness.routeNativeElement?.querySelector('.memory-list-loading')?.textContent).toContain('正在加载当日复习'); response.next({ retcode: 1, result: { items: [{ GeneralID: 120, Title: '张雅茜@任务', kc_title: '9月12日复习', kywsj: '2026-09-12 09:00', fxzt: 0, yhid: 100, xxjlid: 920 }], page: { itemCount: 1, pageCount: 1 }, summary: { total: 1, completed: 0, due: 0, today: 0, income: 0 }, dates: [{ date: '2026-09-12', total: 1, completed: 0 }], }, }); response.complete(); api.reviewPageResponse = null; await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.memory-list-loading')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('9月12日复习'); }); it('shows a retryable error instead of an empty review when the detail has no words', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const post = api.post.bind(api); spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail' ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope) : post(action, params)); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/record?id=900&gid=102&kcid=801'); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('没有读取到单词'); expect(harness.routeNativeElement?.querySelector('.empty-state.error button')?.textContent).toContain('重新加载'); expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull(); }); it('syncs a missing learning record and reopens its review words', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const post = api.post.bind(api); let detailReads = 0; spyOn(api, 'post').and.callFake((action, params) => { if (action === 'e_record_detail') { detailReads += 1; return of({ retcode: 1, result: detailReads === 1 ? [] : [{ GeneralID: 701, Title: 'apple', sy: '苹果' }], addon: [] } as ApiEnvelope); } if (action === 'app_learning_overview') return of({ retcode: 1, result: {} } as ApiEnvelope); return post(action, params); }); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398'); harness.fixture.detectChanges(); expect(detailReads).toBe(2); expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple'); expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull(); }); it('reads the original word list when the detail stays empty after syncing', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const post = api.post.bind(api); const get = api.get.bind(api); spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail' ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope) : action === 'app_learning_overview' ? of({ retcode: 1, result: {} } as ApiEnvelope) : post(action, params)); spyOn(api, 'get').and.callFake((action, params) => action === 'content_get' ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: JSON.stringify([{ GeneralID: 701, Title: 'apple' }]) }] } as ApiEnvelope) : get(action, params)); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398'); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple'); expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull(); }); it('allows a confirmed notes review to finish once without loading missing vocabulary', async () => { user.set({userId:300,groupId:3}); session.groupId.set(3); session.student.set({userId:100,groupId:1}); const api = TestBed.inject(ApiService), post = api.post.bind(api), reply = new Subject>(); const submit = spyOn(api, 'post').and.callFake((action,params) => action === 'e_record_detail' ? of({retcode:1,result:[],addon:[{reviewContentMode:'notes',courseCategoryKey:'high_writing'}]} as ApiEnvelope) : action === 'app_review_complete' ? reply : post(action,params)); const get = spyOn(api,'get').and.callThrough(); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047'); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.textContent).toContain('语法写作复习'); expect(harness.routeNativeElement?.textContent).toContain('请带领学生复习课堂语法笔记'); expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull(); expect(get.calls.allArgs().some(a=>a[0]==='content_get')).toBeFalse(); expect(submit.calls.allArgs().some(a=>a[0]==='app_learning_overview')).toBeFalse(); const end = harness.routeNativeElement?.querySelector('.sticky-actions button') as HTMLButtonElement; expect(end.textContent).toContain('结束复习'); end.click(); harness.fixture.detectChanges(); expect(end.disabled).toBeTrue();end.click(); const requests=submit.calls.allArgs().filter(a=>a[0]==='app_review_complete'); expect(requests.length).toBe(1);expect(requests[0][1]).toEqual({id:'864047'}); expect(harness.routeNativeElement?.querySelector('.review-complete')).toBeNull(); reply.next({retcode:1,result:{amount:1,rewardStatus:'earned'}});reply.complete();harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成'); }); it('identifies a legacy writing review with no original words and keeps it incomplete', async () => { const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const post = api.post.bind(api); const get = api.get.bind(api); spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail' ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope) : action === 'app_learning_overview' ? of({ retcode: 1, result: {} } as ApiEnvelope) : post(action, params)); spyOn(api, 'get').and.callFake((action, params) => action === 'content_get' ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: '', learned: 0, con: '完成了写作学习' }] } as ApiEnvelope) : get(action, params)); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398'); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.textContent).toContain('写作学习记录,原本没有单词'); expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull(); expect(harness.routeNativeElement?.querySelector('a[href="/pages/stu/stu_calendar"]')).not.toBeNull(); expect(harness.routeNativeElement?.textContent).not.toContain('结束本次复习'); }); it('renders source-shaped data for all four home entries', async () => { const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/home/index'); expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull(); expect(harness.routeNativeElement?.querySelector('.home-header [aria-label="搜索"]')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.home-header [aria-label="用户中心"]')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.memory-hero')).not.toBeNull(); expect(harness.routeNativeElement?.querySelectorAll('.memory-dot').length).toBe(21); expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).toContain('学习新词'); expect(harness.routeNativeElement?.querySelectorAll('.student-stats > div').length).toBe(4); expect(harness.routeNativeElement?.querySelectorAll('.quick-grid > a').length).toBe(4); expect(harness.routeNativeElement?.querySelector('.continue-button')?.getAttribute('href')).toBe('/pages/stu/stu_calendar'); expect(harness.routeNativeElement?.querySelector('.task-link')?.getAttribute('href')).toBe('/pages/home/cart'); await harness.navigateByUrl('/pages/stu/index'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/home/cart'); expect(harness.routeNativeElement?.textContent).not.toContain('选择词库教材'); await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2); expect(harness.routeNativeElement?.querySelector('.node-list button')?.textContent).toContain('已学 1/20 · 进度 5%'); expect(harness.routeNativeElement?.querySelectorAll('.unit-progress-ring').length).toBe(2); expect(harness.routeNativeElement?.querySelector('.unit-progress-ring')?.textContent).toContain('5%'); expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.unit-actions')).toBeNull(); const firstUnit = harness.routeNativeElement?.querySelector('.node-list button') as HTMLButtonElement; firstUnit.click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const readonlyRouter = TestBed.inject(Router); expect(readonlyRouter.url).toBe('/pages/stu/stu_abc?nids=811&pnid=801&readonly=1'); expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('词库学习进度'); expect(harness.routeNativeElement?.querySelector('.selection-header .primary-button')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('banana'); expect(harness.routeNativeElement?.querySelector('.word-select-check')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.word-assessment')).toBeNull(); (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('apple'); expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull(); await harness.navigateByUrl('/pages/home/index'); await harness.navigateByUrl('/pages/stu/stu_con?nid=813&pnid=801&readonly=1'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const wordApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService; expect(wordApi.lastWordParams['psize']).toBe(30); expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(30); expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 1–30 条 · 共 65 个单词'); expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('1 / 3 页'); (harness.routeNativeElement?.querySelector('[aria-label="第 2 页"]') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(wordApi.lastWordParams['cpage']).toBe(2); expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(30); expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 31–60 条 · 共 65 个单词'); expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('word-31'); expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('2 / 3 页'); await harness.navigateByUrl('/pages/stu/stu_list?nid=999&bindingId=799'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_abc?nids=999&readonly=1'); expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('词库学习进度'); await harness.navigateByUrl('/pages/stu/stu_calendar'); const calendarComponent=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; calendarComponent.memoryMonth.set(new Date(2026,7,1)); calendarComponent.memorySelectedDate.set('2026-08-02'); (calendarComponent as any).loadCalendar(); harness.fixture.detectChanges(); expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.memory-student-filter')).toBeNull(); expect(harness.routeNativeElement?.querySelectorAll('.memory-date').length).toBe(42); expect(harness.routeNativeElement?.querySelectorAll('.memory-date.completed, .memory-date.due').length).toBe(2); expect(harness.routeNativeElement?.querySelector('.memory-overview')?.textContent).toContain('2 / 3'); expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('高中英语复习'); expect(harness.routeNativeElement?.querySelector('.review-sequence')?.textContent).toContain('第 3 次复习'); expect(harness.routeNativeElement?.querySelector('.first-learned-at')?.textContent).toContain('首次学习:2026-07-20 10:20'); for (let index = 0; index < 3; index += 1) { (harness.routeNativeElement?.querySelector('[aria-label="上个月"]') as HTMLButtonElement).click(); harness.fixture.detectChanges(); } expect(harness.routeNativeElement?.querySelector('[aria-label^="2026-05-15"]')?.classList).toContain('completed'); expect(harness.routeNativeElement?.textContent).toContain('张雅茜'); expect(harness.routeNativeElement?.querySelector('.memory-overview header b')?.textContent).toContain('67%'); expect(harness.routeNativeElement?.querySelector('.memory-stats')?.textContent).toContain('2 / 3'); await harness.navigateByUrl('/pages/home/ucenter'); expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull(); expect(harness.routeNativeElement?.querySelector('.new-word-entry')?.getAttribute('href')).toBe('/pages/home/cart'); expect(harness.routeNativeElement?.querySelector('.course-memory')?.textContent).toContain('21 天抗遗忘计划'); expect(harness.routeNativeElement?.querySelector('.memory-overview em')?.textContent).toContain('67%'); expect(harness.routeNativeElement?.querySelector('.memory-overview')?.textContent).toContain('覆盖 3 个复习日'); expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('3总任务'); expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('2已完成'); expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('1到期待复习'); expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('0未来待复习'); await harness.navigateByUrl('/pages/stu/record?id=827482&gid=828268&kcid=203'); const recordWord = harness.routeNativeElement?.querySelector('.record-word') as HTMLButtonElement; expect(recordWord.querySelector('.record-copy strong')?.textContent).toBe('apple'); expect(harness.routeNativeElement?.textContent).not.toContain('苹果'); recordWord.click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(recordWord.querySelector('.record-copy strong')?.textContent).toBe('苹果'); expect(harness.routeNativeElement?.querySelector('.record-overview')?.textContent).toContain('1 / 1'); await harness.navigateByUrl('/pages/member/starshop'); expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull(); expect(harness.routeNativeElement?.querySelector('.word-select-list strong')?.textContent).toBe('tend'); expect(harness.routeNativeElement?.querySelector('.word-select-list')?.textContent).not.toContain('@张雅茜'); (harness.routeNativeElement?.querySelector('.word-select-list > button') as HTMLButtonElement).click(); (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); const selectedNewWords = JSON.parse(sessionStorage.getItem('xiaoshu.learning.100.100.practice.selectedWords') ?? '[]') as Array<{ word?: string }>; expect(selectedNewWords[0]?.word).toBe('tend'); expect(sessionStorage.getItem('xiaoshu.learning.100.100.practice.selectedWords')).not.toContain('@张雅茜'); await harness.navigateByUrl('/pages/his/well?nid=3'); expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull(); expect(harness.routeNativeElement?.textContent).toContain('阅读理解专项'); await harness.navigateByUrl('/pages/home/cart'); expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull(); expect(harness.fixture.nativeElement.querySelector('.app-header .icon-button')).toBeNull(); expect(harness.fixture.nativeElement.querySelector('.app-header h1')?.textContent).toContain('伴学中心'); expect(harness.routeNativeElement?.querySelector('.library-entry')).toBeNull(); expect(harness.routeNativeElement?.textContent).toContain('我的学习内容'); expect(harness.routeNativeElement?.querySelectorAll('.course-list li').length).toBe(2); expect(harness.routeNativeElement?.querySelectorAll('.course-list button').length).toBe(2); expect((harness.routeNativeElement?.querySelector('.course-list button') as HTMLButtonElement).disabled).toBeFalse(); expect((harness.routeNativeElement?.querySelectorAll('.course-list button')[1] as HTMLButtonElement).disabled).toBeFalse(); expect(harness.routeNativeElement?.textContent).toContain('【七年级上册】同步词汇'); expect(harness.routeNativeElement?.textContent).toContain('点击查看老师分配的单词列表'); (harness.routeNativeElement?.querySelector('.course-list button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801&bindingId=701'); expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2); expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastNodeParams['bindingId']).toBe('701'); await harness.navigateByUrl('/pages/home/cart'); expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(0); expect(harness.routeNativeElement?.querySelectorAll('.calendar-day').length).toBe(42); const allDates = Array.from(harness.routeNativeElement?.querySelectorAll('.range-tabs button') ?? []) .find((button) => button.textContent?.trim() === '全部') as HTMLButtonElement; allDates.click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(3); expect(harness.routeNativeElement?.textContent).toContain('同步伴学'); expect(harness.routeNativeElement?.textContent).toContain('历史伴学'); const plannedIncompleteFilter = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-filters button') ?? []) .find((button) => button.textContent?.includes('计划未完成')) as HTMLButtonElement; expect(plannedIncompleteFilter.textContent).toContain('1'); plannedIncompleteFilter.click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1); expect(harness.routeNativeElement?.textContent).toContain('计划未完成'); expect(harness.routeNativeElement?.textContent).not.toContain('未来计划'); (harness.routeNativeElement?.querySelector('.schedule-filters button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; expect(api.lastScheduleParams['mode']).toBe('student'); expect(api.lastScheduleParams['pageSize']).toBe(20); const finishedFilter = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-filters button') ?? []) .find((button) => button.textContent?.includes('已结束')) as HTMLButtonElement; finishedFilter.click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1); expect(harness.routeNativeElement?.textContent).toContain('历史伴学'); const allFilter = harness.routeNativeElement?.querySelector('.schedule-filters button') as HTMLButtonElement; allFilter.click(); harness.fixture.detectChanges(); const upcomingSchedule = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-list article') ?? []) .find((article) => article.textContent?.includes('同步伴学')) as HTMLElement; upcomingSchedule.click(); await harness.fixture.whenStable(); expect(TestBed.inject(Router).url).toBe('/pages/shop/stu_get_order?id=901'); await harness.navigateByUrl('/pages/his/his'); expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull(); const aiReadingFeature = harness.routeNativeElement?.querySelector('.ai-reading-feature') as HTMLElement; expect(aiReadingFeature.textContent).toContain('AI 阅读巩固'); expect(aiReadingFeature.textContent).toContain('生成阅读'); expect(aiReadingFeature.textContent).toContain('AI 批改'); expect(aiReadingFeature.querySelector('img')?.getAttribute('src')).toBe('/static/images/ai-reading-journey.png'); const aiReadingCta = aiReadingFeature.querySelector('.ai-reading-cta') as HTMLButtonElement; expect(aiReadingCta.disabled).toBeFalse(); expect(aiReadingFeature.textContent).toContain('最近学过的 1 个单词'); expect(aiReadingCta.textContent).toContain('立即生成阅读'); aiReadingCta.click(); await harness.fixture.whenStable(); const aiReadingRouter = TestBed.inject(Router); expect(aiReadingRouter.url).toContain('/words/go-reading'); expect(aiReadingRouter.parseUrl(aiReadingRouter.url).queryParams['returnUrl']).toBe('/pages/his/his'); expect(aiReadingRouter.parseUrl(aiReadingRouter.url).queryParams['GeneralID']).toBe('201'); await harness.navigateByUrl('/pages/his/his'); const currentLearningCard = harness.routeNativeElement?.querySelector('.current-learning-card') as HTMLElement; const learningRecordCard = harness.routeNativeElement?.querySelector('.learning-record-card') as HTMLElement; expect(currentLearningCard.textContent).toContain('我的学习内容'); const records = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent; expect(records.courseTitle({ Title: '单词课|高中@人教版2022年录', Title2: '人教版2022年录' })).toBe('【高中】人教版2022年录'); expect(records.courseTitle({ Title: '单词课|初中@人教版2024年9月录', Title2: '人教版2024年9月录' })).toBe('【初中】人教版2024年9月录'); expect(currentLearningCard.querySelectorAll('.current-learning-list button').length).toBe(2); expect((currentLearningCard.querySelectorAll('.current-learning-list button')[1] as HTMLButtonElement).disabled).toBeFalse(); expect(currentLearningCard.compareDocumentPosition(learningRecordCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); (currentLearningCard.querySelector('.current-learning-list button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801&bindingId=701'); expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2); await harness.navigateByUrl('/pages/his/his'); expect(harness.routeNativeElement?.textContent).toContain('初中英语 / Unit 1'); expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2); (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201'); expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('学生只读'); expect(harness.routeNativeElement?.querySelector('.readonly-word-card')).not.toBeNull(); expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull(); await harness.navigateByUrl('/pages/his/his'); (harness.routeNativeElement?.querySelector('.date-trigger') as HTMLButtonElement).click(); harness.fixture.detectChanges(); const currentMonth = new Date(); const monthDifference = (2026 - currentMonth.getFullYear()) * 12 + (7 - currentMonth.getMonth()); const shiftLabel = monthDifference < 0 ? '上个月' : '下个月'; for (let index = 0; index < Math.abs(monthDifference); index += 1) { (harness.routeNativeElement?.querySelector(`[aria-label="${shiftLabel}"]`) as HTMLButtonElement).click(); harness.fixture.detectChanges(); } expect(harness.routeNativeElement?.querySelector('.date-picker-popover header strong')?.textContent).toContain('2026年8月'); expect(harness.routeNativeElement?.querySelectorAll('.date-picker-day').length).toBe(42); expect(harness.routeNativeElement?.querySelectorAll('.date-picker-day.has-record').length).toBe(2); expect(harness.routeNativeElement?.querySelector('.date-picker-day.has-record')?.getAttribute('aria-label')).toContain('2026-08-01'); (harness.routeNativeElement?.querySelector('[aria-label^="2026-08-01"]') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(1); expect(harness.routeNativeElement?.querySelector('.learning-record-card')?.textContent).not.toContain('高中英语 / Unit 2'); (harness.routeNativeElement?.querySelector('.record-actions button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2); const completedReading = harness.routeNativeElement?.querySelector('.reading-button.completed') as HTMLButtonElement; expect(completedReading.textContent).toContain('已完成阅读'); expect(completedReading.getAttribute('aria-label')).toBe('查看已完成的历史阅读理解'); completedReading.click(); await harness.fixture.whenStable(); const router = TestBed.inject(Router); expect(router.url).toContain('/words/reading'); expect(router.parseUrl(router.url).queryParams['sid']).toBe('readingarticle201'); expect(router.parseUrl(router.url).queryParams['returnUrl']).toBe('/pages/his/his'); await harness.navigateByUrl('/pages/his/his'); const newReading = harness.routeNativeElement?.querySelector('.reading-button:not(.completed)') as HTMLButtonElement; expect(newReading.textContent).toContain('阅读'); newReading.click(); await harness.fixture.whenStable(); expect(router.url).toContain('/words/go-reading'); expect(router.parseUrl(router.url).queryParams['returnUrl']).toBe('/pages/his/his'); expect(harness.routeNativeElement?.querySelector('.back-button')).not.toBeNull(); (harness.routeNativeElement?.querySelector('.back-button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); expect(router.url).toBe('/pages/his/his'); (harness.fixture.nativeElement.querySelector('.app-header .icon-button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); expect(router.url).toBe('/pages/home/index'); await harness.navigateByUrl('/pages/his/exam'); expect(harness.routeNativeElement?.textContent).toContain('第一组问题'); expect(harness.routeNativeElement?.textContent).toContain('相当于初二水平'); expect(harness.routeNativeElement?.textContent).toContain('共 1 题'); const startButton = harness.routeNativeElement?.querySelector('.exam-intro > button') as HTMLButtonElement; startButton.click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.question-word')?.textContent).toContain('apple'); expect(harness.routeNativeElement?.querySelector('.question-card > h2')).toBeNull(); expect(harness.routeNativeElement?.querySelectorAll('.exam-options button').length).toBe(1); (harness.routeNativeElement?.querySelector('.exam-options button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); }); it('matches coach review tasks to student names by ID when task name fields are blank', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const response = new Subject>(); api.reviewPageResponse = response; const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/home/index'); response.next({ retcode: 1, result: { items: [ { GeneralID: 102, yhid: 100, honeyName: '', Title: '旧姓名@复习', kywsj: String(api.lastMemoryParams['date']), fxzt: 0, xxjlid: 900 }, { GeneralID: 103, yhid: '101', honeyName: ' ', HoneyName: '', kywsj: String(api.lastMemoryParams['date']), fxzt: 0, xxjlid: 901 }, ], students: [{ id: '101', name: '李同学', taskCount: 1 }, { id: '100', name: '张雅茜', taskCount: 1 }], page: { itemCount: 2, pageCount: 1 }, summary: { total: 2, completed: 0, due: 2, today: 0, income: 0 }, dates: [], } }); response.complete(); api.reviewPageResponse = null; harness.fixture.detectChanges(); const names = Array.from(harness.routeNativeElement?.querySelectorAll('.coach-memory-copy strong') ?? []).map((node) => node.textContent); expect(names).toEqual(['张雅茜', '李同学']); const home = harness.fixture.debugElement.query(By.directive(HomePageComponent)).componentInstance as HomePageComponent; expect(home.coachReviewStudent({ yhid: 102, honeyName: '', HoneyName: ' ', Title: '王同学@复习' })).toBe('王同学'); expect(home.coachReviewStudent({ yhid: 103, honeyName: '', Title: ' ' })).toBe('学员 103'); expect(home.coachReviewStudent({ honeyName: '' })).toBe('未关联学员'); (harness.routeNativeElement?.querySelectorAll('.coach-memory-list article > button')[1] as HTMLButtonElement).click(); await harness.fixture.whenStable(); expect(session.student()).toEqual(jasmine.objectContaining({ userId: 101, honeyName: '李同学' })); expect(TestBed.inject(Router).url).toContain('/pages/stu/record?id=901&gid=103'); }); it('keeps the complete point-reading flow available to coaches', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/home/index'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const coachLinks = Array.from(harness.routeNativeElement?.querySelectorAll('.coach-quick-grid > a') ?? []).map((link) => link.getAttribute('href')); expect(coachLinks).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/ke_fuxi', '/pages/member/starshop']); expect(harness.routeNativeElement?.querySelector('.home-header .header-action')).toBeNull(); const coachAccountTab = (Array.from(harness.fixture.nativeElement.querySelectorAll('.tabbar a')) as HTMLAnchorElement[]) .find((link) => link.getAttribute('href') === '/pages/home/ucenter'); expect(coachAccountTab?.textContent).toContain('我的'); expect(harness.routeNativeElement?.querySelector('.content-section')).toBeNull(); const memoryApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService; expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:100, scope:'today', includeMetadata:false, includeIncome:false })); expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('抗遗忘带练'); expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('张雅茜'); expect(harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button')?.textContent).toContain('查看计划'); expect(harness.routeNativeElement?.querySelector('.coach-memory-footer')?.getAttribute('href')).toBe('/pages/stu/stu_calendar'); (harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); expect(TestBed.inject(Router).url).toContain('/pages/stu/record?id=900&gid=102'); expect(session.student()?.userId).toBe(100); await harness.navigateByUrl('/pages/stu/stu_calendar'); const calendarComponent=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; calendarComponent.memoryMonth.set(new Date(2026,7,1)); calendarComponent.memorySelectedDate.set('2026-08-02'); (calendarComponent as any).loadCalendar(); harness.fixture.detectChanges(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:20 })); expect(harness.routeNativeElement?.querySelector('.memory-copy')?.textContent).toContain('关联学员抗遗忘任务'); expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('张雅茜'); expect(harness.routeNativeElement?.querySelector('.memory-date-tasks button')?.textContent).toContain('带学生复习'); const memoryStudentFilter = harness.routeNativeElement?.querySelector('[aria-label="筛选抗遗忘学员"] select') as HTMLSelectElement; expect(memoryStudentFilter.options.length).toBe(2); memoryStudentFilter.value = '100'; memoryStudentFilter.dispatchEvent(new Event('change')); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('张雅茜'); expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).not.toContain('李同学'); expect(harness.routeNativeElement?.querySelector('.memory-stats')?.textContent).toContain('2 / 3'); await harness.navigateByUrl('/pages/member/ke_xiao'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const monthFilter = harness.routeNativeElement?.querySelector('[aria-label="按月份筛选陪练记录"]') as HTMLSelectElement; const monthPicker = harness.routeNativeElement?.querySelector('.month-control .xs-dropdown-trigger') as HTMLButtonElement; expect(monthFilter.value).toBe('all'); expect(monthFilter.options.length).toBe(4); expect(monthPicker).not.toBeNull(); expect(Number.parseFloat(getComputedStyle(monthPicker).paddingLeft)).toBeGreaterThanOrEqual(40); expect(monthPicker.getBoundingClientRect().height).toBe(46); expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5); monthFilter.value = '2026-03'; monthFilter.dispatchEvent(new Event('change')); const studentSelect = harness.routeNativeElement?.querySelector('select[aria-label="按学员筛选陪练记录"]') as HTMLSelectElement; expect(harness.routeNativeElement?.querySelector('input[type="search"]')).toBeNull(); studentSelect.value = '102'; studentSelect.dispatchEvent(new Event('change')); expect(memoryApi.lastScheduleParams['studentId']).toBe('102'); expect(memoryApi.lastScheduleParams['search']).toBeUndefined(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1); expect(harness.routeNativeElement?.querySelector('.schedule-list')?.textContent).toContain('花花'); expect(harness.routeNativeElement?.querySelector('.schedule-band > header')?.textContent).toContain('5 节'); (harness.routeNativeElement?.querySelector('.history-filter-panel footer button:last-child') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5); await harness.navigateByUrl('/pages/his/his'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect((harness.routeNativeElement?.querySelector('.student-filter select') as HTMLSelectElement).selectedOptions[0]?.textContent).toContain('张雅茜'); expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2); expect(session.student()?.userId).toBe(100); (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201'); expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('历史记录'); expect(harness.routeNativeElement?.querySelector('.readonly-word-card')?.textContent).toContain('apple'); expect(harness.routeNativeElement?.querySelector('.readonly-word-card')?.textContent).toContain('共 1 个单词'); expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull(); await harness.navigateByUrl('/pages/his/his'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const teacherCourse = harness.routeNativeElement?.querySelector('.current-learning-list button') as HTMLButtonElement; expect(teacherCourse.disabled).toBeFalse(); expect(teacherCourse.classList.contains('readonly')).toBeFalse(); expect(teacherCourse.textContent).toContain('选择本次学习单元'); teacherCourse.click(); await harness.fixture.whenStable(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_list'); await harness.navigateByUrl('/pages/home/ucenter'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const teacherServices = Array.from(harness.routeNativeElement?.querySelectorAll('.service-grid > a') ?? []).map((link) => link.getAttribute('href')); expect(teacherServices).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/feedback']); expect(harness.routeNativeElement?.querySelector('.coach-wallet-strip')?.textContent).toContain('累计抗遗忘费用'); await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastNodeParams['userId']).toBe(100); const units = harness.routeNativeElement?.querySelectorAll('.node-list button') as NodeListOf; units[0].click(); units[1].click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')?.textContent).toContain('已选 2 个单元'); (harness.routeNativeElement?.querySelector('.unit-actions .primary-button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const router = TestBed.inject(Router); expect(router.url).toContain('/pages/stu/stu_abc'); expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('banana'); expect(harness.routeNativeElement?.querySelector('.completed-column')).toBeNull(); expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(1); (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('apple'); expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull(); (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click(); harness.fixture.detectChanges(); const firstWord = harness.routeNativeElement?.querySelector('.pending-column .word-refine-item') as HTMLElement; (firstWord.querySelector('.word-reveal') as HTMLButtonElement).click(); harness.fixture.detectChanges(); (firstWord.querySelector('.known-button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(0); expect(harness.routeNativeElement?.querySelector('.selection-header .primary-button')?.textContent).toContain('保存识词结果'); (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2); (harness.routeNativeElement?.querySelector('.completed-column .word-assessment:has(.known-button.active) .unknown-button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(1); (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(1); (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu_con'); expect(harness.routeNativeElement?.querySelectorAll('.repeat-list > button').length).toBe(1); expect(harness.routeNativeElement?.querySelector('.readonly-word-header')).toBeNull(); await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&bindingId=701'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const spellingActions = harness.routeNativeElement?.querySelector('.teacher-spelling-actions') as HTMLElement; expect(spellingActions.querySelectorAll('button').length).toBe(3); expect(spellingActions.textContent).toContain('跳过这一关'); expect((harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent).words().length).toBe(1); expect(localStorage.getItem('xiaoshu.learning.300.100.701._page_word_orilist')).not.toBeNull(); (spellingActions.querySelectorAll('button')[1] as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toBe('/pages/his/his'); const standaloneRecord = (TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastStandaloneLearningRecord; expect(JSON.parse(String(standaloneRecord['content']))).toEqual(jasmine.objectContaining({ ModelID: 56, Hits: 1, title: '张雅茜' })); expect(JSON.parse(String(standaloneRecord['addon']))).toEqual(jasmine.objectContaining({ UserID: 100, pl: 300, learned: 1, createReviewPlan: false })); }, 20000); it('loads appointment courses and units for the appointment student when no student is selected in the session', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); session.student.set(null); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/shop/get_order?id=901'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; expect(session.student()?.userId).toBe(100); expect(api.lastAppointmentCourseParams['orderId']).toBe('901'); expect(api.lastNodeParams['userId']).toBe(100); const courseSelect = harness.routeNativeElement?.querySelector('.course-section select') as HTMLSelectElement; expect(courseSelect).not.toBeNull(); expect(courseSelect.options.length).toBeGreaterThanOrEqual(2); expect(courseSelect.closest('.course-section')?.textContent).toContain('七年级上册'); expect(harness.routeNativeElement?.querySelectorAll('.unit-grid article').length).toBe(2); const unitButtons = harness.routeNativeElement?.querySelectorAll('.unit-select'); expect(unitButtons?.[0].textContent).toContain('已学 5%'); expect(unitButtons?.[0].textContent).toContain('1/20 词'); expect(unitButtons?.[1].textContent).toContain('已学 0%'); expect(unitButtons?.[1].textContent).toContain('0/18 词'); expect((unitButtons?.[0].querySelector('.unit-progress-track > span') as HTMLElement).style.width).toBe('5%'); unitButtons?.[0].click(); unitButtons?.[1].click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.unit-select[aria-pressed="true"]').length).toBe(2); expect(unitButtons?.[0].textContent).toContain('已学 5%'); }); it('distinguishes completed, empty and unavailable appointment vocabulary progress', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; api.unitRows = [ { NodeID: 811, NodeName: '已学全册', yx_word: '20', total: '20' }, { NodeID: 812, NodeName: '空册', yx_word: 0, total: 0 }, { NodeID: 813, NodeName: '缺少统计' }, { NodeID: 814, NodeName: '异常统计', yx_word: 'invalid', total: 20 }, ]; const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/shop/get_order?id=901'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const units = harness.routeNativeElement?.querySelectorAll('.unit-select'); expect(units?.[0].textContent).toContain('已学 100%'); expect(units?.[0].textContent).toContain('20/20 词'); expect(units?.[1].textContent).toContain('已学 0%'); expect(units?.[1].textContent).toContain('暂无单词'); for (const index of [2, 3]) { expect(units?.[index].textContent).toContain('暂无进度数据'); expect(units?.[index].querySelector('.unit-progress-track')).toBeNull(); } }); it('ends without requiring feedback and keeps lesson pay out of the completion notice', async () => { user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3});session.groupId.set(3); const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10; spyOn(TestBed.inject(PlatformService),'confirm').and.resolveTo(true); const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/shop/get_order?id=901');await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent; await component.endLesson();expect(api.completionRequests.length).toBe(1);expect(api.completionRequests[0]['feedback']).toBeUndefined(); api.appointmentStatus=30;api.completionResponse.next({retcode:0,result:{status:30,teacherPay:45,payStatus:'earned',feedbackStatus:'pending'}});api.completionResponse.complete(); harness.fixture.detectChanges();expect(harness.routeNativeElement?.textContent).toContain('已结课');expect(harness.routeNativeElement?.textContent).not.toMatch(/费用|¥45/);expect(harness.routeNativeElement?.textContent).toContain('反馈待补'); }); it('retains the optional feedback-and-complete dialog and submits once after validation', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; api.appointmentStatus = 10; const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/shop/get_order?id=901'); const component = harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent; await harness.fixture.whenStable(); harness.fixture.detectChanges(); const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement; const endButton = harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement; expect(dialog.open).toBeFalse(); component.openCompletionFeedback(); harness.fixture.detectChanges(); expect(dialog.open).toBeTrue(); expect(api.completionRequests.length).toBe(0); const submit = dialog.querySelector('.feedback-actions .primary-button') as HTMLButtonElement; submit.click(); harness.fixture.detectChanges(); expect(dialog.querySelector('[role="alert"]')?.textContent).toContain('请填写本次学习内容'); expect(api.completionRequests.length).toBe(0); const summary = dialog.querySelector('textarea') as HTMLTextAreaElement; summary.value = 'Unit 3 词汇'; summary.dispatchEvent(new Event('input')); await harness.fixture.whenStable(); (dialog.querySelector('.feedback-actions .secondary-button') as HTMLButtonElement).click(); expect(dialog.open).toBeFalse(); expect(api.appointmentStatus).toBe(10); component.openCompletionFeedback(); harness.fixture.detectChanges(); expect((dialog.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Unit 3 词汇'); component.reviewStatus = '完成'; component.trainingStatus = '需要巩固'; component.trainingSummary = '下次复习 Unit 3'; component.noHomework = true; submit.click(); submit.click(); harness.fixture.detectChanges(); expect(api.completionRequests.length).toBe(1); expect(JSON.parse(String(api.completionRequests[0]['feedback']))).toEqual(jasmine.objectContaining({ contentSummary: 'Unit 3 词汇', noHomework: true, homework: '无作业' })); expect(submit.disabled).toBeTrue(); dialog.dispatchEvent(new Event('cancel', { cancelable: true })); expect(dialog.open).toBeTrue(); api.appointmentStatus = 30; api.completionResponse.next({ retcode: 1, result: {} }); api.completionResponse.complete(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('dialog')).toBeNull(); expect(harness.routeNativeElement?.textContent).toContain('已结课'); }); it('keeps feedback editable after a failed submission and allows closing with Escape', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; api.appointmentStatus = 10; const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/shop/get_order?id=901'); const component = harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent; await harness.fixture.whenStable(); component.openCompletionFeedback(); component.contentSummary = 'Unit 3'; component.reviewStatus = '完成'; component.trainingStatus = '正常进行'; component.trainingSummary = '继续 Unit 4'; component.noHomework = true; component.finishCoachLesson(); api.completionResponse.error(new Error('提交失败,请重试')); harness.fixture.detectChanges(); const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement; expect(dialog.open).toBeTrue(); expect(dialog.querySelector('[role="alert"]')?.textContent).toContain('提交失败,请重试'); expect((dialog.querySelector('fieldset') as HTMLFieldSetElement).disabled).toBeFalse(); expect(component.contentSummary).toBe('Unit 3'); expect(api.appointmentStatus).toBe(10); dialog.dispatchEvent(new Event('cancel', { cancelable: true })); expect(dialog.open).toBeFalse(); }); it('counts words marked as known as completed and persists them for the student', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); session.student.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 }); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/stu_abc?nids=811,812&pnid=801'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const pendingWord = harness.routeNativeElement?.querySelector('.pending-column .word-refine-item') as HTMLElement; (pendingWord.querySelector('.word-reveal') as HTMLButtonElement).click(); harness.fixture.detectChanges(); (pendingWord.querySelector('.known-button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({ uid: 100, wordsId: '1002', kcid: '801', save: 1 })); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc'); expect(harness.routeNativeElement?.querySelector('.completed-column')).toBeNull(); (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2); expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull(); }); it('confirms three rounds into 17 words and can continue selecting before ending the lesson', async () => { user.set({userId:300,groupId:3}); session.groupId.set(3);session.student.set({userId:100,groupId:1}); const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10; api.studyBatches=[9,5,3].map((count,index)=>({batchId:'round-'+index,version:1,status:'draft',bindingId:'binding-801',words:Array.from({length:count},(_,i)=>({GeneralID:index*100+i+1,Title:'word'+index+'-'+i,check:0}))})); const harness=await RouterTestingHarness.create(); for(let index=0;index<3;index++){ await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=round-'+index);await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(component.words().length).toBe([9,5,3][index]);component.markAllKnown();component.submitReview(index<2);await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901'+(index===2?'&feedback=1':'')); expect(harness.routeNativeElement?.textContent).toContain('本节学习成果 · '+[9,14,17][index]+' 个词'); } expect(api.completionRequests.length).toBe(0); }); it('reveals and speaks each assessment word without changing mastery', async () => { user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1}); const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10; api.studyBatches=[{batchId:'meaning-round',version:1,status:'draft',bindingId:'binding-801',words:[{GeneralID:1001,Title:'crop',sy:'庄稼',check:1},{GeneralID:1002,Title:'gather',sy:'聚集',check:2}]}]; const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=meaning-round');await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const play=spyOn(component,'play').and.resolveTo();const cards=harness.routeNativeElement!.querySelectorAll('.review-list article'); expect(cards[0].textContent).not.toContain('庄稼');(cards[0].querySelector('.word-audio') as HTMLButtonElement).click();harness.fixture.detectChanges(); expect(play).toHaveBeenCalledWith(component.words()[0]);expect(cards[0].textContent).toContain('庄稼');expect(cards[1].textContent).not.toContain('聚集'); expect(component.reviewValue(component.words()[0])).toBe(1);expect(component.reviewValue(component.words()[1])).toBe(2); (cards[0].querySelector('.word-audio') as HTMLButtonElement).click();harness.fixture.detectChanges();expect(cards[0].textContent).not.toContain('庄稼');expect(play).toHaveBeenCalledTimes(2); }); it('keeps round choices on save failure and retries with the same command key', async () => { user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1}); const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10; api.studyBatches=[{batchId:'retry-round',version:1,status:'draft',bindingId:'binding-801',courseId:'802',words:[{GeneralID:1001,Title:'apple',check:0}]}]; const post=api.post.bind(api);let fail=true;const spy=spyOn(api,'post').and.callFake((action,params)=>action==='stu_record_update_v2'&&fail?(fail=false,throwError(()=>new Error('断网'))):post(action,params)); const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=retry-round');await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;component.markAllKnown();component.submitReview(true);await harness.fixture.whenStable(); expect(component.words().length).toBe(1);expect(component.reviewCount(1)).toBe(1);expect(TestBed.inject(Router).url).toContain('/pl_end'); component.submitReview(true);await harness.fixture.whenStable();const requests=spy.calls.allArgs().filter(([action])=>action==='stu_record_update_v2');expect(requests.length).toBe(2);expect(requests[0][1]?.['requestKey']).toBe(requests[1][1]?.['requestKey']); expect(api.lastLearningWordUpdate['kcid']).toBe('802'); // Resume uses the batch book, not the scheduled book. }); it('finishes the last vocabulary group, loads the coach record and opens completion feedback', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 }); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; api.appointmentStatus = 10; const word = { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果' }; localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_word_orilist', JSON.stringify([[word]])); localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_current_learn', '1'); // Stale page-count metadata must not create a nonexistent next group. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_total_page', '2'); localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.learn_mode'); const harness = await RouterTestingHarness.create(); const router = TestBed.inject(Router); const navigate = spyOn(router, 'navigate').and.callThrough(); await harness.navigateByUrl('/pages/stu/stu_con?nids=811&pnid=801&orderId=901'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const skipStage = () => { const button = Array.from(harness.routeNativeElement?.querySelectorAll('.sticky-actions button') ?? []) .find(item => item.textContent?.includes('跳过这一关')); expect(button).toBeDefined(); button!.click(); }; skipStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu3'); expect(learning().matchLeft().length).toBe(1); skipStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu4'); expect(learning().correct()).toBe(0); skipStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/pl_stu5'); expect(router.url).toContain('orderId=901'); expect(learning().correct()).toBe(0); skipStage(); expect(learning().hasNextWordBatch).toBeFalse(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/pl_end'); expect(api.lastStudyQuery['orderId']).toBe('901'); expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull(); expect(learning().words()[0].word).toBe('apple'); learning().markAllKnown(); learning().submitReview(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); harness.fixture.detectChanges(); expect(router.url).toBe('/pages/shop/get_order?id=901&feedback=1'); expect((harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement).open).toBeTrue(); expect(api.appointmentStatus).toBe(10); expect(api.completionRequests.length).toBe(0); expect(api.studyUpdates.length).toBe(2); expect(JSON.parse(String(api.studyUpdates[1]['addon'])).xxqs[0].check).toBe(1); expect(navigate.calls.all().every(call => call.args[1]?.replaceUrl === true)).toBeTrue(); }, 20000); it('advances only to a real next group and returns from the last stage to its course in one click', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 }); const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }, { GeneralID: 1002, Title: 'banana', sy: '香蕉' }], [{ GeneralID: 1003, Title: 'cherry', sy: '樱桃' }]]; localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_word_orilist', JSON.stringify(pages)); localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_total_page', '0'); localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_current_learn', '1'); const harness = await RouterTestingHarness.create(); const router = TestBed.inject(Router); await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(component.words().length).toBe(2); component.skipSpellingStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu_con'); const next = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(next.words()[0].word).toBe('cherry'); expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).studyUpdates.length).toBe(0); (harness.routeNativeElement?.querySelector('.app-header button[aria-label="返回"]') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toBe('/pages/shop/get_order?id=901'); for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn', 'skip_spelling_stage']) localStorage.removeItem(`xiaoshu.learning.300.100.901.unbound.${key}`); }); it('keeps teacher-led spelling skipped through the remaining word groups', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 }); const prefix = 'xiaoshu.learning.300.100.901.unbound.'; const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]]; localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify(pages)); localStorage.setItem(prefix + '_page_current_learn', '1'); const harness = await RouterTestingHarness.create(); const router = TestBed.inject(Router); await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901'); const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; learning().skipSpellingStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu_con'); expect(localStorage.getItem(prefix + 'skip_spelling_stage')).toBe('true'); learning().skipStudyList(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); learning().chooseMatch('left', learning().words()[0]); learning().chooseMatch('right', learning().words()[0]); learning().continueAfterStage(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(router.url).toContain('/pages/stu/stu4'); learning().chooseMeaning(learning().words()[0]); expect(learning().stageActionLabel).toBe('查看学习结果'); learning().continueAfterStage(); await harness.fixture.whenStable(); expect(router.url).toContain('/pages/stu/pl_end'); expect(localStorage.getItem(prefix + 'skip_spelling_stage')).toBe('true'); localStorage.removeItem(prefix + 'skip_spelling_stage'); }, 15000); it('resolves the appointment student before reading end results and offers an exit on failure', async () => { user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 }); session.groupId.set(3); session.student.set({ userId: 999, honeyName: '之前的学员', groupId: 1 }); const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService; const get = api.get.bind(api); spyOn(api, 'get').and.callFake((action, params) => action === 'app_lesson_study' ? throwError(() => new Error('学习记录暂时无法加载')) : get(action, params)); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/pl_end?id=901'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); harness.fixture.detectChanges(); expect(session.student()?.userId).toBe(100); expect(api.get).toHaveBeenCalledWith('app_lesson_study', {orderId:'901'}); expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('学习记录暂时无法加载'); (harness.routeNativeElement?.querySelector('.empty-state.error .primary-button') as HTMLButtonElement).click(); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901'); }); it('shows learning history in pages of twenty records', async () => { user.set({ userId: 777, userName: 'paged-student', honeyName: '分页学员', groupId: 1 }); session.groupId.set(1); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/his/his'); await harness.fixture.whenStable(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.record-actions')?.textContent).toContain('45 条'); expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(20); expect(harness.routeNativeElement?.querySelector('.record-pagination')?.textContent).toContain('第 1 / 3 页'); const pageButtons = harness.routeNativeElement?.querySelectorAll('.record-pagination button') as NodeListOf; expect(pageButtons[0].disabled).toBeTrue(); pageButtons[1].click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(20); expect(harness.routeNativeElement?.querySelector('.record-pagination')?.textContent).toContain('第 2 / 3 页'); (harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(5); expect((harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).disabled).toBeTrue(); }); it('keeps review completion free of pay details for every reward outcome', async()=>{ user.set({userId:300,groupId:3,honeyName:'老师'});session.groupId.set(3);session.student.set({userId:100,groupId:1}); const harness=await RouterTestingHarness.create('/pages/stu/record?id=900&gid=103');await harness.fixture.whenStable(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; for (const result of [ {amount:0.5,rewardStatus:'earned'}, {amount:1,rewardStatus:'earned',unchanged:true}, {amount:0,rewardStatus:'ineligible',ineligibleReason:'self_study',unchanged:true}, {amount:0,rewardStatus:'ineligible',ineligibleReason:'duplicate_review',unchanged:true}, {amount:0,rewardStatus:'ineligible'}, {amount:0,rewardStatus:'pending'}, ]) { component.reviewCompletion.set(result);harness.fixture.detectChanges(); const completion=harness.routeNativeElement?.querySelector('.review-complete'); expect(completion?.textContent).toContain('复习已完成'); expect(completion?.textContent).not.toMatch(/收入|费用|计薪|记账|¥/); expect(completion?.textContent).toContain('继续今日任务'); expect(completion?.textContent).toContain('返回原任务列表'); } user.set({userId:100,groupId:1,honeyName:'学生'});session.groupId.set(1);harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成'); expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|费用|计薪|记账|¥/); }); it('waits for server completion and blocks duplicate clicks for overdue work', async()=>{ user.set({userId:300,groupId:3,honeyName:'老师'});session.groupId.set(3);session.student.set({userId:100,groupId:1}); const api=TestBed.inject(ApiService),post=api.post.bind(api),reply=new Subject>(); const submit=spyOn(api,'post').and.callFake((action,params)=>action==='app_review_complete'?reply:post(action,params)); const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/record?id=900&gid=103&plannedDate=2000-01-01');await harness.fixture.whenStable(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; component.endRecord();component.endRecord();harness.fixture.detectChanges();expect(component.reviewCompletion()).toBeNull();expect(component.canDeactivate()).toBeFalse(); expect(submit.calls.allArgs().filter(a=>a[0]==='app_review_complete').length).toBe(1); reply.next({retcode:1,result:{amount:0,rewardStatus:'ineligible',completed:true}});reply.complete();harness.fixture.detectChanges(); expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|费用|计薪|¥/);expect(component.canDeactivate()).toBeTrue(); }); async function teacherSelection(count: number, pass = 0, orderId = '') { user.set({userId:300,groupId:3}); session.groupId.set(3); session.student.set({userId:100,groupId:1}); const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService; api.courseWords=Array.from({length:count},(_,i)=>({GeneralID:1001+i,NodeID:811,Title:'word-'+(i+1),sy:'释义 '+(i+1),w_learned:pass})); const harness=await RouterTestingHarness.create(); await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801' + (orderId ? '&orderId=' + orderId : '')); await harness.fixture.whenStable(); harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; if(pass>0){component.selectCoursePass(pass);await harness.fixture.whenStable();harness.fixture.detectChanges();} return {api,harness,component}; } it('keeps compact known and unknown decisions visible before revealing a word', async () => { const {harness,component}=await teacherSelection(2); const row=harness.routeNativeElement!.querySelector('.word-refine-item')!; expect(component.selectionRevealed().size).toBe(0); expect(row.querySelector('.word-refine-main .known-button')?.textContent?.trim()).toBe('认识'); expect(row.querySelector('.word-refine-main .unknown-button')?.textContent?.trim()).toBe('不认识'); (row.querySelector('.known-button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); expect(component.coursePassCount(1)).toBe(1); }); it('saves 205 accumulated known words in bounded requests without losing words', async () => { const {api,harness,component}=await teacherSelection(205); for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();} const original=api.post.bind(api),sizes:number[]=[]; spyOn(api,'post').and.callFake(((action:string,params:Record={})=>{ if(action==='e_add_words'){const size=String(params['wordsId']).split(',').length;sizes.push(size);if(size>100)return throwError(()=>new Error('单次最多操作 100 个单词'));} return original(action,params); }) as typeof api.post); for(const word of component.words())component.markWordKnown(word); component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(sizes).toEqual([100,100,5]); expect(api.courseIncrements.size).toBe(205); expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue(); expect(component.newlyKnownWords().length).toBe(0); expect(component.coursePassCount(1)).toBe(205); }); it('retains successful known-word batches when a later request fails and retries only remaining words', async () => { const {api,harness,component}=await teacherSelection(205); for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();} const original=api.post.bind(api),requests:Record[]=[];let failed=false; spyOn(api,'post').and.callFake(((action:string,params:Record={})=>{ if(action==='e_add_words'){ requests.push(params); if(String(params['wordsId']).split(',').length>100)return throwError(()=>new Error('单次最多操作 100 个单词')); if(requests.length===2&&!failed){failed=true;return throwError(()=>new Error('第二批网络中断'));} } return original(action,params); }) as typeof api.post); for(const word of component.words())component.markWordKnown(word); component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(component.newlyKnownWords().length).toBe(105); expect(component.coursePassCounts()?.[1]).toBe(100); expect(component.submitting()).toBeFalse(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc'); component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(requests.map(p=>String(p['wordsId']).split(',').length)).toEqual([100,100,100,5]); expect(requests[2]['requestKey']).toBe(requests[1]['requestKey']); expect(api.courseIncrements.size).toBe(205); expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue(); expect(component.coursePassCount(1)).toBe(205); }); it('selects the visible words, allows exceptions, then saves known words and trains only exceptions', async () => { const {api,harness,component}=await teacherSelection(8); const view=()=>harness.routeNativeElement!; const select=view().querySelector('.bulk-select-toggle') as HTMLButtonElement; expect(select).not.toBeNull();select?.click();harness.fixture.detectChanges(); expect(view().querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(8); expect(component.selected().size).toBe(0); // Temporary bulk checks do not change the actual learning selection. const checks=view().querySelectorAll('.word-select-check');checks[1].click();checks[6].click();harness.fixture.detectChanges(); (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click(); await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con'); const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(learning.words().map(w=>w.wordId).sort()).toEqual(['1002','1007']); expect([...api.courseIncrements.keys()].sort()).toEqual(['1001','1003','1004','1005','1006','1008']); }); it('saves all-known words and opens the learned pass without creating an empty learning round', async () => { const {api,harness,component}=await teacherSelection(8); (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click(); await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(api.courseIncrements.size).toBe(8); expect(component.coursePass()).toBe(1); expect(component.visibleCourseWords().length).toBe(8); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc'); }); it('keeps bulk unknown checks intact when they exceed a round and clears them across pass switches', async () => { const {api,harness,component}=await teacherSelection(101); component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges(); component.toggleWord(component.words()[100]); const toast=spyOn(TestBed.inject(PlatformService),'toast'); (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.bulk-assessment .unknown-button') as HTMLButtonElement)?.click();harness.fixture.detectChanges(); expect(toast).toHaveBeenCalledWith(jasmine.stringMatching(/100/),'error'); expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(100); expect(component.selected().size).toBe(1);expect(api.courseIncrements.size).toBe(0); component.selectCoursePass(1);await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(harness.routeNativeElement!.querySelector('.bulk-assessment')).toBeNull(); component.selectCoursePass(0);await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(0); }); it('uses select-all only for loaded words and guards decisions while saving', async () => { const {api,harness,component}=await teacherSelection(105); const original=api.post.bind(api),response=new Subject>(); const post=spyOn(api,'post').and.callFake(((action:string,params:Record={})=>action==='e_add_words'?response:original(action,params)) as typeof api.post); (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges(); expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(100); (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();harness.fixture.detectChanges(); expect(component.submitting()).toBeTrue(); component.startSelected();component.selectCoursePass(1); expect(component.coursePass()).toBe(0); expect(post.calls.allArgs().filter(([action])=>action==='e_add_words').length).toBe(1); response.next({retcode:1,result:{affected:100}});response.complete();await harness.fixture.whenStable(); expect(post.calls.allArgs().filter(([action])=>action==='e_add_words').length).toBe(1); }); it('preserves an ambiguous committed batch key by freezing decisions until retry resolves', async () => { const {api,harness,component}=await teacherSelection(205); for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();} const original=api.post.bind(api),completed=new Map>>(),requests:Record[]=[];let lost=false; spyOn(api,'post').and.callFake(((action:string,params:Record={})=>{ if(action!=='e_add_words')return original(action,params); requests.push(params);const key=String(params['requestKey']); if(completed.has(key))return completed.get(key)!; const saved=original(action,params);completed.set(key,saved); if(!lost){lost=true;return throwError(()=>new Error('服务端已保存但响应丢失'));} return saved; }) as typeof api.post); const first=component.words()[0]; (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges(); for (const word of component.bulkCandidates().slice(100)) component.toggleWord(word); harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click(); await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(harness.routeNativeElement!.querySelector('.selection-retry-note')?.textContent).toContain('尚待确认'); expect(harness.routeNativeElement!.querySelector('.selection-header .primary-button')?.textContent).toContain('重试保存'); expect(api.courseIncrements.size).toBe(100); component.markWordUnknown(first);component.toggleWord(first);component.selectCoursePass(1);component.loadMoreSelection(); expect(component.selectionAssessment()[first.id]).toBe('known');expect(component.selected().size).toBe(0);expect(component.coursePass()).toBe(0); component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(requests[1]['requestKey']).toBe(requests[0]['requestKey']); expect(requests[1]['wordsId']).toBe(requests[0]['wordsId']); expect(api.courseIncrements.size).toBe(205);expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue(); expect(component.coursePass()).toBe(1);expect(harness.routeNativeElement!.querySelector('.selection-retry-note')).toBeNull(); }); it('rebases pending pages after saving known words without skipping remaining vocabulary or selections', async () => { const {api,harness,component}=await teacherSelection(250); const view=()=>harness.routeNativeElement!; const checks=()=>view().querySelectorAll('.word-select-check'); checks()[99].click(); // Preserve an existing individual learning choice. (view().querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges(); for(let i=60;i<100;i++)checks()[i].click();harness.fixture.detectChanges(); (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');expect(api.courseIncrements.size).toBe(60); const get=api.get.bind(api);let failed=false; spyOn(api,'get').and.callFake(((action:string,params:Record={})=>{ if(action==='e_ck_list'&&Number(params['cpage'])===1&&!failed){failed=true;return throwError(()=>new Error('刷新第一页失败'));} return get(action,params); }) as typeof api.get); component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(component.error()).toBe('');expect(view().querySelector('.word-refine-item')).not.toBeNull(); component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges(); component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(component.selectionTotal()).toBe(190); expect(component.visibleCourseWords().map(w=>Number(w.wordId)).sort((a,b)=>a-b)).toEqual(Array.from({length:190},(_,i)=>1061+i)); expect(component.selected().has('1100')).toBeTrue(); }); it('treats an individual unknown decision as an exception to bulk known', async () => { const {api,harness}=await teacherSelection(3); (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.word-refine-item .unknown-button') as HTMLButtonElement).click();harness.fixture.detectChanges(); expect(harness.routeNativeElement!.querySelector('.word-select-check')?.getAttribute('aria-pressed')).toBe('false'); (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges(); const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(learning.words().map(w=>w.wordId)).toEqual(['1001']);expect([...api.courseIncrements.keys()].sort()).toEqual(['1002','1003']); }); it('starts a valid bulk unknown selection without marking those words learned', async () => { const {api,harness}=await teacherSelection(3); (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.bulk-assessment .unknown-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con'); const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(learning.words().length).toBe(3);expect(api.courseIncrements.size).toBe(0); }); for(const pass of [1,2]) { it('bulk assesses learned pass '+pass+' and advances only recognized words by one pass', async () => { const {api,harness,component}=await teacherSelection(4,pass); const view=()=>harness.routeNativeElement!; expect(view().querySelector('.bulk-select-toggle')).not.toBeNull(); expect(view().querySelectorAll('.word-refine-main .known-button').length).toBe(4); (view().querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges(); expect(view().querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(4); (view().querySelectorAll('.word-select-check')[3]).click();harness.fixture.detectChanges(); (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con'); const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(learning.words().map(w=>w.wordId)).toEqual(['1004']); expect([...api.courseIncrements.keys()].sort()).toEqual(['1001','1002','1003']); expect(component.coursePassCount(pass)).toBe(1);expect(component.coursePassCount(pass+1)).toBe(3); expect(component.firstPassProgress()).toBe(100); }); } it('marks only the requested 20 course words known without learning the other 80', async () => { const {api,harness,component} = await teacherSelection(100); spyOn(TestBed.inject(PlatformService), 'toast'); const picker = harness.routeNativeElement!.querySelector('.bulk-select-size')!; picker.value='20';picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click(); await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(api.courseIncrements.size).toBe(20);expect(component.selected().size).toBe(0); expect(component.coursePassCounts()).toEqual([80,20,0,0]); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc'); }); it('preserves individual wordbook choices when a quantity selection request fails', async () => { spyOn(TestBed.inject(PlatformService), 'toast'); const api=TestBed.inject(ApiService), original=api.get.bind(api), pending=new Subject>(); let reads=0; spyOn(api,'get').and.callFake(((action:string,params:Record={})=>{ if(action!=='e_words_list')return original(action,params); if(reads++)return pending; return of({retcode:0,result:[{GeneralID:601,detail:[{GeneralID:701,Title:'apple',sy:'苹果'}]}],page:{itemCount:1,pendingCount:1,completedCount:0}}); }) as typeof api.get); const harness=await RouterTestingHarness.create('/pages/member/starshop'); await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; (harness.routeNativeElement!.querySelector('.word-select-list > button') as HTMLButtonElement).click();harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.selection-header .quiet-button') as HTMLButtonElement).click();harness.fixture.detectChanges(); expect(component.selectionLoading()).toBeTrue(); pending.error(new Error('temporary failure'));harness.fixture.detectChanges(); expect([...component.selected()]).toEqual(['701']);expect(component.selectionLoading()).toBeFalse(); }); for (const entry of ['course-0', 'course-1', 'course-2', 'coach-wordbook', 'student-wordbook']) for (const startCount of [20,50,100]) { it(`selects only the requested quantity and starts ${startCount} words for ${entry}`, async () => { spyOn(TestBed.inject(PlatformService), 'toast'); let harness: RouterTestingHarness; if (entry.startsWith('course-')) { harness = (await teacherSelection(100, Number(entry.slice(-1)), entry === 'course-0' ? '901' : '')).harness; } else { if (entry === 'coach-wordbook') { user.set({userId:300,groupId:3});session.groupId.set(3); } const api = TestBed.inject(ApiService), original = api.get.bind(api); spyOn(api, 'get').and.callFake(((action: string, params: Record = {}) => { if (action === 'e_user_list') return of({retcode:0,result:[{userId:100,honeyName:'学员甲'}]}); if (action === 'e_words_list') { const all = Array.from({length:100}, (_,i) => ({GeneralID:601+i,detail:[{GeneralID:1001+i,Title:'word-'+i,sy:'释义 '+i}]})); const size = Number(params['psize']), page = Number(params['cpage']); return of({retcode:0,result:all.slice((page-1)*size,page*size),page:{itemCount:100,pendingCount:100,completedCount:0,pageCount:Math.ceil(100/size)}}); } return original(action,params); }) as typeof api.get); harness = await RouterTestingHarness.create('/pages/member/starshop'); await harness.fixture.whenStable();harness.fixture.detectChanges(); } const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; const picker = harness.routeNativeElement!.querySelector('.bulk-select-size'); expect(picker).withContext('every editable selection entry has a quantity picker').not.toBeNull(); if (!picker) return; expect(Array.from(picker.options).map(option => option.value)).toEqual(['20','50','100']); expect(picker.value).toBe('100'); const clickAll = () => { (harness.routeNativeElement!.querySelector('.bulk-select-toggle, .quiet-button') as HTMLButtonElement).click(); harness.fixture.detectChanges(); }; for (const count of [20,50,100]) { picker.value = String(count);picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges(); clickAll(); expect((component.bulkSelection() ?? component.selected()).size).toBe(count); if (count < 100) {clickAll();expect((component.bulkSelection() ?? component.selected()).size).toBe(0);} } if (startCount !== 100) { clickAll();picker.value = String(startCount);picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges();clickAll(); } const button = entry.startsWith('course-') ? '.bulk-assessment .unknown-button' : '.selection-header .primary-button'; (harness.routeNativeElement!.querySelector(button) as HTMLButtonElement).click(); await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con'); const pages = JSON.parse(localStorage.getItem(entry === 'coach-wordbook' ? 'xiaoshu.learning.300.100.wordbook._page_word_orilist' : entry === 'student-wordbook' ? 'xiaoshu.learning.100.100.practice._page_word_orilist' : entry === 'course-0' ? 'xiaoshu.learning.300.100.901.unbound._page_word_orilist' : 'xiaoshu.learning.300.100.practice._page_word_orilist') || '[]'); expect(pages.flat().length).toBe(startCount); }); } for (const entry of ['course', 'coach-wordbook', 'student-wordbook'] as const) { it(`shows hints immediately, resets all three rounds and preserves the practice flow for ${entry}`, async () => { let harness: RouterTestingHarness; const platform = TestBed.inject(PlatformService); spyOn(platform, 'toast'); if (entry === 'course') { const selection = await teacherSelection(2,0,'901'); harness = selection.harness; selection.component.selected.set(new Set(selection.component.words().map(word => word.id))); selection.component.startSelected(); } else { if (entry === 'coach-wordbook') { user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1}); } const api = TestBed.inject(ApiService), original = api.get.bind(api); spyOn(api, 'get').and.callFake(((action: string, params: Record = {}) => { if (action === 'e_user_list') return of({retcode:0,result:[{userId:100,honeyName:'学员甲'}]}); if (action === 'e_words_list') return of({retcode:0,result:Array.from({length:2},(_,i)=>({GeneralID:601+i,detail:[{GeneralID:701+i,Title:'word-'+i,sy:'释义'+i,yb:'/wɜːd/'}]}))}); return original(action,params); }) as typeof api.get); harness = await RouterTestingHarness.create('/pages/member/starshop' + (entry === 'coach-wordbook' ? '?wordbookStudentId=100' : '')); await harness.fixture.whenStable();harness.fixture.detectChanges(); for (const button of Array.from(harness.routeNativeElement!.querySelectorAll('.word-select-list > button'))) button.click(); harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.selection-header .primary-button') as HTMLButtonElement).click(); } await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con'); const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; expect(component.words().length).toBe(2); component.words.update(words => words.map(word => ({...word,phonetic:word.phonetic || '/wɜːd/',audio:'course.mp3'}))); const play=spyOn(platform,'play').and.resolveTo('played'); for (const step of [1,2,3] as const) { expect(component.studyStep()).toBe(step);harness.fixture.detectChanges(); const first = component.words()[0], row = harness.routeNativeElement!.querySelector('.repeat-list > button')!; const answer = () => row.querySelector('.study-answer, small:not(.study-phonetic)'); expect(row.querySelector('strong')?.textContent).toBe(step===3 ? first.meaning : first.word); expect(answer()).toBeNull(); row.click();harness.fixture.detectChanges();expect(answer()).toBeNull(); play.calls.mostRecent().args[2]!.onEnded();harness.fixture.detectChanges();expect(answer()).toBeNull(); if(step!==3)expect(row.querySelector('.study-phonetic')?.textContent).toBe(first.phonetic); row.click();harness.fixture.detectChanges();expect(answer()?.textContent).toBe(step===3 ? first.word : first.meaning); const lateEnded=play.calls.mostRecent().args[2]!.onEnded; lateEnded();harness.fixture.detectChanges();expect(answer()?.textContent).toBe(step===3 ? first.word : first.meaning); row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();expect(row.querySelectorAll('.repeat-dots .done').length).toBe(3); if(step!==3)expect(row.querySelector('.study-phonetic')?.textContent).toBe(first.phonetic); lateEnded();harness.fixture.detectChanges();expect(answer()).toBeNull(); row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();expect(component.repetitions()[first.id]).toBe(3); for(const [index,word] of component.words().entries())if(index>0)for(let tap=0;tap<3;tap++)component.repeatWord(word,index); if(step<3){expect(component.repetitions()).toEqual({});harness.fixture.detectChanges();expect(harness.routeNativeElement!.querySelector('.study-answer')).toBeNull();} } await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu3'); const stage=()=>harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; for(const word of [...stage().matchLeft()]){stage().chooseMatch('left',word);stage().chooseMatch('right',word);} stage().continueAfterStage();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain('/pages/stu/stu4'); for(let i=0;i<2;i++)stage().chooseMeaning(stage().current()!); stage().continueAfterStage();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(TestBed.inject(Router).url).toContain(entry==='student-wordbook'?'/pages/stu/stu5':'/pages/stu/pl_stu5'); }); } it('reveals Chinese with a held left swipe without consuming a tap and resets it on shuffle', async () => { const {harness,component:selection}=await teacherSelection(2,0,'901'); selection.selected.set(new Set(selection.words().map(word=>word.id)));selection.startSelected(); await harness.fixture.whenStable();harness.fixture.detectChanges(); const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent; spyOn(TestBed.inject(PlatformService),'play').and.resolveTo('played'); component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'}))); const row=()=>harness.routeNativeElement!.querySelector('.repeat-list > button')!; const swipe=()=>{ const target=row(); for(const [type,x,time] of [['pointerdown',150,0],['pointermove',100,500],['pointerup',100,510]] as const){ const event=new PointerEvent(type,{bubbles:true,button:0,pointerId:1,isPrimary:true,clientX:x,clientY:30}); Object.defineProperty(event,'timeStamp',{value:time});target.dispatchEvent(event); } target.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true,detail:1}));harness.fixture.detectChanges(); }; swipe();expect(row().querySelector('.study-answer')?.textContent).toBe(component.words()[0].meaning); expect(component.repetitions()).toEqual({});expect(component.studyStep()).toBe(1); row().click();harness.fixture.detectChanges();expect(row().querySelector('.study-answer')).toBeNull(); expect(component.repetitions()[component.words()[0].id]).toBe(1); swipe();component.shuffleStudyWords();harness.fixture.detectChanges(); expect(row().querySelector('.study-answer')).toBeNull();expect(component.repetitions()).toEqual({});expect(component.studyStep()).toBe(1); component.studyStep.set(3);harness.fixture.detectChanges();swipe(); expect(row().querySelector('.study-answer')).toBeNull();expect(component.repetitions()).toEqual({}); }); it('uses different save identities for consecutive learned passes and leaves the third pass read-only', async () => { const {api,harness,component}=await teacherSelection(2,1); spyOn(TestBed.inject(PlatformService),'toast'); // Two consecutive saves test state, not toast dismissal timers. const original=api.post.bind(api),saved=new Map>>(),keys:string[]=[]; spyOn(api,'post').and.callFake(((action:string,params:Record={})=>{ if(action!=='e_add_words')return original(action,params); const key=String(params['requestKey']);keys.push(key);if(saved.has(key))return saved.get(key)!; const result=original(action,params);saved.set(key,result);return result; }) as typeof api.post); for(const expectedPass of [2,3]){ (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges(); (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();await harness.fixture.whenStable();harness.fixture.detectChanges(); expect(component.coursePass()).toBe(expectedPass);expect(component.visibleCourseWords().length).toBe(2); } expect(keys.length).toBe(2);expect(new Set(keys).size).toBe(2); expect([...api.courseIncrements.values()]).toEqual([2,2]); expect(component.coursePassCount(3)).toBe(2);expect(component.coursePassCount(0)).toBe(0); expect(harness.routeNativeElement!.querySelector('.bulk-select-toggle')).toBeNull(); expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull(); expect(harness.routeNativeElement!.querySelector('.word-assessment')).toBeNull(); component.toggleBulkSelection();component.applyBulkAssessment('known'); expect(keys.length).toBe(2); }); });