operations-schedule.component.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import {AdminSessionService} from '../admin-session.service';
  2. import { BehaviorSubject, Subject } from 'rxjs';
  3. import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
  4. import { ComponentFixture, fakeAsync, flushMicrotasks, TestBed } from '@angular/core/testing';
  5. import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router';
  6. import { matchesScheduleSearch, OperationsScheduleComponent, scheduleCoachBusy, scheduleCoachesWithAppointments, scheduleCourseAvailable, scheduleCoursesForStudent, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
  7. import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
  8. import { OperationsService } from '../operations.service';
  9. import { scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
  10. describe('排课搜索匹配', () => {
  11. it('支持学员姓名和课程名称的模糊匹配', () => {
  12. expect(matchesScheduleSearch('宇昊', ['万宇昊', '135****5177'])).toBeTrue();
  13. expect(matchesScheduleSearch('pep', ['人教版(PEP)三年级起2022年录'])).toBeTrue();
  14. });
  15. it('支持使用完整手机号匹配脱敏手机号', () => {
  16. expect(matchesScheduleSearch('13576045177', ['135****5177'])).toBeTrue();
  17. expect(matchesScheduleSearch('13876045177', ['135****5177'])).toBeFalse();
  18. });
  19. it('支持用户或课程数字 ID 搜索', () => {
  20. expect(matchesScheduleSearch('2048', ['会员甲', 2048])).toBeTrue();
  21. expect(matchesScheduleSearch('1009', ['自然拼读', 1008])).toBeFalse();
  22. });
  23. });
  24. describe('陪练排课泳道', () => {
  25. it('只显示当前结果中已经有排课的老师', () => {
  26. const coaches = [
  27. { userId: 10, displayName: '有课老师' },
  28. { userId: 20, displayName: '无课老师' },
  29. { userId: 30, displayName: '历史账号老师' },
  30. ] as ScheduleCalendarData['coaches'];
  31. const appointments = [
  32. { coachId: 10, coachAccountMissing: false },
  33. { coachId: 30, coachAccountMissing: true },
  34. ] as ScheduleAppointment[];
  35. expect(scheduleCoachesWithAppointments(coaches, appointments).map((coach) => coach.userId)).toEqual([10]);
  36. });
  37. });
  38. describe('排课状态视觉分类', () => {
  39. const now = Date.parse('2026-08-20T10:00:00+08:00');
  40. it('超过预约结束时间后当天仍待开始', () => {
  41. const item = { cancelled: false, statusCode: 0, statusLabel: '待开始', endsAt: '2026-08-20T01:00:00.000Z' };
  42. expect(scheduleStatusTone(item, now)).toBe('status-upcoming');
  43. expect(scheduleStatusLabel(item, now)).toBe('待开始');
  44. });
  45. it('已开始的课程当天保持上课中', () => {
  46. const item = { cancelled: false, statusCode: 10, statusLabel: '上课中', endsAt: '2026-08-20T01:00:00.000Z' };
  47. expect(scheduleStatusTone(item, now)).toBe('status-live');
  48. expect(scheduleStatusLabel(item, now)).toBe('上课中');
  49. });
  50. it('北京时间次日零点才过期,跨午夜课程仍按预约开始日期计算', () => {
  51. const item = { cancelled: false, statusCode: 0, startsAt: '2026-08-20T15:30:00Z', endsAt: '2026-08-20T16:30:00Z' };
  52. expect(scheduleStatusTone(item, Date.parse('2026-08-20T15:59:59Z'))).toBe('status-upcoming');
  53. expect(scheduleStatusTone(item, Date.parse('2026-08-20T16:00:00Z'))).toBe('status-overdue');
  54. });
  55. it('持续同步不可用时旧预约只标记待核对,不当作缺课', () => {
  56. const item = { cancelled: false, statusCode: 0, statusLabel: '待开始', endsAt: '2026-08-20T01:00:00.000Z', statusUnverified: true, coachId: 88, coachAccountMissing: false };
  57. expect(scheduleStatusTone(item, now)).toBe('status-unverified');
  58. expect(scheduleStatusLabel(item, now)).toBe('状态待核对');
  59. expect(scheduleMatchesStatus(item, 'overdue', now)).toBeFalse();
  60. expect(scheduleMatchesStatus(item, 'unverified', now)).toBeTrue();
  61. });
  62. it('按原系统状态链区分陪练结束、学员确认和最终完课', () => {
  63. expect(scheduleStatusTone({ cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z' }, now)).toBe('status-upcoming');
  64. expect(scheduleStatusTone({ cancelled: false, statusCode: 10, endsAt: '2026-08-20T03:00:00.000Z' }, now)).toBe('status-live');
  65. expect(scheduleStatusTone({ cancelled: false, statusCode: 11, endsAt: '' }, now)).toBe('status-coach-ended');
  66. expect(scheduleStatusLabel({ cancelled: false, statusCode: 11, statusLabel: '已完成', endsAt: '' }, now)).toBe('陪练已结束');
  67. expect(scheduleStatusTone({ cancelled: false, statusCode: 20, endsAt: '' }, now)).toBe('status-confirmed');
  68. expect(scheduleStatusLabel({ cancelled: false, statusCode: 20, statusLabel: '已确认', endsAt: '' }, now)).toBe('学员已确认');
  69. expect(scheduleStatusTone({ cancelled: false, statusCode: 30, endsAt: '' }, now)).toBe('status-completed');
  70. expect(scheduleStatusLabel({ cancelled: false, statusCode: 30, statusLabel: '已归档', endsAt: '' }, now)).toBe('已完课');
  71. expect(scheduleStatusTone({ cancelled: false, statusCode: 40, endsAt: '' }, now)).toBe('status-archived');
  72. expect(scheduleStatusLabel({ cancelled: false, statusCode: 40, statusLabel: '已点评', endsAt: '' }, now)).toBe('已归档');
  73. expect(scheduleStatusTone({ cancelled: true, statusCode: 0, endsAt: '' }, now)).toBe('status-cancelled');
  74. });
  75. it('支持运营人员按业务状态和待分配异常筛选', () => {
  76. const upcoming = { cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z', coachId: 88, coachAccountMissing: false };
  77. expect(scheduleMatchesStatus(upcoming, 'upcoming', now)).toBeTrue();
  78. expect(scheduleMatchesStatus(upcoming, 'overdue', now)).toBeFalse();
  79. expect(scheduleMatchesStatus({ ...upcoming, coachId: 0 }, 'unassigned', now)).toBeTrue();
  80. expect(scheduleMatchesStatus({ ...upcoming, coachAccountMissing: true }, 'unassigned', now)).toBeTrue();
  81. });
  82. });
  83. describe('排课运营操作职责', () => {
  84. it('待开始课程只能修改或取消,不能在后台直接伪造完课', () => {
  85. expect(scheduleRowActions({ cancelled: false, statusCode: 0, readOnly: false })).toEqual({
  86. canEdit: true, canCancel: true, canRestore: false, canComplete: false, completeLabel: '',
  87. });
  88. });
  89. it('只在上课中或陪练已结束时提供补录入口', () => {
  90. expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录结束' }));
  91. expect(scheduleRowActions({ cancelled: false, statusCode: 11, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录上课记录' }));
  92. expect(scheduleRowActions({ cancelled: false, statusCode: 20, readOnly: false }).canComplete).toBeFalse();
  93. expect(scheduleRowActions({ cancelled: false, statusCode: 30, readOnly: false }).canComplete).toBeFalse();
  94. });
  95. it('实时旧系统记录保持只读', () => {
  96. expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: true })).toEqual({
  97. canEdit: false, canCancel: false, canRestore: false, canComplete: false, completeLabel: '',
  98. });
  99. });
  100. });
  101. describe('会员课程归属', () => {
  102. it('只允许选择当前会员已经绑定的课程', () => {
  103. const course = { memberIds: [101, 202], availableToAll: false };
  104. expect(scheduleCourseAvailable(course, 101)).toBeTrue();
  105. expect(scheduleCourseAvailable(course, 303)).toBeFalse();
  106. expect(scheduleCourseAvailable(course, 0)).toBeFalse();
  107. });
  108. it('保留原系统写作课程 398 的全员可选兼容规则', () => {
  109. expect(scheduleCourseAvailable({ memberIds: [], availableToAll: true }, 303)).toBeTrue();
  110. });
  111. it('按学员返回完整的已开课程选项,供唯一课程自动带出', () => {
  112. const courses = [
  113. { bindingId: 'a', courseId: 80, courseName: '人教版', memberIds: [1845] },
  114. { bindingId: 'b', courseId: 81, courseName: '外研版', memberIds: [2000] },
  115. ] as ScheduleCalendarData['courses'];
  116. expect(scheduleCoursesForStudent(courses, 1845).map((course) => course.bindingId)).toEqual(['a']);
  117. expect(scheduleCoursesForStudent(courses, 0)).toEqual([]);
  118. });
  119. });
  120. describe('时间优先陪练候选', () => {
  121. const appointments = [{
  122. targetId: 'appointment-1', coachId: 88, cancelled: false, statusCode: 0,
  123. startsAt: '2026-09-02T02:00:00.000Z', endsAt: '2026-09-02T03:00:00.000Z',
  124. }] as ScheduleAppointment[];
  125. it('按真实起止区间识别跨课型重叠', () => {
  126. expect(scheduleCoachBusy(appointments, 88, '2026-09-02T10:30:00+08:00', 30)).toBeTrue();
  127. expect(scheduleCoachBusy(appointments, 88, '2026-09-02T11:00:00+08:00', 60)).toBeFalse();
  128. });
  129. it('忽略取消、归档和当前编辑记录', () => {
  130. expect(scheduleCoachBusy(appointments, 88, '2026-09-02T10:00:00+08:00', 60, 'appointment-1')).toBeFalse();
  131. expect(scheduleCoachBusy([{ ...appointments[0], cancelled: true }] as never[], 88, '2026-09-02T10:00:00+08:00', 60)).toBeFalse();
  132. expect(scheduleCoachBusy([{ ...appointments[0], statusCode: 30 }] as never[], 88, '2026-09-02T10:00:00+08:00', 60)).toBeFalse();
  133. });
  134. });
  135. describe('批量排课逐项预检', () => {
  136. it('按课次去重冲突原因并保留可提交项', () => {
  137. const results = schedulePreflightCandidates({
  138. candidates: [
  139. { index: 0, startsAt: '2026-09-01T02:00:00.000Z', endsAt: '2026-09-01T03:00:00.000Z' },
  140. { index: 1, startsAt: '2026-09-08T02:00:00.000Z', endsAt: '2026-09-08T03:00:00.000Z' },
  141. ],
  142. conflicts: [
  143. { index: 0, reason: '会员时间冲突' },
  144. { index: 0, reason: '陪练时间冲突' },
  145. { index: 0, reason: '会员时间冲突' },
  146. ],
  147. });
  148. expect(results[0]).toEqual(jasmine.objectContaining({ index: 0, available: false, reasons: ['会员时间冲突', '陪练时间冲突'] }));
  149. expect(results[1]).toEqual(jasmine.objectContaining({ index: 1, available: true, reasons: [] }));
  150. });
  151. });
  152. describe('排课数据加载提示', () => {
  153. it('读取排课数据期间显示明显提示,完成后自动收起', async () => {
  154. let finish!: (value: ScheduleCalendarData) => void;
  155. const response = new Promise<ScheduleCalendarData>(resolve => { finish = resolve; });
  156. await TestBed.configureTestingModule({
  157. imports: [OperationsScheduleComponent],
  158. providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
  159. { provide: OperationsService, useValue: { schedule: jasmine.createSpy().and.returnValue(response) } },
  160. { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
  161. ],
  162. }).compileComponents();
  163. const fixture = TestBed.createComponent(OperationsScheduleComponent);
  164. fixture.detectChanges();
  165. const loading = fixture.nativeElement.querySelector('[data-testid="schedule-loading"]') as HTMLElement;
  166. expect(loading?.textContent).toContain('正在加载排课数据');
  167. expect(fixture.nativeElement.querySelector('.content-card')?.getAttribute('aria-busy')).toBe('true');
  168. finish({
  169. appointments: [], members: [], courses: [], coaches: [], totalAppointments: 0,
  170. unassigned: [], dateFrom: '2026-09-10', dateTo: '2026-09-10',
  171. dataDateFrom: '', dataDateTo: '', refreshedAt: '', liveSourceAvailable: true,
  172. liveSourceMessage: '', liveSourceCount: 0,
  173. });
  174. await fixture.whenStable();
  175. fixture.detectChanges();
  176. expect(fixture.nativeElement.querySelector('[data-testid="schedule-loading"]')).toBeNull();
  177. expect(fixture.nativeElement.querySelector('.content-card')?.getAttribute('aria-busy')).toBe('false');
  178. });
  179. });
  180. describe('排课明细与取消', () => {
  181. it('实时旧系统排课可查看明细,但不能从新后台单边取消', async () => {
  182. const mutate = jasmine.createSpy('mutate');
  183. await TestBed.configureTestingModule({
  184. imports: [OperationsScheduleComponent],
  185. providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
  186. { provide: OperationsService, useValue: { schedule: async () => ({ appointments: [], members: [], courses: [], coaches: [] }), mutate } },
  187. { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
  188. ],
  189. }).compileComponents();
  190. const fixture = TestBed.createComponent(OperationsScheduleComponent);
  191. const page = fixture.componentInstance;
  192. spyOn(page, 'load').and.resolveTo();
  193. fixture.detectChanges();
  194. page.openDetails({ generalId: 123, studentName: '学生甲', coachName: '老师乙', courseName: '单词课', readOnly: true, statusCode: 0, cancelled: false, sourceLabel: '旧系统实时数据' } as ScheduleAppointment);
  195. fixture.detectChanges();
  196. expect(fixture.nativeElement.querySelector('[data-testid="schedule-detail"]')?.textContent).toContain('学生甲');
  197. expect(fixture.nativeElement.querySelector('[data-testid="schedule-detail"]')?.textContent).toContain('原业务端取消');
  198. await page.cancelFromDetails();
  199. expect(mutate).not.toHaveBeenCalled();
  200. });
  201. it('对可写排课填写原因后调用取消接口并刷新', async () => {
  202. const mutate = jasmine.createSpy('mutate').and.resolveTo({});
  203. await TestBed.configureTestingModule({
  204. imports: [OperationsScheduleComponent],
  205. providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
  206. { provide: OperationsService, useValue: { schedule: async () => ({ appointments: [], members: [], courses: [], coaches: [] }), mutate } },
  207. { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
  208. ],
  209. }).compileComponents();
  210. const page = TestBed.createComponent(OperationsScheduleComponent).componentInstance;
  211. const load = spyOn(page, 'load').and.resolveTo();
  212. const item = { targetId: 'appointment-1', updatedAt: '2026-10-04T10:00:00Z', readOnly: false, statusCode: 0, cancelled: false } as ScheduleAppointment;
  213. page.openDetails(item);
  214. await page.cancelFromDetails();
  215. expect(mutate).not.toHaveBeenCalled();
  216. expect(page.detailError()).toContain('取消原因');
  217. page.cancelReason.set('学员请假');
  218. await page.cancelFromDetails();
  219. expect(mutate).toHaveBeenCalledOnceWith('ops/appointments/cancel', 'appointment-1', '学员请假', {}, item.updatedAt);
  220. expect(page.detail()).toBeNull();
  221. expect(load).toHaveBeenCalled();
  222. });
  223. });
  224. describe('排课表单预检', () => {
  225. async function form(request: () => Promise<Record<string, unknown>>) {
  226. await TestBed.configureTestingModule({
  227. imports: [OperationsScheduleComponent],
  228. providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
  229. { provide: OperationsService, useValue: { request } },
  230. { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
  231. ],
  232. }).compileComponents();
  233. const fixture = TestBed.createComponent(OperationsScheduleComponent);
  234. const component = fixture.componentInstance;
  235. spyOn(component, 'load').and.resolveTo();
  236. fixture.detectChanges();await fixture.whenStable();
  237. 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);
  238. component.openCreate();component.studentId.set(20);component.selectedMemberObjectId.set('member');component.chooseCourse(component.data()!.courses[0]);
  239. return { fixture, component };
  240. }
  241. it('预检失败直接显示在抽屉内,旧的成功结果不能继续保存', async () => {
  242. let failed = false;
  243. const { fixture, component } = await form(async () => { if(failed)throw Error('可用课时不足');return { availableCount:1, conflictCount:0 }; });
  244. await component.preflight();expect(component.canSavePreview()).toBeTrue();
  245. failed=true;await component.preflight();fixture.detectChanges();
  246. expect(component.canSavePreview()).toBeFalse();
  247. expect(fixture.nativeElement.querySelector('[data-testid="schedule-form-error"]').textContent).toContain('可用课时不足');
  248. });
  249. it('等待响应时改时间,返回的旧预检结果失效', async () => {
  250. let finish!: (value: Record<string, unknown>) => void;
  251. const { component } = await form(() => new Promise(resolve => { finish=resolve; }));
  252. const pending=component.preflight();expect(component.checking()).toBeTrue();
  253. component.updateStartTime('12:00');finish({ availableCount:1, conflictCount:0 });await pending;
  254. expect(component.preview()).toBeNull();expect(component.canSavePreview()).toBeFalse();expect(component.checking()).toBeFalse();
  255. });
  256. it('修改排课正确还原北京时间,而不是 UTC 小时', async () => {
  257. const { component } = await form(async()=>({}));
  258. component.recurrence.set('weekly');component.occurrences.set(5);
  259. component.openEdit({ startsAt:'2026-10-04T01:00:00Z',studentId:20,studentObjectId:'member',courseId:100,coachId:30,classType:2 } as ScheduleAppointment);
  260. expect(component.date()).toBe('2026-10-04');expect(component.startTime()).toBe('09:00');expect(component.recurrence()).toBe('once');expect(component.occurrences()).toBe(1);
  261. });
  262. });
  263. describe('排课页面复用后的会员与开课数据', () => {
  264. let changes: Subject<'local' | 'broadcast'>;
  265. let params: BehaviorSubject<ParamMap>;
  266. let route: { snapshot: { queryParamMap: ParamMap }; queryParamMap: BehaviorSubject<ParamMap> };
  267. let schedule: jasmine.Spy;
  268. let fixture: ComponentFixture<OperationsScheduleComponent>;
  269. const empty = (): ScheduleCalendarData => ({ members: [], courses: [], appointments: [], coaches: [], unassigned: [], dataDateTo: '' } as unknown as ScheduleCalendarData);
  270. 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);
  271. beforeEach(async () => {
  272. changes = new Subject(); params = new BehaviorSubject(convertToParamMap({}));
  273. route = {snapshot:{queryParamMap:params.value},queryParamMap:params};
  274. schedule = jasmine.createSpy('schedule').and.callFake(async () => empty());
  275. await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
  276. {provide:OperationsService,useValue:{schedule,dataChanges$:changes}},
  277. {provide:ActivatedRoute,useValue:route},
  278. {provide:OperationsLiveRefreshService,useValue:{watch:()=>()=>undefined}},
  279. ]}).compileComponents();
  280. fixture=TestBed.createComponent(OperationsScheduleComponent);fixture.detectChanges();await fixture.whenStable();
  281. });
  282. afterEach(()=>fixture.destroy());
  283. function navigateToMember():void {route.snapshot.queryParamMap=convertToParamMap({studentId:'1935'});params.next(route.snapshot.queryParamMap);}
  284. it('离开期间新增会员、充值和开课后,返回更新候选;普通返回不重复读取', fakeAsync(() => {
  285. fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();expect(schedule).toHaveBeenCalledTimes(1);
  286. changes.next('local');changes.next('broadcast');schedule.and.callFake(async()=>current());
  287. fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();
  288. expect(schedule).toHaveBeenCalledTimes(2);expect(fixture.componentInstance.data()?.members[0].userId).toBe(1935);
  289. fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();expect(schedule).toHaveBeenCalledTimes(2);
  290. }));
  291. it('缓存页面收到会员排课入口时先读取新数据再选择会员,同一会员再次进入也可打开', fakeAsync(() => {
  292. schedule.and.callFake(async()=>current());
  293. fixture.componentInstance.onOperationsRouteAttach();navigateToMember();flushMicrotasks();
  294. expect(schedule).toHaveBeenCalledTimes(2);expect(fixture.componentInstance.drawerOpen()).toBeTrue();
  295. expect(fixture.componentInstance.studentId()).toBe(1935);expect(fixture.componentInstance.bindingId()).toBe('new-binding');
  296. fixture.componentInstance.closeDrawer();fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();
  297. expect(schedule).toHaveBeenCalledTimes(3);expect(fixture.componentInstance.drawerOpen()).toBeTrue();
  298. }));
  299. it('读取失败时不拿旧候选假装已选择新会员,返回重试可恢复', fakeAsync(() => {
  300. schedule.and.rejectWith(new Error('读取失败'));navigateToMember();flushMicrotasks();
  301. expect(fixture.componentInstance.error()).toBe('读取失败');expect(fixture.componentInstance.drawerOpen()).toBeFalse();
  302. schedule.and.callFake(async()=>current());fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();
  303. expect(fixture.componentInstance.studentId()).toBe(1935);
  304. }));
  305. });
  306. describe('排课完整课型选择与教材联动',()=>{
  307. let page:OperationsScheduleComponent;
  308. let fixture:ComponentFixture<OperationsScheduleComponent>;
  309. let request:jasmine.Spy;
  310. 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]});
  311. beforeEach(async()=>{
  312. request=jasmine.createSpy().and.resolveTo({availableCount:1,conflictCount:0});
  313. await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers:[
  314. {provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
  315. {provide:OperationsService,useValue:{request}},
  316. {provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}},
  317. {provide:OperationsLiveRefreshService,useValue:{watch:()=>()=>undefined}},
  318. ]}).compileComponents();
  319. fixture=TestBed.createComponent(OperationsScheduleComponent);page=fixture.componentInstance;
  320. spyOn(page,'load').and.resolveTo();fixture.detectChanges();await fixture.whenStable();
  321. page.data.set({courses:[course('word','word'),course('trial','trial'),course('writing','middle_writing',101)],members:[],appointments:[],coaches:[]} as unknown as ScheduleCalendarData);
  322. page.openCreate();page.studentId.set(20);page.selectedMemberObjectId.set('member');page.selectStandard('word:30');
  323. });
  324. afterEach(()=>fixture.destroy());
  325. it('显示截图中全部十种标准,包括未开课的分类',()=>{
  326. fixture.detectChanges();
  327. const options=Array.from(fixture.nativeElement.querySelectorAll('select[aria-label="课型与时长"] option')) as HTMLOptionElement[];
  328. expect(options.length).toBe(10);
  329. expect(options.map(row=>row.textContent?.split(' · 待开课')[0].split(' · 已开')[0].split(' · 无需开课')[0])).toEqual([
  330. '单词课 · 30分钟','单词课 · 60分钟','体验课 · 60分钟','小学语法写作 · 30分钟','初中语法写作 · 30分钟','高中语法写作 · 30分钟','小学语法写作 · 60分钟','初中语法写作 · 60分钟','高中语法写作 · 60分钟','单词自学课 · 60分钟',
  331. ]);
  332. expect(options.find(row=>row.value==='word_self_study:60')?.disabled).toBeFalse();
  333. });
  334. it('同一教材切换体验课使用对应 bindingId 与60分钟,旧预检失效',async()=>{
  335. await page.preflight();expect(page.canSavePreview()).toBeTrue();
  336. page.selectStandard('trial:60');expect(page.bindingId()).toBe('trial');expect(page.durationMinutes()).toBe(60);expect(page.preview()).toBeNull();
  337. await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({bindingId:'trial',courseId:100,durationMinutes:60}));
  338. });
  339. it('体验和自学展示全量教材,无需已有开课记录且区分同一教材的课型',async()=>{
  340. page.data.update(data=>({...data!,courses:['trial','word_self_study'].map(categoryKey=>({...course('',categoryKey),memberIds:[],availableToAll:true}))}));
  341. for(const category of ['trial','word_self_study']){
  342. page.selectStandard(`${category}:60`);fixture.detectChanges();
  343. expect(page.courseSelectionValid()).toBeTrue();expect(page.selectedCourse()?.categoryKey).toBe(category);
  344. expect(fixture.nativeElement.querySelector('.course-step')).not.toBeNull();expect(fixture.nativeElement.textContent).toContain('无需提前开课');
  345. expect(fixture.nativeElement.textContent).not.toContain('为该学员开课');
  346. await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({categoryKey:category,courseId:100,bindingId:'',durationMinutes:60}));
  347. }
  348. });
  349. it('六种语法写作直接排课,隐藏教材和开课入口',async()=>{
  350. for(const category of ['primary_writing','middle_writing','high_writing'])for(const duration of [30,60]){
  351. page.selectStandard(`${category}:${duration}`);fixture.detectChanges();
  352. expect(page.courseId()).toBe(0);expect(page.bindingId()).toBe('');expect(page.courseSelectionValid()).toBeTrue();
  353. expect(fixture.nativeElement.querySelector('.course-step')).toBeNull();
  354. expect(fixture.nativeElement.textContent).toContain('无需选择单词教材');
  355. expect(page.standardAvailability(category)).toBe('无需开课');
  356. await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({categoryKey:category,courseId:0,bindingId:'',durationMinutes:duration}));
  357. }
  358. page.data.update(data=>({...data!,courses:[]}));page.selectStandard('word:30');await page.preflight();
  359. expect(page.courseSelectionValid()).toBeFalse();expect(page.error()).toContain('选择会员和课程');
  360. });
  361. it('选择60分钟后切换教材不重置为30分钟,并展示超过12门的全部教材',()=>{
  362. page.data.update(data=>({...data!,courses:Array.from({length:15},(_,i)=>course('word-'+i,'word',100+i))}));
  363. page.selectStandard('word:60');expect(page.filteredCourses().length).toBe(15);
  364. page.chooseCourse(page.filteredCourses()[14]);expect(page.durationMinutes()).toBe(60);expect(page.bindingId()).toBe('word-14');expect(page.filteredCourses().length).toBe(15);
  365. });
  366. it('教材或规则刷新后失效不能继续保存旧预检',async()=>{
  367. await page.preflight();expect(page.canSavePreview()).toBeTrue();
  368. page.data.update(data=>({...data!,courses:[]}));expect(page.canSavePreview()).toBeFalse();
  369. });
  370. it('修改排课保留原课型与时长,再新建时重置重复方式',()=>{
  371. 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);
  372. expect(page.standardKey()).toBe('trial:60');expect(page.bindingId()).toBe('trial');expect(page.courseSelectionValid()).toBeTrue();
  373. 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);
  374. });
  375. it('开课后留在排课窗口,刷新并选中新增分类教材,保留日期和陪练',async()=>{
  376. page.selectStandard('word_self_study:60');page.openEnrollment();page.coachId.set(88);page.startTime.set('15:00');
  377. (page.load as jasmine.Spy).and.callFake(async()=>{page.data.update(data=>({...data!,courses:[...data!.courses,course('self','word_self_study')]}));});
  378. 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');
  379. });
  380. });