operations-schedule.component.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. import { matchesScheduleSearch, scheduleCoachBusy, scheduleCourseAvailable, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
  2. import { ScheduleAppointment } from '../admin.models';
  3. import { scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
  4. describe('排课搜索匹配', () => {
  5. it('支持学员姓名和课程名称的模糊匹配', () => {
  6. expect(matchesScheduleSearch('宇昊', ['万宇昊', '135****5177'])).toBeTrue();
  7. expect(matchesScheduleSearch('pep', ['人教版(PEP)三年级起2022年录'])).toBeTrue();
  8. });
  9. it('支持使用完整手机号匹配脱敏手机号', () => {
  10. expect(matchesScheduleSearch('13576045177', ['135****5177'])).toBeTrue();
  11. expect(matchesScheduleSearch('13876045177', ['135****5177'])).toBeFalse();
  12. });
  13. it('支持用户或课程数字 ID 搜索', () => {
  14. expect(matchesScheduleSearch('2048', ['会员甲', 2048])).toBeTrue();
  15. expect(matchesScheduleSearch('1009', ['自然拼读', 1008])).toBeFalse();
  16. });
  17. });
  18. describe('排课状态视觉分类', () => {
  19. const now = Date.parse('2026-08-20T10:00:00+08:00');
  20. it('将已经结束但仍待开始的预约统一识别为逾期未完成', () => {
  21. const item = { cancelled: false, statusCode: 0, statusLabel: '待开始', endsAt: '2026-08-20T01:00:00.000Z' };
  22. expect(scheduleStatusTone(item, now)).toBe('status-overdue');
  23. expect(scheduleStatusLabel(item, now)).toBe('逾期未完成');
  24. });
  25. it('已开始但超过结束时间的记录使用同一个逾期状态', () => {
  26. const item = { cancelled: false, statusCode: 10, statusLabel: '上课中', endsAt: '2026-08-20T01:00:00.000Z' };
  27. expect(scheduleStatusTone(item, now)).toBe('status-overdue');
  28. expect(scheduleStatusLabel(item, now)).toBe('逾期未完成');
  29. });
  30. it('按原系统状态链区分陪练结束、学员确认和最终完课', () => {
  31. expect(scheduleStatusTone({ cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z' }, now)).toBe('status-upcoming');
  32. expect(scheduleStatusTone({ cancelled: false, statusCode: 10, endsAt: '2026-08-20T03:00:00.000Z' }, now)).toBe('status-live');
  33. expect(scheduleStatusTone({ cancelled: false, statusCode: 11, endsAt: '' }, now)).toBe('status-coach-ended');
  34. expect(scheduleStatusLabel({ cancelled: false, statusCode: 11, statusLabel: '已完成', endsAt: '' }, now)).toBe('陪练已结束');
  35. expect(scheduleStatusTone({ cancelled: false, statusCode: 20, endsAt: '' }, now)).toBe('status-confirmed');
  36. expect(scheduleStatusLabel({ cancelled: false, statusCode: 20, statusLabel: '已确认', endsAt: '' }, now)).toBe('学员已确认');
  37. expect(scheduleStatusTone({ cancelled: false, statusCode: 30, endsAt: '' }, now)).toBe('status-completed');
  38. expect(scheduleStatusLabel({ cancelled: false, statusCode: 30, statusLabel: '已归档', endsAt: '' }, now)).toBe('已完课');
  39. expect(scheduleStatusTone({ cancelled: false, statusCode: 40, endsAt: '' }, now)).toBe('status-archived');
  40. expect(scheduleStatusLabel({ cancelled: false, statusCode: 40, statusLabel: '已点评', endsAt: '' }, now)).toBe('已归档');
  41. expect(scheduleStatusTone({ cancelled: true, statusCode: 0, endsAt: '' }, now)).toBe('status-cancelled');
  42. });
  43. it('支持运营人员按业务状态和待分配异常筛选', () => {
  44. const upcoming = { cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z', coachId: 88, coachAccountMissing: false };
  45. expect(scheduleMatchesStatus(upcoming, 'upcoming', now)).toBeTrue();
  46. expect(scheduleMatchesStatus(upcoming, 'overdue', now)).toBeFalse();
  47. expect(scheduleMatchesStatus({ ...upcoming, coachId: 0 }, 'unassigned', now)).toBeTrue();
  48. expect(scheduleMatchesStatus({ ...upcoming, coachAccountMissing: true }, 'unassigned', now)).toBeTrue();
  49. });
  50. });
  51. describe('排课运营操作职责', () => {
  52. it('待开始课程只能修改或取消,不能在后台直接伪造完课', () => {
  53. expect(scheduleRowActions({ cancelled: false, statusCode: 0, readOnly: false })).toEqual({
  54. canEdit: true, canCancel: true, canRestore: false, canComplete: false, completeLabel: '',
  55. });
  56. });
  57. it('只在上课中或陪练已结束时提供补录入口', () => {
  58. expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录结束' }));
  59. expect(scheduleRowActions({ cancelled: false, statusCode: 11, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录上课记录' }));
  60. expect(scheduleRowActions({ cancelled: false, statusCode: 20, readOnly: false }).canComplete).toBeFalse();
  61. expect(scheduleRowActions({ cancelled: false, statusCode: 30, readOnly: false }).canComplete).toBeFalse();
  62. });
  63. it('实时旧系统记录保持只读', () => {
  64. expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: true })).toEqual({
  65. canEdit: false, canCancel: false, canRestore: false, canComplete: false, completeLabel: '',
  66. });
  67. });
  68. });
  69. describe('会员课程归属', () => {
  70. it('只允许选择当前会员已经绑定的课程', () => {
  71. const course = { memberIds: [101, 202], availableToAll: false };
  72. expect(scheduleCourseAvailable(course, 101)).toBeTrue();
  73. expect(scheduleCourseAvailable(course, 303)).toBeFalse();
  74. expect(scheduleCourseAvailable(course, 0)).toBeFalse();
  75. });
  76. it('保留原系统写作课程 398 的全员可选兼容规则', () => {
  77. expect(scheduleCourseAvailable({ memberIds: [], availableToAll: true }, 303)).toBeTrue();
  78. });
  79. });
  80. describe('时间优先陪练候选', () => {
  81. const appointments = [{
  82. targetId: 'appointment-1', coachId: 88, cancelled: false, statusCode: 0,
  83. startsAt: '2026-09-02T02:00:00.000Z', endsAt: '2026-09-02T03:00:00.000Z',
  84. }] as ScheduleAppointment[];
  85. it('按真实起止区间识别跨课型重叠', () => {
  86. expect(scheduleCoachBusy(appointments, 88, '2026-09-02T10:30:00+08:00', 30)).toBeTrue();
  87. expect(scheduleCoachBusy(appointments, 88, '2026-09-02T11:00:00+08:00', 60)).toBeFalse();
  88. });
  89. it('忽略取消、归档和当前编辑记录', () => {
  90. expect(scheduleCoachBusy(appointments, 88, '2026-09-02T10:00:00+08:00', 60, 'appointment-1')).toBeFalse();
  91. expect(scheduleCoachBusy([{ ...appointments[0], cancelled: true }] as never[], 88, '2026-09-02T10:00:00+08:00', 60)).toBeFalse();
  92. expect(scheduleCoachBusy([{ ...appointments[0], statusCode: 30 }] as never[], 88, '2026-09-02T10:00:00+08:00', 60)).toBeFalse();
  93. });
  94. });
  95. describe('批量排课逐项预检', () => {
  96. it('按课次去重冲突原因并保留可提交项', () => {
  97. const results = schedulePreflightCandidates({
  98. candidates: [
  99. { index: 0, startsAt: '2026-09-01T02:00:00.000Z', endsAt: '2026-09-01T03:00:00.000Z' },
  100. { index: 1, startsAt: '2026-09-08T02:00:00.000Z', endsAt: '2026-09-08T03:00:00.000Z' },
  101. ],
  102. conflicts: [
  103. { index: 0, reason: '会员时间冲突' },
  104. { index: 0, reason: '陪练时间冲突' },
  105. { index: 0, reason: '会员时间冲突' },
  106. ],
  107. });
  108. expect(results[0]).toEqual(jasmine.objectContaining({ index: 0, available: false, reasons: ['会员时间冲突', '陪练时间冲突'] }));
  109. expect(results[1]).toEqual(jasmine.objectContaining({ index: 1, available: true, reasons: [] }));
  110. });
  111. });