import { TeachingCourseCategory, TeachingRule } from '../admin.models'; import { TEACHING_STAGE_OPTIONS, teachingRateMatrix } from './operations-payroll.component'; describe('operations payroll teaching standards', () => { it('uses the three standard school stages when presenting database course ancestry', () => { expect(TEACHING_STAGE_OPTIONS.map((stage) => stage.name)).toEqual(['小学', '初中', '高中']); }); it('builds the current online and offline price matrix without using an older version', () => { const categories = [ { key: 'trial', name: '体验训练', sortOrder: 10, active: true }, { key: 'word_self_study', name: '单词自学课', sortOrder: 20, active: true }, ] as TeachingCourseCategory[]; const rule = (categoryKey: string, deliveryMode: 'online' | 'offline', teacherPay: number, effectiveFrom: string): TeachingRule => ({ objectId: `${categoryKey}:${deliveryMode}:${effectiveFrom}`, categoryKey, categoryName: categories.find((category) => category.key === categoryKey)?.name || categoryKey, durationMinutes: 60, deliveryMode, deliveryLabel: deliveryMode === 'online' ? '线上' : '线下', teacherPay, creditCosts: [], effectiveFrom, active: true, updatedAt: '', }); const matrix = teachingRateMatrix([ rule('trial', 'online', 40, '2026-09-01'), rule('trial', 'online', 30, '2026-09-09'), rule('trial', 'offline', 40, '2026-09-09'), rule('word_self_study', 'online', 20, '2026-09-09'), rule('word_self_study', 'offline', 30, '2026-09-09'), ], categories, '2026-09-09'); expect(matrix.map((row) => [row.categoryName, row.durationMinutes, row.online?.teacherPay, row.offline?.teacherPay])).toEqual([ ['体验训练', 60, 30, 40], ['单词自学课', 60, 20, 30], ]); }); }); import { TestBed } from '@angular/core/testing'; import { OperationsPayrollComponent } from './operations-payroll.component'; import { OperationsService } from '../operations.service'; import { AdminSessionService } from '../admin-session.service'; import { PayrollOverview, PayrollSummary } from '../admin.models'; describe('payroll interactions', () => { let component:OperationsPayrollComponent; let service:jasmine.SpyObj; const overview=(month:string)=>({month,lines:[],coachItems:[],sourceStatus:{available:true,mode:'parse'},stores:[]} as unknown as PayrollOverview); beforeEach(()=>{ service=jasmine.createSpyObj('OperationsService',['payrollOverview','payrolls','payRates','teachingStandards','request','mutate','payrollDetail']); service.payrollOverview.and.resolveTo(overview('2026-09'));service.payrolls.and.resolveTo({items:[],total:0,page:1,pageSize:20});service.payRates.and.resolveTo({items:[],total:0,page:1,pageSize:100}); service.teachingStandards.and.resolveTo({categories:[],rules:[],mappings:[],summary:{courseCount:0,confirmedCount:0,unmappedCount:0,activeRuleCount:0},refreshedAt:''});service.request.and.resolveTo({items:[]}); TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:service},{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}]}); component=TestBed.runInInjectionContext(()=>new OperationsPayrollComponent()); }); it('ignores an older query response after month changes',async()=>{ let resolveOld!:(v:PayrollOverview)=>void;service.payrollOverview.and.returnValues(new Promise(r=>resolveOld=r),Promise.resolve(overview('2026-10'))); const old=component.load();component.month.set('2026-10');await component.load();resolveOld(overview('2026-09'));await old;expect(component.overview()?.month).toBe('2026-10'); }); it('reset keeps month and clears search, store and the teacher deep link',async()=>{ component.month.set('2026-08');component.storeId.set(10);component.search.set('老师');component.rewardCoachId.set(30);component.openRewards();await component.resetFilters();expect(component.rewardFilters()).toEqual(jasmine.objectContaining({month:'2026-08',storeId:0,search:'',coachId:0,resetRevision:1})); }); it('selects payroll months through the shared calendar-style picker',()=>{ component.month.set('2026-09');component.message.set('2026年9月全公司费用草稿已生成');component.detail.set({objectId:'old-month'} as never);const event=new Event('click');spyOn(event,'stopPropagation');component.toggleMonthPicker(event);expect(component.monthPickerOpen()).toBeTrue();expect(component.monthPickerYear()).toBe(2026);component.shiftMonthPickerYear(1,event);component.selectMonth(0);expect(component.month()).toBe('2027-01');expect(component.monthLabel()).toBe('2027年1月');expect(component.monthPickerOpen()).toBeFalse();expect(component.message()).toBe('');expect(component.detail()).toBeNull(); }); it('retains adjustment request identity after a lost response and sends batch version',async()=>{ component.detail.set({objectId:'batch',version:3,status:'draft'} as never);component.adjustmentCoachId.set(30);component.adjustmentAmount.set(10);component.adjustmentReason.set('补计费用');service.mutate.and.rejectWith(new Error('网络中断')); await component.addAdjustment();await component.addAdjustment();const calls=service.mutate.calls.allArgs();expect(calls.length).toBe(2);expect(calls[0][5]).toBe(calls[1][5]);expect(calls[0][3]).toEqual(jasmine.objectContaining({version:3,amount:10,coachId:30})); }); it('does not submit an adjustment twice while saving',async()=>{ component.saving.set(true);await component.addAdjustment();expect(service.mutate).not.toHaveBeenCalled(); }); it('loads batch pages independently of income and teacher filters',async()=>{ await component.loadBatchPage(2);expect(service.payrolls).toHaveBeenCalledWith(2,20,'');component.allBatchMonths.set(false);component.month.set('2026-08');await component.loadBatchPage(1);expect(service.payrolls).toHaveBeenCalledWith(1,20,'2026-08'); }); it('keeps successful regions when a secondary request fails',async()=>{ service.teachingStandards.and.rejectWith(new Error('标准服务暂不可用')); await component.load();expect(component.overview()?.month).toBe('2026-09');expect(component.error()).toContain('费用标准');expect(component.error()).toContain('保留上次成功结果');expect(component.loading()).toBeFalse(); }); it('explains missing writing evidence before submitting a mapping',async()=>{ const row={appointmentId:'appointment',appointmentGeneralId:1,studentName:'灵灵',courseName:'阅读写作',startsAt:'2026-09-02 19:30',grade:'',categoryKey:'primary_writing',suggestedCategoryKey:'primary_writing',evidence:'',updatedAt:'2026-09-02T12:00:00.000Z'}; await component.saveWritingMapping(row); expect(service.mutate).not.toHaveBeenCalled(); expect(component.error()).toContain('请填写“灵灵”的核对依据'); }); it('submits a writing mapping once its category and evidence are complete',async()=>{ const row={appointmentId:'appointment',appointmentGeneralId:1,studentName:'灵灵',courseName:'阅读写作',startsAt:'2026-09-02 19:30',grade:'',categoryKey:'primary_writing',suggestedCategoryKey:'primary_writing',evidence:' 已核实为小学 ',updatedAt:'2026-09-02T12:00:00.000Z'}; service.mutate.and.resolveTo({updated:true}); await component.saveWritingMapping(row); expect(service.mutate).toHaveBeenCalledWith('ops/course-mappings/student-commit','appointment','按学员记录核对语法写作分类',{categoryKey:'primary_writing',evidence:'已核实为小学'},'2026-09-02T12:00:00.000Z'); expect(service.request).toHaveBeenCalledWith('ops/course-mappings/students',{month:component.month()}); expect(service.payrollOverview).toHaveBeenCalled(); expect(component.message()).toBe('该预约的学段分类已确认,费用统计已刷新'); }); it('ignores an older batch page response',async()=>{ let resolveOld!:(v:unknown)=>void;service.payrolls.and.returnValues(new Promise(r=>resolveOld=r) as never,Promise.resolve({items:[],total:50,page:3,pageSize:20})); const old=component.loadBatchPage(2);await component.loadBatchPage(3);resolveOld({items:[],total:50,page:2,pageSize:20});await old;expect(component.page()?.page).toBe(3); }); it('does not create a payroll draft when the legacy source is not settled',async()=>{ component.overview.set(overview('2026-09')); await component.create();expect(service.mutate).not.toHaveBeenCalled();expect(component.error()).toContain('旧系统数据尚未追平'); }); });