| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- 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<ScheduleCalendarData>(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<Record<string, unknown>>) {
- 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<string, unknown>) => 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<ParamMap>;
- let route: { snapshot: { queryParamMap: ParamMap }; queryParamMap: BehaviorSubject<ParamMap> };
- let schedule: jasmine.Spy;
- let fixture: ComponentFixture<OperationsScheduleComponent>;
- 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<OperationsScheduleComponent>;
- 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');
- });
- });
|