import {AdminSessionService} from '../admin-session.service'; import { BehaviorSubject, Subject } from 'rxjs'; import { OperationsLiveRefreshService } from '../operations-live-refresh.service'; import { ComponentFixture, fakeAsync, flushMicrotasks, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router'; import { matchesScheduleSearch, OperationsScheduleComponent, scheduleCoachBusy, scheduleCoachesWithAppointments, scheduleCourseAvailable, scheduleCoursesForStudent, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component'; import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models'; import { OperationsService } from '../operations.service'; import { scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status'; describe('排课搜索匹配', () => { it('支持学员姓名和课程名称的模糊匹配', () => { expect(matchesScheduleSearch('宇昊', ['万宇昊', '135****5177'])).toBeTrue(); expect(matchesScheduleSearch('pep', ['人教版(PEP)三年级起2022年录'])).toBeTrue(); }); it('支持使用完整手机号匹配脱敏手机号', () => { expect(matchesScheduleSearch('13576045177', ['135****5177'])).toBeTrue(); expect(matchesScheduleSearch('13876045177', ['135****5177'])).toBeFalse(); }); it('支持用户或课程数字 ID 搜索', () => { expect(matchesScheduleSearch('2048', ['会员甲', 2048])).toBeTrue(); expect(matchesScheduleSearch('1009', ['自然拼读', 1008])).toBeFalse(); }); }); describe('陪练排课泳道', () => { it('只显示当前结果中已经有排课的老师', () => { const coaches = [ { userId: 10, displayName: '有课老师' }, { userId: 20, displayName: '无课老师' }, { userId: 30, displayName: '历史账号老师' }, ] as ScheduleCalendarData['coaches']; const appointments = [ { coachId: 10, coachAccountMissing: false }, { coachId: 30, coachAccountMissing: true }, ] as ScheduleAppointment[]; expect(scheduleCoachesWithAppointments(coaches, appointments).map((coach) => coach.userId)).toEqual([10]); }); }); describe('排课状态视觉分类', () => { const now = Date.parse('2026-08-20T10:00:00+08:00'); it('超过预约结束时间后当天仍待开始', () => { const item = { cancelled: false, statusCode: 0, statusLabel: '待开始', endsAt: '2026-08-20T01:00:00.000Z' }; expect(scheduleStatusTone(item, now)).toBe('status-upcoming'); expect(scheduleStatusLabel(item, now)).toBe('待开始'); }); it('已开始的课程当天保持上课中', () => { const item = { cancelled: false, statusCode: 10, statusLabel: '上课中', endsAt: '2026-08-20T01:00:00.000Z' }; expect(scheduleStatusTone(item, now)).toBe('status-live'); expect(scheduleStatusLabel(item, now)).toBe('上课中'); }); it('北京时间次日零点才过期,跨午夜课程仍按预约开始日期计算', () => { const item = { cancelled: false, statusCode: 0, startsAt: '2026-08-20T15:30:00Z', endsAt: '2026-08-20T16:30:00Z' }; expect(scheduleStatusTone(item, Date.parse('2026-08-20T15:59:59Z'))).toBe('status-upcoming'); expect(scheduleStatusTone(item, Date.parse('2026-08-20T16:00:00Z'))).toBe('status-overdue'); }); it('持续同步不可用时旧预约只标记待核对,不当作缺课', () => { const item = { cancelled: false, statusCode: 0, statusLabel: '待开始', endsAt: '2026-08-20T01:00:00.000Z', statusUnverified: true, coachId: 88, coachAccountMissing: false }; expect(scheduleStatusTone(item, now)).toBe('status-unverified'); expect(scheduleStatusLabel(item, now)).toBe('状态待核对'); expect(scheduleMatchesStatus(item, 'overdue', now)).toBeFalse(); expect(scheduleMatchesStatus(item, 'unverified', now)).toBeTrue(); }); it('按原系统状态链区分陪练结束、学员确认和最终完课', () => { expect(scheduleStatusTone({ cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z' }, now)).toBe('status-upcoming'); expect(scheduleStatusTone({ cancelled: false, statusCode: 10, endsAt: '2026-08-20T03:00:00.000Z' }, now)).toBe('status-live'); expect(scheduleStatusTone({ cancelled: false, statusCode: 11, endsAt: '' }, now)).toBe('status-coach-ended'); expect(scheduleStatusLabel({ cancelled: false, statusCode: 11, statusLabel: '已完成', endsAt: '' }, now)).toBe('陪练已结束'); expect(scheduleStatusTone({ cancelled: false, statusCode: 20, endsAt: '' }, now)).toBe('status-confirmed'); expect(scheduleStatusLabel({ cancelled: false, statusCode: 20, statusLabel: '已确认', endsAt: '' }, now)).toBe('学员已确认'); expect(scheduleStatusTone({ cancelled: false, statusCode: 30, endsAt: '' }, now)).toBe('status-completed'); expect(scheduleStatusLabel({ cancelled: false, statusCode: 30, statusLabel: '已归档', endsAt: '' }, now)).toBe('已完课'); expect(scheduleStatusTone({ cancelled: false, statusCode: 40, endsAt: '' }, now)).toBe('status-archived'); expect(scheduleStatusLabel({ cancelled: false, statusCode: 40, statusLabel: '已点评', endsAt: '' }, now)).toBe('已归档'); expect(scheduleStatusTone({ cancelled: true, statusCode: 0, endsAt: '' }, now)).toBe('status-cancelled'); }); it('支持运营人员按业务状态和待分配异常筛选', () => { const upcoming = { cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z', coachId: 88, coachAccountMissing: false }; expect(scheduleMatchesStatus(upcoming, 'upcoming', now)).toBeTrue(); expect(scheduleMatchesStatus(upcoming, 'overdue', now)).toBeFalse(); expect(scheduleMatchesStatus({ ...upcoming, coachId: 0 }, 'unassigned', now)).toBeTrue(); expect(scheduleMatchesStatus({ ...upcoming, coachAccountMissing: true }, 'unassigned', now)).toBeTrue(); }); }); describe('排课运营操作职责', () => { it('待开始课程只能修改或取消,不能在后台直接伪造完课', () => { expect(scheduleRowActions({ cancelled: false, statusCode: 0, readOnly: false })).toEqual({ canEdit: true, canCancel: true, canRestore: false, canComplete: false, completeLabel: '', }); }); it('只在上课中或陪练已结束时提供补录入口', () => { expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录结束' })); expect(scheduleRowActions({ cancelled: false, statusCode: 11, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录上课记录' })); expect(scheduleRowActions({ cancelled: false, statusCode: 20, readOnly: false }).canComplete).toBeFalse(); expect(scheduleRowActions({ cancelled: false, statusCode: 30, readOnly: false }).canComplete).toBeFalse(); }); it('实时旧系统记录保持只读', () => { expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: true })).toEqual({ canEdit: false, canCancel: false, canRestore: false, canComplete: false, completeLabel: '', }); }); }); describe('会员课程归属', () => { it('只允许选择当前会员已经绑定的课程', () => { const course = { memberIds: [101, 202], availableToAll: false }; expect(scheduleCourseAvailable(course, 101)).toBeTrue(); expect(scheduleCourseAvailable(course, 303)).toBeFalse(); expect(scheduleCourseAvailable(course, 0)).toBeFalse(); }); it('保留原系统写作课程 398 的全员可选兼容规则', () => { expect(scheduleCourseAvailable({ memberIds: [], availableToAll: true }, 303)).toBeTrue(); }); it('按学员返回完整的已开课程选项,供唯一课程自动带出', () => { const courses = [ { bindingId: 'a', courseId: 80, courseName: '人教版', memberIds: [1845] }, { bindingId: 'b', courseId: 81, courseName: '外研版', memberIds: [2000] }, ] as ScheduleCalendarData['courses']; expect(scheduleCoursesForStudent(courses, 1845).map((course) => course.bindingId)).toEqual(['a']); expect(scheduleCoursesForStudent(courses, 0)).toEqual([]); }); }); describe('时间优先陪练候选', () => { const appointments = [{ targetId: 'appointment-1', coachId: 88, cancelled: false, statusCode: 0, startsAt: '2026-09-02T02:00:00.000Z', endsAt: '2026-09-02T03:00:00.000Z', }] as ScheduleAppointment[]; it('按真实起止区间识别跨课型重叠', () => { expect(scheduleCoachBusy(appointments, 88, '2026-09-02T10:30:00+08:00', 30)).toBeTrue(); expect(scheduleCoachBusy(appointments, 88, '2026-09-02T11:00:00+08:00', 60)).toBeFalse(); }); it('忽略取消、归档和当前编辑记录', () => { expect(scheduleCoachBusy(appointments, 88, '2026-09-02T10:00:00+08:00', 60, 'appointment-1')).toBeFalse(); expect(scheduleCoachBusy([{ ...appointments[0], cancelled: true }] as never[], 88, '2026-09-02T10:00:00+08:00', 60)).toBeFalse(); expect(scheduleCoachBusy([{ ...appointments[0], statusCode: 30 }] as never[], 88, '2026-09-02T10:00:00+08:00', 60)).toBeFalse(); }); }); describe('批量排课逐项预检', () => { it('按课次去重冲突原因并保留可提交项', () => { const results = schedulePreflightCandidates({ candidates: [ { index: 0, startsAt: '2026-09-01T02:00:00.000Z', endsAt: '2026-09-01T03:00:00.000Z' }, { index: 1, startsAt: '2026-09-08T02:00:00.000Z', endsAt: '2026-09-08T03:00:00.000Z' }, ], conflicts: [ { index: 0, reason: '会员时间冲突' }, { index: 0, reason: '陪练时间冲突' }, { index: 0, reason: '会员时间冲突' }, ], }); expect(results[0]).toEqual(jasmine.objectContaining({ index: 0, available: false, reasons: ['会员时间冲突', '陪练时间冲突'] })); expect(results[1]).toEqual(jasmine.objectContaining({ index: 1, available: true, reasons: [] })); }); }); describe('排课数据加载提示', () => { it('读取排课数据期间显示明显提示,完成后自动收起', async () => { let finish!: (value: ScheduleCalendarData) => void; const response = new Promise(resolve => { finish = resolve; }); await TestBed.configureTestingModule({ imports: [OperationsScheduleComponent], providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}, { provide: OperationsService, useValue: { schedule: jasmine.createSpy().and.returnValue(response) } }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } }, ], }).compileComponents(); const fixture = TestBed.createComponent(OperationsScheduleComponent); fixture.detectChanges(); const loading = fixture.nativeElement.querySelector('[data-testid="schedule-loading"]') as HTMLElement; expect(loading?.textContent).toContain('正在加载排课数据'); expect(fixture.nativeElement.querySelector('.content-card')?.getAttribute('aria-busy')).toBe('true'); finish({ appointments: [], members: [], courses: [], coaches: [], totalAppointments: 0, unassigned: [], dateFrom: '2026-09-10', dateTo: '2026-09-10', dataDateFrom: '', dataDateTo: '', refreshedAt: '', liveSourceAvailable: true, liveSourceMessage: '', liveSourceCount: 0, }); await fixture.whenStable(); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('[data-testid="schedule-loading"]')).toBeNull(); expect(fixture.nativeElement.querySelector('.content-card')?.getAttribute('aria-busy')).toBe('false'); }); }); describe('排课明细与取消', () => { it('实时旧系统排课可查看明细,但不能从新后台单边取消', async () => { const mutate = jasmine.createSpy('mutate'); await TestBed.configureTestingModule({ imports: [OperationsScheduleComponent], providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}, { provide: OperationsService, useValue: { schedule: async () => ({ appointments: [], members: [], courses: [], coaches: [] }), mutate } }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } }, ], }).compileComponents(); const fixture = TestBed.createComponent(OperationsScheduleComponent); const page = fixture.componentInstance; spyOn(page, 'load').and.resolveTo(); fixture.detectChanges(); page.openDetails({ generalId: 123, studentName: '学生甲', coachName: '老师乙', courseName: '单词课', readOnly: true, statusCode: 0, cancelled: false, sourceLabel: '旧系统实时数据' } as ScheduleAppointment); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('[data-testid="schedule-detail"]')?.textContent).toContain('学生甲'); expect(fixture.nativeElement.querySelector('[data-testid="schedule-detail"]')?.textContent).toContain('原业务端取消'); await page.cancelFromDetails(); expect(mutate).not.toHaveBeenCalled(); }); it('对可写排课填写原因后调用取消接口并刷新', async () => { const mutate = jasmine.createSpy('mutate').and.resolveTo({}); await TestBed.configureTestingModule({ imports: [OperationsScheduleComponent], providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}, { provide: OperationsService, useValue: { schedule: async () => ({ appointments: [], members: [], courses: [], coaches: [] }), mutate } }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } }, ], }).compileComponents(); const page = TestBed.createComponent(OperationsScheduleComponent).componentInstance; const load = spyOn(page, 'load').and.resolveTo(); const item = { targetId: 'appointment-1', updatedAt: '2026-10-04T10:00:00Z', readOnly: false, statusCode: 0, cancelled: false } as ScheduleAppointment; page.openDetails(item); await page.cancelFromDetails(); expect(mutate).not.toHaveBeenCalled(); expect(page.detailError()).toContain('取消原因'); page.cancelReason.set('学员请假'); await page.cancelFromDetails(); expect(mutate).toHaveBeenCalledOnceWith('ops/appointments/cancel', 'appointment-1', '学员请假', {}, item.updatedAt); expect(page.detail()).toBeNull(); expect(load).toHaveBeenCalled(); }); }); describe('排课表单预检', () => { async function form(request: () => Promise>) { await TestBed.configureTestingModule({ imports: [OperationsScheduleComponent], providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}, { provide: OperationsService, useValue: { request } }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } }, ], }).compileComponents(); const fixture = TestBed.createComponent(OperationsScheduleComponent); const component = fixture.componentInstance; spyOn(component, 'load').and.resolveTo(); fixture.detectChanges();await fixture.whenStable(); component.data.set({members:[],courses:[{bindingId:'word-binding',courseId:100,courseName:'词表',memberIds:[20],categoryKey:'word',mappingConfirmed:true,supportedDurations:[30,60]}],appointments:[],coaches:[]} as unknown as ScheduleCalendarData); component.openCreate();component.studentId.set(20);component.selectedMemberObjectId.set('member');component.chooseCourse(component.data()!.courses[0]); return { fixture, component }; } it('预检失败直接显示在抽屉内,旧的成功结果不能继续保存', async () => { let failed = false; const { fixture, component } = await form(async () => { if(failed)throw Error('可用课时不足');return { availableCount:1, conflictCount:0 }; }); await component.preflight();expect(component.canSavePreview()).toBeTrue(); failed=true;await component.preflight();fixture.detectChanges(); expect(component.canSavePreview()).toBeFalse(); expect(fixture.nativeElement.querySelector('[data-testid="schedule-form-error"]').textContent).toContain('可用课时不足'); }); it('等待响应时改时间,返回的旧预检结果失效', async () => { let finish!: (value: Record) => void; const { component } = await form(() => new Promise(resolve => { finish=resolve; })); const pending=component.preflight();expect(component.checking()).toBeTrue(); component.updateStartTime('12:00');finish({ availableCount:1, conflictCount:0 });await pending; expect(component.preview()).toBeNull();expect(component.canSavePreview()).toBeFalse();expect(component.checking()).toBeFalse(); }); it('修改排课正确还原北京时间,而不是 UTC 小时', async () => { const { component } = await form(async()=>({})); component.recurrence.set('weekly');component.occurrences.set(5); component.openEdit({ startsAt:'2026-10-04T01:00:00Z',studentId:20,studentObjectId:'member',courseId:100,coachId:30,classType:2 } as ScheduleAppointment); expect(component.date()).toBe('2026-10-04');expect(component.startTime()).toBe('09:00');expect(component.recurrence()).toBe('once');expect(component.occurrences()).toBe(1); }); }); describe('排课页面复用后的会员与开课数据', () => { let changes: Subject<'local' | 'broadcast'>; let params: BehaviorSubject; let route: { snapshot: { queryParamMap: ParamMap }; queryParamMap: BehaviorSubject }; let schedule: jasmine.Spy; let fixture: ComponentFixture; const empty = (): ScheduleCalendarData => ({ members: [], courses: [], appointments: [], coaches: [], unassigned: [], dataDateTo: '' } as unknown as ScheduleCalendarData); const current = (): ScheduleCalendarData => ({ ...empty(), members: [{objectId:'new-member',userId:1935,displayName:'新学员',mobileMasked:'',readOnly:false}], courses: [{bindingId:'new-binding',courseId:447,courseName:'新开单词课',memberIds:[1935],mappingConfirmed:true,supportedDurations:[30]}] } as ScheduleCalendarData); beforeEach(async () => { changes = new Subject(); params = new BehaviorSubject(convertToParamMap({})); route = {snapshot:{queryParamMap:params.value},queryParamMap:params}; schedule = jasmine.createSpy('schedule').and.callFake(async () => empty()); await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}, {provide:OperationsService,useValue:{schedule,dataChanges$:changes}}, {provide:ActivatedRoute,useValue:route}, {provide:OperationsLiveRefreshService,useValue:{watch:()=>()=>undefined}}, ]}).compileComponents(); fixture=TestBed.createComponent(OperationsScheduleComponent);fixture.detectChanges();await fixture.whenStable(); }); afterEach(()=>fixture.destroy()); function navigateToMember():void {route.snapshot.queryParamMap=convertToParamMap({studentId:'1935'});params.next(route.snapshot.queryParamMap);} it('离开期间新增会员、充值和开课后,返回更新候选;普通返回不重复读取', fakeAsync(() => { fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();expect(schedule).toHaveBeenCalledTimes(1); changes.next('local');changes.next('broadcast');schedule.and.callFake(async()=>current()); fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks(); expect(schedule).toHaveBeenCalledTimes(2);expect(fixture.componentInstance.data()?.members[0].userId).toBe(1935); fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();expect(schedule).toHaveBeenCalledTimes(2); })); it('缓存页面收到会员排课入口时先读取新数据再选择会员,同一会员再次进入也可打开', fakeAsync(() => { schedule.and.callFake(async()=>current()); fixture.componentInstance.onOperationsRouteAttach();navigateToMember();flushMicrotasks(); expect(schedule).toHaveBeenCalledTimes(2);expect(fixture.componentInstance.drawerOpen()).toBeTrue(); expect(fixture.componentInstance.studentId()).toBe(1935);expect(fixture.componentInstance.bindingId()).toBe('new-binding'); fixture.componentInstance.closeDrawer();fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks(); expect(schedule).toHaveBeenCalledTimes(3);expect(fixture.componentInstance.drawerOpen()).toBeTrue(); })); it('读取失败时不拿旧候选假装已选择新会员,返回重试可恢复', fakeAsync(() => { schedule.and.rejectWith(new Error('读取失败'));navigateToMember();flushMicrotasks(); expect(fixture.componentInstance.error()).toBe('读取失败');expect(fixture.componentInstance.drawerOpen()).toBeFalse(); schedule.and.callFake(async()=>current());fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks(); expect(fixture.componentInstance.studentId()).toBe(1935); })); }); describe('排课完整课型选择与教材联动',()=>{ let page:OperationsScheduleComponent; let fixture:ComponentFixture; let request:jasmine.Spy; const course=(bindingId:string,categoryKey:string,courseId=100)=>({bindingId,categoryKey,courseId,courseName:'体验词库一',memberIds:[20],mappingConfirmed:true,supportedDurations:categoryKey==='trial'||categoryKey==='word_self_study'?[60]:[30,60]}); beforeEach(async()=>{ request=jasmine.createSpy().and.resolveTo({availableCount:1,conflictCount:0}); await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers:[ {provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}, {provide:OperationsService,useValue:{request}}, {provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}}, {provide:OperationsLiveRefreshService,useValue:{watch:()=>()=>undefined}}, ]}).compileComponents(); fixture=TestBed.createComponent(OperationsScheduleComponent);page=fixture.componentInstance; spyOn(page,'load').and.resolveTo();fixture.detectChanges();await fixture.whenStable(); page.data.set({courses:[course('word','word'),course('trial','trial'),course('writing','middle_writing',101)],members:[],appointments:[],coaches:[]} as unknown as ScheduleCalendarData); page.openCreate();page.studentId.set(20);page.selectedMemberObjectId.set('member');page.selectStandard('word:30'); }); afterEach(()=>fixture.destroy()); it('显示截图中全部十种标准,包括未开课的分类',()=>{ fixture.detectChanges(); const options=Array.from(fixture.nativeElement.querySelectorAll('select[aria-label="课型与时长"] option')) as HTMLOptionElement[]; expect(options.length).toBe(10); expect(options.map(row=>row.textContent?.split(' · 待开课')[0].split(' · 已开')[0].split(' · 无需开课')[0])).toEqual([ '单词课 · 30分钟','单词课 · 60分钟','体验课 · 60分钟','小学语法写作 · 30分钟','初中语法写作 · 30分钟','高中语法写作 · 30分钟','小学语法写作 · 60分钟','初中语法写作 · 60分钟','高中语法写作 · 60分钟','单词自学课 · 60分钟', ]); expect(options.find(row=>row.value==='word_self_study:60')?.disabled).toBeFalse(); }); it('同一教材切换体验课使用对应 bindingId 与60分钟,旧预检失效',async()=>{ await page.preflight();expect(page.canSavePreview()).toBeTrue(); page.selectStandard('trial:60');expect(page.bindingId()).toBe('trial');expect(page.durationMinutes()).toBe(60);expect(page.preview()).toBeNull(); await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({bindingId:'trial',courseId:100,durationMinutes:60})); }); it('体验和自学展示全量教材,无需已有开课记录且区分同一教材的课型',async()=>{ page.data.update(data=>({...data!,courses:['trial','word_self_study'].map(categoryKey=>({...course('',categoryKey),memberIds:[],availableToAll:true}))})); for(const category of ['trial','word_self_study']){ page.selectStandard(`${category}:60`);fixture.detectChanges(); expect(page.courseSelectionValid()).toBeTrue();expect(page.selectedCourse()?.categoryKey).toBe(category); expect(fixture.nativeElement.querySelector('.course-step')).not.toBeNull();expect(fixture.nativeElement.textContent).toContain('无需提前开课'); expect(fixture.nativeElement.textContent).not.toContain('为该学员开课'); await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({categoryKey:category,courseId:100,bindingId:'',durationMinutes:60})); } }); it('六种语法写作直接排课,隐藏教材和开课入口',async()=>{ for(const category of ['primary_writing','middle_writing','high_writing'])for(const duration of [30,60]){ page.selectStandard(`${category}:${duration}`);fixture.detectChanges(); expect(page.courseId()).toBe(0);expect(page.bindingId()).toBe('');expect(page.courseSelectionValid()).toBeTrue(); expect(fixture.nativeElement.querySelector('.course-step')).toBeNull(); expect(fixture.nativeElement.textContent).toContain('无需选择单词教材'); expect(page.standardAvailability(category)).toBe('无需开课'); await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({categoryKey:category,courseId:0,bindingId:'',durationMinutes:duration})); } page.data.update(data=>({...data!,courses:[]}));page.selectStandard('word:30');await page.preflight(); expect(page.courseSelectionValid()).toBeFalse();expect(page.error()).toContain('选择会员和课程'); }); it('选择60分钟后切换教材不重置为30分钟,并展示超过12门的全部教材',()=>{ page.data.update(data=>({...data!,courses:Array.from({length:15},(_,i)=>course('word-'+i,'word',100+i))})); page.selectStandard('word:60');expect(page.filteredCourses().length).toBe(15); page.chooseCourse(page.filteredCourses()[14]);expect(page.durationMinutes()).toBe(60);expect(page.bindingId()).toBe('word-14');expect(page.filteredCourses().length).toBe(15); }); it('教材或规则刷新后失效不能继续保存旧预检',async()=>{ await page.preflight();expect(page.canSavePreview()).toBeTrue(); page.data.update(data=>({...data!,courses:[]}));expect(page.canSavePreview()).toBeFalse(); }); it('修改排课保留原课型与时长,再新建时重置重复方式',()=>{ page.openEdit({startsAt:'2026-10-20T11:00:00Z',studentId:20,studentObjectId:'member',courseId:100,bindingId:'trial',courseCategoryKey:'trial',courseName:'体验词库一',classType:3,durationMinutes:60} as ScheduleAppointment); expect(page.standardKey()).toBe('trial:60');expect(page.bindingId()).toBe('trial');expect(page.courseSelectionValid()).toBeTrue(); page.recurrence.set('daily');page.occurrences.set(10);page.openCreate();expect(page.standardKey()).toBe('word:30');expect(page.recurrence()).toBe('once');expect(page.occurrences()).toBe(1); }); it('开课后留在排课窗口,刷新并选中新增分类教材,保留日期和陪练',async()=>{ page.selectStandard('word_self_study:60');page.openEnrollment();page.coachId.set(88);page.startTime.set('15:00'); (page.load as jasmine.Spy).and.callFake(async()=>{page.data.update(data=>({...data!,courses:[...data!.courses,course('self','word_self_study')]}));}); await page.enrollmentChanged();expect(page.enrollmentOpen()).toBeFalse();expect(page.bindingId()).toBe('self');expect(page.durationMinutes()).toBe(60);expect(page.coachId()).toBe(88);expect(page.startTime()).toBe('15:00'); }); });