home-learning-modules.integration.spec.ts 118 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844
  1. import { provideHttpClient } from '@angular/common/http';
  2. import { signal } from '@angular/core';
  3. import { TestBed } from '@angular/core/testing';
  4. import { By } from '@angular/platform-browser';
  5. import { provideRouter, Router } from '@angular/router';
  6. import { RouterTestingHarness } from '@angular/router/testing';
  7. import { Observable, of, Subject, throwError } from 'rxjs';
  8. import { CourseCenterPageComponent } from '../features/course-center/course-center-page.component';
  9. import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
  10. import { LearningPageComponent } from '../features/learning/learning-page.component';
  11. import { StudyRecordsPageComponent } from '../features/study-records/study-records-page.component';
  12. import { HomePageComponent } from '../features/home/home-page.component';
  13. import { routes } from '../app.routes';
  14. import { ApiService } from './api.service';
  15. import { ApiEnvelope, AppUser } from './models';
  16. import { PlatformService } from './platform.service';
  17. import { SessionService } from './session.service';
  18. class LearningModulesApiService {
  19. lastScheduleParams: Record<string, unknown> = {};
  20. lastNodeParams: Record<string, unknown> = {};
  21. lastAppointmentCourseParams: Record<string, unknown> = {};
  22. lastWordParams: Record<string, unknown> = {};
  23. lastLearningWordUpdate: Record<string, unknown> = {};
  24. lastMemoryParams: Record<string, unknown> = {};
  25. lastStandaloneLearningRecord: Record<string, unknown> = {};
  26. appointmentStatus = 0;
  27. historyWordAudio = '';
  28. completionRequests: Record<string, unknown>[] = [];
  29. completionResponse = new Subject<ApiEnvelope<unknown>>();
  30. lastAppointmentRecordParams: Record<string, unknown> = {};
  31. studyUpdates: Record<string, unknown>[] = [];
  32. studyBatches: any[] = [];
  33. lastStudyQuery: Record<string, unknown> = {};
  34. reviewPageResponse: Subject<ApiEnvelope<unknown>> | null = null;
  35. unitRows: Record<string, unknown>[] = [
  36. { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
  37. { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
  38. ];
  39. get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
  40. if (action === 'app_account_overview') return this.post<T>(action, params);
  41. if (action === 'content_list' && params['orders'] === 'NEWID()') {
  42. return this.result<T>(Array.from({length: 8}, (_, i) => ({GeneralID: 9000 + i, Title: `external-${i}`, sy: `外部释义 ${i}`})));
  43. }
  44. if (action === 'content_list' && Number(params['ModelID'] ?? params['modelId']) === 56 && String(params['myfield2']).includes('dsid=')) {
  45. this.lastAppointmentRecordParams = params;
  46. if (!String(params['myfield2']).includes('|pl=300')) return throwError(() => new Error('无权查看该学员的内容记录'));
  47. return this.result<T>([{ GeneralID: 9901 }]);
  48. }
  49. if (action === 'app_lesson_study') {
  50. this.lastStudyQuery = params;
  51. return this.result<T>({record: {learned: this.studyBatches.filter(b => b.status === 'confirmed').reduce((n, b) => n + b.words.length, 0)}, batches: this.studyBatches, pending: this.studyBatches.filter(b => b.status === 'draft').length});
  52. }
  53. if (action === 'e_record_detail') {
  54. return this.result<T>([{ GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果', check: 0 }]);
  55. }
  56. if (action === 'app_lesson_courses') { this.lastAppointmentCourseParams=params; return this.result<T>([{bindingId:'binding-801',kcid:801,Title:'七年级上册',courseName:'七年级上册',syjd:'{}'},{bindingId:'binding-802',kcid:802,Title:'阅读训练',courseName:'阅读训练'}]); }
  57. if (action === 'e_order_detail') {
  58. return this.result<T>([{
  59. GeneralID: 901,
  60. szyh: 100,
  61. HoneyName: '张雅茜',
  62. kcid: 801, bindingId:'binding-801',
  63. yykcmc: '七年级上册',
  64. dszt: this.appointmentStatus,
  65. updatedAt: "2026-10-11T00:00:00Z",
  66. allowedActions: this.appointmentStatus===0?["start"]:this.appointmentStatus===10?["continue","complete"]:this.appointmentStatus===30?[]:["complete"],
  67. reviewStatus: "pending",
  68. dslx: 2,
  69. bxrq: '2026-08-10',
  70. sdsd: '19:00',
  71. jffs: '线上',
  72. }]);
  73. }
  74. if (action === 'content_list' && Number(params['nid']) === 291) {
  75. return this.result<T>([
  76. { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) },
  77. { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', con: '高中英语@Unit 2' },
  78. ]);
  79. }
  80. if (action === 'content_list' && Number(params['nid']) === 388) {
  81. return this.result<T>([
  82. { GeneralID: 100, Title: '张雅茜@历史任务', kywsj: '2026-05-15 09:00', fxzt: 1, yhid: 100, xxjlid: 900 },
  83. { GeneralID: 101, Title: '张雅茜@任务', kywsj: '2026-08-01 09:00', fxzt: 1, yhid: 100, xxjlid: 900 },
  84. { GeneralID: 102, Title: '张雅茜@任务', kywsj: '2026-08-02 09:00', fxzt: 0, yhid: 100, xxjlid: 900 },
  85. ]);
  86. }
  87. if (action === 'content_list' && Number(params['nid']) === 3) {
  88. return this.result<T>([{ GeneralID: 301, Title: '阅读理解专项' }]);
  89. }
  90. if (action === 'content_list' && Number(params['nid']) === 389) {
  91. return this.result<T>([{ GeneralID: 401, df: 18, totalScore: 48, dontKnow: 20, wrong: 10 }]);
  92. }
  93. if (action === 'content_list' && Number(params['nid']) === 28) {
  94. this.lastAppointmentCourseParams = params;
  95. return this.result<T>([
  96. { GeneralID: 801, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
  97. { GeneralID: 802, kcid: 801, Title2: '【初中英语】阅读训练' },
  98. ]);
  99. }
  100. if (action === 'content_list' && Number(params['ModelID']) === 58) {
  101. return this.result<T>([{ GeneralID: 701, yhid: 100, kcid: 801, syjd: '{}' }]);
  102. }
  103. if (action === 'content_list' && Number(params['nid']) === 29) {
  104. this.lastScheduleParams = params;
  105. return this.result<T>([
  106. { GeneralID: 901, bxrq: '2026-08-10 00:00:00', sdsd: '19:00', Title: '同步伴学', plxm: '张老师', dszt: 10 },
  107. { GeneralID: 902, bxrq: '2026-08-03 00:00:00', sdsd: '18:00', Title: '历史伴学', plxm: '李老师', dszt: 30 },
  108. ]);
  109. }
  110. if (action === 'node_list' && Number(params['pid']) === 8) {
  111. return this.result<T>([{ NodeID: 801, NodeName: '小学人教版词库' }]);
  112. }
  113. if (action === 'node_list' && Number(params['pid']) === 801) {
  114. this.lastNodeParams = params;
  115. return this.result<T>(this.unitRows);
  116. }
  117. if (action === 'e_ck_list') {
  118. this.lastWordParams = params;
  119. const allWords = Number(params['nids']) === 813
  120. ? Array.from({ length: 65 }, (_, index) => ({ GeneralID: 2001 + index, NodeID: 813, Title: `word-${index + 1}`, sy: `释义 ${index + 1}` }))
  121. : [
  122. { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: 'n. 苹果', yp: 'apple.mp3', w_learned: 1 },
  123. { GeneralID: 1002, NodeID: 812, Title: 'banana', sy: 'n. 香蕉', yp: 'banana.mp3', w_learned: 0 },
  124. ];
  125. const pageIndex = Math.max(1, Number(params['cpage'] ?? 1));
  126. const pageSize = Math.max(1, Number(params['psize'] ?? 30));
  127. const result = allWords.slice((pageIndex - 1) * pageSize, pageIndex * pageSize);
  128. return of({
  129. retcode: 1,
  130. result: result as T,
  131. page: { itemCount: allWords.length, pageCount: Math.ceil(allWords.length / pageSize), pageIndex, pageSize },
  132. });
  133. }
  134. if (action === 'node_get') return this.result<T>([{ NodeID: 3, NodeName: '专项训练' }]);
  135. if (action === 'vote_ask') return this.result<T>([{ ID: 1, Title: '第一组问题' }]);
  136. if (action === 'vote_question') {
  137. return this.result<T>([{ ID: 501, AskID: 1, QType: 1, QTitle: 'apple', QContent: '苹果', QOption: JSON.stringify([{ text: '苹果', value: 1 }]) }]);
  138. }
  139. if (action === 'e_words_list') {
  140. return this.result<T>([{
  141. GeneralID: 601,
  142. Title: 'tend@张雅茜',
  143. detail: [{ GeneralID: 701, Title: 'tend', sy: 'vt.照顾;照料', yp: 'tend.mp3' }],
  144. }]);
  145. }
  146. return this.result<T>([]);
  147. }
  148. post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
  149. if (action === 'stu_record_update_v2') {
  150. this.studyUpdates.push(params);
  151. const batchId=String(params['batchId']), words=JSON.parse(String(params['addon'])).xxqs;
  152. const version=Number(params['expectedVersion']||0)+1;
  153. this.studyBatches=this.studyBatches.filter(b=>b.batchId!==batchId).concat({batchId,words,status:params['saveMode']==='confirm'?'confirmed':'draft',version,bindingId:'binding-801'});
  154. return this.result<T>({ GeneralID: 9901, batchVersion:version, learned:new Set(this.studyBatches.filter(b=>b.status==='confirmed').flatMap(b=>b.words.map((w:any)=>w.GeneralID))).size });
  155. }
  156. if (action === 'app_lesson_complete') {
  157. this.completionRequests.push(params);
  158. return this.completionResponse as Observable<ApiEnvelope<T>>;
  159. }
  160. if (action === 'e_add_words') {
  161. this.lastLearningWordUpdate = params;
  162. return this.result<T>({ affected: String(params['wordsId'] ?? '').split(',').filter(Boolean).length });
  163. }
  164. if (action === 'content_add_zt') {
  165. this.lastStandaloneLearningRecord = params;
  166. return this.result<T>({ GeneralID: 9901 });
  167. }
  168. if (action === 'e_order_tongji') {
  169. return this.result<T>({ t30: 4, t60: 6, t_tiyan: 1, t_total: 11, t_shichang: 9, t_yongji: 320 });
  170. }
  171. if (action === 'e_user_list') {
  172. return this.result<T>([{ szyh: 100, honeyname: '张雅茜' }]);
  173. }
  174. if (action === 'app_learning_overview') {
  175. const records = Number(params['studentId']) === 777
  176. ? Array.from({ length: 45 }, (_, index) => ({
  177. GeneralID: 5000 + index,
  178. CreateTime: `2026-08-${String(31 - (index % 20)).padStart(2, '0')} 09:30:00`,
  179. dqrq: '20260801',
  180. con: `分页学习记录 ${index + 1}`,
  181. }))
  182. : [
  183. { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', dqrq: '20260801', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]), readingArticleId: 'readingarticle201', readingCompletedAt: '2026-08-01T03:00:00.000Z' },
  184. { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', dqrq: '20260804', con: '高中英语@Unit 2', xxqs: JSON.stringify([{ Title: 'banana' }]) },
  185. ];
  186. const selected = String(params['date'] ?? '');
  187. return this.result<T>({
  188. records: (selected ? records.filter((item) => item.CreateTime.startsWith(selected)) : records).slice((Number(params['page']||1)-1)*20,Number(params['page']||1)*20),
  189. page:{itemCount:selected?records.filter(item=>item.CreateTime.startsWith(selected)).length:records.length,pageCount:Math.ceil(records.length/20)},
  190. latestReadingRecord: records[0],
  191. recordDateCounts: { '2026-08-01': 1, '2026-08-04': 1 },
  192. scheduleDateCounts: {},
  193. schedules: [],
  194. courses: [
  195. { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
  196. { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' },
  197. ],
  198. refreshedAt: '2026-08-26T00:00:00.000Z',
  199. });
  200. }
  201. if (action === 'app_companion_overview') {
  202. this.lastScheduleParams = params;
  203. const schedules = [
  204. { GeneralID: 901, bxrq: '2026-08-10 00:00:00', sdsd: '19:00', Title: '同步伴学', plxm: '张老师', dszt: 10 },
  205. { GeneralID: 902, bxrq: '2026-08-03 00:00:00', sdsd: '18:00', Title: '历史伴学', plxm: '李老师', dszt: 30 },
  206. { GeneralID: 903, bxrq: '2099-01-01 00:00:00', sdsd: '18:00', Title: '未来计划', plxm: '王老师', dszt: 0 },
  207. ];
  208. const historySchedules = [
  209. { GeneralID: 911, bxrq: '2026-07-14 00:00:00', sdsd: '22:00', Title: '谢晨晨(60分钟练习)', plxm: '张老师', dszt: 30 },
  210. { GeneralID: 912, bxrq: '2026-07-14 00:00:00', sdsd: '17:00', Title: '谢谢(60分钟练习)', plxm: '张老师', dszt: 30 },
  211. { GeneralID: 913, bxrq: '2026-03-09 00:00:00', sdsd: '10:00', Title: '花花(60分钟练习)', plxm: '张老师', dszt: 30 },
  212. { GeneralID: 914, bxrq: '2026-01-18 00:00:00', sdsd: '16:30', Title: '小赵(60分钟练习)', plxm: '张老师', dszt: 30 },
  213. { GeneralID: 915, bxrq: '2026-01-18 00:00:00', sdsd: '16:00', Title: '小黄(60分钟练习)', plxm: '张老师', dszt: 30 },
  214. ];
  215. const rangeStart = String(params['rangeStart'] ?? '');
  216. const rangeEnd = String(params['rangeEnd'] ?? '');
  217. return this.result<T>({
  218. courses: [
  219. { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
  220. { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' },
  221. ],
  222. dates:(params['history']?historySchedules:schedules).map(item=>({date:item.bxrq.slice(0,10),finished:item.dszt>=30?1:0,planned:item.dszt>=30?0:1})),
  223. schedules: params['history']
  224. ? historySchedules
  225. : rangeStart && rangeEnd
  226. ? schedules.filter((item) => item.bxrq.slice(0, 10) >= rangeStart && item.bxrq.slice(0, 10) <= rangeEnd)
  227. : schedules,
  228. refreshedAt: '2026-08-26T00:00:00.000Z',
  229. });
  230. }
  231. if (action === 'app_account_overview') {
  232. if (Number(params['uid']) === 300) {
  233. return this.result<T>({
  234. profile: { userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 },
  235. coachPanel: { todaySchedules: 2, completedLessons: 8, reviewIncome: 36, reviewRecords: 3, totalLessons: 11, totalHours: 9, course30: 4, course60: 6, trialLessons: 1 },
  236. refreshedAt: '2026-08-26T00:00:00.000Z',
  237. });
  238. }
  239. return this.result<T>({
  240. profile: { userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 },
  241. companionSummary: { completedSessions: 1, completedMinutes: 60 },
  242. antiForgetSummary: { total: 3, completed: 2, progress: 67, todayTotal: 0, todayCompleted: 0, duePending: 1, scheduled: 0, scheduledDays: 3 },
  243. refreshedAt: '2026-08-26T00:00:00.000Z',
  244. });
  245. }
  246. if (action === 'app_review_page') {
  247. this.lastMemoryParams=params;
  248. if (this.reviewPageResponse) return this.reviewPageResponse as Observable<ApiEnvelope<T>>;
  249. const items=params['scope']==='today' ? [{ GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: String(params['date'])+' 09:00', fxzt: 0, yhid: 100, xxjlid: 900 }] : [
  250. { GeneralID: 100, Title: '张雅茜@历史任务', kc_title: '历史复习', kywsj: '2026-05-15 09:00', learned_at: '2026-05-01T01:15:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
  251. { GeneralID: 101, Title: '张雅茜@任务', kc_title: '初中英语复习', kywsj: '2026-08-01 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
  252. { GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: '2026-08-02 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 0, yhid: 100, xxjlid: 900 },
  253. ...(Number(params['shouru']) === 1
  254. ? [{ GeneralID: 103, Title: '李同学@任务', kc_title: '小学英语复习', kywsj: '2026-08-02 10:00', learned_at: '2026-07-21T02:20:00.000Z', fxzt: 0, yhid: 101, xxjlid: 901 }]
  255. : []),
  256. ];return this.result<T>({items,page:{itemCount:items.length,pageCount:1},summary:{total:items.length,completed:items.filter(i=>i.fxzt===1).length,due:items.filter(i=>i.fxzt!==1).length,today:0,income:0},dates:items.map(i=>({date:i.kywsj.slice(0,10),total:1,completed:i.fxzt===1?1:0}))});
  257. }
  258. if (action === 'e_get_21list') {
  259. this.lastMemoryParams = params;
  260. return this.result<T>([
  261. { GeneralID: 100, Title: '张雅茜@历史任务', kc_title: '历史复习', kywsj: '2026-05-15 09:00', learned_at: '2026-05-01T01:15:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
  262. { GeneralID: 101, Title: '张雅茜@任务', kc_title: '初中英语复习', kywsj: '2026-08-01 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
  263. { GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: '2026-08-02 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 0, yhid: 100, xxjlid: 900 },
  264. ...(Number(params['shouru']) === 1
  265. ? [{ GeneralID: 103, Title: '李同学@任务', kc_title: '小学英语复习', kywsj: '2026-08-02 10:00', learned_at: '2026-07-21T02:20:00.000Z', fxzt: 0, yhid: 101, xxjlid: 901 }]
  266. : []),
  267. ]);
  268. }
  269. if (action === 'e_record_detail') {
  270. return of({
  271. retcode: 1,
  272. result: [{ GeneralID: 701, Title: 'apple', detail: [{ GeneralID: 701, Title: 'apple', sy: '苹果', yp: this.historyWordAudio }] }],
  273. addon: [{ if_writing: '0', if_writing_tips: '先回忆单词,再查看释义' }],
  274. } as unknown as ApiEnvelope<T>);
  275. }
  276. return this.result<T>([]);
  277. }
  278. assetUrl(path: unknown): string { return String(path ?? ''); }
  279. private result<T>(result: unknown): Observable<ApiEnvelope<T>> {
  280. return of({ retcode: 1, result: result as T });
  281. }
  282. }
  283. describe('home learning modules', () => {
  284. const user = signal<AppUser | null>({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
  285. const selectedStudent = signal<AppUser | null>(null);
  286. const session = {
  287. user,
  288. token: signal('test-token'),
  289. student: selectedStudent,
  290. isLoggedIn: signal(true),
  291. groupId: signal(1),
  292. setStudent: (student: AppUser | null) => selectedStudent.set(student),
  293. patchUser: () => undefined,
  294. logout: () => undefined,
  295. };
  296. beforeEach(() => {
  297. sessionStorage.removeItem('xiaoshu.learning.teacherTodayReview');
  298. localStorage.removeItem('xiaoshu.learning.homeDaily.v1');
  299. spyOn(window, 'confirm').and.returnValue(true);
  300. // Route tests do not depend on remote audio availability or its error-toast timer.
  301. spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve());
  302. localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.skip_spelling_stage');
  303. user.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
  304. session.groupId.set(1);
  305. session.student.set(null);
  306. TestBed.configureTestingModule({
  307. providers: [
  308. provideRouter(routes),
  309. provideHttpClient(),
  310. { provide: ApiService, useClass: LearningModulesApiService },
  311. { provide: SessionService, useValue: session },
  312. ],
  313. });
  314. });
  315. it('keeps all today tasks cached across navigation and visibility', async () => {
  316. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  317. session.groupId.set(3);
  318. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  319. const originalPost = api.post.bind(api);
  320. const post = spyOn(api, 'post').and.callFake((action, params) => {
  321. if (action !== 'app_review_page') return originalPost(action, params);
  322. return of({ retcode: 1, result: {
  323. items: Array.from({length: 7}, (_, i) => ({ GeneralID: 100+i, yhid: 100, Title: '今日学员', kywsj: params?.['date'], fxzt: i === 6 ? 1 : 0 })),
  324. page: { itemCount: 7, pageCount: 1 }, summary: { total: 7, completed: 1, due: 6, today: 7, income: 0 }, dates: [],
  325. } }) as never;
  326. });
  327. const count = () => post.calls.allArgs().filter(([name]) => name === 'app_review_page').length;
  328. const harness = await RouterTestingHarness.create();
  329. await harness.navigateByUrl('/pages/home/index');
  330. harness.fixture.detectChanges();
  331. expect(harness.routeNativeElement?.querySelectorAll('.coach-memory-list article').length).toBe(7);
  332. expect(harness.routeNativeElement?.querySelectorAll('.coach-memory-footer').length).toBe(1);
  333. expect(harness.routeNativeElement?.textContent).not.toContain('查看今日全部任务');
  334. document.dispatchEvent(new Event('visibilitychange'));
  335. await harness.navigateByUrl('/pages/member/starshop');
  336. await harness.navigateByUrl('/pages/home/index');
  337. harness.fixture.detectChanges();
  338. expect(count()).toBe(1);
  339. expect(harness.routeNativeElement?.querySelector('.coach-memory-state')).toBeNull();
  340. (harness.routeNativeElement?.querySelector('.coach-memory-refresh') as HTMLButtonElement).click();
  341. harness.fixture.detectChanges();
  342. expect(count()).toBe(2);
  343. });
  344. for (const groupId of [1, 3]) {
  345. it(`caches homepage overview for role ${groupId} until an explicit refresh`, async () => {
  346. user.set({ userId: groupId === 3 ? 300 : 100, groupId });
  347. session.groupId.set(groupId);
  348. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  349. const post = spyOn(api, 'post').and.callThrough();
  350. const get = spyOn(api, 'get').and.callThrough();
  351. const count = (action: string) => (action === 'app_account_overview' ? get.calls.allArgs() : post.calls.allArgs()).filter(([name]) => name === action).length;
  352. const harness = await RouterTestingHarness.create();
  353. await harness.navigateByUrl('/pages/home/index');
  354. const first = { stats: count('e_order_tongji'), review: count('app_review_page'), balances: count('app_account_overview') };
  355. document.dispatchEvent(new Event('visibilitychange'));
  356. await harness.navigateByUrl('/pages/member/starshop');
  357. await harness.navigateByUrl('/pages/home/index');
  358. harness.fixture.detectChanges();
  359. expect(count('e_order_tongji')).toBe(first.stats);
  360. expect(count('app_review_page')).toBe(first.review);
  361. expect(count('app_account_overview')).toBe(first.balances);
  362. expect(harness.routeNativeElement?.textContent).not.toContain('正在同步学习计划');
  363. const button = harness.routeNativeElement?.querySelector(groupId === 3 ? '.coach-hero .coach-refresh' : '.student-home-refresh') as HTMLButtonElement;
  364. button.click();
  365. harness.fixture.detectChanges();
  366. expect(count('e_order_tongji')).toBe(first.stats + 1);
  367. expect(count('app_review_page')).toBe(first.review + (groupId === 1 ? 1 : 0));
  368. expect(count('app_account_overview')).toBe(first.balances + (groupId === 1 ? 1 : 0));
  369. });
  370. }
  371. it('loads the review calendar once per login and refreshes only on an explicit request', async () => {
  372. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  373. const originalPost = api.post.bind(api);
  374. const post = spyOn(api, 'post').and.callFake((action, params) => originalPost(action, params));
  375. const count = (action: string) => post.calls.allArgs().filter(([name]) => name === action).length;
  376. const harness = await RouterTestingHarness.create();
  377. await harness.navigateByUrl('/pages/stu/stu_calendar');
  378. harness.fixture.detectChanges();
  379. expect(count('app_review_page')).toBe(1);
  380. expect(count('app_live_versions')).toBe(0);
  381. await harness.navigateByUrl('/pages/member/starshop');
  382. await harness.navigateByUrl('/pages/stu/stu_calendar');
  383. harness.fixture.detectChanges();
  384. expect(count('app_review_page')).toBe(1);
  385. const calendar = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  386. calendar.retryCalendar();
  387. harness.fixture.detectChanges();
  388. expect(count('app_review_page')).toBe(2);
  389. await harness.navigateByUrl('/pages/member/starshop');
  390. session.token.set('next-login-token');
  391. await harness.navigateByUrl('/pages/stu/stu_calendar');
  392. harness.fixture.detectChanges();
  393. expect(count('app_review_page')).toBe(3);
  394. session.token.set('test-token');
  395. });
  396. it('silently reveals pending and completed words opened from student learning content', async () => {
  397. const play = spyOn(TestBed.inject(PlatformService), 'play');
  398. const harness = await RouterTestingHarness.create();
  399. await harness.navigateByUrl('/pages/his/his');
  400. (harness.routeNativeElement?.querySelector('.current-learning-list button') as HTMLButtonElement).click();
  401. await harness.fixture.whenStable();
  402. harness.fixture.detectChanges();
  403. (harness.routeNativeElement?.querySelector('.node-list button') as HTMLButtonElement).click();
  404. await harness.fixture.whenStable();
  405. harness.fixture.detectChanges();
  406. expect(TestBed.inject(Router).url).toContain('readonly=1');
  407. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  408. for (const [index, meaning] of ['n. 香蕉', 'n. 苹果'].entries()) {
  409. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[index] as HTMLButtonElement).click();
  410. harness.fixture.detectChanges();
  411. expect(harness.routeNativeElement?.querySelector('.audio')).toBeNull();
  412. expect(harness.routeNativeElement?.textContent).not.toContain('读音');
  413. const word = harness.routeNativeElement?.querySelector('.word-reveal') as HTMLButtonElement;
  414. word.click();
  415. harness.fixture.detectChanges();
  416. expect(word.textContent).toContain(meaning);
  417. expect(word.getAttribute('aria-expanded')).toBe('true');
  418. word.click();
  419. harness.fixture.detectChanges();
  420. expect(word.getAttribute('aria-expanded')).toBe('false');
  421. }
  422. learning.words().forEach((word) => learning.play(word));
  423. expect(play).not.toHaveBeenCalled();
  424. });
  425. it('pages through all words in a history record without requesting a course unit', async () => {
  426. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  427. const originalPost = api.post.bind(api);
  428. const post = spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
  429. ? of({ retcode: 1, result: Array.from({ length: 31 }, (_, index) => ({
  430. GeneralID: 700 + index,
  431. Title: `history-${index + 1}`,
  432. detail: [{ GeneralID: 700 + index, Title: `history-${index + 1}`, sy: `释义 ${index + 1}` }],
  433. })), addon: [] } as ApiEnvelope<any>)
  434. : originalPost(action, params));
  435. const get = spyOn(api, 'get').and.callThrough();
  436. const harness = await RouterTestingHarness.create();
  437. await harness.navigateByUrl('/pages/his/his_words?id=201');
  438. harness.fixture.detectChanges();
  439. const list = () => harness.routeNativeElement?.querySelectorAll('.readonly-word-list article') ?? [];
  440. expect(list().length).toBe(30);
  441. expect(harness.routeNativeElement?.textContent).toContain('共 31 个单词');
  442. (harness.routeNativeElement?.querySelector('.word-preview-pagination .page-direction:last-of-type') as HTMLButtonElement).click();
  443. await harness.fixture.whenStable();
  444. harness.fixture.detectChanges();
  445. expect(list().length).toBe(1);
  446. expect(list()[0].textContent).toContain('history-31');
  447. expect(harness.routeNativeElement?.textContent).toContain('2 / 2 页');
  448. expect(get.calls.allArgs().some(([action]) => action === 'e_ck_list')).toBeFalse();
  449. expect(post.calls.allArgs().filter(([action]) => action === 'e_record_detail').length).toBe(1);
  450. (harness.routeNativeElement?.querySelector('.word-preview-pagination .page-direction:first-child') as HTMLButtonElement).click();
  451. await harness.fixture.whenStable();
  452. harness.fixture.detectChanges();
  453. expect(list().length).toBe(30);
  454. expect(list()[0].textContent).toContain('history-1');
  455. });
  456. it('plays student history words on click without changing learning records or enabling unit preview audio', async () => {
  457. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  458. api.historyWordAudio = 'apple.mp3';
  459. const post = spyOn(api, 'post').and.callThrough();
  460. const platform = TestBed.inject(PlatformService);
  461. const play = spyOn(platform, 'play');
  462. const stopAudio = spyOn(platform, 'stopAudio');
  463. const harness = await RouterTestingHarness.create();
  464. await harness.navigateByUrl('/pages/his/his');
  465. (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click();
  466. await harness.fixture.whenStable();
  467. harness.fixture.detectChanges();
  468. expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
  469. expect(play).not.toHaveBeenCalled();
  470. post.calls.reset();
  471. const button = harness.routeNativeElement?.querySelector('.history-word-audio') as HTMLButtonElement;
  472. expect(button.disabled).toBeFalse();
  473. expect(button.getAttribute('aria-label')).toBe('播放单词读音:apple');
  474. button.querySelector('strong')!.click();
  475. (button.querySelector('lucide-icon') as HTMLElement).click();
  476. expect(play).toHaveBeenCalledTimes(2);
  477. expect(play.calls.mostRecent().args[0]).toContain('apple.mp3');
  478. expect(post).not.toHaveBeenCalled();
  479. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  480. learning.words.update((words) => words.map((word) => ({ ...word, audio: '' })));
  481. harness.fixture.detectChanges();
  482. expect(button.disabled).toBeTrue();
  483. expect(harness.routeNativeElement?.textContent).toContain('暂无音频');
  484. button.click();
  485. expect(play).toHaveBeenCalledTimes(2);
  486. stopAudio.calls.reset();
  487. await harness.navigateByUrl('/pages/home/index');
  488. expect(stopAudio).toHaveBeenCalled();
  489. await harness.navigateByUrl('/pages/stu/stu_con?nid=811&readonly=1');
  490. expect(harness.routeNativeElement?.querySelector('.history-word-audio')).toBeNull();
  491. const preview = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  492. preview.words().forEach((word) => preview.play(word));
  493. expect(play).toHaveBeenCalledTimes(2);
  494. });
  495. it('keeps the spoken word among listening choices and discards answers from an earlier question', async () => {
  496. const prefix = 'xiaoshu.learning.100.100.practice.';
  497. const selectedWords = [
  498. { GeneralID: 1001, Title: 'apple', sy: '苹果', yp: 'apple.mp3' },
  499. { GeneralID: 1002, Title: 'banana', sy: '香蕉', yp: 'banana.mp3' },
  500. ];
  501. localStorage.setItem(prefix + 'learn_mode', 'newword');
  502. localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify(selectedWords));
  503. localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([selectedWords]));
  504. localStorage.setItem(prefix + '_page_current_learn', '1');
  505. const responses = [new Subject<ApiEnvelope<any>>(), new Subject<ApiEnvelope<any>>()];
  506. let requestCount = 0;
  507. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  508. const get = api.get.bind(api);
  509. spyOn(api, 'get').and.callFake((action, params) => action === 'content_list' && params?.['orders'] === 'NEWID()'
  510. ? responses[requestCount++] ?? of({ retcode: 1, result: [] } as ApiEnvelope<any>)
  511. : get(action, params));
  512. spyOn(TestBed.inject(PlatformService), 'play');
  513. const harness = await RouterTestingHarness.create();
  514. await harness.navigateByUrl('/pages/stu/stu4');
  515. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  516. const firstWord = learning.current()!;
  517. const firstResponse = responses[0];
  518. expect(requestCount).toBe(1);
  519. expect(learning.choicesLoading()).toBeTrue();
  520. learning.chooseMeaning(firstWord);
  521. expect(learning.current()).toBe(firstWord); // Cannot answer while options are loading.
  522. firstResponse?.next({ retcode: 1, result: Array.from({ length: 8 }, (_, index) => ({
  523. GeneralID: 301 + index, Title: `extra-${index}`, sy: `干扰项 ${index}`,
  524. })) });
  525. expect(learning.choiceOptions().length).toBe(4);
  526. expect(learning.choiceOptions().filter(option => Number(option.id) >= 301).length).toBeGreaterThanOrEqual(2);
  527. learning.chooseMeaning(firstWord);
  528. const secondWord = learning.current()!;
  529. expect(secondWord.id).not.toBe(firstWord.id);
  530. expect(requestCount).toBe(1); // Reuse the pool instead of requesting each question.
  531. firstResponse?.next({ retcode: 1, result: [{ GeneralID: 999, Title: 'stale', sy: '迟到结果' }] });
  532. expect(learning.choiceOptions().length).toBe(4);
  533. expect(learning.choiceOptions().some(option => option.id === '999')).toBeFalse();
  534. expect(learning.choiceOptions().some(option => option.id === secondWord.id)).toBeTrue();
  535. localStorage.removeItem(prefix + 'learn_mode');
  536. localStorage.removeItem(prefix + 'choose_newword_learn');
  537. localStorage.removeItem(prefix + '_page_word_orilist');
  538. localStorage.removeItem(prefix + '_page_current_learn');
  539. });
  540. it('lets a student study selected words from their wordbook while keeping course stages teacher-led', async () => {
  541. localStorage.removeItem('xiaoshu.learning.100.100.practice.learn_mode');
  542. localStorage.removeItem('xiaoshu.learning.100.100.practice.choose_newword_learn');
  543. const harness = await RouterTestingHarness.create();
  544. const router = TestBed.inject(Router);
  545. await harness.navigateByUrl('/pages/member/starshop');
  546. (harness.routeNativeElement?.querySelector('.word-select-list > button') as HTMLButtonElement).click();
  547. (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  548. await harness.fixture.whenStable();
  549. harness.fixture.detectChanges();
  550. const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  551. expect(router.url).toContain('/pages/stu/stu_con');
  552. expect(learning().words().map((word) => word.word)).toEqual(['tend']);
  553. learning().skipStudyList();
  554. await harness.fixture.whenStable();
  555. harness.fixture.detectChanges();
  556. expect(router.url).toContain('/pages/stu/stu3');
  557. expect(learning().words().map((word) => word.word)).toEqual(['tend']);
  558. learning().chooseMatch('left', learning().words()[0]);
  559. learning().chooseMatch('right', learning().words()[0]);
  560. learning().continueAfterStage();
  561. await harness.fixture.whenStable();
  562. harness.fixture.detectChanges();
  563. expect(router.url).toContain('/pages/stu/stu4');
  564. expect(learning().words().map((word) => word.word)).toEqual(['tend']);
  565. learning().chooseMeaning(learning().words()[0]);
  566. learning().continueAfterStage();
  567. await harness.fixture.whenStable();
  568. harness.fixture.detectChanges();
  569. expect(router.url).toContain('/pages/stu/stu5');
  570. for (const letter of learning().current()!.word) {
  571. const index = learning().letters().findIndex((choice) => !choice.used && choice.value === letter);
  572. expect(index).toBeGreaterThanOrEqual(0);
  573. learning().spellLetter(index);
  574. }
  575. learning().nextSpellingWord();
  576. learning().continueAfterStage();
  577. await harness.fixture.whenStable();
  578. expect(router.url).toBe('/pages/member/starshop');
  579. expect(localStorage.getItem('xiaoshu.learning.100.100.practice.learn_mode')).toBeNull();
  580. await harness.navigateByUrl('/pages/stu/stu_con?nids=811&pnid=801');
  581. await harness.fixture.whenStable();
  582. expect(router.url).toBe('/pages/home/cart');
  583. });
  584. it('separates unlearned and learned wordbook words while keeping selections across tabs', async () => {
  585. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  586. const get = api.get.bind(api);
  587. spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
  588. ? of({ retcode: 1, result: params?.['learningStatus'] === 'completed'
  589. ? [{ GeneralID: 602, Title: 'prepare@张雅茜', xxcs: 2, detail: [{ GeneralID: 702, Title: 'prepare', sy: '准备' }] }]
  590. : [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 1, detail: [{ GeneralID: 701, Title: 'tend', sy: '照顾' }] }],
  591. page: { itemCount: 1, pendingCount: 1, completedCount: 1, pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope<any>)
  592. : get(action, params));
  593. const harness = await RouterTestingHarness.create();
  594. await harness.navigateByUrl('/pages/member/starshop');
  595. harness.fixture.detectChanges();
  596. const tabs = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.word-selection-tabs button') ?? [];
  597. expect(tabs[0].textContent).toContain('未学习');
  598. expect(tabs[0].textContent).toContain('1');
  599. expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('tend');
  600. (harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button') as HTMLButtonElement).click();
  601. tabs[1].click();
  602. harness.fixture.detectChanges();
  603. expect(tabs[1].textContent).toContain('已学习');
  604. expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('prepare');
  605. (harness.routeNativeElement?.querySelector('.completed-column .word-select-list > button') as HTMLButtonElement).click();
  606. harness.fixture.detectChanges();
  607. expect(harness.routeNativeElement?.querySelector('.selection-header strong')?.textContent).toContain('已选 2 个单词');
  608. tabs[0].click();
  609. harness.fixture.detectChanges();
  610. expect(harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button.selected')).not.toBeNull();
  611. });
  612. it('pronounces a wordbook word on row click and only once on speaker click', async () => {
  613. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  614. const get = api.get.bind(api);
  615. spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
  616. ? of({ retcode: 1, result: [{ GeneralID: 601, xxcs: 1, detail: [{ GeneralID: 701, Title: 'tend', yp: 'UploadFiles/tend.mp3' }] }],
  617. page: { itemCount: 1, pageCount: 1, pageIndex: 1, pageSize: Number(params?.['psize']) } } as ApiEnvelope<any>)
  618. : get(action, params));
  619. const play = spyOn(TestBed.inject(PlatformService), 'play');
  620. const harness = await RouterTestingHarness.create();
  621. await harness.navigateByUrl('/pages/member/starshop');
  622. harness.fixture.detectChanges();
  623. const row = harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button') as HTMLButtonElement;
  624. row.querySelector('strong')!.click();
  625. harness.fixture.detectChanges();
  626. expect(row.classList.contains('selected')).toBeTrue();
  627. expect(play).toHaveBeenCalledTimes(1);
  628. expect(play.calls.mostRecent().args[0]).toBe('UploadFiles/tend.mp3');
  629. expect(play.calls.mostRecent().args[0]).not.toContain('UploadFiles/UploadFiles');
  630. (row.querySelector('.audio') as HTMLElement).click();
  631. harness.fixture.detectChanges();
  632. expect(play).toHaveBeenCalledTimes(2);
  633. expect(row.classList.contains('selected')).toBeTrue();
  634. });
  635. it('shows 30 wordbook words per page while counting every learning status', async () => {
  636. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  637. const get = api.get.bind(api);
  638. const pending = Array.from({ length: 65 }, (_, index) => ({ GeneralID: 601 + index, xxcs: 1, detail: [{ GeneralID: 701 + index, Title: `pending-${index + 1}` }] }));
  639. const completed = Array.from({ length: 7 }, (_, index) => ({ GeneralID: 801 + index, xxcs: 2, detail: [{ GeneralID: 901 + index, Title: `completed-${index + 1}` }] }));
  640. const requests: Record<string, unknown>[] = [];
  641. spyOn(api, 'get').and.callFake((action, params) => {
  642. if (action !== 'e_words_list') return get(action, params);
  643. requests.push(params ?? {});
  644. const rows = params?.['learningStatus'] === 'completed' ? completed : pending;
  645. const page = Number(params?.['cpage']);
  646. const size = Number(params?.['psize']);
  647. return of({ retcode: 1, result: rows.slice((page - 1) * size, page * size),
  648. page: { itemCount: rows.length, pendingCount: pending.length, completedCount: completed.length,
  649. pageCount: Math.ceil(rows.length / size), pageIndex: page, pageSize: size } } as ApiEnvelope<any>);
  650. });
  651. const harness = await RouterTestingHarness.create();
  652. await harness.navigateByUrl('/pages/member/starshop');
  653. harness.fixture.detectChanges();
  654. const view = () => harness.routeNativeElement!;
  655. const rows = () => view().querySelectorAll('.pending-column .word-select-list > button');
  656. expect(requests[0]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', cpage: 1, psize: 30 }));
  657. expect(rows().length).toBe(30);
  658. expect(view().querySelectorAll('.word-selection-tabs b')[0].textContent).toContain('65');
  659. expect(view().querySelectorAll('.word-selection-tabs b')[1].textContent).toContain('7');
  660. (rows()[0] as HTMLButtonElement).click();
  661. (view().querySelectorAll('.mobile-pagination button')[1] as HTMLButtonElement).click();
  662. harness.fixture.detectChanges();
  663. expect(requests[1]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', cpage: 2, psize: 30 }));
  664. expect(rows().length).toBe(30);
  665. expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 2 / 3 页');
  666. (view().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  667. harness.fixture.detectChanges();
  668. expect(requests[2]).toEqual(jasmine.objectContaining({ learningStatus: 'completed', cpage: 1, psize: 30 }));
  669. expect(view().querySelectorAll('.completed-column .word-select-list > button').length).toBe(7);
  670. expect(view().querySelector('.selection-header strong')?.textContent).toContain('已选 1 个单词');
  671. });
  672. it('keeps wordbook counts and rows correct when the server only returns the old mixed list', async () => {
  673. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  674. const get = api.get.bind(api);
  675. const all = Array.from({ length: 139 }, (_, index) => ({
  676. GeneralID: 601 + index,
  677. xxcs: 1,
  678. detail: [{ GeneralID: 701 + index, Title: `word-${index + 1}` }],
  679. }));
  680. const requests: Record<string, unknown>[] = [];
  681. spyOn(api, 'get').and.callFake((action, params) => {
  682. if (action !== 'e_words_list') return get(action, params);
  683. requests.push(params ?? {});
  684. const page = Number(params?.['cpage']);
  685. const size = Number(params?.['psize']);
  686. return of({ retcode: 1, result: all.slice((page - 1) * size, page * size),
  687. page: { itemCount: all.length, pageCount: Math.ceil(all.length / size), pageIndex: page, pageSize: size } } as ApiEnvelope<any>);
  688. });
  689. const harness = await RouterTestingHarness.create();
  690. await harness.navigateByUrl('/pages/member/starshop');
  691. harness.fixture.detectChanges();
  692. const view = () => harness.routeNativeElement!;
  693. expect(requests.length).toBe(2);
  694. expect(requests[0]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', psize: 30 }));
  695. expect(requests[1]).toEqual(jasmine.objectContaining({ cpage: 1, psize: 500 }));
  696. expect(view().querySelectorAll('.word-selection-tabs b')[0].textContent).toContain('139');
  697. expect(view().querySelectorAll('.word-selection-tabs b')[1].textContent).toContain('0');
  698. expect(view().querySelectorAll('.pending-column .word-select-list > button').length).toBe(30);
  699. expect(view().querySelector('.pending-column .word-select-list > button')?.textContent).toContain('word-1');
  700. expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 1 / 5 页 · 共 139 个单词');
  701. (view().querySelectorAll('.mobile-pagination button')[1] as HTMLButtonElement).click();
  702. harness.fixture.detectChanges();
  703. expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 2 / 5 页');
  704. (view().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  705. harness.fixture.detectChanges();
  706. expect(view().querySelectorAll('.completed-column .word-select-list > button').length).toBe(0);
  707. expect(requests.length).toBe(2);
  708. });
  709. it('saves completed wordbook study before showing the word in the learned tab', async () => {
  710. const prefix = 'xiaoshu.learning.100.100.practice.';
  711. const word = { GeneralID: 701, Title: 'tend', sy: '照顾' };
  712. localStorage.setItem(prefix + 'learn_mode', JSON.stringify('newword'));
  713. localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify([word]));
  714. localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([[word]]));
  715. localStorage.setItem(prefix + '_page_current_learn', JSON.stringify(1));
  716. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  717. const get = api.get.bind(api);
  718. spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
  719. ? of({ retcode: 1, result: api.lastLearningWordUpdate['save'] === 1 && params?.['learningStatus'] === 'completed'
  720. ? [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 2, detail: [word] }]
  721. : api.lastLearningWordUpdate['save'] === 1 ? [] : [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 1, detail: [word] }],
  722. page: { itemCount: params?.['learningStatus'] === 'completed' ? 1 : api.lastLearningWordUpdate['save'] === 1 ? 0 : 1,
  723. pendingCount: api.lastLearningWordUpdate['save'] === 1 ? 0 : 1, completedCount: api.lastLearningWordUpdate['save'] === 1 ? 1 : 0,
  724. pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope<any>)
  725. : get(action, params));
  726. const harness = await RouterTestingHarness.create();
  727. const router = TestBed.inject(Router);
  728. await harness.navigateByUrl('/pages/stu/stu5');
  729. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  730. learning.skipSpellingStage();
  731. await harness.fixture.whenStable();
  732. harness.fixture.detectChanges();
  733. expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({ uid: 100, wordsId: '701', save: 1 }));
  734. expect(router.url).toBe('/pages/member/starshop');
  735. const tabs = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.word-selection-tabs button') ?? [];
  736. expect(tabs[0].textContent).toContain('0');
  737. tabs[1].click();
  738. harness.fixture.detectChanges();
  739. expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('tend');
  740. }, 15000);
  741. it('skips the entire spelling stage for student wordbook self-study in one click', async () => {
  742. const prefix = 'xiaoshu.learning.100.100.practice.';
  743. const words = [
  744. { GeneralID: 701, Title: 'tend', sy: '照顾' },
  745. { GeneralID: 702, Title: 'work on', sy: '从事' },
  746. { GeneralID: 703, Title: 'prepare', sy: '准备' },
  747. ];
  748. localStorage.setItem(prefix + 'learn_mode', JSON.stringify('newword'));
  749. localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify(words.slice(0, 2)));
  750. localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([words.slice(0, 2), words.slice(2)]));
  751. localStorage.setItem(prefix + '_page_current_learn', JSON.stringify(1));
  752. const harness = await RouterTestingHarness.create();
  753. const router = TestBed.inject(Router);
  754. await harness.navigateByUrl('/pages/stu/stu5');
  755. harness.fixture.detectChanges();
  756. const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  757. expect(learning().words().length).toBe(2);
  758. expect(learning().currentIndex()).toBe(0);
  759. const skip = Array.from(harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.skippable-spelling-actions button') ?? [])
  760. .find((button) => button.textContent?.includes('跳到下一关'))!;
  761. skip.click();
  762. await harness.fixture.whenStable();
  763. harness.fixture.detectChanges();
  764. expect(router.url).toContain('/pages/stu/stu_con');
  765. expect(learning().words().map((word) => word.word)).toEqual(['prepare']);
  766. learning().skipStudyList();
  767. await harness.fixture.whenStable();
  768. harness.fixture.detectChanges();
  769. learning().chooseMatch('left', learning().words()[0]);
  770. learning().chooseMatch('right', learning().words()[0]);
  771. learning().continueAfterStage();
  772. await harness.fixture.whenStable();
  773. harness.fixture.detectChanges();
  774. expect(router.url).toContain('/pages/stu/stu4');
  775. learning().chooseMeaning(learning().words()[0]);
  776. learning().continueAfterStage();
  777. await harness.fixture.whenStable();
  778. expect(router.url).toBe('/pages/member/starshop');
  779. expect(localStorage.getItem(prefix + 'learn_mode')).toBeNull();
  780. }, 15000);
  781. it('still pronounces pending and completed words when a coach reveals them', async () => {
  782. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  783. session.groupId.set(3);
  784. session.student.set({ userId: 100, userName: 'student', groupId: 1 });
  785. const play = spyOn(TestBed.inject(PlatformService), 'play');
  786. const harness = await RouterTestingHarness.create();
  787. await harness.navigateByUrl('/pages/stu/stu_abc?nids=811,812&pnid=801');
  788. for (const index of [0, 1]) {
  789. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[index] as HTMLButtonElement).click();
  790. harness.fixture.detectChanges();
  791. expect(harness.routeNativeElement?.querySelector('.audio')).not.toBeNull();
  792. (harness.routeNativeElement?.querySelector('.word-reveal') as HTMLButtonElement).click();
  793. }
  794. expect(play).toHaveBeenCalledTimes(2);
  795. expect(play.calls.argsFor(0)[0]).toContain('banana.mp3');
  796. expect(play.calls.argsFor(1)[0]).toContain('apple.mp3');
  797. });
  798. it('shows named schedule students below the trigger and closes after selection or Escape', async () => {
  799. user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3});
  800. const harness=await RouterTestingHarness.create();
  801. await harness.navigateByUrl('/pages/home/shop');
  802. const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
  803. component.serverStudents.set([{id:'100',name:'张雅茜',count:15},{id:'101',name:'花花',count:9},{id:'102',name:' ',count:1}]);
  804. spyOn(component,'load').and.stub();
  805. harness.fixture.detectChanges();
  806. await new Promise(resolve => setTimeout(resolve, 0));
  807. const trigger=harness.routeNativeElement!.querySelector<HTMLButtonElement>('.student-filter .xs-dropdown-trigger')!;
  808. expect(trigger.textContent).toContain('全部学员(25)');
  809. trigger.click();harness.fixture.detectChanges();
  810. const menu=document.querySelector<HTMLElement>('.xs-dropdown-menu')!;
  811. expect(menu.textContent).toContain('张雅茜(15)');
  812. expect(menu.textContent).toContain('学员 102(1)');
  813. expect(menu.getBoundingClientRect().top).toBeGreaterThan(trigger.getBoundingClientRect().bottom);
  814. menu.querySelectorAll('button')[2].click();harness.fixture.detectChanges();
  815. await new Promise(resolve => setTimeout(resolve, 0));
  816. expect(component.selectedStudent()).toBe('101');
  817. expect(trigger.textContent).toContain('花花(9)');
  818. expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
  819. trigger.click();harness.fixture.detectChanges();
  820. document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));harness.fixture.detectChanges();
  821. expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
  822. });
  823. it('keeps the anti-forget page visible while a selected date loads locally', async () => {
  824. const harness = await RouterTestingHarness.create();
  825. await harness.navigateByUrl('/pages/stu/stu_calendar');
  826. await harness.fixture.whenStable();
  827. harness.fixture.detectChanges();
  828. const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  829. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  830. component.memoryMonth.set(new Date(2026, 8, 1, 12));
  831. component.memorySelectedDate.set('2026-09-11');
  832. harness.fixture.detectChanges();
  833. const response = new Subject<ApiEnvelope<unknown>>();
  834. api.reviewPageResponse = response;
  835. (harness.routeNativeElement?.querySelector('[aria-label^="2026-09-12"]') as HTMLButtonElement).click();
  836. harness.fixture.detectChanges();
  837. expect(api.lastMemoryParams['date']).toBe('2026-09-12');
  838. expect(harness.routeNativeElement?.querySelector('.memory-overview')).not.toBeNull();
  839. expect(harness.routeNativeElement?.querySelector('.memory-calendar')).not.toBeNull();
  840. expect(harness.routeNativeElement?.querySelector('.memory-list-loading')?.textContent).toContain('正在加载当日复习');
  841. response.next({
  842. retcode: 1,
  843. result: {
  844. items: [{ GeneralID: 120, Title: '张雅茜@任务', kc_title: '9月12日复习', kywsj: '2026-09-12 09:00', fxzt: 0, yhid: 100, xxjlid: 920 }],
  845. page: { itemCount: 1, pageCount: 1 },
  846. summary: { total: 1, completed: 0, due: 0, today: 0, income: 0 },
  847. dates: [{ date: '2026-09-12', total: 1, completed: 0 }],
  848. },
  849. });
  850. response.complete();
  851. api.reviewPageResponse = null;
  852. await harness.fixture.whenStable();
  853. harness.fixture.detectChanges();
  854. expect(harness.routeNativeElement?.querySelector('.memory-list-loading')).toBeNull();
  855. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('9月12日复习');
  856. });
  857. it('shows a retryable error instead of an empty review when the detail has no words', async () => {
  858. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  859. const post = api.post.bind(api);
  860. spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
  861. ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : post(action, params));
  862. const harness = await RouterTestingHarness.create();
  863. await harness.navigateByUrl('/pages/stu/record?id=900&gid=102&kcid=801');
  864. harness.fixture.detectChanges();
  865. expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('没有读取到单词');
  866. expect(harness.routeNativeElement?.querySelector('.empty-state.error button')?.textContent).toContain('重新加载');
  867. expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
  868. });
  869. it('syncs a missing learning record and reopens its review words', async () => {
  870. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  871. const post = api.post.bind(api);
  872. let detailReads = 0;
  873. spyOn(api, 'post').and.callFake((action, params) => {
  874. if (action === 'e_record_detail') {
  875. detailReads += 1;
  876. return of({ retcode: 1, result: detailReads === 1 ? [] : [{ GeneralID: 701, Title: 'apple', sy: '苹果' }], addon: [] } as ApiEnvelope<any>);
  877. }
  878. if (action === 'app_learning_overview') return of({ retcode: 1, result: {} } as ApiEnvelope<any>);
  879. return post(action, params);
  880. });
  881. const harness = await RouterTestingHarness.create();
  882. await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
  883. harness.fixture.detectChanges();
  884. expect(detailReads).toBe(2);
  885. expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
  886. expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
  887. });
  888. it('reads the original word list when the detail stays empty after syncing', async () => {
  889. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  890. const post = api.post.bind(api);
  891. const get = api.get.bind(api);
  892. spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
  893. ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
  894. ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
  895. spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
  896. ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: JSON.stringify([{ GeneralID: 701, Title: 'apple' }]) }] } as ApiEnvelope<any>)
  897. : get(action, params));
  898. const harness = await RouterTestingHarness.create();
  899. await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
  900. harness.fixture.detectChanges();
  901. expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
  902. expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
  903. });
  904. it('identifies a legacy writing review with no original words and keeps it incomplete', async () => {
  905. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  906. const post = api.post.bind(api);
  907. const get = api.get.bind(api);
  908. spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
  909. ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
  910. ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
  911. spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
  912. ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: '', learned: 0, con: '完成了写作学习' }] } as ApiEnvelope<any>)
  913. : get(action, params));
  914. const harness = await RouterTestingHarness.create();
  915. await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
  916. harness.fixture.detectChanges();
  917. expect(harness.routeNativeElement?.textContent).toContain('写作学习记录,原本没有单词');
  918. expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
  919. expect(harness.routeNativeElement?.querySelector('a[href="/pages/stu/stu_calendar"]')).not.toBeNull();
  920. expect(harness.routeNativeElement?.textContent).not.toContain('结束本次复习');
  921. });
  922. it('renders source-shaped data for all four home entries', async () => {
  923. const harness = await RouterTestingHarness.create();
  924. await harness.navigateByUrl('/pages/home/index');
  925. expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull();
  926. expect(harness.routeNativeElement?.querySelector('.home-header [aria-label="搜索"]')).toBeNull();
  927. expect(harness.routeNativeElement?.querySelector('.home-header [aria-label="用户中心"]')).toBeNull();
  928. expect(harness.routeNativeElement?.querySelector('.memory-hero')).not.toBeNull();
  929. expect(harness.routeNativeElement?.querySelectorAll('.memory-dot').length).toBe(21);
  930. expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).toContain('学习新词');
  931. expect(harness.routeNativeElement?.querySelectorAll('.student-stats > div').length).toBe(5);
  932. expect(harness.routeNativeElement?.querySelectorAll('.quick-grid > a').length).toBe(4);
  933. expect(harness.routeNativeElement?.querySelector('.continue-button')?.getAttribute('href')).toBe('/pages/stu/stu_calendar');
  934. expect(harness.routeNativeElement?.querySelector('.task-link')?.getAttribute('href')).toBe('/pages/home/cart');
  935. await harness.navigateByUrl('/pages/stu/index');
  936. await harness.fixture.whenStable();
  937. harness.fixture.detectChanges();
  938. expect(TestBed.inject(Router).url).toBe('/pages/home/cart');
  939. expect(harness.routeNativeElement?.textContent).not.toContain('选择词库教材');
  940. await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
  941. await harness.fixture.whenStable();
  942. harness.fixture.detectChanges();
  943. expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
  944. expect(harness.routeNativeElement?.querySelector('.node-list button')?.textContent).toContain('已学 1/20 · 进度 5%');
  945. expect(harness.routeNativeElement?.querySelectorAll('.unit-progress-ring').length).toBe(2);
  946. expect(harness.routeNativeElement?.querySelector('.unit-progress-ring')?.textContent).toContain('5%');
  947. expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')).toBeNull();
  948. expect(harness.routeNativeElement?.querySelector('.unit-actions')).toBeNull();
  949. const firstUnit = harness.routeNativeElement?.querySelector('.node-list button') as HTMLButtonElement;
  950. firstUnit.click();
  951. await harness.fixture.whenStable();
  952. harness.fixture.detectChanges();
  953. const readonlyRouter = TestBed.inject(Router);
  954. expect(readonlyRouter.url).toBe('/pages/stu/stu_abc?nids=811&pnid=801&readonly=1');
  955. expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('词库学习进度');
  956. expect(harness.routeNativeElement?.querySelector('.selection-header .primary-button')).toBeNull();
  957. expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('banana');
  958. expect(harness.routeNativeElement?.querySelector('.word-select-check')).toBeNull();
  959. expect(harness.routeNativeElement?.querySelector('.word-assessment')).toBeNull();
  960. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  961. harness.fixture.detectChanges();
  962. expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('apple');
  963. expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
  964. await harness.navigateByUrl('/pages/home/index');
  965. await harness.navigateByUrl('/pages/stu/stu_con?nid=813&pnid=801&readonly=1');
  966. await harness.fixture.whenStable();
  967. harness.fixture.detectChanges();
  968. const wordApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  969. expect(wordApi.lastWordParams['psize']).toBe(30);
  970. expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(30);
  971. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 1–30 条 · 共 65 个单词');
  972. expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('1 / 3 页');
  973. (harness.routeNativeElement?.querySelector('[aria-label="第 2 页"]') as HTMLButtonElement).click();
  974. await harness.fixture.whenStable();
  975. harness.fixture.detectChanges();
  976. expect(wordApi.lastWordParams['cpage']).toBe(2);
  977. expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(30);
  978. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 31–60 条 · 共 65 个单词');
  979. expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('word-31');
  980. expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('2 / 3 页');
  981. await harness.navigateByUrl('/pages/stu/stu_list?nid=999&bindingId=799');
  982. await harness.fixture.whenStable();
  983. harness.fixture.detectChanges();
  984. expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_abc?nids=999&readonly=1');
  985. expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('词库学习进度');
  986. await harness.navigateByUrl('/pages/stu/stu_calendar');
  987. const calendarComponent=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  988. calendarComponent.memoryMonth.set(new Date(2026,7,1));
  989. calendarComponent.memorySelectedDate.set('2026-08-02');
  990. (calendarComponent as any).loadCalendar();
  991. harness.fixture.detectChanges();
  992. expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
  993. expect(harness.routeNativeElement?.querySelector('.memory-student-filter')).toBeNull();
  994. expect(harness.routeNativeElement?.querySelectorAll('.memory-date').length).toBe(42);
  995. expect(harness.routeNativeElement?.querySelectorAll('.memory-date.completed, .memory-date.due').length).toBe(2);
  996. expect(harness.routeNativeElement?.querySelector('.memory-overview')?.textContent).toContain('2 / 3');
  997. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('高中英语复习');
  998. expect(harness.routeNativeElement?.querySelector('.review-sequence')?.textContent).toContain('第 3 次复习');
  999. expect(harness.routeNativeElement?.querySelector('.first-learned-at')?.textContent).toContain('首次学习:2026-07-20 10:20');
  1000. for (let index = 0; index < 3; index += 1) {
  1001. (harness.routeNativeElement?.querySelector('[aria-label="上个月"]') as HTMLButtonElement).click();
  1002. harness.fixture.detectChanges();
  1003. }
  1004. expect(harness.routeNativeElement?.querySelector('[aria-label^="2026-05-15"]')?.classList).toContain('completed');
  1005. expect(harness.routeNativeElement?.textContent).toContain('张雅茜');
  1006. expect(harness.routeNativeElement?.querySelector('.memory-overview header b')?.textContent).toContain('67%');
  1007. expect(harness.routeNativeElement?.querySelector('.memory-stats')?.textContent).toContain('2 / 3');
  1008. await harness.navigateByUrl('/pages/home/ucenter');
  1009. expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull();
  1010. expect(harness.routeNativeElement?.querySelector('.new-word-entry')?.getAttribute('href')).toBe('/pages/home/cart');
  1011. expect(harness.routeNativeElement?.querySelector('.course-memory')?.textContent).toContain('21 天抗遗忘计划');
  1012. expect(harness.routeNativeElement?.querySelector('.memory-overview em')?.textContent).toContain('67%');
  1013. expect(harness.routeNativeElement?.querySelector('.memory-overview')?.textContent).toContain('覆盖 3 个复习日');
  1014. expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('3总任务');
  1015. expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('2已完成');
  1016. expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('1到期待复习');
  1017. expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('0未来待复习');
  1018. await harness.navigateByUrl('/pages/stu/record?id=827482&gid=828268&kcid=203');
  1019. const recordWord = harness.routeNativeElement?.querySelector('.record-word') as HTMLButtonElement;
  1020. expect(recordWord.querySelector('.record-copy strong')?.textContent).toBe('apple');
  1021. expect(harness.routeNativeElement?.textContent).not.toContain('苹果');
  1022. recordWord.click();
  1023. await harness.fixture.whenStable();
  1024. harness.fixture.detectChanges();
  1025. expect(recordWord.querySelector('.record-copy strong')?.textContent).toBe('苹果');
  1026. expect(harness.routeNativeElement?.querySelector('.record-overview')?.textContent).toContain('1 / 1');
  1027. await harness.navigateByUrl('/pages/member/starshop');
  1028. expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
  1029. expect(harness.routeNativeElement?.querySelector('.word-select-list strong')?.textContent).toBe('tend');
  1030. expect(harness.routeNativeElement?.querySelector('.word-select-list')?.textContent).not.toContain('@张雅茜');
  1031. (harness.routeNativeElement?.querySelector('.word-select-list > button') as HTMLButtonElement).click();
  1032. (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  1033. await harness.fixture.whenStable();
  1034. const selectedNewWords = JSON.parse(sessionStorage.getItem('xiaoshu.learning.100.100.practice.selectedWords') ?? '[]') as Array<{ word?: string }>;
  1035. expect(selectedNewWords[0]?.word).toBe('tend');
  1036. expect(sessionStorage.getItem('xiaoshu.learning.100.100.practice.selectedWords')).not.toContain('@张雅茜');
  1037. await harness.navigateByUrl('/pages/his/well?nid=3');
  1038. expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
  1039. expect(harness.routeNativeElement?.textContent).toContain('阅读理解专项');
  1040. await harness.navigateByUrl('/pages/home/cart');
  1041. expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull();
  1042. expect(harness.fixture.nativeElement.querySelector('.app-header .icon-button')).toBeNull();
  1043. expect(harness.fixture.nativeElement.querySelector('.app-header h1')?.textContent).toContain('伴学中心');
  1044. expect(harness.routeNativeElement?.querySelector('.library-entry')).toBeNull();
  1045. expect(harness.routeNativeElement?.textContent).toContain('我的学习内容');
  1046. expect(harness.routeNativeElement?.querySelectorAll('.course-list li').length).toBe(2);
  1047. expect(harness.routeNativeElement?.querySelectorAll('.course-list button').length).toBe(2);
  1048. expect((harness.routeNativeElement?.querySelector('.course-list button') as HTMLButtonElement).disabled).toBeFalse();
  1049. expect((harness.routeNativeElement?.querySelectorAll('.course-list button')[1] as HTMLButtonElement).disabled).toBeFalse();
  1050. expect(harness.routeNativeElement?.textContent).toContain('【七年级上册】同步词汇');
  1051. expect(harness.routeNativeElement?.textContent).toContain('点击查看老师分配的单词列表');
  1052. (harness.routeNativeElement?.querySelector('.course-list button') as HTMLButtonElement).click();
  1053. await harness.fixture.whenStable();
  1054. harness.fixture.detectChanges();
  1055. expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801&bindingId=701');
  1056. expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
  1057. expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastNodeParams['bindingId']).toBe('701');
  1058. await harness.navigateByUrl('/pages/home/cart');
  1059. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(0);
  1060. expect(harness.routeNativeElement?.querySelectorAll('.calendar-day').length).toBe(42);
  1061. const allDates = Array.from(harness.routeNativeElement?.querySelectorAll('.range-tabs button') ?? [])
  1062. .find((button) => button.textContent?.trim() === '全部') as HTMLButtonElement;
  1063. allDates.click();
  1064. harness.fixture.detectChanges();
  1065. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(3);
  1066. expect(harness.routeNativeElement?.textContent).toContain('同步伴学');
  1067. expect(harness.routeNativeElement?.textContent).toContain('历史伴学');
  1068. const plannedIncompleteFilter = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-filters button') ?? [])
  1069. .find((button) => button.textContent?.includes('计划未完成')) as HTMLButtonElement;
  1070. expect(plannedIncompleteFilter.textContent).toContain('1');
  1071. plannedIncompleteFilter.click();
  1072. harness.fixture.detectChanges();
  1073. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1);
  1074. expect(harness.routeNativeElement?.textContent).toContain('计划未完成');
  1075. expect(harness.routeNativeElement?.textContent).not.toContain('未来计划');
  1076. (harness.routeNativeElement?.querySelector('.schedule-filters button') as HTMLButtonElement).click();
  1077. harness.fixture.detectChanges();
  1078. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1079. expect(api.lastScheduleParams['mode']).toBe('student');
  1080. expect(api.lastScheduleParams['pageSize']).toBe(20);
  1081. const finishedFilter = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-filters button') ?? [])
  1082. .find((button) => button.textContent?.includes('已结束')) as HTMLButtonElement;
  1083. finishedFilter.click();
  1084. harness.fixture.detectChanges();
  1085. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1);
  1086. expect(harness.routeNativeElement?.textContent).toContain('历史伴学');
  1087. const allFilter = harness.routeNativeElement?.querySelector('.schedule-filters button') as HTMLButtonElement;
  1088. allFilter.click();
  1089. harness.fixture.detectChanges();
  1090. const upcomingSchedule = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-list article') ?? [])
  1091. .find((article) => article.textContent?.includes('同步伴学')) as HTMLElement;
  1092. upcomingSchedule.click();
  1093. await harness.fixture.whenStable();
  1094. expect(TestBed.inject(Router).url).toBe('/pages/shop/stu_get_order?id=901');
  1095. await harness.navigateByUrl('/pages/his/his');
  1096. expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
  1097. const aiReadingFeature = harness.routeNativeElement?.querySelector('.ai-reading-feature') as HTMLElement;
  1098. expect(aiReadingFeature.textContent).toContain('AI 阅读巩固');
  1099. expect(aiReadingFeature.textContent).toContain('生成阅读');
  1100. expect(aiReadingFeature.textContent).toContain('AI 批改');
  1101. expect(aiReadingFeature.querySelector('img')?.getAttribute('src')).toBe('/static/images/ai-reading-journey.png');
  1102. const aiReadingCta = aiReadingFeature.querySelector('.ai-reading-cta') as HTMLButtonElement;
  1103. expect(aiReadingCta.disabled).toBeFalse();
  1104. expect(aiReadingFeature.textContent).toContain('最近学过的 1 个单词');
  1105. expect(aiReadingCta.textContent).toContain('立即生成阅读');
  1106. aiReadingCta.click();
  1107. await harness.fixture.whenStable();
  1108. const aiReadingRouter = TestBed.inject(Router);
  1109. expect(aiReadingRouter.url).toContain('/words/go-reading');
  1110. expect(aiReadingRouter.parseUrl(aiReadingRouter.url).queryParams['returnUrl']).toBe('/pages/his/his');
  1111. expect(aiReadingRouter.parseUrl(aiReadingRouter.url).queryParams['GeneralID']).toBe('201');
  1112. await harness.navigateByUrl('/pages/his/his');
  1113. const currentLearningCard = harness.routeNativeElement?.querySelector('.current-learning-card') as HTMLElement;
  1114. const learningRecordCard = harness.routeNativeElement?.querySelector('.learning-record-card') as HTMLElement;
  1115. expect(currentLearningCard.textContent).toContain('我的学习内容');
  1116. const records = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
  1117. expect(records.courseTitle({ Title: '单词课|高中@人教版2022年录', Title2: '人教版2022年录' })).toBe('【高中】人教版2022年录');
  1118. expect(records.courseTitle({ Title: '单词课|初中@人教版2024年9月录', Title2: '人教版2024年9月录' })).toBe('【初中】人教版2024年9月录');
  1119. expect(currentLearningCard.querySelectorAll('.current-learning-list button').length).toBe(2);
  1120. expect((currentLearningCard.querySelectorAll('.current-learning-list button')[1] as HTMLButtonElement).disabled).toBeFalse();
  1121. expect(currentLearningCard.compareDocumentPosition(learningRecordCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  1122. (currentLearningCard.querySelector('.current-learning-list button') as HTMLButtonElement).click();
  1123. await harness.fixture.whenStable();
  1124. harness.fixture.detectChanges();
  1125. expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801&bindingId=701');
  1126. expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
  1127. await harness.navigateByUrl('/pages/his/his');
  1128. expect(harness.routeNativeElement?.textContent).toContain('初中英语 / Unit 1');
  1129. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
  1130. (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click();
  1131. await harness.fixture.whenStable();
  1132. harness.fixture.detectChanges();
  1133. expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
  1134. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('学生只读');
  1135. expect(harness.routeNativeElement?.querySelector('.readonly-word-card')).not.toBeNull();
  1136. expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull();
  1137. await harness.navigateByUrl('/pages/his/his');
  1138. (harness.routeNativeElement?.querySelector('.date-trigger') as HTMLButtonElement).click();
  1139. harness.fixture.detectChanges();
  1140. const currentMonth = new Date();
  1141. const monthDifference = (2026 - currentMonth.getFullYear()) * 12 + (7 - currentMonth.getMonth());
  1142. const shiftLabel = monthDifference < 0 ? '上个月' : '下个月';
  1143. for (let index = 0; index < Math.abs(monthDifference); index += 1) {
  1144. (harness.routeNativeElement?.querySelector(`[aria-label="${shiftLabel}"]`) as HTMLButtonElement).click();
  1145. harness.fixture.detectChanges();
  1146. }
  1147. expect(harness.routeNativeElement?.querySelector('.date-picker-popover header strong')?.textContent).toContain('2026年8月');
  1148. expect(harness.routeNativeElement?.querySelectorAll('.date-picker-day').length).toBe(42);
  1149. expect(harness.routeNativeElement?.querySelectorAll('.date-picker-day.has-record').length).toBe(2);
  1150. expect(harness.routeNativeElement?.querySelector('.date-picker-day.has-record')?.getAttribute('aria-label')).toContain('2026-08-01');
  1151. (harness.routeNativeElement?.querySelector('[aria-label^="2026-08-01"]') as HTMLButtonElement).click();
  1152. harness.fixture.detectChanges();
  1153. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(1);
  1154. expect(harness.routeNativeElement?.querySelector('.learning-record-card')?.textContent).not.toContain('高中英语 / Unit 2');
  1155. (harness.routeNativeElement?.querySelector('.record-actions button') as HTMLButtonElement).click();
  1156. harness.fixture.detectChanges();
  1157. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
  1158. const completedReading = harness.routeNativeElement?.querySelector('.reading-button.completed') as HTMLButtonElement;
  1159. expect(completedReading.textContent).toContain('已完成阅读');
  1160. expect(completedReading.getAttribute('aria-label')).toBe('查看已完成的历史阅读理解');
  1161. completedReading.click();
  1162. await harness.fixture.whenStable();
  1163. const router = TestBed.inject(Router);
  1164. expect(router.url).toContain('/words/reading');
  1165. expect(router.parseUrl(router.url).queryParams['sid']).toBe('readingarticle201');
  1166. expect(router.parseUrl(router.url).queryParams['returnUrl']).toBe('/pages/his/his');
  1167. await harness.navigateByUrl('/pages/his/his');
  1168. const newReading = harness.routeNativeElement?.querySelector('.reading-button:not(.completed)') as HTMLButtonElement;
  1169. expect(newReading.textContent).toContain('阅读');
  1170. newReading.click();
  1171. await harness.fixture.whenStable();
  1172. expect(router.url).toContain('/words/go-reading');
  1173. expect(router.parseUrl(router.url).queryParams['returnUrl']).toBe('/pages/his/his');
  1174. expect(harness.routeNativeElement?.querySelector('.back-button')).not.toBeNull();
  1175. (harness.routeNativeElement?.querySelector('.back-button') as HTMLButtonElement).click();
  1176. await harness.fixture.whenStable();
  1177. expect(router.url).toBe('/pages/his/his');
  1178. (harness.fixture.nativeElement.querySelector('.app-header .icon-button') as HTMLButtonElement).click();
  1179. await harness.fixture.whenStable();
  1180. expect(router.url).toBe('/pages/home/index');
  1181. await harness.navigateByUrl('/pages/his/exam');
  1182. expect(harness.routeNativeElement?.textContent).toContain('第一组问题');
  1183. expect(harness.routeNativeElement?.textContent).toContain('相当于初二水平');
  1184. expect(harness.routeNativeElement?.textContent).toContain('共 1 题');
  1185. const startButton = harness.routeNativeElement?.querySelector('.exam-intro > button') as HTMLButtonElement;
  1186. startButton.click();
  1187. harness.fixture.detectChanges();
  1188. expect(harness.routeNativeElement?.querySelector('.question-word')?.textContent).toContain('apple');
  1189. expect(harness.routeNativeElement?.querySelector('.question-card > h2')).toBeNull();
  1190. expect(harness.routeNativeElement?.querySelectorAll('.exam-options button').length).toBe(1);
  1191. (harness.routeNativeElement?.querySelector('.exam-options button') as HTMLButtonElement).click();
  1192. harness.fixture.detectChanges();
  1193. });
  1194. it('matches coach review tasks to student names by ID when task name fields are blank', async () => {
  1195. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1196. session.groupId.set(3);
  1197. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1198. const response = new Subject<ApiEnvelope<unknown>>();
  1199. api.reviewPageResponse = response;
  1200. const harness = await RouterTestingHarness.create();
  1201. await harness.navigateByUrl('/pages/home/index');
  1202. response.next({ retcode: 1, result: {
  1203. items: [
  1204. { GeneralID: 102, yhid: 100, honeyName: '', Title: '旧姓名@复习', kywsj: String(api.lastMemoryParams['date']), fxzt: 0, xxjlid: 900 },
  1205. { GeneralID: 103, yhid: '101', honeyName: ' ', HoneyName: '', kywsj: String(api.lastMemoryParams['date']), fxzt: 0, xxjlid: 901 },
  1206. ],
  1207. students: [{ id: '101', name: '李同学', taskCount: 1 }, { id: '100', name: '张雅茜', taskCount: 1 }],
  1208. page: { itemCount: 2, pageCount: 1 },
  1209. summary: { total: 2, completed: 0, due: 2, today: 0, income: 0 }, dates: [],
  1210. } });
  1211. response.complete();
  1212. api.reviewPageResponse = null;
  1213. harness.fixture.detectChanges();
  1214. const names = Array.from(harness.routeNativeElement?.querySelectorAll('.coach-memory-copy strong') ?? []).map((node) => node.textContent);
  1215. expect(names).toEqual(['张雅茜', '李同学']);
  1216. const home = harness.fixture.debugElement.query(By.directive(HomePageComponent)).componentInstance as HomePageComponent;
  1217. expect(home.coachReviewStudent({ yhid: 102, honeyName: '', HoneyName: ' ', Title: '王同学@复习' })).toBe('王同学');
  1218. expect(home.coachReviewStudent({ yhid: 103, honeyName: '', Title: ' ' })).toBe('学员 103');
  1219. expect(home.coachReviewStudent({ honeyName: '' })).toBe('未关联学员');
  1220. (harness.routeNativeElement?.querySelectorAll('.coach-memory-list article > button')[1] as HTMLButtonElement).click();
  1221. await harness.fixture.whenStable();
  1222. expect(session.student()).toEqual(jasmine.objectContaining({ userId: 101, honeyName: '李同学' }));
  1223. expect(TestBed.inject(Router).url).toContain('/pages/stu/record?id=901&gid=103');
  1224. });
  1225. it('keeps the complete point-reading flow available to coaches', async () => {
  1226. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1227. session.groupId.set(3);
  1228. const harness = await RouterTestingHarness.create();
  1229. await harness.navigateByUrl('/pages/home/index');
  1230. await harness.fixture.whenStable();
  1231. harness.fixture.detectChanges();
  1232. const coachLinks = Array.from(harness.routeNativeElement?.querySelectorAll('.coach-quick-grid > a') ?? []).map((link) => link.getAttribute('href'));
  1233. expect(coachLinks).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/ke_fuxi']);
  1234. expect(harness.routeNativeElement?.querySelector('.home-header .header-action')).toBeNull();
  1235. const coachAccountTab = (Array.from(harness.fixture.nativeElement.querySelectorAll('.tabbar a')) as HTMLAnchorElement[])
  1236. .find((link) => link.getAttribute('href') === '/pages/home/ucenter');
  1237. expect(coachAccountTab?.textContent).toContain('我的');
  1238. expect(harness.routeNativeElement?.querySelector('.content-section')).toBeNull();
  1239. const memoryApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1240. expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:100, scope:'today', includeMetadata:false, includeIncome:false }));
  1241. expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('抗遗忘带练');
  1242. expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('张雅茜');
  1243. expect(harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button')?.textContent).toContain('查看计划');
  1244. expect(harness.routeNativeElement?.querySelector('.coach-memory-footer')?.getAttribute('href')).toBe('/pages/stu/stu_calendar');
  1245. (harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button') as HTMLButtonElement).click();
  1246. await harness.fixture.whenStable();
  1247. expect(TestBed.inject(Router).url).toContain('/pages/stu/record?id=900&gid=102');
  1248. expect(session.student()?.userId).toBe(100);
  1249. await harness.navigateByUrl('/pages/stu/stu_calendar');
  1250. const calendarComponent=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1251. calendarComponent.memoryMonth.set(new Date(2026,7,1));
  1252. calendarComponent.memorySelectedDate.set('2026-08-02');
  1253. (calendarComponent as any).loadCalendar();
  1254. harness.fixture.detectChanges();
  1255. await harness.fixture.whenStable();
  1256. harness.fixture.detectChanges();
  1257. expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:20 }));
  1258. expect(harness.routeNativeElement?.querySelector('.memory-copy')?.textContent).toContain('关联学员抗遗忘任务');
  1259. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('张雅茜');
  1260. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks button')?.textContent).toContain('带学生复习');
  1261. const memoryStudentFilter = harness.routeNativeElement?.querySelector('[aria-label="筛选抗遗忘学员"] select') as HTMLSelectElement;
  1262. expect(memoryStudentFilter.options.length).toBe(2);
  1263. memoryStudentFilter.value = '100';
  1264. memoryStudentFilter.dispatchEvent(new Event('change'));
  1265. harness.fixture.detectChanges();
  1266. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('张雅茜');
  1267. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).not.toContain('李同学');
  1268. expect(harness.routeNativeElement?.querySelector('.memory-stats')?.textContent).toContain('2 / 3');
  1269. await harness.navigateByUrl('/pages/member/ke_xiao');
  1270. await harness.fixture.whenStable();
  1271. harness.fixture.detectChanges();
  1272. const monthFilter = harness.routeNativeElement?.querySelector('[aria-label="按月份筛选销课记录"]') as HTMLSelectElement;
  1273. const monthPicker = harness.routeNativeElement?.querySelector('.month-control .xs-dropdown-trigger') as HTMLButtonElement;
  1274. expect(monthFilter.value).toBe('all');
  1275. expect(monthFilter.options.length).toBe(4);
  1276. expect(monthPicker).not.toBeNull();
  1277. expect(Number.parseFloat(getComputedStyle(monthPicker).paddingLeft)).toBeGreaterThanOrEqual(40);
  1278. expect(monthPicker.getBoundingClientRect().height).toBe(46);
  1279. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5);
  1280. monthFilter.value = '2026-03';
  1281. monthFilter.dispatchEvent(new Event('change'));
  1282. const studentSearch = harness.routeNativeElement?.querySelector('[aria-label="按学生姓名筛选销课记录"]') as HTMLInputElement;
  1283. studentSearch.value = '花';
  1284. studentSearch.dispatchEvent(new Event('input'));
  1285. harness.fixture.detectChanges();
  1286. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1);
  1287. expect(harness.routeNativeElement?.querySelector('.schedule-list')?.textContent).toContain('花花');
  1288. expect(harness.routeNativeElement?.querySelector('.schedule-band > header')?.textContent).toContain('1 / 5');
  1289. (harness.routeNativeElement?.querySelector('.history-filter-panel footer button') as HTMLButtonElement).click();
  1290. harness.fixture.detectChanges();
  1291. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5);
  1292. await harness.navigateByUrl('/pages/his/his');
  1293. await harness.fixture.whenStable();
  1294. harness.fixture.detectChanges();
  1295. expect((harness.routeNativeElement?.querySelector('.student-filter select') as HTMLSelectElement).selectedOptions[0]?.textContent).toContain('张雅茜');
  1296. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
  1297. expect(session.student()?.userId).toBe(100);
  1298. (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click();
  1299. await harness.fixture.whenStable();
  1300. harness.fixture.detectChanges();
  1301. expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
  1302. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('历史记录');
  1303. expect(harness.routeNativeElement?.querySelector('.readonly-word-card')?.textContent).toContain('apple');
  1304. expect(harness.routeNativeElement?.querySelector('.readonly-word-card')?.textContent).toContain('共 1 个单词');
  1305. expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull();
  1306. await harness.navigateByUrl('/pages/his/his');
  1307. await harness.fixture.whenStable();
  1308. harness.fixture.detectChanges();
  1309. const teacherCourse = harness.routeNativeElement?.querySelector('.current-learning-list button') as HTMLButtonElement;
  1310. expect(teacherCourse.disabled).toBeFalse();
  1311. expect(teacherCourse.classList.contains('readonly')).toBeFalse();
  1312. expect(teacherCourse.textContent).toContain('选择本次学习单元');
  1313. teacherCourse.click();
  1314. await harness.fixture.whenStable();
  1315. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_list');
  1316. await harness.navigateByUrl('/pages/home/ucenter');
  1317. await harness.fixture.whenStable();
  1318. harness.fixture.detectChanges();
  1319. const teacherServices = Array.from(harness.routeNativeElement?.querySelectorAll('.service-grid > a') ?? []).map((link) => link.getAttribute('href'));
  1320. expect(teacherServices).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/feedback']);
  1321. expect(harness.routeNativeElement?.querySelector('.coach-wallet-strip')?.textContent).toContain('累计复习工资');
  1322. await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
  1323. await harness.fixture.whenStable();
  1324. harness.fixture.detectChanges();
  1325. expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastNodeParams['userId']).toBe(100);
  1326. const units = harness.routeNativeElement?.querySelectorAll('.node-list button') as NodeListOf<HTMLButtonElement>;
  1327. units[0].click();
  1328. units[1].click();
  1329. harness.fixture.detectChanges();
  1330. expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')?.textContent).toContain('已选 2 个单元');
  1331. (harness.routeNativeElement?.querySelector('.unit-actions .primary-button') as HTMLButtonElement).click();
  1332. await harness.fixture.whenStable();
  1333. harness.fixture.detectChanges();
  1334. const router = TestBed.inject(Router);
  1335. expect(router.url).toContain('/pages/stu/stu_abc');
  1336. expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('banana');
  1337. expect(harness.routeNativeElement?.querySelector('.completed-column')).toBeNull();
  1338. expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(1);
  1339. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  1340. harness.fixture.detectChanges();
  1341. expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('apple');
  1342. expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
  1343. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click();
  1344. harness.fixture.detectChanges();
  1345. const firstWord = harness.routeNativeElement?.querySelector('.pending-column .word-refine-item') as HTMLElement;
  1346. (firstWord.querySelector('.word-reveal') as HTMLButtonElement).click();
  1347. harness.fixture.detectChanges();
  1348. (firstWord.querySelector('.known-button') as HTMLButtonElement).click();
  1349. harness.fixture.detectChanges();
  1350. expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(0);
  1351. expect(harness.routeNativeElement?.querySelector('.selection-header .primary-button')?.textContent).toContain('保存识词结果');
  1352. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  1353. harness.fixture.detectChanges();
  1354. expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2);
  1355. (harness.routeNativeElement?.querySelector('.completed-word-action button') as HTMLButtonElement).click();
  1356. harness.fixture.detectChanges();
  1357. expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(1);
  1358. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click();
  1359. harness.fixture.detectChanges();
  1360. expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(1);
  1361. (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  1362. await harness.fixture.whenStable();
  1363. harness.fixture.detectChanges();
  1364. expect(router.url).toContain('/pages/stu/stu_con');
  1365. expect(harness.routeNativeElement?.querySelectorAll('.repeat-list > button').length).toBe(1);
  1366. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')).toBeNull();
  1367. await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&bindingId=701');
  1368. await harness.fixture.whenStable();
  1369. harness.fixture.detectChanges();
  1370. const spellingActions = harness.routeNativeElement?.querySelector('.teacher-spelling-actions') as HTMLElement;
  1371. expect(spellingActions.querySelectorAll('button').length).toBe(3);
  1372. expect(spellingActions.textContent).toContain('跳到下一关');
  1373. expect((harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent).words().length).toBe(1);
  1374. expect(localStorage.getItem('xiaoshu.learning.300.100.701._page_word_orilist')).not.toBeNull();
  1375. (spellingActions.querySelectorAll('button')[1] as HTMLButtonElement).click();
  1376. await harness.fixture.whenStable();
  1377. harness.fixture.detectChanges();
  1378. expect(router.url).toBe('/pages/his/his');
  1379. const standaloneRecord = (TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastStandaloneLearningRecord;
  1380. expect(JSON.parse(String(standaloneRecord['content']))).toEqual(jasmine.objectContaining({ ModelID: 56, Hits: 1, title: '张雅茜' }));
  1381. expect(JSON.parse(String(standaloneRecord['addon']))).toEqual(jasmine.objectContaining({ UserID: 100, pl: 300, learned: 1, createReviewPlan: false }));
  1382. }, 20000);
  1383. it('loads appointment courses and units for the appointment student when no student is selected in the session', async () => {
  1384. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1385. session.groupId.set(3);
  1386. session.student.set(null);
  1387. const harness = await RouterTestingHarness.create();
  1388. await harness.navigateByUrl('/pages/shop/get_order?id=901');
  1389. await harness.fixture.whenStable();
  1390. harness.fixture.detectChanges();
  1391. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1392. expect(session.student()?.userId).toBe(100);
  1393. expect(api.lastAppointmentCourseParams['orderId']).toBe('901');
  1394. expect(api.lastNodeParams['userId']).toBe(100);
  1395. const courseSelect = harness.routeNativeElement?.querySelector('.course-section select') as HTMLSelectElement;
  1396. expect(courseSelect).not.toBeNull();
  1397. expect(courseSelect.options.length).toBeGreaterThanOrEqual(2);
  1398. expect(courseSelect.closest('.course-section')?.textContent).toContain('七年级上册');
  1399. expect(harness.routeNativeElement?.querySelectorAll('.unit-grid article').length).toBe(2);
  1400. const unitButtons = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.unit-select');
  1401. expect(unitButtons?.[0].textContent).toContain('已学 5%');
  1402. expect(unitButtons?.[0].textContent).toContain('1/20 词');
  1403. expect(unitButtons?.[1].textContent).toContain('已学 0%');
  1404. expect(unitButtons?.[1].textContent).toContain('0/18 词');
  1405. expect((unitButtons?.[0].querySelector('.unit-progress-track > span') as HTMLElement).style.width).toBe('5%');
  1406. unitButtons?.[0].click();
  1407. unitButtons?.[1].click();
  1408. harness.fixture.detectChanges();
  1409. expect(harness.routeNativeElement?.querySelectorAll('.unit-select[aria-pressed="true"]').length).toBe(2);
  1410. expect(unitButtons?.[0].textContent).toContain('已学 5%');
  1411. });
  1412. it('distinguishes completed, empty and unavailable appointment vocabulary progress', async () => {
  1413. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1414. session.groupId.set(3);
  1415. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1416. api.unitRows = [
  1417. { NodeID: 811, NodeName: '已学全册', yx_word: '20', total: '20' },
  1418. { NodeID: 812, NodeName: '空册', yx_word: 0, total: 0 },
  1419. { NodeID: 813, NodeName: '缺少统计' },
  1420. { NodeID: 814, NodeName: '异常统计', yx_word: 'invalid', total: 20 },
  1421. ];
  1422. const harness = await RouterTestingHarness.create();
  1423. await harness.navigateByUrl('/pages/shop/get_order?id=901');
  1424. await harness.fixture.whenStable();
  1425. harness.fixture.detectChanges();
  1426. const units = harness.routeNativeElement?.querySelectorAll('.unit-select');
  1427. expect(units?.[0].textContent).toContain('已学 100%');
  1428. expect(units?.[0].textContent).toContain('20/20 词');
  1429. expect(units?.[1].textContent).toContain('已学 0%');
  1430. expect(units?.[1].textContent).toContain('暂无单词');
  1431. for (const index of [2, 3]) {
  1432. expect(units?.[index].textContent).toContain('暂无进度数据');
  1433. expect(units?.[index].querySelector('.unit-progress-track')).toBeNull();
  1434. }
  1435. });
  1436. it('ends without requiring feedback and displays the server-confirmed lesson pay', async () => {
  1437. user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3});session.groupId.set(3);
  1438. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
  1439. spyOn(TestBed.inject(PlatformService),'confirm').and.resolveTo(true);
  1440. const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/shop/get_order?id=901');await harness.fixture.whenStable();harness.fixture.detectChanges();
  1441. const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
  1442. await component.endLesson();expect(api.completionRequests.length).toBe(1);expect(api.completionRequests[0]['feedback']).toBeUndefined();
  1443. api.appointmentStatus=30;api.completionResponse.next({retcode:0,result:{status:30,teacherPay:45,payStatus:'earned',feedbackStatus:'pending'}});api.completionResponse.complete();
  1444. harness.fixture.detectChanges();expect(harness.routeNativeElement?.textContent).toContain('本节带课工资 ¥45.00');expect(harness.routeNativeElement?.textContent).toContain('反馈待补');
  1445. });
  1446. it('retains the optional feedback-and-complete dialog and submits once after validation', async () => {
  1447. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1448. session.groupId.set(3);
  1449. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1450. api.appointmentStatus = 10;
  1451. const harness = await RouterTestingHarness.create();
  1452. await harness.navigateByUrl('/pages/shop/get_order?id=901');
  1453. const component = harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
  1454. await harness.fixture.whenStable();
  1455. harness.fixture.detectChanges();
  1456. const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement;
  1457. const endButton = harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement;
  1458. expect(dialog.open).toBeFalse();
  1459. component.openCompletionFeedback();
  1460. harness.fixture.detectChanges();
  1461. expect(dialog.open).toBeTrue();
  1462. expect(api.completionRequests.length).toBe(0);
  1463. const submit = dialog.querySelector('.feedback-actions .primary-button') as HTMLButtonElement;
  1464. submit.click();
  1465. harness.fixture.detectChanges();
  1466. expect(dialog.querySelector('[role="alert"]')?.textContent).toContain('请填写本次学习内容');
  1467. expect(api.completionRequests.length).toBe(0);
  1468. const summary = dialog.querySelector('textarea') as HTMLTextAreaElement;
  1469. summary.value = 'Unit 3 词汇';
  1470. summary.dispatchEvent(new Event('input'));
  1471. await harness.fixture.whenStable();
  1472. (dialog.querySelector('.feedback-actions .secondary-button') as HTMLButtonElement).click();
  1473. expect(dialog.open).toBeFalse();
  1474. expect(api.appointmentStatus).toBe(10);
  1475. component.openCompletionFeedback();
  1476. harness.fixture.detectChanges();
  1477. expect((dialog.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Unit 3 词汇');
  1478. component.reviewStatus = '完成';
  1479. component.trainingStatus = '需要巩固';
  1480. component.trainingSummary = '下次复习 Unit 3';
  1481. component.noHomework = true;
  1482. submit.click();
  1483. submit.click();
  1484. harness.fixture.detectChanges();
  1485. expect(api.completionRequests.length).toBe(1);
  1486. expect(JSON.parse(String(api.completionRequests[0]['feedback']))).toEqual(jasmine.objectContaining({ contentSummary: 'Unit 3 词汇', noHomework: true, homework: '无作业' }));
  1487. expect(submit.disabled).toBeTrue();
  1488. dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
  1489. expect(dialog.open).toBeTrue();
  1490. api.appointmentStatus = 30;
  1491. api.completionResponse.next({ retcode: 1, result: {} });
  1492. api.completionResponse.complete();
  1493. await harness.fixture.whenStable();
  1494. harness.fixture.detectChanges();
  1495. expect(harness.routeNativeElement?.querySelector('dialog')).toBeNull();
  1496. expect(harness.routeNativeElement?.textContent).toContain('已结课');
  1497. });
  1498. it('keeps feedback editable after a failed submission and allows closing with Escape', async () => {
  1499. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1500. session.groupId.set(3);
  1501. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1502. api.appointmentStatus = 10;
  1503. const harness = await RouterTestingHarness.create();
  1504. await harness.navigateByUrl('/pages/shop/get_order?id=901');
  1505. const component = harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
  1506. await harness.fixture.whenStable();
  1507. component.openCompletionFeedback();
  1508. component.contentSummary = 'Unit 3';
  1509. component.reviewStatus = '完成';
  1510. component.trainingStatus = '正常进行';
  1511. component.trainingSummary = '继续 Unit 4';
  1512. component.noHomework = true;
  1513. component.finishCoachLesson();
  1514. api.completionResponse.error(new Error('提交失败,请重试'));
  1515. harness.fixture.detectChanges();
  1516. const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement;
  1517. expect(dialog.open).toBeTrue();
  1518. expect(dialog.querySelector('[role="alert"]')?.textContent).toContain('提交失败,请重试');
  1519. expect((dialog.querySelector('fieldset') as HTMLFieldSetElement).disabled).toBeFalse();
  1520. expect(component.contentSummary).toBe('Unit 3');
  1521. expect(api.appointmentStatus).toBe(10);
  1522. dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
  1523. expect(dialog.open).toBeFalse();
  1524. });
  1525. it('counts words marked as known as completed and persists them for the student', async () => {
  1526. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1527. session.groupId.set(3);
  1528. session.student.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
  1529. const harness = await RouterTestingHarness.create();
  1530. await harness.navigateByUrl('/pages/stu/stu_abc?nids=811,812&pnid=801');
  1531. await harness.fixture.whenStable();
  1532. harness.fixture.detectChanges();
  1533. const pendingWord = harness.routeNativeElement?.querySelector('.pending-column .word-refine-item') as HTMLElement;
  1534. (pendingWord.querySelector('.word-reveal') as HTMLButtonElement).click();
  1535. harness.fixture.detectChanges();
  1536. (pendingWord.querySelector('.known-button') as HTMLButtonElement).click();
  1537. harness.fixture.detectChanges();
  1538. (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  1539. await harness.fixture.whenStable();
  1540. harness.fixture.detectChanges();
  1541. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1542. expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({ uid: 100, wordsId: '1002', kcid: '801', save: 1 }));
  1543. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
  1544. expect(harness.routeNativeElement?.querySelector('.completed-column')).toBeNull();
  1545. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  1546. harness.fixture.detectChanges();
  1547. expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2);
  1548. expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
  1549. });
  1550. it('confirms three rounds into 17 words and can continue selecting before ending the lesson', async () => {
  1551. user.set({userId:300,groupId:3}); session.groupId.set(3);session.student.set({userId:100,groupId:1});
  1552. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
  1553. api.studyBatches=[9,5,3].map((count,index)=>({batchId:'round-'+index,version:1,status:'draft',bindingId:'binding-801',words:Array.from({length:count},(_,i)=>({GeneralID:index*100+i+1,Title:'word'+index+'-'+i,check:0}))}));
  1554. const harness=await RouterTestingHarness.create();
  1555. for(let index=0;index<3;index++){
  1556. await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=round-'+index);await harness.fixture.whenStable();harness.fixture.detectChanges();
  1557. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1558. expect(component.words().length).toBe([9,5,3][index]);component.markAllKnown();component.submitReview(index<2);await harness.fixture.whenStable();harness.fixture.detectChanges();
  1559. expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901'+(index===2?'&feedback=1':''));
  1560. expect(harness.routeNativeElement?.textContent).toContain('本节学习成果 · '+[9,14,17][index]+' 个词');
  1561. }
  1562. expect(api.completionRequests.length).toBe(0);
  1563. });
  1564. it('keeps round choices on save failure and retries with the same command key', async () => {
  1565. user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
  1566. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
  1567. api.studyBatches=[{batchId:'retry-round',version:1,status:'draft',bindingId:'binding-801',courseId:'802',words:[{GeneralID:1001,Title:'apple',check:0}]}];
  1568. const post=api.post.bind(api);let fail=true;const spy=spyOn(api,'post').and.callFake((action,params)=>action==='stu_record_update_v2'&&fail?(fail=false,throwError(()=>new Error('断网'))):post(action,params));
  1569. const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=retry-round');await harness.fixture.whenStable();harness.fixture.detectChanges();
  1570. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;component.markAllKnown();component.submitReview(true);await harness.fixture.whenStable();
  1571. expect(component.words().length).toBe(1);expect(component.reviewCount(1)).toBe(1);expect(TestBed.inject(Router).url).toContain('/pl_end');
  1572. component.submitReview(true);await harness.fixture.whenStable();const requests=spy.calls.allArgs().filter(([action])=>action==='stu_record_update_v2');expect(requests.length).toBe(2);expect(requests[0][1]?.['requestKey']).toBe(requests[1][1]?.['requestKey']);
  1573. expect(api.lastLearningWordUpdate['kcid']).toBe('802'); // Resume uses the batch book, not the scheduled book.
  1574. });
  1575. it('finishes the last vocabulary group, loads the coach record and opens completion feedback', async () => {
  1576. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1577. session.groupId.set(3);
  1578. session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
  1579. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1580. api.appointmentStatus = 10;
  1581. const word = { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果' };
  1582. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_word_orilist', JSON.stringify([[word]]));
  1583. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_current_learn', '1');
  1584. // Stale page-count metadata must not create a nonexistent next group.
  1585. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_total_page', '2');
  1586. localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.learn_mode');
  1587. const harness = await RouterTestingHarness.create();
  1588. const router = TestBed.inject(Router);
  1589. const navigate = spyOn(router, 'navigate').and.callThrough();
  1590. await harness.navigateByUrl('/pages/stu/stu_con?nids=811&pnid=801&orderId=901');
  1591. await harness.fixture.whenStable();
  1592. harness.fixture.detectChanges();
  1593. const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1594. learning().skipStudyList();
  1595. await harness.fixture.whenStable();
  1596. harness.fixture.detectChanges();
  1597. expect(router.url).toContain('/pages/stu/stu3');
  1598. const pair = learning().words()[0];
  1599. learning().chooseMatch('left', pair);
  1600. learning().chooseMatch('right', pair);
  1601. learning().continueAfterStage();
  1602. await harness.fixture.whenStable();
  1603. harness.fixture.detectChanges();
  1604. expect(router.url).toContain('/pages/stu/stu4');
  1605. learning().chooseMeaning(learning().words()[0]);
  1606. learning().continueAfterStage();
  1607. await harness.fixture.whenStable();
  1608. harness.fixture.detectChanges();
  1609. expect(router.url).toContain('/pages/stu/pl_stu5');
  1610. learning().skipSpellingStage();
  1611. expect(learning().hasNextWordBatch).toBeFalse();
  1612. await harness.fixture.whenStable();
  1613. harness.fixture.detectChanges();
  1614. harness.fixture.detectChanges();
  1615. expect(router.url).toContain('/pages/stu/pl_end');
  1616. expect(api.lastStudyQuery['orderId']).toBe('901');
  1617. expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
  1618. expect(learning().words()[0].word).toBe('apple');
  1619. learning().markAllKnown();
  1620. learning().submitReview();
  1621. await harness.fixture.whenStable();
  1622. harness.fixture.detectChanges();
  1623. harness.fixture.detectChanges();
  1624. expect(router.url).toBe('/pages/shop/get_order?id=901&feedback=1');
  1625. expect((harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement).open).toBeTrue();
  1626. expect(api.appointmentStatus).toBe(10);
  1627. expect(api.completionRequests.length).toBe(0);
  1628. expect(api.studyUpdates.length).toBe(2);
  1629. expect(JSON.parse(String(api.studyUpdates[1]['addon'])).xxqs[0].check).toBe(1);
  1630. expect(navigate.calls.all().every(call => call.args[1]?.replaceUrl === true)).toBeTrue();
  1631. }, 20000);
  1632. it('advances only to a real next group and returns from the last stage to its course in one click', async () => {
  1633. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1634. session.groupId.set(3);
  1635. session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
  1636. const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }, { GeneralID: 1002, Title: 'banana', sy: '香蕉' }], [{ GeneralID: 1003, Title: 'cherry', sy: '樱桃' }]];
  1637. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_word_orilist', JSON.stringify(pages));
  1638. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_total_page', '0');
  1639. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_current_learn', '1');
  1640. const harness = await RouterTestingHarness.create();
  1641. const router = TestBed.inject(Router);
  1642. await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
  1643. await harness.fixture.whenStable();
  1644. harness.fixture.detectChanges();
  1645. const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1646. expect(component.words().length).toBe(2);
  1647. component.skipSpellingStage();
  1648. await harness.fixture.whenStable();
  1649. harness.fixture.detectChanges();
  1650. expect(router.url).toContain('/pages/stu/stu_con');
  1651. const next = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1652. expect(next.words()[0].word).toBe('cherry');
  1653. expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).studyUpdates.length).toBe(0);
  1654. (harness.routeNativeElement?.querySelector('.app-header button[aria-label="返回"]') as HTMLButtonElement).click();
  1655. await harness.fixture.whenStable();
  1656. harness.fixture.detectChanges();
  1657. expect(router.url).toBe('/pages/shop/get_order?id=901');
  1658. for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn', 'skip_spelling_stage']) localStorage.removeItem(`xiaoshu.learning.300.100.901.unbound.${key}`);
  1659. });
  1660. it('keeps teacher-led spelling skipped through the remaining word groups', async () => {
  1661. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1662. session.groupId.set(3);
  1663. session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
  1664. const prefix = 'xiaoshu.learning.300.100.901.unbound.';
  1665. const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]];
  1666. localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify(pages));
  1667. localStorage.setItem(prefix + '_page_current_learn', '1');
  1668. const harness = await RouterTestingHarness.create();
  1669. const router = TestBed.inject(Router);
  1670. await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
  1671. const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1672. learning().skipSpellingStage();
  1673. await harness.fixture.whenStable();
  1674. harness.fixture.detectChanges();
  1675. expect(router.url).toContain('/pages/stu/stu_con');
  1676. expect(localStorage.getItem(prefix + 'skip_spelling_stage')).toBe('true');
  1677. learning().skipStudyList();
  1678. await harness.fixture.whenStable();
  1679. harness.fixture.detectChanges();
  1680. learning().chooseMatch('left', learning().words()[0]);
  1681. learning().chooseMatch('right', learning().words()[0]);
  1682. learning().continueAfterStage();
  1683. await harness.fixture.whenStable();
  1684. harness.fixture.detectChanges();
  1685. expect(router.url).toContain('/pages/stu/stu4');
  1686. learning().chooseMeaning(learning().words()[0]);
  1687. expect(learning().stageActionLabel).toBe('查看学习结果');
  1688. learning().continueAfterStage();
  1689. await harness.fixture.whenStable();
  1690. expect(router.url).toContain('/pages/stu/pl_end');
  1691. expect(localStorage.getItem(prefix + 'skip_spelling_stage')).toBe('true');
  1692. localStorage.removeItem(prefix + 'skip_spelling_stage');
  1693. }, 15000);
  1694. it('resolves the appointment student before reading end results and offers an exit on failure', async () => {
  1695. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1696. session.groupId.set(3);
  1697. session.student.set({ userId: 999, honeyName: '之前的学员', groupId: 1 });
  1698. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1699. const get = api.get.bind(api);
  1700. spyOn(api, 'get').and.callFake((action, params) => action === 'app_lesson_study'
  1701. ? throwError(() => new Error('学习记录暂时无法加载')) : get(action, params));
  1702. const harness = await RouterTestingHarness.create();
  1703. await harness.navigateByUrl('/pages/stu/pl_end?id=901');
  1704. await harness.fixture.whenStable();
  1705. harness.fixture.detectChanges();
  1706. harness.fixture.detectChanges();
  1707. expect(session.student()?.userId).toBe(100);
  1708. expect(api.get).toHaveBeenCalledWith('app_lesson_study', {orderId:'901'});
  1709. expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('学习记录暂时无法加载');
  1710. (harness.routeNativeElement?.querySelector('.empty-state.error .primary-button') as HTMLButtonElement).click();
  1711. await harness.fixture.whenStable();
  1712. harness.fixture.detectChanges();
  1713. expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
  1714. });
  1715. it('shows learning history in pages of twenty records', async () => {
  1716. user.set({ userId: 777, userName: 'paged-student', honeyName: '分页学员', groupId: 1 });
  1717. session.groupId.set(1);
  1718. const harness = await RouterTestingHarness.create();
  1719. await harness.navigateByUrl('/pages/his/his');
  1720. await harness.fixture.whenStable();
  1721. harness.fixture.detectChanges();
  1722. expect(harness.routeNativeElement?.querySelector('.record-actions')?.textContent).toContain('45 条');
  1723. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(20);
  1724. expect(harness.routeNativeElement?.querySelector('.record-pagination')?.textContent).toContain('第 1 / 3 页');
  1725. const pageButtons = harness.routeNativeElement?.querySelectorAll('.record-pagination button') as NodeListOf<HTMLButtonElement>;
  1726. expect(pageButtons[0].disabled).toBeTrue();
  1727. pageButtons[1].click();
  1728. harness.fixture.detectChanges();
  1729. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(20);
  1730. expect(harness.routeNativeElement?.querySelector('.record-pagination')?.textContent).toContain('第 2 / 3 页');
  1731. (harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).click();
  1732. harness.fixture.detectChanges();
  1733. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(5);
  1734. expect((harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).disabled).toBeTrue();
  1735. });
  1736. it('waits for server completion, blocks duplicate clicks and shows zero for overdue work', async()=>{
  1737. user.set({userId:300,groupId:3,honeyName:'老师'});session.groupId.set(3);session.student.set({userId:100,groupId:1});
  1738. const api=TestBed.inject(ApiService),post=api.post.bind(api),reply=new Subject<ApiEnvelope<any>>();
  1739. const submit=spyOn(api,'post').and.callFake((action,params)=>action==='app_review_complete'?reply:post(action,params));
  1740. const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/record?id=900&gid=103&plannedDate=2000-01-01');await harness.fixture.whenStable();
  1741. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1742. component.endRecord();component.endRecord();harness.fixture.detectChanges();expect(component.reviewCompletion()).toBeNull();expect(component.canDeactivate()).toBeFalse();
  1743. expect(submit.calls.allArgs().filter(a=>a[0]==='app_review_complete').length).toBe(1);
  1744. reply.next({retcode:1,result:{amount:0,rewardStatus:'ineligible',completed:true}});reply.complete();harness.fixture.detectChanges();
  1745. expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('本次收入 ¥0.00');expect(component.canDeactivate()).toBeTrue();
  1746. });
  1747. });