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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976
  1. import { provideHttpClient } from '@angular/common/http';
  2. import { signal } from '@angular/core';
  3. import { fakeAsync, tick, 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 { LiveDataRefreshService } from './live-data-refresh.service';
  17. import { PlatformService } from './platform.service';
  18. import { SessionService } from './session.service';
  19. class LearningModulesApiService {
  20. lastScheduleParams: Record<string, unknown> = {};
  21. lastNodeParams: Record<string, unknown> = {};
  22. lastAppointmentCourseParams: Record<string, unknown> = {};
  23. lastWordParams: Record<string, unknown> = {};
  24. lastLearningWordUpdate: Record<string, unknown> = {};
  25. lastMemoryParams: Record<string, unknown> = {};
  26. lastStandaloneLearningRecord: Record<string, unknown> = {};
  27. appointmentStatus = 0;
  28. historyWordAudio = '';
  29. completionRequests: Record<string, unknown>[] = [];
  30. completionResponse = new Subject<ApiEnvelope<unknown>>();
  31. lastAppointmentRecordParams: Record<string, unknown> = {};
  32. studyUpdates: Record<string, unknown>[] = [];
  33. studyBatches: any[] = [];
  34. lastStudyQuery: Record<string, unknown> = {};
  35. reviewPageResponse: Subject<ApiEnvelope<unknown>> | null = null;
  36. courseWords: Record<string, unknown>[] | null = null;
  37. courseIncrements = new Map<string, number>();
  38. courseBookCounts: number[] | null = null;
  39. unitRows: Record<string, unknown>[] = [
  40. { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
  41. { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
  42. ];
  43. get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
  44. if (action === 'app_account_overview') return this.post<T>(action, params);
  45. if (action === 'content_list' && params['orders'] === 'NEWID()') {
  46. return this.result<T>(Array.from({length: 8}, (_, i) => ({GeneralID: 9000 + i, Title: `external-${i}`, sy: `外部释义 ${i}`})));
  47. }
  48. if (action === 'content_list' && Number(params['ModelID'] ?? params['modelId']) === 56 && String(params['myfield2']).includes('dsid=')) {
  49. this.lastAppointmentRecordParams = params;
  50. if (!String(params['myfield2']).includes('|pl=300')) return throwError(() => new Error('无权查看该学员的内容记录'));
  51. return this.result<T>([{ GeneralID: 9901 }]);
  52. }
  53. if (action === 'app_lesson_study') {
  54. this.lastStudyQuery = params;
  55. 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});
  56. }
  57. if (action === 'e_record_detail') {
  58. return this.result<T>([{ GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果', check: 0 }]);
  59. }
  60. 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:'阅读训练'}]); }
  61. if (action === 'e_order_detail') {
  62. return this.result<T>([{
  63. GeneralID: 901,
  64. szyh: 100,
  65. HoneyName: '张雅茜',
  66. kcid: 801, bindingId:'binding-801',
  67. yykcmc: '七年级上册',
  68. dszt: this.appointmentStatus,
  69. updatedAt: "2026-10-11T00:00:00Z",
  70. allowedActions: this.appointmentStatus===0?["start"]:this.appointmentStatus===10?["continue","complete"]:this.appointmentStatus===30?[]:["complete"],
  71. reviewStatus: "pending",
  72. dslx: 2,
  73. bxrq: '2026-08-10',
  74. sdsd: '19:00',
  75. jffs: '线上',
  76. }]);
  77. }
  78. if (action === 'content_list' && Number(params['nid']) === 291) {
  79. return this.result<T>([
  80. { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) },
  81. { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', con: '高中英语@Unit 2' },
  82. ]);
  83. }
  84. if (action === 'content_list' && Number(params['nid']) === 388) {
  85. return this.result<T>([
  86. { GeneralID: 100, Title: '张雅茜@历史任务', kywsj: '2026-05-15 09:00', fxzt: 1, yhid: 100, xxjlid: 900 },
  87. { GeneralID: 101, Title: '张雅茜@任务', kywsj: '2026-08-01 09:00', fxzt: 1, yhid: 100, xxjlid: 900 },
  88. { GeneralID: 102, Title: '张雅茜@任务', kywsj: '2026-08-02 09:00', fxzt: 0, yhid: 100, xxjlid: 900 },
  89. ]);
  90. }
  91. if (action === 'content_list' && Number(params['nid']) === 3) {
  92. return this.result<T>([{ GeneralID: 301, Title: '阅读理解专项' }]);
  93. }
  94. if (action === 'content_list' && Number(params['nid']) === 389) {
  95. return this.result<T>([{ GeneralID: 401, df: 18, totalScore: 48, dontKnow: 20, wrong: 10 }]);
  96. }
  97. if (action === 'content_list' && Number(params['nid']) === 28) {
  98. this.lastAppointmentCourseParams = params;
  99. return this.result<T>([
  100. { GeneralID: 801, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
  101. { GeneralID: 802, kcid: 801, Title2: '【初中英语】阅读训练' },
  102. ]);
  103. }
  104. if (action === 'content_list' && Number(params['ModelID']) === 58) {
  105. return this.result<T>([{ GeneralID: 701, yhid: 100, kcid: 801, syjd: '{}' }]);
  106. }
  107. if (action === 'content_list' && Number(params['nid']) === 29) {
  108. this.lastScheduleParams = params;
  109. return this.result<T>([
  110. { GeneralID: 901, bxrq: '2026-08-10 00:00:00', sdsd: '19:00', Title: '同步伴学', plxm: '张老师', dszt: 10 },
  111. { GeneralID: 902, bxrq: '2026-08-03 00:00:00', sdsd: '18:00', Title: '历史伴学', plxm: '李老师', dszt: 30 },
  112. ]);
  113. }
  114. if (action === 'node_list' && Number(params['pid']) === 8) {
  115. return this.result<T>([{ NodeID: 801, NodeName: '小学人教版词库' }]);
  116. }
  117. if (action === 'node_list' && Number(params['pid']) === 801) {
  118. this.lastNodeParams = params;
  119. return this.result<T>(this.unitRows);
  120. }
  121. if (action === 'e_ck_list') {
  122. this.lastWordParams = params;
  123. const allWords = (this.courseWords ?? (Number(params['nids']) === 813
  124. ? Array.from({ length: 65 }, (_, index) => ({ GeneralID: 2001 + index, NodeID: 813, Title: `word-${index + 1}`, sy: `释义 ${index + 1}` }))
  125. : [
  126. { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: 'n. 苹果', yp: 'apple.mp3', w_learned: 1 },
  127. { GeneralID: 1002, NodeID: 812, Title: 'banana', sy: 'n. 香蕉', yp: 'banana.mp3', w_learned: 0 },
  128. ])).map((word: Record<string, unknown>) => ({...word,w_learned:(Number(word['w_learned'])||0)+(this.courseIncrements.get(String(word['GeneralID']))||0)}));
  129. const pageIndex = Math.max(1, Number(params['cpage'] ?? 1));
  130. const pageSize = Math.max(1, Number(params['psize'] ?? 30));
  131. const passCounts = [0,1,2,3].map(pass => allWords.filter(word => Math.min(3,Number(word['w_learned']) || 0) === pass).length);
  132. const filtered = params['learningPass'] == null ? allWords : allWords.filter(word => Math.min(3,Number(word['w_learned']) || 0) === Number(params['learningPass']));
  133. const result = filtered.slice((pageIndex - 1) * pageSize, pageIndex * pageSize);
  134. return of({
  135. retcode: 1,
  136. result: result as T,
  137. page: { itemCount: filtered.length, pageCount: Math.ceil(filtered.length / pageSize), pageIndex, pageSize, passCounts, bookPassCounts: this.courseBookCounts ?? passCounts },
  138. });
  139. }
  140. if (action === 'node_get') return this.result<T>([{ NodeID: 3, NodeName: '专项训练' }]);
  141. if (action === 'app_exam_history') return this.result<T>({items:[{GeneralID:'answer:401',df:18,totalScore:48,dontKnow:20,wrong:10,resultAvailable:true,askid:1,testedAt:'2026-10-01T08:00:00Z'}],hasMore:false});
  142. if (action === 'vote_ask') return this.result<T>([{ ID: 1, Title: '第一组问题' }]);
  143. if (action === 'vote_question') {
  144. return this.result<T>([{ ID: 501, AskID: 1, QType: 1, QTitle: 'apple', QContent: '苹果', QOption: JSON.stringify([{ text: '苹果', value: 1 }]) }]);
  145. }
  146. if (action === 'e_words_list') {
  147. return this.result<T>([{
  148. GeneralID: 601,
  149. Title: 'tend@张雅茜',
  150. detail: [{ GeneralID: 701, Title: 'tend', sy: 'vt.照顾;照料', yp: 'tend.mp3' }],
  151. }]);
  152. }
  153. return this.result<T>([]);
  154. }
  155. post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
  156. if (action === 'stu_record_update_v2') {
  157. this.studyUpdates.push(params);
  158. const batchId=String(params['batchId']), words=JSON.parse(String(params['addon'])).xxqs;
  159. const version=Number(params['expectedVersion']||0)+1;
  160. this.studyBatches=this.studyBatches.filter(b=>b.batchId!==batchId).concat({batchId,words,status:params['saveMode']==='confirm'?'confirmed':'draft',version,bindingId:'binding-801'});
  161. 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 });
  162. }
  163. if (action === 'app_lesson_complete') {
  164. this.completionRequests.push(params);
  165. return this.completionResponse as Observable<ApiEnvelope<T>>;
  166. }
  167. if (action === 'e_add_words') {
  168. this.lastLearningWordUpdate = params;
  169. if (params['save'] === 1) for (const id of String(params['wordsId']).split(',')) this.courseIncrements.set(id,(this.courseIncrements.get(id)||0)+1);
  170. return this.result<T>({ affected: String(params['wordsId'] ?? '').split(',').filter(Boolean).length });
  171. }
  172. if (action === 'content_add_zt') {
  173. this.lastStandaloneLearningRecord = params;
  174. return this.result<T>({ GeneralID: 9901 });
  175. }
  176. if (action === 'e_order_tongji') {
  177. return this.result<T>({ t30: 4, t60: 6, t_tiyan: 1, t_total: 11, t_shichang: 9, t_yongji: 320 });
  178. }
  179. if (action === 'e_user_list') {
  180. return this.result<T>([{ szyh: 100, honeyname: '张雅茜' }]);
  181. }
  182. if (action === 'app_learning_overview') {
  183. const records = Number(params['studentId']) === 777
  184. ? Array.from({ length: 45 }, (_, index) => ({
  185. GeneralID: 5000 + index,
  186. CreateTime: `2026-08-${String(31 - (index % 20)).padStart(2, '0')} 09:30:00`,
  187. dqrq: '20260801',
  188. con: `分页学习记录 ${index + 1}`,
  189. }))
  190. : [
  191. { 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' },
  192. { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', dqrq: '20260804', con: '高中英语@Unit 2', xxqs: JSON.stringify([{ Title: 'banana' }]) },
  193. ];
  194. const selected = String(params['date'] ?? '');
  195. return this.result<T>({
  196. records: (selected ? records.filter((item) => item.CreateTime.startsWith(selected)) : records).slice((Number(params['page']||1)-1)*20,Number(params['page']||1)*20),
  197. page:{itemCount:selected?records.filter(item=>item.CreateTime.startsWith(selected)).length:records.length,pageCount:Math.ceil(records.length/20)},
  198. latestReadingRecord: records[0],
  199. recordDateCounts: { '2026-08-01': 1, '2026-08-04': 1 },
  200. scheduleDateCounts: {},
  201. schedules: [],
  202. courses: [
  203. { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
  204. { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' },
  205. ],
  206. refreshedAt: '2026-08-26T00:00:00.000Z',
  207. });
  208. }
  209. if (action === 'app_companion_overview') {
  210. this.lastScheduleParams = params;
  211. const schedules = [
  212. { GeneralID: 901, bxrq: '2026-08-10 00:00:00', sdsd: '19:00', Title: '同步伴学', plxm: '张老师', dszt: 10 },
  213. { GeneralID: 902, bxrq: '2026-08-03 00:00:00', sdsd: '18:00', Title: '历史伴学', plxm: '李老师', dszt: 30 },
  214. { GeneralID: 903, bxrq: '2099-01-01 00:00:00', sdsd: '18:00', Title: '未来计划', plxm: '王老师', dszt: 0 },
  215. ];
  216. const historySchedules = [
  217. { GeneralID: 911, szyh:100, bxrq: '2026-07-14 00:00:00', sdsd: '22:00', Title: '谢晨晨(60分钟练习)', plxm: '张老师', dszt: 30 },
  218. { GeneralID: 912, szyh:101, bxrq: '2026-07-14 00:00:00', sdsd: '17:00', Title: '谢谢(60分钟练习)', plxm: '张老师', dszt: 30 },
  219. { GeneralID: 913, szyh:102, bxrq: '2026-03-09 00:00:00', sdsd: '10:00', Title: '花花(60分钟练习)', plxm: '张老师', dszt: 30 },
  220. { GeneralID: 914, szyh:103, bxrq: '2026-01-18 00:00:00', sdsd: '16:30', Title: '小赵(60分钟练习)', plxm: '张老师', dszt: 30 },
  221. { GeneralID: 915, szyh:104, bxrq: '2026-01-18 00:00:00', sdsd: '16:00', Title: '小黄(60分钟练习)', plxm: '张老师', dszt: 30 },
  222. ];
  223. const rangeStart = String(params['rangeStart'] ?? '');
  224. const rangeEnd = String(params['rangeEnd'] ?? '');
  225. return this.result<T>({
  226. studentOptions: params['history'] ? [{id:'100',name:'谢晨晨',count:1},{id:'101',name:'谢谢',count:1},{id:'102',name:'花花',count:1},{id:'103',name:'小赵',count:1},{id:'104',name:'小黄',count:1}] : [],
  227. courses: [
  228. { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
  229. { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' },
  230. ],
  231. 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})),
  232. schedules: params['history']
  233. ? historySchedules
  234. : rangeStart && rangeEnd
  235. ? schedules.filter((item) => item.bxrq.slice(0, 10) >= rangeStart && item.bxrq.slice(0, 10) <= rangeEnd)
  236. : schedules,
  237. refreshedAt: '2026-08-26T00:00:00.000Z',
  238. });
  239. }
  240. if (action === 'app_account_overview') {
  241. if (Number(params['uid']) === 300) {
  242. return this.result<T>({
  243. profile: { userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 },
  244. coachPanel: { todaySchedules: 2, completedLessons: 8, reviewIncome: 36, reviewRecords: 3, totalLessons: 11, totalHours: 9, course30: 4, course60: 6, trialLessons: 1 },
  245. refreshedAt: '2026-08-26T00:00:00.000Z',
  246. });
  247. }
  248. return this.result<T>({
  249. profile: { userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 },
  250. companionSummary: { completedSessions: 1, completedMinutes: 60 },
  251. antiForgetSummary: { total: 3, completed: 2, progress: 67, todayTotal: 0, todayCompleted: 0, duePending: 1, scheduled: 0, scheduledDays: 3 },
  252. refreshedAt: '2026-08-26T00:00:00.000Z',
  253. });
  254. }
  255. if (action === 'app_review_page') {
  256. this.lastMemoryParams=params;
  257. if (this.reviewPageResponse) return this.reviewPageResponse as Observable<ApiEnvelope<T>>;
  258. const items=params['scope']==='today' ? [{ GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: String(params['date'])+' 09:00', fxzt: 0, yhid: 100, xxjlid: 900 }] : [
  259. { 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 },
  260. { 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 },
  261. { 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 },
  262. ...(Number(params['shouru']) === 1
  263. ? [{ 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 }]
  264. : []),
  265. ];return this.result<T>({...(params['view']==='home'?{view:'home',legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}}:{}),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}))});
  266. }
  267. if (action === 'e_get_21list') {
  268. this.lastMemoryParams = params;
  269. return this.result<T>([
  270. { 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 },
  271. { 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 },
  272. { 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 },
  273. ...(Number(params['shouru']) === 1
  274. ? [{ 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 }]
  275. : []),
  276. ]);
  277. }
  278. if (action === 'e_record_detail') {
  279. return of({
  280. retcode: 1,
  281. result: [{ GeneralID: 701, Title: 'apple', detail: [{ GeneralID: 701, Title: 'apple', sy: '苹果', yp: this.historyWordAudio }] }],
  282. addon: [{ if_writing: '0', if_writing_tips: '先回忆单词,再查看释义' }],
  283. } as unknown as ApiEnvelope<T>);
  284. }
  285. return this.result<T>([]);
  286. }
  287. assetUrl(path: unknown): string { return String(path ?? ''); }
  288. private result<T>(result: unknown): Observable<ApiEnvelope<T>> {
  289. return of({ retcode: 1, result: result as T });
  290. }
  291. }
  292. describe('home learning modules', () => {
  293. const user = signal<AppUser | null>({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
  294. const selectedStudent = signal<AppUser | null>(null);
  295. const session = {
  296. user,
  297. token: signal('test-token'),
  298. student: selectedStudent,
  299. isLoggedIn: signal(true),
  300. groupId: signal(1),
  301. setStudent: (student: AppUser | null) => selectedStudent.set(student),
  302. patchUser: () => undefined,
  303. logout: () => undefined,
  304. };
  305. beforeEach(() => {
  306. sessionStorage.removeItem('xiaoshu.learning.teacherTodayReview');
  307. localStorage.removeItem('xiaoshu.learning.homeDaily.v1');
  308. spyOn(window, 'confirm').and.returnValue(true);
  309. // Route tests do not depend on remote audio availability or its error-toast timer.
  310. spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve());
  311. localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.skip_spelling_stage');
  312. user.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
  313. session.groupId.set(1);
  314. session.student.set(null);
  315. TestBed.configureTestingModule({
  316. providers: [
  317. provideRouter(routes),
  318. provideHttpClient(),
  319. { provide: ApiService, useClass: LearningModulesApiService },
  320. { provide: SessionService, useValue: session },
  321. ],
  322. });
  323. });
  324. for (const role of [1,3]) {
  325. it(`numbers learning records in reverse across pages and date filters for role ${role}`, async () => {
  326. user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role);
  327. const api=TestBed.inject(ApiService), original=api.post.bind(api);
  328. spyOn(TestBed.inject(LiveDataRefreshService),'watch').and.returnValue(()=>{});
  329. spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  330. if(action!=='app_learning_overview')return original(action,params);
  331. const total=params['date']?2:26,offset=(Number(params['page'])-1)*20;
  332. return of({retcode:0,result:{records:Array.from({length:Math.min(20,total-offset)},(_,i)=>({GeneralID:1000+offset+i,CreateTime:params['date']||'2026-11-12 12:00',con:'课程 '+(total-offset-i)})),courses:[],schedules:[],recordDateCounts:{},scheduleDateCounts:{},page:{itemCount:total,pageCount:Math.ceil(total/20)}}});
  333. }) as typeof api.post);
  334. const harness=await RouterTestingHarness.create('/pages/his/his');await harness.fixture.whenStable();harness.fixture.detectChanges();
  335. const component=harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
  336. const numbers=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.learning-item .record-number'),node=>node.textContent!.trim());
  337. expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i)));
  338. component.goToRecordPage(2);await harness.fixture.whenStable();harness.fixture.detectChanges();
  339. expect(numbers()).toEqual(['6','5','4','3','2','1']);
  340. component.selectDate('2026-11-12');await harness.fixture.whenStable();harness.fixture.detectChanges();
  341. expect(numbers()).toEqual(['2','1']);
  342. });
  343. }
  344. for (const role of [1,3]) {
  345. it(`numbers training records across date groups and pages with newest first for role ${role}`, async () => {
  346. user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role);
  347. const api=TestBed.inject(ApiService), original=api.post.bind(api);
  348. spyOn(TestBed.inject(LiveDataRefreshService),'watch').and.returnValue(()=>{});
  349. const calls:Record<string,unknown>[]=[];
  350. const delayed = new Subject<ApiEnvelope<unknown>>();let retryPage = false;
  351. spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  352. if(action!=='app_companion_overview')return original(action,params);
  353. calls.push(params);if(Number(params['page'])===2 && !retryPage)return delayed;
  354. const total=26,offset=(Number(params['page'])-1)*20;
  355. return of({retcode:0,result:{schedules:Array.from({length:Math.min(20,total-offset)},(_,i)=>({GeneralID:1000+offset+i,bxrq:'2026-11-'+String(26-offset-i).padStart(2,'0'),sdsd:'19:00',Title:'训练 '+(26-offset-i),dszt:30})),courses:[],studentOptions:[],dates:[],earningsSummary:{amount:0,pendingCount:0},page:{itemCount:26,pageCount:2}}});
  356. }) as typeof api.post);
  357. const harness=await RouterTestingHarness.create(role===3?'/pages/member/ke_xiao':'/pages/home/cart?csStatus=finished&csRange=all');
  358. await harness.fixture.whenStable();harness.fixture.detectChanges();
  359. const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
  360. const numbers=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.schedule-list .record-number'),node=>node.textContent!.trim());
  361. expect(calls[0]['history']).toBe(role===3);
  362. expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i)));
  363. expect(harness.routeNativeElement!.querySelector('.schedule-copy strong')?.textContent).toContain('训练 26');
  364. component.turnPage(1);harness.fixture.detectChanges();
  365. expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i)));
  366. delayed.error(new Error('翻页失败'));await harness.fixture.whenStable();harness.fixture.detectChanges();
  367. expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i)));
  368. retryPage=true;component.load(true,true);await harness.fixture.whenStable();harness.fixture.detectChanges();
  369. expect(numbers()).toEqual(['6','5','4','3','2','1']);
  370. });
  371. }
  372. it('filters teacher history students before restoring a stale selection and uses server pagination', async () => {
  373. user.set({userId:300, groupId:3}); session.groupId.set(3);
  374. const watch = spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
  375. const api = TestBed.inject(ApiService), original = api.post.bind(api);
  376. const students = new Subject<ApiEnvelope<unknown>>();
  377. const post = spyOn(api, 'post').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
  378. if (action === 'e_user_list') return students;
  379. return original(action, params);
  380. }) as typeof api.post);
  381. const harness = await RouterTestingHarness.create('/pages/his/his?uid=999');
  382. expect(post.calls.allArgs().find(([action]) => action === 'e_user_list')?.[1]).toEqual(jasmine.objectContaining({scope:'current-scheduled',selectedStudentId:999}));
  383. expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse();
  384. expect(watch).not.toHaveBeenCalled();
  385. students.next({retcode:0,result:[{szyh:100,honeyname:'在学学员'}],page:{itemCount:1,pageCount:1}});
  386. students.complete();
  387. await harness.fixture.whenStable(); harness.fixture.detectChanges();
  388. const component = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
  389. expect(component.studentId).toBe(100);
  390. expect(watch).toHaveBeenCalledTimes(1);
  391. expect(session.student()?.userId).toBe(100);
  392. expect(TestBed.inject(Router).url).toContain('uid=100');
  393. expect(component.studentsHaveMore()).toBeFalse();
  394. expect(post.calls.allArgs().filter(([action]) => action === 'app_learning_overview').map(([,params]) => params?.['studentId'])).toEqual([100]);
  395. expect(harness.routeNativeElement?.querySelector('.student-filter select')?.textContent).toContain('在学学员');
  396. });
  397. it('clears teacher history and the old student context when no eligible scheduled students remain', async () => {
  398. user.set({userId:300, groupId:3}); session.groupId.set(3);
  399. session.student.set({userId:999,groupId:1,honeyName:'旧学员'});
  400. spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
  401. const api = TestBed.inject(ApiService), original = api.post.bind(api);
  402. const post = spyOn(api, 'post').and.callFake(((action: string, params: Record<string, unknown> = {}) =>
  403. action === 'e_user_list' ? of({retcode:0,result:[],page:{itemCount:0,pageCount:0}}) : original(action,params)) as typeof api.post);
  404. const harness = await RouterTestingHarness.create('/pages/his/his?uid=999');
  405. await harness.fixture.whenStable(); harness.fixture.detectChanges();
  406. expect(session.student()).toBeNull();
  407. expect(TestBed.inject(Router).url).toBe('/pages/his/his');
  408. expect(harness.routeNativeElement?.textContent).toContain('暂无在排课的正常学员');
  409. expect(harness.routeNativeElement?.querySelector('.learning-record-card')).toBeNull();
  410. expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse();
  411. });
  412. it('keeps an eligible restored student and a stable student order while loading more pages', async () => {
  413. user.set({userId:300, groupId:3}); session.groupId.set(3);
  414. spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
  415. const api = TestBed.inject(ApiService), original = api.post.bind(api), requests: Record<string,unknown>[] = [];
  416. spyOn(api, 'post').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
  417. if (action !== 'e_user_list') return original(action,params);
  418. requests.push(params);
  419. return of({retcode:0,result:params['cpage']===1?[{szyh:777,honeyname:'原学员'},{szyh:100,honeyname:'另一学员'}]:[{szyh:101,honeyname:'下一页学员'}],page:{itemCount:3,pageCount:2}});
  420. }) as typeof api.post);
  421. const harness = await RouterTestingHarness.create('/pages/his/his?uid=777');
  422. await harness.fixture.whenStable(); harness.fixture.detectChanges();
  423. const component = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
  424. expect(component.studentId).toBe(777);
  425. expect(component.studentsHaveMore()).toBeTrue();
  426. component.studentId=100; component.onFilterChange();
  427. component.loadStudents(true);
  428. await harness.fixture.whenStable(); harness.fixture.detectChanges();
  429. expect(requests.map(params => params['selectedStudentId'])).toEqual([777,777]);
  430. expect(component.students().map(row => component.studentValue(row))).toEqual([777,100,101]);
  431. expect(component.studentId).toBe(100);
  432. expect(component.studentsHaveMore()).toBeFalse();
  433. });
  434. it('shows a retry state instead of loading a stale student when the filtered student request fails', async () => {
  435. user.set({userId:300, groupId:3}); session.groupId.set(3);
  436. session.student.set({userId:999,groupId:1,honeyName:'旧学员'});
  437. const watch = spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
  438. const api = TestBed.inject(ApiService), original = api.post.bind(api);
  439. let fail = true;
  440. const post = spyOn(api, 'post').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
  441. if (action !== 'e_user_list') return original(action,params);
  442. return fail ? throwError(() => new Error('学员列表暂时无法加载')) : of({retcode:0,result:[{szyh:100,honeyname:'在学学员'}],page:{itemCount:1,pageCount:1}});
  443. }) as typeof api.post);
  444. const harness = await RouterTestingHarness.create('/pages/his/his?uid=999');
  445. await harness.fixture.whenStable(); harness.fixture.detectChanges();
  446. expect(session.student()).toBeNull();
  447. expect(harness.routeNativeElement?.textContent).toContain('学员列表加载失败');
  448. expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse();
  449. expect(watch).not.toHaveBeenCalled();
  450. fail = false;
  451. (harness.routeNativeElement?.querySelector('.teacher-student-state button') as HTMLButtonElement).click();
  452. await harness.fixture.whenStable(); harness.fixture.detectChanges();
  453. expect(session.student()?.userId).toBe(100);
  454. expect(harness.routeNativeElement?.querySelector('.teacher-student-state')).toBeNull();
  455. });
  456. it('reconciles monthly review fees from every matching page when an old gateway always returns 29', async () => {
  457. user.set({userId:300, groupId:3, honeyName:'钱老师'}); session.groupId.set(3);
  458. const api = TestBed.inject(ApiService), original = api.post.bind(api), requests: Record<string,unknown>[] = [];
  459. spyOn(api, 'post').and.callFake(((action:string, params:Record<string,unknown> = {}) => {
  460. if (action !== 'app_review_page') return original(action,params);
  461. requests.push(params);
  462. const rows = Array.from({length:params['month']==='2026-09'?6:28}, (_,i) => ({GeneralID:i+1,money:params['month']==='2026-09'?1:i<21?1:0,fxzt:1,kywrq:params['month']+'-15'}));
  463. const size=Number(params['pageSize']), index=Number(params['page']);
  464. return of({result:{items:rows.slice((index-1)*size,index*size),page:{itemCount:rows.length,pageCount:Math.ceil(rows.length/size)},summary:{income:29},dates:[{date:'2026-09-15',total:6,completed:6},{date:'2026-10-15',total:28,completed:28}]}});
  465. }) as typeof api.post);
  466. const harness=await RouterTestingHarness.create('/pages/member/ke_fuxi');
  467. const component=harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
  468. component.setIncomeMonth('2026-10'); await harness.fixture.whenStable(); harness.fixture.detectChanges();
  469. expect(component.totalIncome()).toBe(21); expect(component.items().length).toBe(20);
  470. expect(requests.some(p=>p['month']==='2026-10'&&p['pageSize']===100&&p['includeMetadata']===false)).toBeTrue();
  471. expect(harness.routeNativeElement!.querySelector('.fee-summary-amount')!.textContent).toContain('21.00');
  472. component.setIncomeMonth('2026-09'); await harness.fixture.whenStable(); harness.fixture.detectChanges();
  473. expect(component.totalIncome()).toBe(6); expect(component.serverTotal()).toBe(6);
  474. expect(harness.routeNativeElement!.querySelector('.fee-summary-top')!.textContent).toContain('2026年09月');
  475. });
  476. it('shows lesson fee total with older gateways that omit earningsSummary', async () => {
  477. spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
  478. user.set({userId:300, groupId:3, honeyName:'钱老师'}); session.groupId.set(3);
  479. const api=TestBed.inject(ApiService), original=api.post.bind(api);
  480. spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={}) => {
  481. if(action!=='app_companion_overview')return original(action,params);
  482. return of({result:{courses:[],schedules:Array.from({length:6},(_,i)=>({GeneralID:i+1,bxrq:'2026-09-15',dszt:30,teacherPay:30,payStatus:'earned'})),page:{itemCount:6,pageCount:1},dates:[{date:'2026-09-15',finished:6,planned:0}],studentOptions:[],refreshedAt:'2026-10-28'}});
  483. }) as typeof api.post);
  484. const harness=await RouterTestingHarness.create();
  485. await harness.navigateByUrl('/pages/member/ke_xiao');
  486. const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
  487. component.setHistoryMonth('2026-09'); await harness.fixture.whenStable(); harness.fixture.detectChanges();
  488. expect(component.earningsSummary()?.amount).toBe(180);
  489. expect(harness.routeNativeElement!.querySelector('.fee-summary-amount')!.textContent).toContain('180.00');
  490. });
  491. it('renders scheduled duration for new lessons without duplicating legacy duration labels', async () => {
  492. user.set({ userId: 300, userName: 'coach', honeyName: '钱老师', groupId: 3 });
  493. session.groupId.set(3);
  494. const harness = await RouterTestingHarness.create('/pages/home/shop');
  495. const center = harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
  496. center.scheduleRange.set('all');
  497. center.schedules.set([
  498. { GeneralID: 1, Title: '可爱同学(单词课)', durationMinutes: 30, bxrq: '2026-10-19', sdsd: '15:00', dszt: 0 },
  499. { GeneralID: 2, Title: '小黄(单词课)', durationMinutes: 60, bxrq: '2026-10-19', sdsd: '19:00', dszt: 0 },
  500. { GeneralID: 3, Title: '熊小俊(60分钟练习)', durationMinutes: 60, bxrq: '2026-10-19', sdsd: '14:00', dszt: 0 },
  501. ]);
  502. harness.fixture.detectChanges();
  503. const titles = Array.from(harness.routeNativeElement!.querySelectorAll('.schedule-copy strong')).map(element => element.textContent?.trim());
  504. expect(titles).toEqual(['熊小俊(60分钟练习)', '可爱同学(单词课) · 30分钟', '小黄(单词课) · 60分钟']);
  505. expect(center.scheduleTitle({ Title: '旧课(60分钟练习)', durationMinutes: 30 })).toBe('旧课(30分钟练习)');
  506. for (const durationMinutes of [undefined, null, 0, -30, 'invalid']) {
  507. expect(center.scheduleTitle({ Title: '旧课(60分钟练习)', durationMinutes })).toBe('旧课(60分钟练习)');
  508. }
  509. });
  510. it('keeps all today tasks cached across navigation and visibility', async () => {
  511. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  512. session.groupId.set(3);
  513. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  514. const originalPost = api.post.bind(api);
  515. const post = spyOn(api, 'post').and.callFake((action, params) => {
  516. if (action !== 'app_review_page') return originalPost(action, params);
  517. return of({ retcode: 1, result: {
  518. items: Array.from({length: 7}, (_, i) => ({ GeneralID: 100+i, yhid: 100, Title: '今日学员', kywsj: params?.['date'], fxzt: i === 6 ? 1 : 0 })),
  519. page: { itemCount: 7, pageCount: 1 }, summary: { total: 7, completed: 1, due: 6, today: 7, income: 0 }, dates: [],
  520. } }) as never;
  521. });
  522. const count = () => post.calls.allArgs().filter(([name]) => name === 'app_review_page').length;
  523. const harness = await RouterTestingHarness.create();
  524. await harness.navigateByUrl('/pages/home/index');
  525. harness.fixture.detectChanges();
  526. expect(harness.routeNativeElement?.querySelectorAll('.coach-memory-list article').length).toBe(7);
  527. expect(harness.routeNativeElement?.querySelectorAll('.coach-memory-footer').length).toBe(1);
  528. expect(harness.routeNativeElement?.textContent).not.toContain('查看今日全部任务');
  529. document.dispatchEvent(new Event('visibilitychange'));
  530. await harness.navigateByUrl('/pages/member/starshop');
  531. await harness.navigateByUrl('/pages/home/index');
  532. harness.fixture.detectChanges();
  533. expect(count()).toBe(1);
  534. expect(harness.routeNativeElement?.querySelector('.coach-memory-state')).toBeNull();
  535. (harness.routeNativeElement?.querySelector('.coach-memory-refresh') as HTMLButtonElement).click();
  536. harness.fixture.detectChanges();
  537. expect(count()).toBe(2);
  538. });
  539. it('upgrades a warm legacy home review while preserving existing balance and stats snapshots', async () => {
  540. user.set({userId:100,groupId:1});session.groupId.set(1);
  541. const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date()),at=new Date().toISOString();
  542. localStorage.setItem('xiaoshu.learning.homeDaily.v1',JSON.stringify({identity:`100|1|${today}`,values:{
  543. review:{value:{items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:99,completed:0,due:99,today:99,income:0},legacyReviewSync:{state:'current'}},updatedAt:at},
  544. balances:{value:{UserPoint:12.5,Purse:8,SilverCoin:7},updatedAt:at},
  545. stats:{value:{t_total:18,t_shichang:9},updatedAt:at}
  546. }}));
  547. const api=TestBed.inject(ApiService),original=api.post.bind(api);
  548. const post=spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>action==='e_order_tongji'?of({retcode:0,result:{t_total:18,t_shichang:19}}):action==='app_review_page'?of({retcode:0,result:{view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:2,completed:0,due:2,today:2,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}}}):original(action,params)) as typeof api.post);
  549. const harness=await RouterTestingHarness.create('/pages/home/index');harness.fixture.detectChanges();
  550. expect(post.calls.allArgs().filter(([action])=>action==='app_review_page').length).toBe(1);
  551. expect(post.calls.allArgs().filter(([action])=>action==='e_order_tongji').length).toBe(1);
  552. expect(harness.routeNativeElement?.querySelector('.review-sync-notice')).not.toBeNull();
  553. expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).map(e=>e.textContent?.trim())).toEqual(['18','12.5','8','7']);
  554. expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).not.toContain('99');
  555. });
  556. it('ages the sync check on a mounted or restored home without reloading balances or synchronizing tasks', fakeAsync(() => {
  557. user.set({userId:100,groupId:1});session.groupId.set(1);
  558. const api=TestBed.inject(ApiService),original=api.post.bind(api);
  559. const post=spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>action==='app_review_page'?of({retcode:0,result:{view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:2,completed:0,due:2,today:2,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}}}):original(action,params)) as typeof api.post);
  560. let fixture=TestBed.createComponent(HomePageComponent);fixture.detectChanges();
  561. expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('上次检查');
  562. tick(61_000);fixture.detectChanges();
  563. expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('已过期');
  564. const requests=post.calls.count();fixture.destroy();
  565. fixture=TestBed.createComponent(HomePageComponent);fixture.detectChanges();
  566. expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('已过期');
  567. expect(post.calls.count()).toBe(requests);
  568. fixture.destroy();
  569. }));
  570. it('student home requests a read-only summary and exposes stale sync without blocking learning or altering balances', async () => {
  571. user.set({userId:100,groupId:1});session.groupId.set(1);
  572. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  573. const original=api.post.bind(api);
  574. spyOn(api,'post').and.callFake((action,params={})=>{
  575. if(action==='app_account_overview')return of({retcode:0,result:{profile:{UserPoint:12.5,Purse:8,SilverCoin:7}}}) as any;
  576. if(action!=='app_review_page')return original(action,params);
  577. api.lastMemoryParams=params;
  578. return of({retcode:0,result:{view:'home',items:[],page:{itemCount:0,pageCount:0},summary:{total:50,completed:1,due:2,today:2,income:0},dates:[],legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}}}) as any;
  579. });
  580. const harness=await RouterTestingHarness.create('/pages/home/index');
  581. harness.fixture.detectChanges();
  582. expect(harness.routeNativeElement?.querySelector('.student-stats')?.textContent).not.toContain('累计陪练时长');
  583. expect(api.lastMemoryParams).toEqual(jasmine.objectContaining({view:'home',source:'database'}));
  584. expect(harness.routeNativeElement?.querySelector('.review-sync-notice')?.textContent).toContain('已同步');
  585. expect(harness.routeNativeElement?.querySelector('.review-sync-notice')?.textContent).toContain('暂不可用');
  586. expect(harness.routeNativeElement?.querySelector('.continue-button')).not.toBeNull();
  587. expect((harness.routeNativeElement?.querySelector('.student-home-refresh') as HTMLButtonElement).disabled).toBeFalse();
  588. expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).toContain('2');
  589. expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).slice(1).map(e=>e.textContent?.trim())).toEqual(['12.5','8','7']);
  590. });
  591. for (const groupId of [1, 3]) {
  592. it(`caches homepage overview for role ${groupId} until an explicit refresh`, async () => {
  593. user.set({ userId: groupId === 3 ? 300 : 100, groupId });
  594. session.groupId.set(groupId);
  595. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  596. const post = spyOn(api, 'post').and.callThrough();
  597. const get = spyOn(api, 'get').and.callThrough();
  598. const count = (action: string) => (action === 'app_account_overview' ? get.calls.allArgs() : post.calls.allArgs()).filter(([name]) => name === action).length;
  599. const harness = await RouterTestingHarness.create();
  600. await harness.navigateByUrl('/pages/home/index');
  601. const first = { stats: count('e_order_tongji'), review: count('app_review_page'), balances: count('app_account_overview') };
  602. if (groupId === 1) expect(get.calls.allArgs().find(([name]) => name === 'app_account_overview')?.[1]).toEqual(jasmine.objectContaining({ profileOnly: true, refresh: true }));
  603. document.dispatchEvent(new Event('visibilitychange'));
  604. await harness.navigateByUrl('/pages/member/starshop');
  605. await harness.navigateByUrl('/pages/home/index');
  606. harness.fixture.detectChanges();
  607. expect(count('e_order_tongji')).toBe(first.stats);
  608. expect(count('app_review_page')).toBe(first.review);
  609. expect(count('app_account_overview')).toBe(first.balances);
  610. expect(harness.routeNativeElement?.textContent).not.toContain('正在同步学习计划');
  611. const button = harness.routeNativeElement?.querySelector(groupId === 3 ? '.coach-hero .coach-refresh' : '.student-home-refresh') as HTMLButtonElement;
  612. button.click();
  613. harness.fixture.detectChanges();
  614. expect(count('e_order_tongji')).toBe(first.stats + 1);
  615. expect(count('app_review_page')).toBe(first.review + (groupId === 1 ? 1 : 0));
  616. expect(count('app_account_overview')).toBe(first.balances + (groupId === 1 ? 1 : 0));
  617. });
  618. }
  619. for (const groupId of [1, 3]) {
  620. it(`caches companion and account tabs for role ${groupId} and refreshes only on request`, async () => {
  621. user.set({ userId: groupId === 3 ? 300 : 100, groupId });
  622. session.groupId.set(groupId);
  623. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  624. const post = spyOn(api, 'post').and.callThrough();
  625. const count = (action: string) => post.calls.allArgs().filter(([name]) => name === action).length;
  626. const harness = await RouterTestingHarness.create();
  627. const route = groupId === 3 ? '/pages/home/shop' : '/pages/home/cart';
  628. await harness.navigateByUrl(route);
  629. await harness.navigateByUrl('/pages/home/ucenter');
  630. const before = { companion: count('app_companion_overview'), account: count('app_account_overview'), versions: count('app_live_versions') };
  631. document.dispatchEvent(new Event('visibilitychange'));
  632. await harness.navigateByUrl(route);
  633. harness.fixture.detectChanges();
  634. expect(count('app_companion_overview')).toBe(before.companion);
  635. expect(harness.routeNativeElement?.querySelector('.center-sync')).toBeNull();
  636. const center = harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
  637. center.showTomorrow();
  638. expect(count('app_companion_overview')).toBe(before.companion + 1);
  639. center.showToday();
  640. expect(count('app_companion_overview')).toBe(before.companion + 1);
  641. center.load(true);
  642. expect(count('app_companion_overview')).toBe(before.companion + 2);
  643. await harness.navigateByUrl('/pages/home/ucenter');
  644. harness.fixture.detectChanges();
  645. expect(count('app_account_overview')).toBe(before.account);
  646. expect(harness.routeNativeElement?.textContent).not.toContain('正在同步...');
  647. expect(harness.routeNativeElement?.querySelector('.account-live-status')).toBeNull();
  648. expect(harness.routeNativeElement?.querySelector('.account-refresh')).toBeNull();
  649. expect(count('app_account_overview')).toBe(before.account);
  650. expect(count('app_live_versions')).toBe(before.versions);
  651. });
  652. }
  653. it('shows only review states to students and keeps pay labels on teacher calendars', async () => {
  654. const harness = await RouterTestingHarness.create('/pages/stu/stu_calendar?scope=overdue');
  655. const calendar = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  656. const today = new Date().toLocaleDateString('sv-SE', {timeZone:'Asia/Shanghai'});
  657. calendar.calendarItems.set([
  658. {GeneralID:1,kywsj:'2000-01-01',kywrq:'2000-01-01',fxzt:0},
  659. {GeneralID:2,kywsj:today,kywrq:today,fxzt:0},
  660. {GeneralID:3,kywsj:'2999-01-01',kywrq:'2999-01-01',fxzt:0},
  661. {GeneralID:4,kywsj:'2000-01-01',kywrq:'2000-01-01',fxzt:1},
  662. ]);
  663. const states = () => Array.from(harness.routeNativeElement!.querySelectorAll('.task-state')).map(node => node.textContent!.trim());
  664. harness.fixture.detectChanges();
  665. expect(states()).toEqual(['逾期补做','待复习','未到期','已完成']);
  666. expect(harness.routeNativeElement!.querySelector('.memory-date-tasks h2')?.textContent).toBe('逾期补做');
  667. expect(harness.routeNativeElement!.textContent).not.toMatch(/计薪|记薪|费用|薪酬|收入/);
  668. user.set({userId:300,groupId:3});session.groupId.set(3);harness.fixture.detectChanges();
  669. expect(states()).toEqual(['逾期补做 · 不计薪','待复习 · 当天完成计薪','未到期','已完成']);
  670. expect(harness.routeNativeElement!.querySelector('.memory-date-tasks h2')?.textContent).toBe('逾期补做 · 不计薪');
  671. user.set({userId:100,groupId:1});session.groupId.set(1);harness.fixture.detectChanges();
  672. expect(harness.routeNativeElement!.textContent).not.toMatch(/计薪|记薪|费用|薪酬|收入/);
  673. });
  674. it('loads the review calendar once per login and refreshes only on an explicit request', async () => {
  675. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  676. const originalPost = api.post.bind(api);
  677. const post = spyOn(api, 'post').and.callFake((action, params) => originalPost(action, params));
  678. const count = (action: string) => post.calls.allArgs().filter(([name]) => name === action).length;
  679. const harness = await RouterTestingHarness.create();
  680. await harness.navigateByUrl('/pages/stu/stu_calendar');
  681. harness.fixture.detectChanges();
  682. expect(count('app_review_page')).toBe(1);
  683. expect(count('app_live_versions')).toBe(0);
  684. await harness.navigateByUrl('/pages/member/starshop');
  685. await harness.navigateByUrl('/pages/stu/stu_calendar');
  686. harness.fixture.detectChanges();
  687. expect(count('app_review_page')).toBe(1);
  688. const calendar = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  689. calendar.retryCalendar();
  690. harness.fixture.detectChanges();
  691. expect(count('app_review_page')).toBe(2);
  692. await harness.navigateByUrl('/pages/member/starshop');
  693. session.token.set('next-login-token');
  694. await harness.navigateByUrl('/pages/stu/stu_calendar');
  695. harness.fixture.detectChanges();
  696. expect(count('app_review_page')).toBe(3);
  697. session.token.set('test-token');
  698. });
  699. it('silently reveals pending and completed words opened from student learning content', async () => {
  700. const play = spyOn(TestBed.inject(PlatformService), 'play');
  701. const harness = await RouterTestingHarness.create();
  702. await harness.navigateByUrl('/pages/his/his');
  703. (harness.routeNativeElement?.querySelector('.current-learning-list button') as HTMLButtonElement).click();
  704. await harness.fixture.whenStable();
  705. harness.fixture.detectChanges();
  706. (harness.routeNativeElement?.querySelector('.node-list button') as HTMLButtonElement).click();
  707. await harness.fixture.whenStable();
  708. harness.fixture.detectChanges();
  709. expect(TestBed.inject(Router).url).toContain('readonly=1');
  710. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  711. for (const [index, meaning] of ['n. 香蕉', 'n. 苹果'].entries()) {
  712. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[index] as HTMLButtonElement).click();
  713. harness.fixture.detectChanges();
  714. expect(harness.routeNativeElement?.querySelector('.audio')).toBeNull();
  715. expect(harness.routeNativeElement?.textContent).not.toContain('读音');
  716. const word = harness.routeNativeElement?.querySelector('.word-reveal') as HTMLButtonElement;
  717. word.click();
  718. harness.fixture.detectChanges();
  719. expect(word.textContent).toContain(meaning);
  720. expect(word.getAttribute('aria-expanded')).toBe('true');
  721. word.click();
  722. harness.fixture.detectChanges();
  723. expect(word.getAttribute('aria-expanded')).toBe('false');
  724. }
  725. learning.words().forEach((word) => learning.play(word));
  726. expect(play).not.toHaveBeenCalled();
  727. });
  728. it('pages through all words in a history record without requesting a course unit', async () => {
  729. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  730. const originalPost = api.post.bind(api);
  731. const post = spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
  732. ? of({ retcode: 1, result: Array.from({ length: 31 }, (_, index) => ({
  733. GeneralID: 700 + index,
  734. Title: `history-${index + 1}`,
  735. detail: [{ GeneralID: 700 + index, Title: `history-${index + 1}`, sy: `释义 ${index + 1}` }],
  736. })), addon: [] } as ApiEnvelope<any>)
  737. : originalPost(action, params));
  738. const get = spyOn(api, 'get').and.callThrough();
  739. const harness = await RouterTestingHarness.create();
  740. await harness.navigateByUrl('/pages/his/his_words?id=201');
  741. harness.fixture.detectChanges();
  742. const list = () => harness.routeNativeElement?.querySelectorAll('.readonly-word-list article') ?? [];
  743. expect(list().length).toBe(30);
  744. expect(harness.routeNativeElement?.textContent).toContain('共 31 个单词');
  745. (harness.routeNativeElement?.querySelector('.word-preview-pagination .page-direction:last-of-type') as HTMLButtonElement).click();
  746. await harness.fixture.whenStable();
  747. harness.fixture.detectChanges();
  748. expect(list().length).toBe(1);
  749. expect(list()[0].textContent).toContain('history-31');
  750. expect(harness.routeNativeElement?.textContent).toContain('2 / 2 页');
  751. expect(get.calls.allArgs().some(([action]) => action === 'e_ck_list')).toBeFalse();
  752. expect(post.calls.allArgs().filter(([action]) => action === 'e_record_detail').length).toBe(1);
  753. (harness.routeNativeElement?.querySelector('.word-preview-pagination .page-direction:first-child') as HTMLButtonElement).click();
  754. await harness.fixture.whenStable();
  755. harness.fixture.detectChanges();
  756. expect(list().length).toBe(30);
  757. expect(list()[0].textContent).toContain('history-1');
  758. });
  759. it('plays student history words on click without changing learning records or enabling unit preview audio', async () => {
  760. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  761. api.historyWordAudio = 'apple.mp3';
  762. const post = spyOn(api, 'post').and.callThrough();
  763. const platform = TestBed.inject(PlatformService);
  764. const play = spyOn(platform, 'play').and.resolveTo('played');
  765. const stopAudio = spyOn(platform, 'stopAudio');
  766. const harness = await RouterTestingHarness.create();
  767. await harness.navigateByUrl('/pages/his/his');
  768. (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click();
  769. await harness.fixture.whenStable();
  770. harness.fixture.detectChanges();
  771. expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
  772. expect(play).not.toHaveBeenCalled();
  773. post.calls.reset();
  774. const button = harness.routeNativeElement?.querySelector('.history-word-audio') as HTMLButtonElement;
  775. expect(button.disabled).toBeFalse();
  776. expect(button.getAttribute('aria-label')).toBe('播放单词读音:apple');
  777. expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).not.toContain('苹果');
  778. button.querySelector('strong')!.click();
  779. await harness.fixture.whenStable();
  780. harness.fixture.detectChanges();
  781. expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).not.toContain('苹果');
  782. play.calls.mostRecent().args[2]!.onEnded();
  783. harness.fixture.detectChanges();
  784. expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('苹果');
  785. (button.querySelector('lucide-icon') as HTMLElement).click();
  786. expect(play).toHaveBeenCalledTimes(2);
  787. expect(play.calls.mostRecent().args[0]).toContain('apple.mp3');
  788. expect(post).not.toHaveBeenCalled();
  789. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  790. learning.words.update((words) => words.map((word) => ({ ...word, audio: '' })));
  791. harness.fixture.detectChanges();
  792. expect(button.disabled).toBeFalse();
  793. expect(harness.routeNativeElement?.textContent).toContain('点击生成读音');
  794. button.click();
  795. expect(play).toHaveBeenCalledTimes(2);
  796. stopAudio.calls.reset();
  797. await harness.navigateByUrl('/pages/home/index');
  798. expect(stopAudio).toHaveBeenCalled();
  799. await harness.navigateByUrl('/pages/stu/stu_con?nid=811&readonly=1');
  800. expect(harness.routeNativeElement?.querySelector('.history-word-audio')).toBeNull();
  801. const preview = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  802. preview.words().forEach((word) => preview.play(word));
  803. expect(play).toHaveBeenCalledTimes(2);
  804. });
  805. it('prepares listening choices during matching and reuses them when advancing', async () => {
  806. const prefix = 'xiaoshu.learning.100.100.practice.';
  807. const words = [{GeneralID:1001,Title:'apple',sy:'苹果'},{GeneralID:1002,Title:'banana',sy:'香蕉'}];
  808. localStorage.setItem(prefix+'learn_mode','newword');
  809. localStorage.setItem(prefix+'choose_newword_learn',JSON.stringify(words));
  810. localStorage.setItem(prefix+'_page_word_orilist',JSON.stringify([words]));
  811. localStorage.setItem(prefix+'_page_current_learn','1');
  812. const response = new Subject<ApiEnvelope<any>>();
  813. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  814. const get = api.get.bind(api);
  815. let count = 0;
  816. spyOn(api,'get').and.callFake((action,params) => {
  817. if (action==='content_list' && params?.['orders']==='NEWID()') {count++;return response;}
  818. return get(action,params);
  819. });
  820. spyOn(TestBed.inject(PlatformService),'play');
  821. const harness = await RouterTestingHarness.create();
  822. await harness.navigateByUrl('/pages/stu/stu3');
  823. expect(harness.routeNativeElement?.querySelector('.match-grid')).not.toBeNull();
  824. expect(count).toBe(1);
  825. response.next({retcode:1,result:[{GeneralID:301,Title:'cat',sy:'猫'},{GeneralID:302,Title:'dog',sy:'狗'},{GeneralID:303,Title:'bird',sy:'鸟'}]});
  826. response.complete();
  827. await harness.navigateByUrl('/pages/stu/stu4');
  828. expect(count).toBe(1);
  829. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  830. expect(learning.choicesLoading()).toBeFalse();
  831. expect(harness.routeNativeElement?.querySelectorAll('.option-list button').length).toBe(4);
  832. });
  833. it('keeps the spoken word among listening choices and discards answers from an earlier question', async () => {
  834. const prefix = 'xiaoshu.learning.100.100.practice.';
  835. const selectedWords = [
  836. { GeneralID: 1001, Title: 'apple', sy: '苹果', yp: 'apple.mp3' },
  837. { GeneralID: 1002, Title: 'banana', sy: '香蕉', yp: 'banana.mp3' },
  838. ];
  839. localStorage.setItem(prefix + 'learn_mode', 'newword');
  840. localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify(selectedWords));
  841. localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([selectedWords]));
  842. localStorage.setItem(prefix + '_page_current_learn', '1');
  843. const responses = [new Subject<ApiEnvelope<any>>(), new Subject<ApiEnvelope<any>>()];
  844. let requestCount = 0;
  845. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  846. const get = api.get.bind(api);
  847. spyOn(api, 'get').and.callFake((action, params) => action === 'content_list' && params?.['orders'] === 'NEWID()'
  848. ? responses[requestCount++] ?? of({ retcode: 1, result: [] } as ApiEnvelope<any>)
  849. : get(action, params));
  850. spyOn(TestBed.inject(PlatformService), 'play');
  851. const harness = await RouterTestingHarness.create();
  852. await harness.navigateByUrl('/pages/stu/stu4');
  853. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  854. const firstWord = learning.current()!;
  855. const firstResponse = responses[0];
  856. expect(requestCount).toBe(1);
  857. expect(learning.choicesLoading()).toBeTrue();
  858. learning.chooseMeaning(firstWord);
  859. expect(learning.current()).toBe(firstWord); // Cannot answer while options are loading.
  860. firstResponse?.next({ retcode: 1, result: Array.from({ length: 8 }, (_, index) => ({
  861. GeneralID: 301 + index, Title: `extra-${index}`, sy: `干扰项 ${index}`,
  862. })) });
  863. expect(learning.choiceOptions().length).toBe(4);
  864. expect(learning.choiceOptions().filter(option => Number(option.id) >= 301).length).toBeGreaterThanOrEqual(2);
  865. learning.chooseMeaning(firstWord);
  866. const secondWord = learning.current()!;
  867. expect(secondWord.id).not.toBe(firstWord.id);
  868. expect(requestCount).toBe(1); // Reuse the pool instead of requesting each question.
  869. firstResponse?.next({ retcode: 1, result: [{ GeneralID: 999, Title: 'stale', sy: '迟到结果' }] });
  870. expect(learning.choiceOptions().length).toBe(4);
  871. expect(learning.choiceOptions().some(option => option.id === '999')).toBeFalse();
  872. expect(learning.choiceOptions().some(option => option.id === secondWord.id)).toBeTrue();
  873. localStorage.removeItem(prefix + 'learn_mode');
  874. localStorage.removeItem(prefix + 'choose_newword_learn');
  875. localStorage.removeItem(prefix + '_page_word_orilist');
  876. localStorage.removeItem(prefix + '_page_current_learn');
  877. });
  878. it('lets a student study selected words from their wordbook while keeping course stages teacher-led', async () => {
  879. localStorage.removeItem('xiaoshu.learning.100.100.practice.learn_mode');
  880. localStorage.removeItem('xiaoshu.learning.100.100.practice.choose_newword_learn');
  881. const harness = await RouterTestingHarness.create();
  882. const router = TestBed.inject(Router);
  883. await harness.navigateByUrl('/pages/member/starshop');
  884. (harness.routeNativeElement?.querySelector('.word-select-list > button') as HTMLButtonElement).click();
  885. (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  886. await harness.fixture.whenStable();
  887. harness.fixture.detectChanges();
  888. const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  889. expect(router.url).toContain('/pages/stu/stu_con');
  890. expect(learning().words().map((word) => word.word)).toEqual(['tend']);
  891. learning().skipStudyList();
  892. await harness.fixture.whenStable();
  893. harness.fixture.detectChanges();
  894. expect(router.url).toContain('/pages/stu/stu3');
  895. expect(learning().words().map((word) => word.word)).toEqual(['tend']);
  896. learning().chooseMatch('left', learning().words()[0]);
  897. learning().chooseMatch('right', learning().words()[0]);
  898. learning().continueAfterStage();
  899. await harness.fixture.whenStable();
  900. harness.fixture.detectChanges();
  901. expect(router.url).toContain('/pages/stu/stu4');
  902. expect(learning().words().map((word) => word.word)).toEqual(['tend']);
  903. learning().chooseMeaning(learning().words()[0]);
  904. learning().continueAfterStage();
  905. await harness.fixture.whenStable();
  906. harness.fixture.detectChanges();
  907. expect(router.url).toContain('/pages/stu/stu5');
  908. for (const letter of learning().current()!.word) {
  909. const index = learning().letters().findIndex((choice) => !choice.used && choice.value === letter);
  910. expect(index).toBeGreaterThanOrEqual(0);
  911. learning().spellLetter(index);
  912. }
  913. learning().nextSpellingWord();
  914. learning().continueAfterStage();
  915. await harness.fixture.whenStable();
  916. expect(router.url).toBe('/pages/member/starshop');
  917. expect(localStorage.getItem('xiaoshu.learning.100.100.practice.learn_mode')).toBeNull();
  918. await harness.navigateByUrl('/pages/stu/stu_con?nids=811&pnid=801');
  919. await harness.fixture.whenStable();
  920. expect(router.url).toBe('/pages/home/cart');
  921. });
  922. it('lets a teacher open a student wordbook, switch learners and save practice for the selected learner', async () => {
  923. user.set({userId:300, userName:'coach', honeyName:'老师', groupId:3});
  924. session.groupId.set(3);
  925. selectedStudent.set({userId:888, honeyName:'正在排课的学员', groupId:1});
  926. spyOn(TestBed.inject(PlatformService), 'play').and.resolveTo('played');
  927. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  928. const get = api.get.bind(api);
  929. const pendingFirstLearner = new Subject<ApiEnvelope<any>>();
  930. const words = (uid: number, completed = false) => ({
  931. retcode:1, result:[{GeneralID:uid, xxcs:completed ? 2 : 1,
  932. detail:[{GeneralID:uid+700, Title:uid===100 ? 'apple' : 'banana', sy:uid===100 ? '苹果' : '香蕉', yp:'/UploadFiles/test.mp3'}]}],
  933. page:{itemCount:1, pendingCount:completed ? 0 : 1, completedCount:completed ? 1 : 0},
  934. });
  935. const read = spyOn(api, 'get').and.callFake((action, params): Observable<ApiEnvelope<any>> => {
  936. if (action === 'e_user_list') return of({retcode:1, result:[{userId:100, honeyName:'学员甲'}, {userId:101, honeyName:'学员乙'}]});
  937. if (action !== 'e_words_list') return get(action, params);
  938. const uid = Number(params?.['uid']);
  939. const completed = params?.['learningStatus'] === 'completed';
  940. if (uid === 100 && completed) return pendingFirstLearner;
  941. const saved = uid === 101 && api.lastLearningWordUpdate['save'] === 1;
  942. if (saved !== completed) return of({retcode:1, result:[], page:{itemCount:0, pendingCount:saved ? 0 : 1, completedCount:saved ? 1 : 0}});
  943. return of(words(uid, saved));
  944. });
  945. const harness = await RouterTestingHarness.create();
  946. const router = TestBed.inject(Router);
  947. const page = () => harness.routeNativeElement!;
  948. const stage = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  949. const settled = async () => { await harness.fixture.whenStable(); harness.fixture.detectChanges(); };
  950. await harness.navigateByUrl('/pages/home/index');
  951. (page().querySelector('.coach-wordbook-entry') as HTMLAnchorElement).click();
  952. await settled();
  953. expect(router.url).toBe('/pages/member/starshop');
  954. expect(read).toHaveBeenCalledWith('e_user_list', jasmine.objectContaining({scope:'wordbook'}));
  955. expect(page().querySelector('.word-select-list')?.textContent).toContain('apple');
  956. expect(page().querySelector('.danger-button')).toBeNull();
  957. (page().querySelector('.word-select-list button') as HTMLButtonElement).click();
  958. (page().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  959. const picker = page().querySelector<HTMLSelectElement>('#wordbook-student')!;
  960. picker.value = '101';
  961. picker.dispatchEvent(new Event('change'));
  962. pendingFirstLearner.next(words(100, true));
  963. await settled();
  964. expect(page().querySelector('.word-select-list')?.textContent).toContain('banana');
  965. expect(page().querySelector('.word-select-list')?.textContent).not.toContain('apple');
  966. expect(stage().selected().size).toBe(0);
  967. expect(selectedStudent()?.userId).toBe(888);
  968. (page().querySelector('.word-select-list button') as HTMLButtonElement).click();
  969. await settled();
  970. (page().querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  971. await settled();
  972. expect(router.url).toBe('/pages/stu/stu_con?wordbookStudentId=101');
  973. expect(stage().words().map(word=>word.word)).toEqual(['banana']);
  974. stage().skipStudyList();
  975. await settled();
  976. expect(router.url).toBe('/pages/stu/stu3?wordbookStudentId=101');
  977. stage().chooseMatch('left', stage().words()[0]);
  978. stage().chooseMatch('right', stage().words()[0]);
  979. stage().continueAfterStage();
  980. await settled();
  981. stage().chooseMeaning(stage().words()[0]);
  982. stage().continueAfterStage();
  983. await settled();
  984. expect(router.url).toBe('/pages/stu/pl_stu5?wordbookStudentId=101');
  985. stage().skipSpellingStage();
  986. await settled();
  987. expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({uid:101, scope:'wordbook', wordsId:'801', save:1}));
  988. expect(router.url).toBe('/pages/member/starshop?wordbookStudentId=101');
  989. expect(page().querySelector<HTMLSelectElement>('#wordbook-student')?.value).toBe('101');
  990. expect(selectedStudent()?.userId).toBe(888);
  991. (page().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  992. await settled();
  993. expect(page().querySelector('.completed-column')?.textContent).toContain('banana');
  994. }, 15000);
  995. it('separates unlearned and learned wordbook words while keeping selections across tabs', async () => {
  996. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  997. const get = api.get.bind(api);
  998. spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
  999. ? of({ retcode: 1, result: params?.['learningStatus'] === 'completed'
  1000. ? [{ GeneralID: 602, Title: 'prepare@张雅茜', xxcs: 2, detail: [{ GeneralID: 702, Title: 'prepare', sy: '准备' }] }]
  1001. : [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 1, detail: [{ GeneralID: 701, Title: 'tend', sy: '照顾' }] }],
  1002. page: { itemCount: 1, pendingCount: 1, completedCount: 1, pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope<any>)
  1003. : get(action, params));
  1004. const harness = await RouterTestingHarness.create();
  1005. await harness.navigateByUrl('/pages/member/starshop');
  1006. harness.fixture.detectChanges();
  1007. const tabs = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.word-selection-tabs button') ?? [];
  1008. expect(tabs[0].textContent).toContain('未学习');
  1009. expect(tabs[0].textContent).toContain('1');
  1010. expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('tend');
  1011. (harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button') as HTMLButtonElement).click();
  1012. tabs[1].click();
  1013. harness.fixture.detectChanges();
  1014. expect(tabs[1].textContent).toContain('已学习');
  1015. expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('prepare');
  1016. (harness.routeNativeElement?.querySelector('.completed-column .word-select-list > button') as HTMLButtonElement).click();
  1017. harness.fixture.detectChanges();
  1018. expect(harness.routeNativeElement?.querySelector('.selection-header strong')?.textContent).toContain('已选 2 个单词');
  1019. tabs[0].click();
  1020. harness.fixture.detectChanges();
  1021. expect(harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button.selected')).not.toBeNull();
  1022. });
  1023. for (const legacy of [false, true]) {
  1024. it(`selects wordbook words independently when practice IDs repeat (${legacy ? 'legacy' : 'paged'} response)`, async () => {
  1025. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1026. const get = api.get.bind(api);
  1027. const pending = Array.from({ length: 35 }, (_, index) => ({
  1028. GeneralID: 601,
  1029. scid: 701 + index,
  1030. xxcs: 1,
  1031. detail: [{ GeneralID: 701 + index, Title: `word-${index + 1}`, yp: 'word.mp3' }],
  1032. }));
  1033. const completed = [{ GeneralID: 601, scid: 801, xxcs: 2, detail: [{ GeneralID: 801, Title: 'done' }] }];
  1034. spyOn(api, 'get').and.callFake((action, params) => {
  1035. if (action !== 'e_words_list') return get(action, params);
  1036. const rows = legacy ? [...pending, ...completed] : params?.['learningStatus'] === 'completed' ? completed : pending;
  1037. const page = Number(params?.['cpage']);
  1038. const size = Number(params?.['psize']);
  1039. return of({ retcode: 1, result: rows.slice((page - 1) * size, page * size), page: {
  1040. itemCount: rows.length, pageCount: Math.ceil(rows.length / size), pageIndex: page, pageSize: size,
  1041. ...(!legacy ? { pendingCount: pending.length, completedCount: completed.length } : {}),
  1042. } } as ApiEnvelope<any>);
  1043. });
  1044. const harness = await RouterTestingHarness.create();
  1045. await harness.navigateByUrl('/pages/member/starshop');
  1046. const view = () => harness.routeNativeElement!;
  1047. const rows = () => view().querySelectorAll<HTMLButtonElement>('.word-select-list > button');
  1048. const selected = () => Array.from(view().querySelectorAll('.word-select-list > button.selected strong'), node => node.textContent);
  1049. const click = (element: HTMLElement) => { element.click(); harness.fixture.detectChanges(); };
  1050. const expectCount = (count: number) => expect(view().querySelector('.selection-header strong')?.textContent).toContain(`已选 ${count} 个单词`);
  1051. click(rows()[3]);
  1052. expect(selected()).toEqual(['word-4']);
  1053. expectCount(1);
  1054. click(rows()[1]);
  1055. expect(selected()).toEqual(['word-2', 'word-4']);
  1056. click(rows()[3]);
  1057. expect(selected()).toEqual(['word-2']);
  1058. click(rows()[1].querySelector('.audio') as HTMLElement);
  1059. expect(selected()).toEqual(['word-2']);
  1060. click(view().querySelectorAll<HTMLButtonElement>('.mobile-pagination button')[1]);
  1061. click(rows()[0]);
  1062. expect(selected()).toEqual(['word-31']);
  1063. expectCount(2);
  1064. click(view().querySelectorAll<HTMLButtonElement>('.word-selection-tabs button')[1]);
  1065. expect(selected()).toEqual([]);
  1066. click(rows()[0]);
  1067. expectCount(3);
  1068. click(view().querySelectorAll<HTMLButtonElement>('.word-selection-tabs button')[0]);
  1069. expect(selected()).toEqual(['word-2']);
  1070. click(view().querySelector('.selection-header .primary-button') as HTMLButtonElement);
  1071. await harness.fixture.whenStable();
  1072. harness.fixture.detectChanges();
  1073. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1074. expect(learning.words().map(word => word.word)).toEqual(['word-2', 'word-31', 'done']);
  1075. expect(new Set(learning.words().map(word => word.id)).size).toBe(3);
  1076. learning.leave();
  1077. await harness.fixture.whenStable();
  1078. });
  1079. }
  1080. it('deletes only the selected vocabulary ID when a wordbook detail uses scid as its identity', async () => {
  1081. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1082. const get = api.get.bind(api);
  1083. spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
  1084. ? of({ retcode: 1, result: [
  1085. { GeneralID: 601, scid: 701, xxcs: 1, detail: [{ GeneralID: 701, Title: 'first' }] },
  1086. { GeneralID: 601, scid: 702, xxcs: 1, detail: { Title: 'second' } },
  1087. ], page: { itemCount: 2, pendingCount: 2, completedCount: 0, pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope<any>)
  1088. : get(action, params));
  1089. const post = spyOn(api, 'post').and.callThrough();
  1090. const harness = await RouterTestingHarness.create();
  1091. await harness.navigateByUrl('/pages/member/starshop');
  1092. const view = harness.routeNativeElement!;
  1093. view.querySelectorAll<HTMLButtonElement>('.word-select-list > button')[1].click();
  1094. harness.fixture.detectChanges();
  1095. expect(view.querySelectorAll('.word-select-list > button.selected').length).toBe(1);
  1096. (view.querySelector('.danger-button') as HTMLButtonElement).click();
  1097. expect(post).toHaveBeenCalledWith('e_add_words', jasmine.objectContaining({ uid: 100, wordsId: '702', ifnew: 0 }));
  1098. });
  1099. for (const role of [1,3]) {
  1100. it(`reveals a wordbook meaning only after completed pronunciation for role ${role}`,async()=>{
  1101. user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role);
  1102. const api=TestBed.inject(ApiService),original=api.get.bind(api);
  1103. spyOn(api,'get').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  1104. if(action==='e_user_list')return of({retcode:0,result:[{userId:100,honeyName:'学员甲'},{userId:101,honeyName:'学员乙'}]});
  1105. if(action==='e_words_list')return of({retcode:0,result:[{GeneralID:601,xxcs:params['learningStatus']==='completed'?2:1,detail:[{GeneralID:701,Title:'apple',sy:'苹果',yp:'apple.mp3'}]},{GeneralID:602,xxcs:params['learningStatus']==='completed'?2:1,detail:[{GeneralID:702,Title:'banana',sy:'香蕉',yp:'banana.mp3'}]}],...(role===3?{page:{itemCount:2,pendingCount:2,completedCount:2}}:{})});
  1106. return original(action,params);
  1107. }) as typeof api.get);
  1108. const platform=TestBed.inject(PlatformService),play=spyOn(platform,'play').and.resolveTo('played');spyOn(platform,'toast');
  1109. const harness=await RouterTestingHarness.create('/pages/member/starshop');await harness.fixture.whenStable();harness.fixture.detectChanges();
  1110. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1111. const rows=()=>harness.routeNativeElement!.querySelectorAll<HTMLButtonElement>('.word-select-list > button');
  1112. const meanings=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.word-select-list small'),node=>node.textContent);
  1113. expect(meanings()).toEqual([]);
  1114. rows()[0].click();await harness.fixture.whenStable();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
  1115. const old=play.calls.mostRecent().args[2]!;expect(old).toBeDefined();if(!old)return;
  1116. rows()[1].click();await harness.fixture.whenStable();harness.fixture.detectChanges();
  1117. old.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
  1118. play.calls.mostRecent().args[2]!.onError?.();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
  1119. rows()[0].querySelector<HTMLElement>('.audio')!.click();await harness.fixture.whenStable();
  1120. play.calls.mostRecent().args[2]!.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual(['苹果']);
  1121. expect(component.selected().size).toBe(2);
  1122. component.load();await harness.fixture.whenStable();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
  1123. rows()[0].click();await harness.fixture.whenStable();const late=play.calls.mostRecent().args[2]!;
  1124. if(role===3)component.selectWordbookStudent(101);else {component.selectNewWordBucket('completed');component.selectNewWordBucket('pending');}
  1125. await harness.fixture.whenStable();late.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
  1126. });
  1127. }
  1128. it('pronounces a wordbook word on row click and only once on speaker click', async () => {
  1129. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1130. const get = api.get.bind(api);
  1131. spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
  1132. ? of({ retcode: 1, result: [{ GeneralID: 601, xxcs: 1, detail: [{ GeneralID: 701, Title: 'tend', yp: 'UploadFiles/tend.mp3' }] }],
  1133. page: { itemCount: 1, pageCount: 1, pageIndex: 1, pageSize: Number(params?.['psize']) } } as ApiEnvelope<any>)
  1134. : get(action, params));
  1135. const play = spyOn(TestBed.inject(PlatformService), 'play');
  1136. const harness = await RouterTestingHarness.create();
  1137. await harness.navigateByUrl('/pages/member/starshop');
  1138. harness.fixture.detectChanges();
  1139. const row = harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button') as HTMLButtonElement;
  1140. row.querySelector('strong')!.click();
  1141. harness.fixture.detectChanges();
  1142. expect(row.classList.contains('selected')).toBeTrue();
  1143. expect(play).toHaveBeenCalledTimes(1);
  1144. expect(play.calls.mostRecent().args[0]).toBe('UploadFiles/tend.mp3');
  1145. expect(play.calls.mostRecent().args[0]).not.toContain('UploadFiles/UploadFiles');
  1146. (row.querySelector('.audio') as HTMLElement).click();
  1147. harness.fixture.detectChanges();
  1148. expect(play).toHaveBeenCalledTimes(2);
  1149. expect(row.classList.contains('selected')).toBeTrue();
  1150. });
  1151. it('shows 30 wordbook words per page while counting every learning status', async () => {
  1152. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1153. const get = api.get.bind(api);
  1154. const pending = Array.from({ length: 65 }, (_, index) => ({ GeneralID: 601 + index, xxcs: 1, detail: [{ GeneralID: 701 + index, Title: `pending-${index + 1}` }] }));
  1155. const completed = Array.from({ length: 7 }, (_, index) => ({ GeneralID: 801 + index, xxcs: 2, detail: [{ GeneralID: 901 + index, Title: `completed-${index + 1}` }] }));
  1156. const requests: Record<string, unknown>[] = [];
  1157. spyOn(api, 'get').and.callFake((action, params) => {
  1158. if (action !== 'e_words_list') return get(action, params);
  1159. requests.push(params ?? {});
  1160. const rows = params?.['learningStatus'] === 'completed' ? completed : pending;
  1161. const page = Number(params?.['cpage']);
  1162. const size = Number(params?.['psize']);
  1163. return of({ retcode: 1, result: rows.slice((page - 1) * size, page * size),
  1164. page: { itemCount: rows.length, pendingCount: pending.length, completedCount: completed.length,
  1165. pageCount: Math.ceil(rows.length / size), pageIndex: page, pageSize: size } } as ApiEnvelope<any>);
  1166. });
  1167. const harness = await RouterTestingHarness.create();
  1168. await harness.navigateByUrl('/pages/member/starshop');
  1169. harness.fixture.detectChanges();
  1170. const view = () => harness.routeNativeElement!;
  1171. const rows = () => view().querySelectorAll('.pending-column .word-select-list > button');
  1172. expect(requests[0]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', cpage: 1, psize: 30 }));
  1173. expect(rows().length).toBe(30);
  1174. expect(view().querySelectorAll('.word-selection-tabs b')[0].textContent).toContain('65');
  1175. expect(view().querySelectorAll('.word-selection-tabs b')[1].textContent).toContain('7');
  1176. (rows()[0] as HTMLButtonElement).click();
  1177. (view().querySelectorAll('.mobile-pagination button')[1] as HTMLButtonElement).click();
  1178. harness.fixture.detectChanges();
  1179. expect(requests[1]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', cpage: 2, psize: 30 }));
  1180. expect(rows().length).toBe(30);
  1181. expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 2 / 3 页');
  1182. (view().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  1183. harness.fixture.detectChanges();
  1184. expect(requests[2]).toEqual(jasmine.objectContaining({ learningStatus: 'completed', cpage: 1, psize: 30 }));
  1185. expect(view().querySelectorAll('.completed-column .word-select-list > button').length).toBe(7);
  1186. expect(view().querySelector('.selection-header strong')?.textContent).toContain('已选 1 个单词');
  1187. });
  1188. it('keeps wordbook counts and rows correct when the server only returns the old mixed list', async () => {
  1189. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1190. const get = api.get.bind(api);
  1191. const all = Array.from({ length: 139 }, (_, index) => ({
  1192. GeneralID: 601 + index,
  1193. xxcs: 1,
  1194. detail: [{ GeneralID: 701 + index, Title: `word-${index + 1}` }],
  1195. }));
  1196. const requests: Record<string, unknown>[] = [];
  1197. spyOn(api, 'get').and.callFake((action, params) => {
  1198. if (action !== 'e_words_list') return get(action, params);
  1199. requests.push(params ?? {});
  1200. const page = Number(params?.['cpage']);
  1201. const size = Number(params?.['psize']);
  1202. return of({ retcode: 1, result: all.slice((page - 1) * size, page * size),
  1203. page: { itemCount: all.length, pageCount: Math.ceil(all.length / size), pageIndex: page, pageSize: size } } as ApiEnvelope<any>);
  1204. });
  1205. const harness = await RouterTestingHarness.create();
  1206. await harness.navigateByUrl('/pages/member/starshop');
  1207. harness.fixture.detectChanges();
  1208. const view = () => harness.routeNativeElement!;
  1209. expect(requests.length).toBe(2);
  1210. expect(requests[0]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', psize: 30 }));
  1211. expect(requests[1]).toEqual(jasmine.objectContaining({ cpage: 1, psize: 500 }));
  1212. expect(view().querySelectorAll('.word-selection-tabs b')[0].textContent).toContain('139');
  1213. expect(view().querySelectorAll('.word-selection-tabs b')[1].textContent).toContain('0');
  1214. expect(view().querySelectorAll('.pending-column .word-select-list > button').length).toBe(30);
  1215. expect(view().querySelector('.pending-column .word-select-list > button')?.textContent).toContain('word-1');
  1216. expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 1 / 5 页 · 共 139 个单词');
  1217. (view().querySelectorAll('.mobile-pagination button')[1] as HTMLButtonElement).click();
  1218. harness.fixture.detectChanges();
  1219. expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 2 / 5 页');
  1220. (view().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  1221. harness.fixture.detectChanges();
  1222. expect(view().querySelectorAll('.completed-column .word-select-list > button').length).toBe(0);
  1223. expect(requests.length).toBe(2);
  1224. });
  1225. it('saves completed wordbook study before showing the word in the learned tab', async () => {
  1226. const prefix = 'xiaoshu.learning.100.100.practice.';
  1227. const word = { GeneralID: 701, Title: 'tend', sy: '照顾' };
  1228. localStorage.setItem(prefix + 'learn_mode', JSON.stringify('newword'));
  1229. localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify([word]));
  1230. localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([[word]]));
  1231. localStorage.setItem(prefix + '_page_current_learn', JSON.stringify(1));
  1232. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1233. const get = api.get.bind(api);
  1234. spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
  1235. ? of({ retcode: 1, result: api.lastLearningWordUpdate['save'] === 1 && params?.['learningStatus'] === 'completed'
  1236. ? [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 2, detail: [word] }]
  1237. : api.lastLearningWordUpdate['save'] === 1 ? [] : [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 1, detail: [word] }],
  1238. page: { itemCount: params?.['learningStatus'] === 'completed' ? 1 : api.lastLearningWordUpdate['save'] === 1 ? 0 : 1,
  1239. pendingCount: api.lastLearningWordUpdate['save'] === 1 ? 0 : 1, completedCount: api.lastLearningWordUpdate['save'] === 1 ? 1 : 0,
  1240. pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope<any>)
  1241. : get(action, params));
  1242. const harness = await RouterTestingHarness.create();
  1243. const router = TestBed.inject(Router);
  1244. await harness.navigateByUrl('/pages/stu/stu5');
  1245. const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1246. learning.skipSpellingStage();
  1247. await harness.fixture.whenStable();
  1248. harness.fixture.detectChanges();
  1249. expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({ uid: 100, wordsId: '701', save: 1 }));
  1250. expect(router.url).toBe('/pages/member/starshop');
  1251. const tabs = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.word-selection-tabs button') ?? [];
  1252. expect(tabs[0].textContent).toContain('0');
  1253. tabs[1].click();
  1254. harness.fixture.detectChanges();
  1255. expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('tend');
  1256. }, 15000);
  1257. it('skips the entire spelling stage for student wordbook self-study in one click', async () => {
  1258. const prefix = 'xiaoshu.learning.100.100.practice.';
  1259. const words = [
  1260. { GeneralID: 701, Title: 'tend', sy: '照顾' },
  1261. { GeneralID: 702, Title: 'work on', sy: '从事' },
  1262. { GeneralID: 703, Title: 'prepare', sy: '准备' },
  1263. ];
  1264. localStorage.setItem(prefix + 'learn_mode', JSON.stringify('newword'));
  1265. localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify(words.slice(0, 2)));
  1266. localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([words.slice(0, 2), words.slice(2)]));
  1267. localStorage.setItem(prefix + '_page_current_learn', JSON.stringify(1));
  1268. const harness = await RouterTestingHarness.create();
  1269. const router = TestBed.inject(Router);
  1270. await harness.navigateByUrl('/pages/stu/stu5');
  1271. harness.fixture.detectChanges();
  1272. const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1273. expect(learning().words().length).toBe(2);
  1274. expect(learning().currentIndex()).toBe(0);
  1275. const skip = Array.from(harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.skippable-spelling-actions button') ?? [])
  1276. .find((button) => button.textContent?.includes('跳过这一关'))!;
  1277. skip.click();
  1278. await harness.fixture.whenStable();
  1279. harness.fixture.detectChanges();
  1280. expect(router.url).toContain('/pages/stu/stu_con');
  1281. expect(learning().words().map((word) => word.word)).toEqual(['prepare']);
  1282. learning().skipStudyList();
  1283. await harness.fixture.whenStable();
  1284. harness.fixture.detectChanges();
  1285. learning().chooseMatch('left', learning().words()[0]);
  1286. learning().chooseMatch('right', learning().words()[0]);
  1287. learning().continueAfterStage();
  1288. await harness.fixture.whenStable();
  1289. harness.fixture.detectChanges();
  1290. expect(router.url).toContain('/pages/stu/stu4');
  1291. learning().chooseMeaning(learning().words()[0]);
  1292. learning().continueAfterStage();
  1293. await harness.fixture.whenStable();
  1294. expect(router.url).toBe('/pages/member/starshop');
  1295. expect(localStorage.getItem(prefix + 'learn_mode')).toBeNull();
  1296. }, 15000);
  1297. for(const pass of [0,1,2,3]) {
  1298. it(`waits for course word audio before revealing meanings in pass ${pass}`,async()=>{
  1299. const {harness,component}=await teacherSelection(2,pass,'901');
  1300. component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'})));
  1301. const platform=TestBed.inject(PlatformService),play=spyOn(platform,'play').and.resolveTo('played');spyOn(platform,'toast');
  1302. harness.fixture.detectChanges();const view=()=>harness.routeNativeElement!;
  1303. const meanings=()=>view().querySelectorAll('.word-reveal small.revealed').length;
  1304. expect(meanings()).toBe(0);
  1305. (view().querySelector('.word-reveal') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges();
  1306. expect(meanings()).toBe(0);const old=play.calls.mostRecent().args[2]!;expect(old).toBeDefined();if(!old)return;
  1307. old.onEnded();harness.fixture.detectChanges();expect(meanings()).toBe(1);
  1308. component.load();await harness.fixture.whenStable();if(pass>0){component.selectCoursePass(pass);await harness.fixture.whenStable();}component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'})));harness.fixture.detectChanges();expect(meanings()).toBe(0);
  1309. (view().querySelector('.word-reveal') as HTMLButtonElement).click();await harness.fixture.whenStable();const late=play.calls.mostRecent().args[2]!;
  1310. (view().querySelectorAll('.word-reveal')[1] as HTMLButtonElement).click();await harness.fixture.whenStable();late.onEnded();harness.fixture.detectChanges();expect(meanings()).toBe(0);
  1311. play.calls.mostRecent().args[2]!.onError?.();harness.fixture.detectChanges();expect(meanings()).toBe(0);
  1312. });
  1313. }
  1314. it('still pronounces pending and completed words when a coach reveals them', async () => {
  1315. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1316. session.groupId.set(3);
  1317. session.student.set({ userId: 100, userName: 'student', groupId: 1 });
  1318. const play = spyOn(TestBed.inject(PlatformService), 'play');
  1319. const harness = await RouterTestingHarness.create();
  1320. await harness.navigateByUrl('/pages/stu/stu_abc?nids=811,812&pnid=801');
  1321. for (const index of [0, 1]) {
  1322. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[index] as HTMLButtonElement).click();
  1323. harness.fixture.detectChanges();
  1324. expect(harness.routeNativeElement?.querySelector('.audio')).not.toBeNull();
  1325. (harness.routeNativeElement?.querySelector('.word-reveal') as HTMLButtonElement).click();
  1326. }
  1327. expect(play).toHaveBeenCalledTimes(2);
  1328. expect(play.calls.argsFor(0)[0]).toContain('banana.mp3');
  1329. expect(play.calls.argsFor(1)[0]).toContain('apple.mp3');
  1330. });
  1331. it('allows second and third passes per word before the whole book is finished and stops selection after three passes', async () => {
  1332. user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
  1333. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1334. api.courseWords=[{GeneralID:1001,NodeID:811,Title:'apple',w_learned:1},{GeneralID:1002,NodeID:811,Title:'banana',w_learned:2},{GeneralID:1003,NodeID:811,Title:'pear',w_learned:3}];
  1335. api.courseBookCounts=[1,1,1,1]; // An unselected unit still has a first-pass word.
  1336. const harness=await RouterTestingHarness.create('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701');
  1337. const learning=()=>harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1338. const tab=(n:number)=>(harness.routeNativeElement!.querySelectorAll('.course-pass-tabs button')[n] as HTMLButtonElement);
  1339. harness.fixture.detectChanges();
  1340. expect(harness.routeNativeElement!.querySelectorAll('.course-pass-tabs button').length).toBe(4);
  1341. tab(1).click();harness.fixture.detectChanges();
  1342. expect(harness.routeNativeElement!.querySelector('.word-select-check')).not.toBeNull();
  1343. expect(harness.routeNativeElement!.textContent).toContain('选择单词开始第二遍学习');
  1344. expect(learning().firstPassProgress()).toBe(75);
  1345. const select=harness.routeNativeElement!.querySelector('.word-select-check') as HTMLButtonElement;
  1346. select.click();harness.fixture.detectChanges();
  1347. expect(learning().selected().size).toBe(1);
  1348. tab(2).click();harness.fixture.detectChanges();
  1349. const secondPass=harness.routeNativeElement!.querySelector('.word-select-check') as HTMLButtonElement;
  1350. expect(secondPass).not.toBeNull();
  1351. secondPass.click();harness.fixture.detectChanges();
  1352. expect(learning().selected().size).toBe(2);
  1353. secondPass.click();harness.fixture.detectChanges();
  1354. expect(learning().selected().size).toBe(1);
  1355. tab(3).click();harness.fixture.detectChanges();
  1356. expect(harness.routeNativeElement!.textContent).toContain('已完成三遍学习');
  1357. expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull();
  1358. expect(learning().firstPassProgress()).toBe(75);
  1359. // Selecting a learned word must actually enter the regular learning flow.
  1360. tab(1).click();harness.fixture.detectChanges();
  1361. (harness.routeNativeElement!.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  1362. await harness.fixture.whenStable();harness.fixture.detectChanges();
  1363. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
  1364. expect(learning().words().map(word=>word.word)).toEqual(['apple']);
  1365. const finishPass=async()=>{
  1366. for(let stage=0;stage<4;stage++){
  1367. const skip=Array.from(harness.routeNativeElement!.querySelectorAll<HTMLButtonElement>('.sticky-actions button')).find(button=>button.textContent?.includes('跳过这一关'))!;
  1368. skip.click();await harness.fixture.whenStable();harness.fixture.detectChanges();
  1369. }
  1370. expect(TestBed.inject(Router).url).toBe('/pages/his/his');
  1371. };
  1372. await finishPass();
  1373. expect(api.courseIncrements.get('1001')).toBe(1);
  1374. await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701');harness.fixture.detectChanges();
  1375. tab(2).click();harness.fixture.detectChanges();
  1376. expect(learning().words().find(word=>word.word==='apple')?.['w_learned']).toBe(2);
  1377. (harness.routeNativeElement!.querySelector('[aria-label="加入学习:apple"]') as HTMLButtonElement).click();
  1378. (harness.routeNativeElement!.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  1379. await harness.fixture.whenStable();harness.fixture.detectChanges();
  1380. await finishPass();
  1381. expect(api.courseIncrements.get('1001')).toBe(2);
  1382. await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701');harness.fixture.detectChanges();
  1383. tab(3).click();harness.fixture.detectChanges();
  1384. expect(learning().words().find(word=>word.word==='apple')?.['w_learned']).toBe(3);
  1385. expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull();
  1386. expect(learning().firstPassProgress()).toBe(75);
  1387. api.courseBookCounts=[0,0,1,3];tab(0).click();tab(3).click();harness.fixture.detectChanges();
  1388. expect(learning().firstPassProgress()).toBe(100);
  1389. },30000);
  1390. it('shows named schedule students below the trigger and closes after selection or Escape', async () => {
  1391. user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3});
  1392. const harness=await RouterTestingHarness.create();
  1393. await harness.navigateByUrl('/pages/home/shop');
  1394. const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
  1395. component.serverStudents.set([{id:'100',name:'张雅茜',count:15},{id:'101',name:'花花',count:9},{id:'102',name:' ',count:1}]);
  1396. spyOn(component,'load').and.stub();
  1397. harness.fixture.detectChanges();
  1398. await new Promise(resolve => setTimeout(resolve, 0));
  1399. const trigger=harness.routeNativeElement!.querySelector<HTMLButtonElement>('.student-filter .xs-dropdown-trigger')!;
  1400. expect(trigger.textContent).toContain('全部学员(25)');
  1401. trigger.click();harness.fixture.detectChanges();
  1402. const menu=document.querySelector<HTMLElement>('.xs-dropdown-menu')!;
  1403. expect(menu.textContent).toContain('张雅茜(15)');
  1404. expect(menu.textContent).toContain('学员 102(1)');
  1405. expect(menu.getBoundingClientRect().top).toBeGreaterThan(trigger.getBoundingClientRect().bottom);
  1406. menu.querySelectorAll('button')[2].click();harness.fixture.detectChanges();
  1407. await new Promise(resolve => setTimeout(resolve, 0));
  1408. expect(component.selectedStudent()).toBe('101');
  1409. expect(trigger.textContent).toContain('花花(9)');
  1410. expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
  1411. trigger.click();harness.fixture.detectChanges();
  1412. document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));harness.fixture.detectChanges();
  1413. expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
  1414. });
  1415. it('shows the planned review day and original learning time for migrated August tasks', async () => {
  1416. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1417. session.groupId.set(3);
  1418. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1419. const post = api.post.bind(api);
  1420. spyOn(api, 'post').and.callFake((action, params) => action === 'app_review_page'
  1421. ? of({ retcode: 1, result: {
  1422. items: [{ GeneralID: 102, yhid: 100, xxjlid: 900, kcid: 801, Title: '张雅茜@任务',
  1423. kywrq: params?.['date'], kywsj: '2026-09-01 09:00', CreateTime: '2026-09-01T02:25:17Z',
  1424. learned_at: '2026-07-31T01:20:00Z', learned_date: '20260731', fxzt: 0 }],
  1425. page: { itemCount: 1, pageCount: 1 }, summary: { total: 1, completed: 0, due: 1, today: 0, income: 0 },
  1426. dates: [{ date: '2026-08-01', total: 1, completed: 0 }, { date: '2026-08-15', total: 1, completed: 0 }],
  1427. } } as ApiEnvelope<any>) : post(action, params));
  1428. const harness = await RouterTestingHarness.create();
  1429. await harness.navigateByUrl('/pages/stu/stu_calendar?date=2026-08-01');
  1430. await harness.fixture.whenStable();harness.fixture.detectChanges();
  1431. expect(harness.routeNativeElement?.querySelector('.task-context')?.textContent).toContain('复习日期 2026-08-01');
  1432. expect(harness.routeNativeElement?.querySelector('.first-learned-at')?.textContent).toContain('2026-07-31 09:20');
  1433. (harness.routeNativeElement?.querySelector('[aria-label^="2026-08-15"]') as HTMLButtonElement).click();
  1434. await harness.fixture.whenStable();harness.fixture.detectChanges();
  1435. expect(harness.routeNativeElement?.querySelector('.task-context')?.textContent).toContain('复习日期 2026-08-15');
  1436. const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1437. const task = component.calendarItems()[0];
  1438. expect(component.calendarFirstLearnedAt({ learned_date: '20260731' })).toContain('2026-07-31(时间未记录)');
  1439. component.openCalendar(task);
  1440. await harness.fixture.whenStable();
  1441. expect(TestBed.inject(Router).url).toContain('plannedDate=2026-08-15');
  1442. });
  1443. it('keeps the anti-forget page visible while a selected date loads locally', async () => {
  1444. const harness = await RouterTestingHarness.create();
  1445. await harness.navigateByUrl('/pages/stu/stu_calendar');
  1446. await harness.fixture.whenStable();
  1447. harness.fixture.detectChanges();
  1448. const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1449. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1450. component.memoryMonth.set(new Date(2026, 8, 1, 12));
  1451. component.memorySelectedDate.set('2026-09-11');
  1452. harness.fixture.detectChanges();
  1453. const response = new Subject<ApiEnvelope<unknown>>();
  1454. api.reviewPageResponse = response;
  1455. (harness.routeNativeElement?.querySelector('[aria-label^="2026-09-12"]') as HTMLButtonElement).click();
  1456. harness.fixture.detectChanges();
  1457. expect(api.lastMemoryParams['date']).toBe('2026-09-12');
  1458. expect(harness.routeNativeElement?.querySelector('.memory-overview')).not.toBeNull();
  1459. expect(harness.routeNativeElement?.querySelector('.memory-calendar')).not.toBeNull();
  1460. expect(harness.routeNativeElement?.querySelector('.memory-list-loading')?.textContent).toContain('正在加载当日复习');
  1461. response.next({
  1462. retcode: 1,
  1463. result: {
  1464. items: [{ GeneralID: 120, Title: '张雅茜@任务', kc_title: '9月12日复习', kywsj: '2026-09-12 09:00', fxzt: 0, yhid: 100, xxjlid: 920 }],
  1465. page: { itemCount: 1, pageCount: 1 },
  1466. summary: { total: 1, completed: 0, due: 0, today: 0, income: 0 },
  1467. dates: [{ date: '2026-09-12', total: 1, completed: 0 }],
  1468. },
  1469. });
  1470. response.complete();
  1471. api.reviewPageResponse = null;
  1472. await harness.fixture.whenStable();
  1473. harness.fixture.detectChanges();
  1474. expect(harness.routeNativeElement?.querySelector('.memory-list-loading')).toBeNull();
  1475. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('9月12日复习');
  1476. });
  1477. it('shows a retryable error instead of an empty review when the detail has no words', async () => {
  1478. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1479. const post = api.post.bind(api);
  1480. spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
  1481. ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : post(action, params));
  1482. const harness = await RouterTestingHarness.create();
  1483. await harness.navigateByUrl('/pages/stu/record?id=900&gid=102&kcid=801');
  1484. harness.fixture.detectChanges();
  1485. expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('没有读取到单词');
  1486. expect(harness.routeNativeElement?.querySelector('.empty-state.error button')?.textContent).toContain('重新加载');
  1487. expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
  1488. });
  1489. it('syncs a missing learning record and reopens its review words', async () => {
  1490. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1491. const post = api.post.bind(api);
  1492. let detailReads = 0;
  1493. spyOn(api, 'post').and.callFake((action, params) => {
  1494. if (action === 'e_record_detail') {
  1495. detailReads += 1;
  1496. return of({ retcode: 1, result: detailReads === 1 ? [] : [{ GeneralID: 701, Title: 'apple', sy: '苹果' }], addon: [] } as ApiEnvelope<any>);
  1497. }
  1498. if (action === 'app_learning_overview') return of({ retcode: 1, result: {} } as ApiEnvelope<any>);
  1499. return post(action, params);
  1500. });
  1501. const harness = await RouterTestingHarness.create();
  1502. await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
  1503. harness.fixture.detectChanges();
  1504. expect(detailReads).toBe(2);
  1505. expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
  1506. expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
  1507. });
  1508. it('reads the original word list when the detail stays empty after syncing', async () => {
  1509. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1510. const post = api.post.bind(api);
  1511. const get = api.get.bind(api);
  1512. spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
  1513. ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
  1514. ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
  1515. spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
  1516. ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: JSON.stringify([{ GeneralID: 701, Title: 'apple' }]) }] } as ApiEnvelope<any>)
  1517. : get(action, params));
  1518. const harness = await RouterTestingHarness.create();
  1519. await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
  1520. harness.fixture.detectChanges();
  1521. expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
  1522. expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
  1523. });
  1524. it('allows a confirmed notes review to finish once without loading missing vocabulary', async () => {
  1525. user.set({userId:300,groupId:3}); session.groupId.set(3); session.student.set({userId:100,groupId:1});
  1526. const api = TestBed.inject(ApiService), post = api.post.bind(api), reply = new Subject<ApiEnvelope<any>>();
  1527. const submit = spyOn(api, 'post').and.callFake((action,params) => action === 'e_record_detail'
  1528. ? of({retcode:1,result:[],addon:[{reviewContentMode:'notes',courseCategoryKey:'high_writing'}]} as ApiEnvelope<any>)
  1529. : action === 'app_review_complete' ? reply : post(action,params));
  1530. const get = spyOn(api,'get').and.callThrough();
  1531. const harness = await RouterTestingHarness.create();
  1532. await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047'); harness.fixture.detectChanges();
  1533. expect(harness.routeNativeElement?.textContent).toContain('语法写作复习');
  1534. expect(harness.routeNativeElement?.textContent).toContain('请带领学生复习课堂语法笔记');
  1535. expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
  1536. expect(get.calls.allArgs().some(a=>a[0]==='content_get')).toBeFalse();
  1537. expect(submit.calls.allArgs().some(a=>a[0]==='app_learning_overview')).toBeFalse();
  1538. const end = harness.routeNativeElement?.querySelector('.sticky-actions button') as HTMLButtonElement;
  1539. expect(end.textContent).toContain('结束复习'); end.click(); harness.fixture.detectChanges();
  1540. expect(end.disabled).toBeTrue();end.click();
  1541. const requests=submit.calls.allArgs().filter(a=>a[0]==='app_review_complete');
  1542. expect(requests.length).toBe(1);expect(requests[0][1]).toEqual({id:'864047'});
  1543. expect(harness.routeNativeElement?.querySelector('.review-complete')).toBeNull();
  1544. reply.next({retcode:1,result:{amount:1,rewardStatus:'earned'}});reply.complete();harness.fixture.detectChanges();
  1545. expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');
  1546. });
  1547. it('identifies a legacy writing review with no original words and keeps it incomplete', async () => {
  1548. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1549. const post = api.post.bind(api);
  1550. const get = api.get.bind(api);
  1551. spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
  1552. ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
  1553. ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
  1554. spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
  1555. ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: '', learned: 0, con: '完成了写作学习' }] } as ApiEnvelope<any>)
  1556. : get(action, params));
  1557. const harness = await RouterTestingHarness.create();
  1558. await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
  1559. harness.fixture.detectChanges();
  1560. expect(harness.routeNativeElement?.textContent).toContain('写作学习记录,原本没有单词');
  1561. expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
  1562. expect(harness.routeNativeElement?.querySelector('a[href="/pages/stu/stu_calendar"]')).not.toBeNull();
  1563. expect(harness.routeNativeElement?.textContent).not.toContain('结束本次复习');
  1564. });
  1565. it('renders source-shaped data for all four home entries', async () => {
  1566. const harness = await RouterTestingHarness.create();
  1567. await harness.navigateByUrl('/pages/home/index');
  1568. expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull();
  1569. expect(harness.routeNativeElement?.querySelector('.home-header [aria-label="搜索"]')).toBeNull();
  1570. expect(harness.routeNativeElement?.querySelector('.home-header [aria-label="用户中心"]')).toBeNull();
  1571. expect(harness.routeNativeElement?.querySelector('.memory-hero')).not.toBeNull();
  1572. expect(harness.routeNativeElement?.querySelectorAll('.memory-dot').length).toBe(21);
  1573. expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).toContain('学习新词');
  1574. expect(harness.routeNativeElement?.querySelectorAll('.student-stats > div').length).toBe(4);
  1575. expect(harness.routeNativeElement?.querySelectorAll('.quick-grid > a').length).toBe(4);
  1576. expect(harness.routeNativeElement?.querySelector('.continue-button')?.getAttribute('href')).toBe('/pages/stu/stu_calendar');
  1577. expect(harness.routeNativeElement?.querySelector('.task-link')?.getAttribute('href')).toBe('/pages/home/cart');
  1578. await harness.navigateByUrl('/pages/stu/index');
  1579. await harness.fixture.whenStable();
  1580. harness.fixture.detectChanges();
  1581. expect(TestBed.inject(Router).url).toBe('/pages/home/cart');
  1582. expect(harness.routeNativeElement?.textContent).not.toContain('选择词库教材');
  1583. await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
  1584. await harness.fixture.whenStable();
  1585. harness.fixture.detectChanges();
  1586. expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
  1587. expect(harness.routeNativeElement?.querySelector('.node-list button')?.textContent).toContain('已学 1/20 · 进度 5%');
  1588. expect(harness.routeNativeElement?.querySelectorAll('.unit-progress-ring').length).toBe(2);
  1589. expect(harness.routeNativeElement?.querySelector('.unit-progress-ring')?.textContent).toContain('5%');
  1590. expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')).toBeNull();
  1591. expect(harness.routeNativeElement?.querySelector('.unit-actions')).toBeNull();
  1592. const firstUnit = harness.routeNativeElement?.querySelector('.node-list button') as HTMLButtonElement;
  1593. firstUnit.click();
  1594. await harness.fixture.whenStable();
  1595. harness.fixture.detectChanges();
  1596. const readonlyRouter = TestBed.inject(Router);
  1597. expect(readonlyRouter.url).toBe('/pages/stu/stu_abc?nids=811&pnid=801&readonly=1');
  1598. expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('词库学习进度');
  1599. expect(harness.routeNativeElement?.querySelector('.selection-header .primary-button')).toBeNull();
  1600. expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('banana');
  1601. expect(harness.routeNativeElement?.querySelector('.word-select-check')).toBeNull();
  1602. expect(harness.routeNativeElement?.querySelector('.word-assessment')).toBeNull();
  1603. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  1604. harness.fixture.detectChanges();
  1605. expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('apple');
  1606. expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
  1607. await harness.navigateByUrl('/pages/home/index');
  1608. await harness.navigateByUrl('/pages/stu/stu_con?nid=813&pnid=801&readonly=1');
  1609. await harness.fixture.whenStable();
  1610. harness.fixture.detectChanges();
  1611. const wordApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1612. expect(wordApi.lastWordParams['psize']).toBe(30);
  1613. expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(30);
  1614. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 1–30 条 · 共 65 个单词');
  1615. expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('1 / 3 页');
  1616. (harness.routeNativeElement?.querySelector('[aria-label="第 2 页"]') as HTMLButtonElement).click();
  1617. await harness.fixture.whenStable();
  1618. harness.fixture.detectChanges();
  1619. expect(wordApi.lastWordParams['cpage']).toBe(2);
  1620. expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(30);
  1621. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 31–60 条 · 共 65 个单词');
  1622. expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('word-31');
  1623. expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('2 / 3 页');
  1624. await harness.navigateByUrl('/pages/stu/stu_list?nid=999&bindingId=799');
  1625. await harness.fixture.whenStable();
  1626. harness.fixture.detectChanges();
  1627. expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_abc?nids=999&readonly=1');
  1628. expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('词库学习进度');
  1629. await harness.navigateByUrl('/pages/stu/stu_calendar');
  1630. const calendarComponent=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1631. calendarComponent.memoryMonth.set(new Date(2026,7,1));
  1632. calendarComponent.memorySelectedDate.set('2026-08-02');
  1633. (calendarComponent as any).loadCalendar();
  1634. harness.fixture.detectChanges();
  1635. expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
  1636. expect(harness.routeNativeElement?.querySelector('.memory-student-filter')).toBeNull();
  1637. expect(harness.routeNativeElement?.querySelectorAll('.memory-date').length).toBe(42);
  1638. expect(harness.routeNativeElement?.querySelectorAll('.memory-date.completed, .memory-date.due').length).toBe(2);
  1639. expect(harness.routeNativeElement?.querySelector('.memory-overview')?.textContent).toContain('2 / 3');
  1640. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('高中英语复习');
  1641. expect(harness.routeNativeElement?.querySelector('.review-sequence')?.textContent).toContain('第 3 次复习');
  1642. expect(harness.routeNativeElement?.querySelector('.first-learned-at')?.textContent).toContain('首次学习:2026-07-20 10:20');
  1643. for (let index = 0; index < 3; index += 1) {
  1644. (harness.routeNativeElement?.querySelector('[aria-label="上个月"]') as HTMLButtonElement).click();
  1645. harness.fixture.detectChanges();
  1646. }
  1647. expect(harness.routeNativeElement?.querySelector('[aria-label^="2026-05-15"]')?.classList).toContain('completed');
  1648. expect(harness.routeNativeElement?.textContent).toContain('张雅茜');
  1649. expect(harness.routeNativeElement?.querySelector('.memory-overview header b')?.textContent).toContain('67%');
  1650. expect(harness.routeNativeElement?.querySelector('.memory-stats')?.textContent).toContain('2 / 3');
  1651. await harness.navigateByUrl('/pages/home/ucenter');
  1652. expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull();
  1653. expect(harness.routeNativeElement?.querySelector('.new-word-entry')?.getAttribute('href')).toBe('/pages/home/cart');
  1654. expect(harness.routeNativeElement?.querySelector('.course-memory')?.textContent).toContain('21 天抗遗忘计划');
  1655. expect(harness.routeNativeElement?.querySelector('.memory-overview em')?.textContent).toContain('67%');
  1656. expect(harness.routeNativeElement?.querySelector('.memory-overview')?.textContent).toContain('覆盖 3 个复习日');
  1657. expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('3总任务');
  1658. expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('2已完成');
  1659. expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('1到期待复习');
  1660. expect(harness.routeNativeElement?.querySelector('.memory-counts')?.textContent).toContain('0未来待复习');
  1661. await harness.navigateByUrl('/pages/stu/record?id=827482&gid=828268&kcid=203');
  1662. const recordWord = harness.routeNativeElement?.querySelector('.record-word') as HTMLButtonElement;
  1663. expect(recordWord.querySelector('.record-copy strong')?.textContent).toBe('apple');
  1664. expect(harness.routeNativeElement?.textContent).not.toContain('苹果');
  1665. recordWord.click();
  1666. await harness.fixture.whenStable();
  1667. harness.fixture.detectChanges();
  1668. expect(recordWord.querySelector('.record-copy strong')?.textContent).toBe('苹果');
  1669. expect(harness.routeNativeElement?.querySelector('.record-overview')?.textContent).toContain('1 / 1');
  1670. await harness.navigateByUrl('/pages/member/starshop');
  1671. expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
  1672. expect(harness.routeNativeElement?.querySelector('.word-select-list strong')?.textContent).toBe('tend');
  1673. expect(harness.routeNativeElement?.querySelector('.word-select-list')?.textContent).not.toContain('@张雅茜');
  1674. (harness.routeNativeElement?.querySelector('.word-select-list > button') as HTMLButtonElement).click();
  1675. (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  1676. await harness.fixture.whenStable();
  1677. const selectedNewWords = JSON.parse(sessionStorage.getItem('xiaoshu.learning.100.100.practice.selectedWords') ?? '[]') as Array<{ word?: string }>;
  1678. expect(selectedNewWords[0]?.word).toBe('tend');
  1679. expect(sessionStorage.getItem('xiaoshu.learning.100.100.practice.selectedWords')).not.toContain('@张雅茜');
  1680. await harness.navigateByUrl('/pages/his/well?nid=3');
  1681. expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
  1682. expect(harness.routeNativeElement?.textContent).toContain('阅读理解专项');
  1683. await harness.navigateByUrl('/pages/home/cart');
  1684. expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull();
  1685. expect(harness.fixture.nativeElement.querySelector('.app-header .icon-button')).toBeNull();
  1686. expect(harness.fixture.nativeElement.querySelector('.app-header h1')?.textContent).toContain('伴学中心');
  1687. expect(harness.routeNativeElement?.querySelector('.library-entry')).toBeNull();
  1688. expect(harness.routeNativeElement?.textContent).toContain('我的学习内容');
  1689. expect(harness.routeNativeElement?.querySelectorAll('.course-list li').length).toBe(2);
  1690. expect(harness.routeNativeElement?.querySelectorAll('.course-list button').length).toBe(2);
  1691. expect((harness.routeNativeElement?.querySelector('.course-list button') as HTMLButtonElement).disabled).toBeFalse();
  1692. expect((harness.routeNativeElement?.querySelectorAll('.course-list button')[1] as HTMLButtonElement).disabled).toBeFalse();
  1693. expect(harness.routeNativeElement?.textContent).toContain('【七年级上册】同步词汇');
  1694. expect(harness.routeNativeElement?.textContent).toContain('点击查看老师分配的单词列表');
  1695. (harness.routeNativeElement?.querySelector('.course-list button') as HTMLButtonElement).click();
  1696. await harness.fixture.whenStable();
  1697. harness.fixture.detectChanges();
  1698. expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801&bindingId=701');
  1699. expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
  1700. expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastNodeParams['bindingId']).toBe('701');
  1701. await harness.navigateByUrl('/pages/home/cart');
  1702. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(0);
  1703. expect(harness.routeNativeElement?.querySelectorAll('.calendar-day').length).toBe(42);
  1704. const allDates = Array.from(harness.routeNativeElement?.querySelectorAll('.range-tabs button') ?? [])
  1705. .find((button) => button.textContent?.trim() === '全部') as HTMLButtonElement;
  1706. allDates.click();
  1707. harness.fixture.detectChanges();
  1708. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(3);
  1709. expect(harness.routeNativeElement?.textContent).toContain('同步伴学');
  1710. expect(harness.routeNativeElement?.textContent).toContain('历史伴学');
  1711. const plannedIncompleteFilter = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-filters button') ?? [])
  1712. .find((button) => button.textContent?.includes('计划未完成')) as HTMLButtonElement;
  1713. expect(plannedIncompleteFilter.textContent).toContain('1');
  1714. plannedIncompleteFilter.click();
  1715. harness.fixture.detectChanges();
  1716. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1);
  1717. expect(harness.routeNativeElement?.textContent).toContain('计划未完成');
  1718. expect(harness.routeNativeElement?.textContent).not.toContain('未来计划');
  1719. (harness.routeNativeElement?.querySelector('.schedule-filters button') as HTMLButtonElement).click();
  1720. harness.fixture.detectChanges();
  1721. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1722. expect(api.lastScheduleParams['mode']).toBe('student');
  1723. expect(api.lastScheduleParams['pageSize']).toBe(20);
  1724. const finishedFilter = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-filters button') ?? [])
  1725. .find((button) => button.textContent?.includes('已结束')) as HTMLButtonElement;
  1726. finishedFilter.click();
  1727. harness.fixture.detectChanges();
  1728. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1);
  1729. expect(harness.routeNativeElement?.textContent).toContain('历史伴学');
  1730. const allFilter = harness.routeNativeElement?.querySelector('.schedule-filters button') as HTMLButtonElement;
  1731. allFilter.click();
  1732. harness.fixture.detectChanges();
  1733. const upcomingSchedule = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-list article') ?? [])
  1734. .find((article) => article.textContent?.includes('同步伴学')) as HTMLElement;
  1735. upcomingSchedule.click();
  1736. await harness.fixture.whenStable();
  1737. expect(TestBed.inject(Router).url).toBe('/pages/shop/stu_get_order?id=901');
  1738. await harness.navigateByUrl('/pages/his/his');
  1739. expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
  1740. const aiReadingFeature = harness.routeNativeElement?.querySelector('.ai-reading-feature') as HTMLElement;
  1741. expect(aiReadingFeature.textContent).toContain('AI 阅读巩固');
  1742. expect(aiReadingFeature.textContent).toContain('生成阅读');
  1743. expect(aiReadingFeature.textContent).toContain('AI 批改');
  1744. expect(aiReadingFeature.querySelector('img')?.getAttribute('src')).toBe('/static/images/ai-reading-journey.png');
  1745. const aiReadingCta = aiReadingFeature.querySelector('.ai-reading-cta') as HTMLButtonElement;
  1746. expect(aiReadingCta.disabled).toBeFalse();
  1747. expect(aiReadingFeature.textContent).toContain('最近学过的 1 个单词');
  1748. expect(aiReadingCta.textContent).toContain('立即生成阅读');
  1749. aiReadingCta.click();
  1750. await harness.fixture.whenStable();
  1751. const aiReadingRouter = TestBed.inject(Router);
  1752. expect(aiReadingRouter.url).toContain('/words/go-reading');
  1753. expect(aiReadingRouter.parseUrl(aiReadingRouter.url).queryParams['returnUrl']).toBe('/pages/his/his');
  1754. expect(aiReadingRouter.parseUrl(aiReadingRouter.url).queryParams['GeneralID']).toBe('201');
  1755. await harness.navigateByUrl('/pages/his/his');
  1756. const currentLearningCard = harness.routeNativeElement?.querySelector('.current-learning-card') as HTMLElement;
  1757. const learningRecordCard = harness.routeNativeElement?.querySelector('.learning-record-card') as HTMLElement;
  1758. expect(currentLearningCard.textContent).toContain('我的学习内容');
  1759. const records = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
  1760. expect(records.courseTitle({ Title: '单词课|高中@人教版2022年录', Title2: '人教版2022年录' })).toBe('【高中】人教版2022年录');
  1761. expect(records.courseTitle({ Title: '单词课|初中@人教版2024年9月录', Title2: '人教版2024年9月录' })).toBe('【初中】人教版2024年9月录');
  1762. expect(currentLearningCard.querySelectorAll('.current-learning-list button').length).toBe(2);
  1763. expect((currentLearningCard.querySelectorAll('.current-learning-list button')[1] as HTMLButtonElement).disabled).toBeFalse();
  1764. expect(currentLearningCard.compareDocumentPosition(learningRecordCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  1765. (currentLearningCard.querySelector('.current-learning-list button') as HTMLButtonElement).click();
  1766. await harness.fixture.whenStable();
  1767. harness.fixture.detectChanges();
  1768. expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801&bindingId=701');
  1769. expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
  1770. await harness.navigateByUrl('/pages/his/his');
  1771. expect(harness.routeNativeElement?.textContent).toContain('初中英语 / Unit 1');
  1772. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
  1773. (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click();
  1774. await harness.fixture.whenStable();
  1775. harness.fixture.detectChanges();
  1776. expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
  1777. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('学生只读');
  1778. expect(harness.routeNativeElement?.querySelector('.readonly-word-card')).not.toBeNull();
  1779. expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull();
  1780. await harness.navigateByUrl('/pages/his/his');
  1781. (harness.routeNativeElement?.querySelector('.date-trigger') as HTMLButtonElement).click();
  1782. harness.fixture.detectChanges();
  1783. const currentMonth = new Date();
  1784. const monthDifference = (2026 - currentMonth.getFullYear()) * 12 + (7 - currentMonth.getMonth());
  1785. const shiftLabel = monthDifference < 0 ? '上个月' : '下个月';
  1786. for (let index = 0; index < Math.abs(monthDifference); index += 1) {
  1787. (harness.routeNativeElement?.querySelector(`[aria-label="${shiftLabel}"]`) as HTMLButtonElement).click();
  1788. harness.fixture.detectChanges();
  1789. }
  1790. expect(harness.routeNativeElement?.querySelector('.date-picker-popover header strong')?.textContent).toContain('2026年8月');
  1791. expect(harness.routeNativeElement?.querySelectorAll('.date-picker-day').length).toBe(42);
  1792. expect(harness.routeNativeElement?.querySelectorAll('.date-picker-day.has-record').length).toBe(2);
  1793. expect(harness.routeNativeElement?.querySelector('.date-picker-day.has-record')?.getAttribute('aria-label')).toContain('2026-08-01');
  1794. (harness.routeNativeElement?.querySelector('[aria-label^="2026-08-01"]') as HTMLButtonElement).click();
  1795. harness.fixture.detectChanges();
  1796. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(1);
  1797. expect(harness.routeNativeElement?.querySelector('.learning-record-card')?.textContent).not.toContain('高中英语 / Unit 2');
  1798. (harness.routeNativeElement?.querySelector('.record-actions button') as HTMLButtonElement).click();
  1799. harness.fixture.detectChanges();
  1800. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
  1801. const completedReading = harness.routeNativeElement?.querySelector('.reading-button.completed') as HTMLButtonElement;
  1802. expect(completedReading.textContent).toContain('已完成阅读');
  1803. expect(completedReading.getAttribute('aria-label')).toBe('查看已完成的历史阅读理解');
  1804. completedReading.click();
  1805. await harness.fixture.whenStable();
  1806. const router = TestBed.inject(Router);
  1807. expect(router.url).toContain('/words/reading');
  1808. expect(router.parseUrl(router.url).queryParams['sid']).toBe('readingarticle201');
  1809. expect(router.parseUrl(router.url).queryParams['returnUrl']).toBe('/pages/his/his');
  1810. await harness.navigateByUrl('/pages/his/his');
  1811. const newReading = harness.routeNativeElement?.querySelector('.reading-button:not(.completed)') as HTMLButtonElement;
  1812. expect(newReading.textContent).toContain('阅读');
  1813. newReading.click();
  1814. await harness.fixture.whenStable();
  1815. expect(router.url).toContain('/words/go-reading');
  1816. expect(router.parseUrl(router.url).queryParams['returnUrl']).toBe('/pages/his/his');
  1817. expect(harness.routeNativeElement?.querySelector('.back-button')).not.toBeNull();
  1818. (harness.routeNativeElement?.querySelector('.back-button') as HTMLButtonElement).click();
  1819. await harness.fixture.whenStable();
  1820. expect(router.url).toBe('/pages/his/his');
  1821. (harness.fixture.nativeElement.querySelector('.app-header .icon-button') as HTMLButtonElement).click();
  1822. await harness.fixture.whenStable();
  1823. expect(router.url).toBe('/pages/home/index');
  1824. await harness.navigateByUrl('/pages/his/exam');
  1825. expect(harness.routeNativeElement?.textContent).toContain('第一组问题');
  1826. expect(harness.routeNativeElement?.textContent).toContain('相当于初二水平');
  1827. expect(harness.routeNativeElement?.textContent).toContain('共 1 题');
  1828. const startButton = harness.routeNativeElement?.querySelector('.exam-intro > button') as HTMLButtonElement;
  1829. startButton.click();
  1830. harness.fixture.detectChanges();
  1831. expect(harness.routeNativeElement?.querySelector('.question-word')?.textContent).toContain('apple');
  1832. expect(harness.routeNativeElement?.querySelector('.question-card > h2')).toBeNull();
  1833. expect(harness.routeNativeElement?.querySelectorAll('.exam-options button').length).toBe(1);
  1834. (harness.routeNativeElement?.querySelector('.exam-options button') as HTMLButtonElement).click();
  1835. harness.fixture.detectChanges();
  1836. });
  1837. it('matches coach review tasks to student names by ID when task name fields are blank', async () => {
  1838. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1839. session.groupId.set(3);
  1840. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1841. const response = new Subject<ApiEnvelope<unknown>>();
  1842. api.reviewPageResponse = response;
  1843. const harness = await RouterTestingHarness.create();
  1844. await harness.navigateByUrl('/pages/home/index');
  1845. response.next({ retcode: 1, result: {
  1846. items: [
  1847. { GeneralID: 102, yhid: 100, honeyName: '', Title: '旧姓名@复习', kywsj: String(api.lastMemoryParams['date']), fxzt: 0, xxjlid: 900 },
  1848. { GeneralID: 103, yhid: '101', honeyName: ' ', HoneyName: '', kywsj: String(api.lastMemoryParams['date']), fxzt: 0, xxjlid: 901 },
  1849. ],
  1850. students: [{ id: '101', name: '李同学', taskCount: 1 }, { id: '100', name: '张雅茜', taskCount: 1 }],
  1851. page: { itemCount: 2, pageCount: 1 },
  1852. summary: { total: 2, completed: 0, due: 2, today: 0, income: 0 }, dates: [],
  1853. } });
  1854. response.complete();
  1855. api.reviewPageResponse = null;
  1856. harness.fixture.detectChanges();
  1857. const names = Array.from(harness.routeNativeElement?.querySelectorAll('.coach-memory-copy strong') ?? []).map((node) => node.textContent);
  1858. expect(names).toEqual(['张雅茜', '李同学']);
  1859. const home = harness.fixture.debugElement.query(By.directive(HomePageComponent)).componentInstance as HomePageComponent;
  1860. expect(home.coachReviewStudent({ yhid: 102, honeyName: '', HoneyName: ' ', Title: '王同学@复习' })).toBe('王同学');
  1861. expect(home.coachReviewStudent({ yhid: 103, honeyName: '', Title: ' ' })).toBe('学员 103');
  1862. expect(home.coachReviewStudent({ honeyName: '' })).toBe('未关联学员');
  1863. (harness.routeNativeElement?.querySelectorAll('.coach-memory-list article > button')[1] as HTMLButtonElement).click();
  1864. await harness.fixture.whenStable();
  1865. expect(session.student()).toEqual(jasmine.objectContaining({ userId: 101, honeyName: '李同学' }));
  1866. expect(TestBed.inject(Router).url).toContain('/pages/stu/record?id=901&gid=103');
  1867. });
  1868. it('keeps the complete point-reading flow available to coaches', async () => {
  1869. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  1870. session.groupId.set(3);
  1871. const harness = await RouterTestingHarness.create();
  1872. await harness.navigateByUrl('/pages/home/index');
  1873. await harness.fixture.whenStable();
  1874. harness.fixture.detectChanges();
  1875. const coachLinks = Array.from(harness.routeNativeElement?.querySelectorAll('.coach-quick-grid > a') ?? []).map((link) => link.getAttribute('href'));
  1876. expect(coachLinks).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/ke_fuxi', '/pages/member/starshop']);
  1877. expect(harness.routeNativeElement?.querySelector('.home-header .header-action')).toBeNull();
  1878. const coachAccountTab = (Array.from(harness.fixture.nativeElement.querySelectorAll('.tabbar a')) as HTMLAnchorElement[])
  1879. .find((link) => link.getAttribute('href') === '/pages/home/ucenter');
  1880. expect(coachAccountTab?.textContent).toContain('我的');
  1881. expect(harness.routeNativeElement?.querySelector('.content-section')).toBeNull();
  1882. const memoryApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  1883. expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:100, scope:'today', includeMetadata:false, includeIncome:false }));
  1884. expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('抗遗忘带练');
  1885. expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('张雅茜');
  1886. expect(harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button')?.textContent).toContain('查看计划');
  1887. expect(harness.routeNativeElement?.querySelector('.coach-memory-footer')?.getAttribute('href')).toBe('/pages/stu/stu_calendar');
  1888. (harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button') as HTMLButtonElement).click();
  1889. await harness.fixture.whenStable();
  1890. expect(TestBed.inject(Router).url).toContain('/pages/stu/record?id=900&gid=102');
  1891. expect(session.student()?.userId).toBe(100);
  1892. await harness.navigateByUrl('/pages/stu/stu_calendar');
  1893. const calendarComponent=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  1894. calendarComponent.memoryMonth.set(new Date(2026,7,1));
  1895. calendarComponent.memorySelectedDate.set('2026-08-02');
  1896. (calendarComponent as any).loadCalendar();
  1897. harness.fixture.detectChanges();
  1898. await harness.fixture.whenStable();
  1899. harness.fixture.detectChanges();
  1900. expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:20 }));
  1901. expect(harness.routeNativeElement?.querySelector('.memory-copy')?.textContent).toContain('关联学员抗遗忘任务');
  1902. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('张雅茜');
  1903. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks button')?.textContent).toContain('带学生复习');
  1904. const memoryStudentFilter = harness.routeNativeElement?.querySelector('[aria-label="筛选抗遗忘学员"] select') as HTMLSelectElement;
  1905. expect(memoryStudentFilter.options.length).toBe(2);
  1906. memoryStudentFilter.value = '100';
  1907. memoryStudentFilter.dispatchEvent(new Event('change'));
  1908. harness.fixture.detectChanges();
  1909. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('张雅茜');
  1910. expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).not.toContain('李同学');
  1911. expect(harness.routeNativeElement?.querySelector('.memory-stats')?.textContent).toContain('2 / 3');
  1912. await harness.navigateByUrl('/pages/member/ke_xiao');
  1913. await harness.fixture.whenStable();
  1914. harness.fixture.detectChanges();
  1915. const monthFilter = harness.routeNativeElement?.querySelector('[aria-label="按月份筛选陪练记录"]') as HTMLSelectElement;
  1916. const monthPicker = harness.routeNativeElement?.querySelector('.month-control .xs-dropdown-trigger') as HTMLButtonElement;
  1917. expect(monthFilter.value).toBe('all');
  1918. expect(monthFilter.options.length).toBe(4);
  1919. expect(monthPicker).not.toBeNull();
  1920. expect(Number.parseFloat(getComputedStyle(monthPicker).paddingLeft)).toBeGreaterThanOrEqual(40);
  1921. expect(monthPicker.getBoundingClientRect().height).toBe(46);
  1922. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5);
  1923. monthFilter.value = '2026-03';
  1924. monthFilter.dispatchEvent(new Event('change'));
  1925. const studentSelect = harness.routeNativeElement?.querySelector('select[aria-label="按学员筛选陪练记录"]') as HTMLSelectElement;
  1926. expect(harness.routeNativeElement?.querySelector('input[type="search"]')).toBeNull();
  1927. studentSelect.value = '102';
  1928. studentSelect.dispatchEvent(new Event('change'));
  1929. expect(memoryApi.lastScheduleParams['studentId']).toBe('102');
  1930. expect(memoryApi.lastScheduleParams['search']).toBeUndefined();
  1931. harness.fixture.detectChanges();
  1932. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1);
  1933. expect(harness.routeNativeElement?.querySelector('.schedule-list')?.textContent).toContain('花花');
  1934. expect(harness.routeNativeElement?.querySelector('.schedule-band > header')?.textContent).toContain('5 节');
  1935. (harness.routeNativeElement?.querySelector('.history-filter-panel footer button:last-child') as HTMLButtonElement).click();
  1936. harness.fixture.detectChanges();
  1937. expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5);
  1938. await harness.navigateByUrl('/pages/his/his');
  1939. await harness.fixture.whenStable();
  1940. harness.fixture.detectChanges();
  1941. expect((harness.routeNativeElement?.querySelector('.student-filter select') as HTMLSelectElement).selectedOptions[0]?.textContent).toContain('张雅茜');
  1942. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
  1943. expect(session.student()?.userId).toBe(100);
  1944. (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click();
  1945. await harness.fixture.whenStable();
  1946. harness.fixture.detectChanges();
  1947. expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
  1948. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('历史记录');
  1949. expect(harness.routeNativeElement?.querySelector('.readonly-word-card')?.textContent).toContain('apple');
  1950. expect(harness.routeNativeElement?.querySelector('.readonly-word-card')?.textContent).toContain('共 1 个单词');
  1951. expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull();
  1952. await harness.navigateByUrl('/pages/his/his');
  1953. await harness.fixture.whenStable();
  1954. harness.fixture.detectChanges();
  1955. const teacherCourse = harness.routeNativeElement?.querySelector('.current-learning-list button') as HTMLButtonElement;
  1956. expect(teacherCourse.disabled).toBeFalse();
  1957. expect(teacherCourse.classList.contains('readonly')).toBeFalse();
  1958. expect(teacherCourse.textContent).toContain('选择本次学习单元');
  1959. teacherCourse.click();
  1960. await harness.fixture.whenStable();
  1961. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_list');
  1962. await harness.navigateByUrl('/pages/home/ucenter');
  1963. await harness.fixture.whenStable();
  1964. harness.fixture.detectChanges();
  1965. const teacherServices = Array.from(harness.routeNativeElement?.querySelectorAll('.service-grid > a') ?? []).map((link) => link.getAttribute('href'));
  1966. expect(teacherServices).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/feedback']);
  1967. expect(harness.routeNativeElement?.querySelector('.coach-wallet-strip')?.textContent).toContain('累计抗遗忘费用');
  1968. await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
  1969. await harness.fixture.whenStable();
  1970. harness.fixture.detectChanges();
  1971. expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastNodeParams['userId']).toBe(100);
  1972. const units = harness.routeNativeElement?.querySelectorAll('.node-list button') as NodeListOf<HTMLButtonElement>;
  1973. units[0].click();
  1974. units[1].click();
  1975. harness.fixture.detectChanges();
  1976. expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')?.textContent).toContain('已选 2 个单元');
  1977. (harness.routeNativeElement?.querySelector('.unit-actions .primary-button') as HTMLButtonElement).click();
  1978. await harness.fixture.whenStable();
  1979. harness.fixture.detectChanges();
  1980. const router = TestBed.inject(Router);
  1981. expect(router.url).toContain('/pages/stu/stu_abc');
  1982. expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('banana');
  1983. expect(harness.routeNativeElement?.querySelector('.completed-column')).toBeNull();
  1984. expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(1);
  1985. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  1986. harness.fixture.detectChanges();
  1987. expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('apple');
  1988. expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
  1989. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click();
  1990. harness.fixture.detectChanges();
  1991. const firstWord = harness.routeNativeElement?.querySelector('.pending-column .word-refine-item') as HTMLElement;
  1992. (firstWord.querySelector('.word-reveal') as HTMLButtonElement).click();
  1993. harness.fixture.detectChanges();
  1994. (firstWord.querySelector('.known-button') as HTMLButtonElement).click();
  1995. harness.fixture.detectChanges();
  1996. expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(0);
  1997. expect(harness.routeNativeElement?.querySelector('.selection-header .primary-button')?.textContent).toContain('保存识词结果');
  1998. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  1999. harness.fixture.detectChanges();
  2000. expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2);
  2001. (harness.routeNativeElement?.querySelector('.completed-column .word-assessment:has(.known-button.active) .unknown-button') as HTMLButtonElement).click();
  2002. harness.fixture.detectChanges();
  2003. expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(1);
  2004. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click();
  2005. harness.fixture.detectChanges();
  2006. expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(1);
  2007. (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  2008. await harness.fixture.whenStable();
  2009. harness.fixture.detectChanges();
  2010. expect(router.url).toContain('/pages/stu/stu_con');
  2011. expect(harness.routeNativeElement?.querySelectorAll('.repeat-list > button').length).toBe(1);
  2012. expect(harness.routeNativeElement?.querySelector('.readonly-word-header')).toBeNull();
  2013. await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&bindingId=701');
  2014. await harness.fixture.whenStable();
  2015. harness.fixture.detectChanges();
  2016. const spellingActions = harness.routeNativeElement?.querySelector('.teacher-spelling-actions') as HTMLElement;
  2017. expect(spellingActions.querySelectorAll('button').length).toBe(3);
  2018. expect(spellingActions.textContent).toContain('跳过这一关');
  2019. expect((harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent).words().length).toBe(1);
  2020. expect(localStorage.getItem('xiaoshu.learning.300.100.701._page_word_orilist')).not.toBeNull();
  2021. (spellingActions.querySelectorAll('button')[1] as HTMLButtonElement).click();
  2022. await harness.fixture.whenStable();
  2023. harness.fixture.detectChanges();
  2024. expect(router.url).toBe('/pages/his/his');
  2025. const standaloneRecord = (TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastStandaloneLearningRecord;
  2026. expect(JSON.parse(String(standaloneRecord['content']))).toEqual(jasmine.objectContaining({ ModelID: 56, Hits: 1, title: '张雅茜' }));
  2027. expect(JSON.parse(String(standaloneRecord['addon']))).toEqual(jasmine.objectContaining({ UserID: 100, pl: 300, learned: 1, createReviewPlan: false }));
  2028. }, 20000);
  2029. it('loads appointment courses and units for the appointment student when no student is selected in the session', async () => {
  2030. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2031. session.groupId.set(3);
  2032. session.student.set(null);
  2033. const harness = await RouterTestingHarness.create();
  2034. await harness.navigateByUrl('/pages/shop/get_order?id=901');
  2035. await harness.fixture.whenStable();
  2036. harness.fixture.detectChanges();
  2037. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  2038. expect(session.student()?.userId).toBe(100);
  2039. expect(api.lastAppointmentCourseParams['orderId']).toBe('901');
  2040. expect(api.lastNodeParams['userId']).toBe(100);
  2041. const courseSelect = harness.routeNativeElement?.querySelector('.course-section select') as HTMLSelectElement;
  2042. expect(courseSelect).not.toBeNull();
  2043. expect(courseSelect.options.length).toBeGreaterThanOrEqual(2);
  2044. expect(courseSelect.closest('.course-section')?.textContent).toContain('七年级上册');
  2045. expect(harness.routeNativeElement?.querySelectorAll('.unit-grid article').length).toBe(2);
  2046. const unitButtons = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.unit-select');
  2047. expect(unitButtons?.[0].textContent).toContain('已学 5%');
  2048. expect(unitButtons?.[0].textContent).toContain('1/20 词');
  2049. expect(unitButtons?.[1].textContent).toContain('已学 0%');
  2050. expect(unitButtons?.[1].textContent).toContain('0/18 词');
  2051. expect((unitButtons?.[0].querySelector('.unit-progress-track > span') as HTMLElement).style.width).toBe('5%');
  2052. unitButtons?.[0].click();
  2053. unitButtons?.[1].click();
  2054. harness.fixture.detectChanges();
  2055. expect(harness.routeNativeElement?.querySelectorAll('.unit-select[aria-pressed="true"]').length).toBe(2);
  2056. expect(unitButtons?.[0].textContent).toContain('已学 5%');
  2057. });
  2058. it('distinguishes completed, empty and unavailable appointment vocabulary progress', async () => {
  2059. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2060. session.groupId.set(3);
  2061. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  2062. api.unitRows = [
  2063. { NodeID: 811, NodeName: '已学全册', yx_word: '20', total: '20' },
  2064. { NodeID: 812, NodeName: '空册', yx_word: 0, total: 0 },
  2065. { NodeID: 813, NodeName: '缺少统计' },
  2066. { NodeID: 814, NodeName: '异常统计', yx_word: 'invalid', total: 20 },
  2067. ];
  2068. const harness = await RouterTestingHarness.create();
  2069. await harness.navigateByUrl('/pages/shop/get_order?id=901');
  2070. await harness.fixture.whenStable();
  2071. harness.fixture.detectChanges();
  2072. const units = harness.routeNativeElement?.querySelectorAll('.unit-select');
  2073. expect(units?.[0].textContent).toContain('已学 100%');
  2074. expect(units?.[0].textContent).toContain('20/20 词');
  2075. expect(units?.[1].textContent).toContain('已学 0%');
  2076. expect(units?.[1].textContent).toContain('暂无单词');
  2077. for (const index of [2, 3]) {
  2078. expect(units?.[index].textContent).toContain('暂无进度数据');
  2079. expect(units?.[index].querySelector('.unit-progress-track')).toBeNull();
  2080. }
  2081. });
  2082. it('ends without requiring feedback and keeps lesson pay out of the completion notice', async () => {
  2083. user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3});session.groupId.set(3);
  2084. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
  2085. spyOn(TestBed.inject(PlatformService),'confirm').and.resolveTo(true);
  2086. const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/shop/get_order?id=901');await harness.fixture.whenStable();harness.fixture.detectChanges();
  2087. const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
  2088. await component.endLesson();expect(api.completionRequests.length).toBe(1);expect(api.completionRequests[0]['feedback']).toBeUndefined();
  2089. api.appointmentStatus=30;api.completionResponse.next({retcode:0,result:{status:30,teacherPay:45,payStatus:'earned',feedbackStatus:'pending'}});api.completionResponse.complete();
  2090. harness.fixture.detectChanges();expect(harness.routeNativeElement?.textContent).toContain('已结课');expect(harness.routeNativeElement?.textContent).not.toMatch(/费用|¥45/);expect(harness.routeNativeElement?.textContent).toContain('反馈待补');
  2091. });
  2092. it('retains the optional feedback-and-complete dialog and submits once after validation', async () => {
  2093. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2094. session.groupId.set(3);
  2095. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  2096. api.appointmentStatus = 10;
  2097. const harness = await RouterTestingHarness.create();
  2098. await harness.navigateByUrl('/pages/shop/get_order?id=901');
  2099. const component = harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
  2100. await harness.fixture.whenStable();
  2101. harness.fixture.detectChanges();
  2102. const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement;
  2103. const endButton = harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement;
  2104. expect(dialog.open).toBeFalse();
  2105. component.openCompletionFeedback();
  2106. harness.fixture.detectChanges();
  2107. expect(dialog.open).toBeTrue();
  2108. expect(api.completionRequests.length).toBe(0);
  2109. const submit = dialog.querySelector('.feedback-actions .primary-button') as HTMLButtonElement;
  2110. submit.click();
  2111. harness.fixture.detectChanges();
  2112. expect(dialog.querySelector('[role="alert"]')?.textContent).toContain('请填写本次学习内容');
  2113. expect(api.completionRequests.length).toBe(0);
  2114. const summary = dialog.querySelector('textarea') as HTMLTextAreaElement;
  2115. summary.value = 'Unit 3 词汇';
  2116. summary.dispatchEvent(new Event('input'));
  2117. await harness.fixture.whenStable();
  2118. (dialog.querySelector('.feedback-actions .secondary-button') as HTMLButtonElement).click();
  2119. expect(dialog.open).toBeFalse();
  2120. expect(api.appointmentStatus).toBe(10);
  2121. component.openCompletionFeedback();
  2122. harness.fixture.detectChanges();
  2123. expect((dialog.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Unit 3 词汇');
  2124. component.reviewStatus = '完成';
  2125. component.trainingStatus = '需要巩固';
  2126. component.trainingSummary = '下次复习 Unit 3';
  2127. component.noHomework = true;
  2128. submit.click();
  2129. submit.click();
  2130. harness.fixture.detectChanges();
  2131. expect(api.completionRequests.length).toBe(1);
  2132. expect(JSON.parse(String(api.completionRequests[0]['feedback']))).toEqual(jasmine.objectContaining({ contentSummary: 'Unit 3 词汇', noHomework: true, homework: '无作业' }));
  2133. expect(submit.disabled).toBeTrue();
  2134. dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
  2135. expect(dialog.open).toBeTrue();
  2136. api.appointmentStatus = 30;
  2137. api.completionResponse.next({ retcode: 1, result: {} });
  2138. api.completionResponse.complete();
  2139. await harness.fixture.whenStable();
  2140. harness.fixture.detectChanges();
  2141. expect(harness.routeNativeElement?.querySelector('dialog')).toBeNull();
  2142. expect(harness.routeNativeElement?.textContent).toContain('已结课');
  2143. });
  2144. it('keeps feedback editable after a failed submission and allows closing with Escape', async () => {
  2145. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2146. session.groupId.set(3);
  2147. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  2148. api.appointmentStatus = 10;
  2149. const harness = await RouterTestingHarness.create();
  2150. await harness.navigateByUrl('/pages/shop/get_order?id=901');
  2151. const component = harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
  2152. await harness.fixture.whenStable();
  2153. component.openCompletionFeedback();
  2154. component.contentSummary = 'Unit 3';
  2155. component.reviewStatus = '完成';
  2156. component.trainingStatus = '正常进行';
  2157. component.trainingSummary = '继续 Unit 4';
  2158. component.noHomework = true;
  2159. component.finishCoachLesson();
  2160. api.completionResponse.error(new Error('提交失败,请重试'));
  2161. harness.fixture.detectChanges();
  2162. const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement;
  2163. expect(dialog.open).toBeTrue();
  2164. expect(dialog.querySelector('[role="alert"]')?.textContent).toContain('提交失败,请重试');
  2165. expect((dialog.querySelector('fieldset') as HTMLFieldSetElement).disabled).toBeFalse();
  2166. expect(component.contentSummary).toBe('Unit 3');
  2167. expect(api.appointmentStatus).toBe(10);
  2168. dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
  2169. expect(dialog.open).toBeFalse();
  2170. });
  2171. it('counts words marked as known as completed and persists them for the student', async () => {
  2172. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2173. session.groupId.set(3);
  2174. session.student.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
  2175. const harness = await RouterTestingHarness.create();
  2176. await harness.navigateByUrl('/pages/stu/stu_abc?nids=811,812&pnid=801');
  2177. await harness.fixture.whenStable();
  2178. harness.fixture.detectChanges();
  2179. const pendingWord = harness.routeNativeElement?.querySelector('.pending-column .word-refine-item') as HTMLElement;
  2180. (pendingWord.querySelector('.word-reveal') as HTMLButtonElement).click();
  2181. harness.fixture.detectChanges();
  2182. (pendingWord.querySelector('.known-button') as HTMLButtonElement).click();
  2183. harness.fixture.detectChanges();
  2184. (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  2185. await harness.fixture.whenStable();
  2186. harness.fixture.detectChanges();
  2187. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  2188. expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({ uid: 100, wordsId: '1002', kcid: '801', save: 1 }));
  2189. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
  2190. expect(harness.routeNativeElement?.querySelector('.completed-column')).toBeNull();
  2191. (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
  2192. harness.fixture.detectChanges();
  2193. expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2);
  2194. expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
  2195. });
  2196. it('confirms three rounds into 17 words and can continue selecting before ending the lesson', async () => {
  2197. user.set({userId:300,groupId:3}); session.groupId.set(3);session.student.set({userId:100,groupId:1});
  2198. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
  2199. 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}))}));
  2200. const harness=await RouterTestingHarness.create();
  2201. for(let index=0;index<3;index++){
  2202. await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=round-'+index);await harness.fixture.whenStable();harness.fixture.detectChanges();
  2203. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2204. expect(component.words().length).toBe([9,5,3][index]);component.markAllKnown();component.submitReview(index<2);await harness.fixture.whenStable();harness.fixture.detectChanges();
  2205. expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901'+(index===2?'&feedback=1':''));
  2206. expect(harness.routeNativeElement?.textContent).toContain('本节学习成果 · '+[9,14,17][index]+' 个词');
  2207. }
  2208. expect(api.completionRequests.length).toBe(0);
  2209. });
  2210. it('reveals and speaks each assessment word without changing mastery', async () => {
  2211. user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
  2212. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
  2213. api.studyBatches=[{batchId:'meaning-round',version:1,status:'draft',bindingId:'binding-801',words:[{GeneralID:1001,Title:'crop',sy:'庄稼',check:1},{GeneralID:1002,Title:'gather',sy:'聚集',check:2}]}];
  2214. const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=meaning-round');await harness.fixture.whenStable();harness.fixture.detectChanges();
  2215. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2216. const play=spyOn(component,'play').and.resolveTo();const cards=harness.routeNativeElement!.querySelectorAll('.review-list article');
  2217. expect(cards[0].textContent).not.toContain('庄稼');(cards[0].querySelector('.word-audio') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2218. expect(play).toHaveBeenCalledWith(component.words()[0]);expect(cards[0].textContent).toContain('庄稼');expect(cards[1].textContent).not.toContain('聚集');
  2219. expect(component.reviewValue(component.words()[0])).toBe(1);expect(component.reviewValue(component.words()[1])).toBe(2);
  2220. (cards[0].querySelector('.word-audio') as HTMLButtonElement).click();harness.fixture.detectChanges();expect(cards[0].textContent).not.toContain('庄稼');expect(play).toHaveBeenCalledTimes(2);
  2221. });
  2222. it('keeps round choices on save failure and retries with the same command key', async () => {
  2223. user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
  2224. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
  2225. api.studyBatches=[{batchId:'retry-round',version:1,status:'draft',bindingId:'binding-801',courseId:'802',words:[{GeneralID:1001,Title:'apple',check:0}]}];
  2226. 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));
  2227. 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();
  2228. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;component.markAllKnown();component.submitReview(true);await harness.fixture.whenStable();
  2229. expect(component.words().length).toBe(1);expect(component.reviewCount(1)).toBe(1);expect(TestBed.inject(Router).url).toContain('/pl_end');
  2230. 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']);
  2231. expect(api.lastLearningWordUpdate['kcid']).toBe('802'); // Resume uses the batch book, not the scheduled book.
  2232. });
  2233. it('finishes the last vocabulary group, loads the coach record and opens completion feedback', async () => {
  2234. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2235. session.groupId.set(3);
  2236. session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
  2237. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  2238. api.appointmentStatus = 10;
  2239. const word = { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果' };
  2240. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_word_orilist', JSON.stringify([[word]]));
  2241. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_current_learn', '1');
  2242. // Stale page-count metadata must not create a nonexistent next group.
  2243. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_total_page', '2');
  2244. localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.learn_mode');
  2245. const harness = await RouterTestingHarness.create();
  2246. const router = TestBed.inject(Router);
  2247. const navigate = spyOn(router, 'navigate').and.callThrough();
  2248. await harness.navigateByUrl('/pages/stu/stu_con?nids=811&pnid=801&orderId=901');
  2249. await harness.fixture.whenStable();
  2250. harness.fixture.detectChanges();
  2251. const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2252. const skipStage = () => {
  2253. const button = Array.from(harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.sticky-actions button') ?? [])
  2254. .find(item => item.textContent?.includes('跳过这一关'));
  2255. expect(button).toBeDefined();
  2256. button!.click();
  2257. };
  2258. skipStage();
  2259. await harness.fixture.whenStable();
  2260. harness.fixture.detectChanges();
  2261. expect(router.url).toContain('/pages/stu/stu3');
  2262. expect(learning().matchLeft().length).toBe(1);
  2263. skipStage();
  2264. await harness.fixture.whenStable();
  2265. harness.fixture.detectChanges();
  2266. expect(router.url).toContain('/pages/stu/stu4');
  2267. expect(learning().correct()).toBe(0);
  2268. skipStage();
  2269. await harness.fixture.whenStable();
  2270. harness.fixture.detectChanges();
  2271. expect(router.url).toContain('/pages/stu/pl_stu5');
  2272. expect(router.url).toContain('orderId=901');
  2273. expect(learning().correct()).toBe(0);
  2274. skipStage();
  2275. expect(learning().hasNextWordBatch).toBeFalse();
  2276. await harness.fixture.whenStable();
  2277. harness.fixture.detectChanges();
  2278. harness.fixture.detectChanges();
  2279. expect(router.url).toContain('/pages/stu/pl_end');
  2280. expect(api.lastStudyQuery['orderId']).toBe('901');
  2281. expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
  2282. expect(learning().words()[0].word).toBe('apple');
  2283. learning().markAllKnown();
  2284. learning().submitReview();
  2285. await harness.fixture.whenStable();
  2286. harness.fixture.detectChanges();
  2287. harness.fixture.detectChanges();
  2288. expect(router.url).toBe('/pages/shop/get_order?id=901&feedback=1');
  2289. expect((harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement).open).toBeTrue();
  2290. expect(api.appointmentStatus).toBe(10);
  2291. expect(api.completionRequests.length).toBe(0);
  2292. expect(api.studyUpdates.length).toBe(2);
  2293. expect(JSON.parse(String(api.studyUpdates[1]['addon'])).xxqs[0].check).toBe(1);
  2294. expect(navigate.calls.all().every(call => call.args[1]?.replaceUrl === true)).toBeTrue();
  2295. }, 20000);
  2296. it('advances only to a real next group and returns from the last stage to its course in one click', async () => {
  2297. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2298. session.groupId.set(3);
  2299. session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
  2300. const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }, { GeneralID: 1002, Title: 'banana', sy: '香蕉' }], [{ GeneralID: 1003, Title: 'cherry', sy: '樱桃' }]];
  2301. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_word_orilist', JSON.stringify(pages));
  2302. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_total_page', '0');
  2303. localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_current_learn', '1');
  2304. const harness = await RouterTestingHarness.create();
  2305. const router = TestBed.inject(Router);
  2306. await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
  2307. await harness.fixture.whenStable();
  2308. harness.fixture.detectChanges();
  2309. const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2310. expect(component.words().length).toBe(2);
  2311. component.skipSpellingStage();
  2312. await harness.fixture.whenStable();
  2313. harness.fixture.detectChanges();
  2314. expect(router.url).toContain('/pages/stu/stu_con');
  2315. const next = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2316. expect(next.words()[0].word).toBe('cherry');
  2317. expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).studyUpdates.length).toBe(0);
  2318. (harness.routeNativeElement?.querySelector('.app-header button[aria-label="返回"]') as HTMLButtonElement).click();
  2319. await harness.fixture.whenStable();
  2320. harness.fixture.detectChanges();
  2321. expect(router.url).toBe('/pages/shop/get_order?id=901');
  2322. 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}`);
  2323. });
  2324. it('keeps teacher-led spelling skipped through the remaining word groups', async () => {
  2325. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2326. session.groupId.set(3);
  2327. session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
  2328. const prefix = 'xiaoshu.learning.300.100.901.unbound.';
  2329. const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]];
  2330. localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify(pages));
  2331. localStorage.setItem(prefix + '_page_current_learn', '1');
  2332. const harness = await RouterTestingHarness.create();
  2333. const router = TestBed.inject(Router);
  2334. await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
  2335. const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2336. learning().skipSpellingStage();
  2337. await harness.fixture.whenStable();
  2338. harness.fixture.detectChanges();
  2339. expect(router.url).toContain('/pages/stu/stu_con');
  2340. expect(localStorage.getItem(prefix + 'skip_spelling_stage')).toBe('true');
  2341. learning().skipStudyList();
  2342. await harness.fixture.whenStable();
  2343. harness.fixture.detectChanges();
  2344. learning().chooseMatch('left', learning().words()[0]);
  2345. learning().chooseMatch('right', learning().words()[0]);
  2346. learning().continueAfterStage();
  2347. await harness.fixture.whenStable();
  2348. harness.fixture.detectChanges();
  2349. expect(router.url).toContain('/pages/stu/stu4');
  2350. learning().chooseMeaning(learning().words()[0]);
  2351. expect(learning().stageActionLabel).toBe('查看学习结果');
  2352. learning().continueAfterStage();
  2353. await harness.fixture.whenStable();
  2354. expect(router.url).toContain('/pages/stu/pl_end');
  2355. expect(localStorage.getItem(prefix + 'skip_spelling_stage')).toBe('true');
  2356. localStorage.removeItem(prefix + 'skip_spelling_stage');
  2357. }, 15000);
  2358. it('resolves the appointment student before reading end results and offers an exit on failure', async () => {
  2359. user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
  2360. session.groupId.set(3);
  2361. session.student.set({ userId: 999, honeyName: '之前的学员', groupId: 1 });
  2362. const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  2363. const get = api.get.bind(api);
  2364. spyOn(api, 'get').and.callFake((action, params) => action === 'app_lesson_study'
  2365. ? throwError(() => new Error('学习记录暂时无法加载')) : get(action, params));
  2366. const harness = await RouterTestingHarness.create();
  2367. await harness.navigateByUrl('/pages/stu/pl_end?id=901');
  2368. await harness.fixture.whenStable();
  2369. harness.fixture.detectChanges();
  2370. harness.fixture.detectChanges();
  2371. expect(session.student()?.userId).toBe(100);
  2372. expect(api.get).toHaveBeenCalledWith('app_lesson_study', {orderId:'901'});
  2373. expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('学习记录暂时无法加载');
  2374. (harness.routeNativeElement?.querySelector('.empty-state.error .primary-button') as HTMLButtonElement).click();
  2375. await harness.fixture.whenStable();
  2376. harness.fixture.detectChanges();
  2377. expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
  2378. });
  2379. it('shows learning history in pages of twenty records', async () => {
  2380. user.set({ userId: 777, userName: 'paged-student', honeyName: '分页学员', groupId: 1 });
  2381. session.groupId.set(1);
  2382. const harness = await RouterTestingHarness.create();
  2383. await harness.navigateByUrl('/pages/his/his');
  2384. await harness.fixture.whenStable();
  2385. harness.fixture.detectChanges();
  2386. expect(harness.routeNativeElement?.querySelector('.record-actions')?.textContent).toContain('45 条');
  2387. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(20);
  2388. expect(harness.routeNativeElement?.querySelector('.record-pagination')?.textContent).toContain('第 1 / 3 页');
  2389. const pageButtons = harness.routeNativeElement?.querySelectorAll('.record-pagination button') as NodeListOf<HTMLButtonElement>;
  2390. expect(pageButtons[0].disabled).toBeTrue();
  2391. pageButtons[1].click();
  2392. harness.fixture.detectChanges();
  2393. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(20);
  2394. expect(harness.routeNativeElement?.querySelector('.record-pagination')?.textContent).toContain('第 2 / 3 页');
  2395. (harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).click();
  2396. harness.fixture.detectChanges();
  2397. expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(5);
  2398. expect((harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).disabled).toBeTrue();
  2399. });
  2400. it('keeps review completion free of pay details for every reward outcome', async()=>{
  2401. user.set({userId:300,groupId:3,honeyName:'老师'});session.groupId.set(3);session.student.set({userId:100,groupId:1});
  2402. const harness=await RouterTestingHarness.create('/pages/stu/record?id=900&gid=103');await harness.fixture.whenStable();
  2403. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2404. for (const result of [
  2405. {amount:0.5,rewardStatus:'earned'},
  2406. {amount:1,rewardStatus:'earned',unchanged:true},
  2407. {amount:0,rewardStatus:'ineligible',ineligibleReason:'self_study',unchanged:true},
  2408. {amount:0,rewardStatus:'ineligible',ineligibleReason:'duplicate_review',unchanged:true},
  2409. {amount:0,rewardStatus:'ineligible'},
  2410. {amount:0,rewardStatus:'pending'},
  2411. ]) {
  2412. component.reviewCompletion.set(result);harness.fixture.detectChanges();
  2413. const completion=harness.routeNativeElement?.querySelector('.review-complete');
  2414. expect(completion?.textContent).toContain('复习已完成');
  2415. expect(completion?.textContent).not.toMatch(/收入|费用|计薪|记账|¥/);
  2416. expect(completion?.textContent).toContain('继续今日任务');
  2417. expect(completion?.textContent).toContain('返回原任务列表');
  2418. }
  2419. user.set({userId:100,groupId:1,honeyName:'学生'});session.groupId.set(1);harness.fixture.detectChanges();
  2420. expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');
  2421. expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|费用|计薪|记账|¥/);
  2422. });
  2423. it('waits for server completion and blocks duplicate clicks for overdue work', async()=>{
  2424. user.set({userId:300,groupId:3,honeyName:'老师'});session.groupId.set(3);session.student.set({userId:100,groupId:1});
  2425. const api=TestBed.inject(ApiService),post=api.post.bind(api),reply=new Subject<ApiEnvelope<any>>();
  2426. const submit=spyOn(api,'post').and.callFake((action,params)=>action==='app_review_complete'?reply:post(action,params));
  2427. const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/record?id=900&gid=103&plannedDate=2000-01-01');await harness.fixture.whenStable();
  2428. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2429. component.endRecord();component.endRecord();harness.fixture.detectChanges();expect(component.reviewCompletion()).toBeNull();expect(component.canDeactivate()).toBeFalse();
  2430. expect(submit.calls.allArgs().filter(a=>a[0]==='app_review_complete').length).toBe(1);
  2431. reply.next({retcode:1,result:{amount:0,rewardStatus:'ineligible',completed:true}});reply.complete();harness.fixture.detectChanges();
  2432. expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|费用|计薪|¥/);expect(component.canDeactivate()).toBeTrue();
  2433. });
  2434. async function teacherSelection(count: number, pass = 0, orderId = '') {
  2435. user.set({userId:300,groupId:3}); session.groupId.set(3); session.student.set({userId:100,groupId:1});
  2436. const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;
  2437. api.courseWords=Array.from({length:count},(_,i)=>({GeneralID:1001+i,NodeID:811,Title:'word-'+(i+1),sy:'释义 '+(i+1),w_learned:pass}));
  2438. const harness=await RouterTestingHarness.create();
  2439. await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801' + (orderId ? '&orderId=' + orderId : ''));
  2440. await harness.fixture.whenStable(); harness.fixture.detectChanges();
  2441. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2442. if(pass>0){component.selectCoursePass(pass);await harness.fixture.whenStable();harness.fixture.detectChanges();}
  2443. return {api,harness,component};
  2444. }
  2445. it('keeps compact known and unknown decisions visible before revealing a word', async () => {
  2446. const {harness,component}=await teacherSelection(2);
  2447. const row=harness.routeNativeElement!.querySelector('.word-refine-item')!;
  2448. expect(component.selectionRevealed().size).toBe(0);
  2449. expect(row.querySelector('.word-refine-main .known-button')?.textContent?.trim()).toBe('认识');
  2450. expect(row.querySelector('.word-refine-main .unknown-button')?.textContent?.trim()).toBe('不认识');
  2451. (row.querySelector('.known-button') as HTMLButtonElement).click(); harness.fixture.detectChanges();
  2452. expect(component.coursePassCount(1)).toBe(1);
  2453. });
  2454. it('saves 205 accumulated known words in bounded requests without losing words', async () => {
  2455. const {api,harness,component}=await teacherSelection(205);
  2456. for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();}
  2457. const original=api.post.bind(api),sizes:number[]=[];
  2458. spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  2459. if(action==='e_add_words'){const size=String(params['wordsId']).split(',').length;sizes.push(size);if(size>100)return throwError(()=>new Error('单次最多操作 100 个单词'));}
  2460. return original(action,params);
  2461. }) as typeof api.post);
  2462. for(const word of component.words())component.markWordKnown(word);
  2463. component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2464. expect(sizes).toEqual([100,100,5]);
  2465. expect(api.courseIncrements.size).toBe(205);
  2466. expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue();
  2467. expect(component.newlyKnownWords().length).toBe(0);
  2468. expect(component.coursePassCount(1)).toBe(205);
  2469. });
  2470. it('retains successful known-word batches when a later request fails and retries only remaining words', async () => {
  2471. const {api,harness,component}=await teacherSelection(205);
  2472. for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();}
  2473. const original=api.post.bind(api),requests:Record<string,unknown>[]=[];let failed=false;
  2474. spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  2475. if(action==='e_add_words'){
  2476. requests.push(params);
  2477. if(String(params['wordsId']).split(',').length>100)return throwError(()=>new Error('单次最多操作 100 个单词'));
  2478. if(requests.length===2&&!failed){failed=true;return throwError(()=>new Error('第二批网络中断'));}
  2479. }
  2480. return original(action,params);
  2481. }) as typeof api.post);
  2482. for(const word of component.words())component.markWordKnown(word);
  2483. component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2484. expect(component.newlyKnownWords().length).toBe(105);
  2485. expect(component.coursePassCounts()?.[1]).toBe(100);
  2486. expect(component.submitting()).toBeFalse();
  2487. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
  2488. component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2489. expect(requests.map(p=>String(p['wordsId']).split(',').length)).toEqual([100,100,100,5]);
  2490. expect(requests[2]['requestKey']).toBe(requests[1]['requestKey']);
  2491. expect(api.courseIncrements.size).toBe(205);
  2492. expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue();
  2493. expect(component.coursePassCount(1)).toBe(205);
  2494. });
  2495. it('selects the visible words, allows exceptions, then saves known words and trains only exceptions', async () => {
  2496. const {api,harness,component}=await teacherSelection(8);
  2497. const view=()=>harness.routeNativeElement!;
  2498. const select=view().querySelector('.bulk-select-toggle') as HTMLButtonElement;
  2499. expect(select).not.toBeNull();select?.click();harness.fixture.detectChanges();
  2500. expect(view().querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(8);
  2501. expect(component.selected().size).toBe(0); // Temporary bulk checks do not change the actual learning selection.
  2502. const checks=view().querySelectorAll<HTMLButtonElement>('.word-select-check');checks[1].click();checks[6].click();harness.fixture.detectChanges();
  2503. (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();
  2504. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2505. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
  2506. const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2507. expect(learning.words().map(w=>w.wordId).sort()).toEqual(['1002','1007']);
  2508. expect([...api.courseIncrements.keys()].sort()).toEqual(['1001','1003','1004','1005','1006','1008']);
  2509. });
  2510. it('saves all-known words and opens the learned pass without creating an empty learning round', async () => {
  2511. const {api,harness,component}=await teacherSelection(8);
  2512. (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
  2513. (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();
  2514. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2515. expect(api.courseIncrements.size).toBe(8);
  2516. expect(component.coursePass()).toBe(1);
  2517. expect(component.visibleCourseWords().length).toBe(8);
  2518. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
  2519. });
  2520. it('keeps bulk unknown checks intact when they exceed a round and clears them across pass switches', async () => {
  2521. const {api,harness,component}=await teacherSelection(101);
  2522. component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2523. component.toggleWord(component.words()[100]);
  2524. const toast=spyOn(TestBed.inject(PlatformService),'toast');
  2525. (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
  2526. (harness.routeNativeElement!.querySelector('.bulk-assessment .unknown-button') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
  2527. expect(toast).toHaveBeenCalledWith(jasmine.stringMatching(/100/),'error');
  2528. expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(100);
  2529. expect(component.selected().size).toBe(1);expect(api.courseIncrements.size).toBe(0);
  2530. component.selectCoursePass(1);await harness.fixture.whenStable();harness.fixture.detectChanges();
  2531. expect(harness.routeNativeElement!.querySelector('.bulk-assessment')).toBeNull();
  2532. component.selectCoursePass(0);await harness.fixture.whenStable();harness.fixture.detectChanges();
  2533. expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(0);
  2534. });
  2535. it('uses select-all only for loaded words and guards decisions while saving', async () => {
  2536. const {api,harness,component}=await teacherSelection(105);
  2537. const original=api.post.bind(api),response=new Subject<ApiEnvelope<unknown>>();
  2538. const post=spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>action==='e_add_words'?response:original(action,params)) as typeof api.post);
  2539. (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
  2540. expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(100);
  2541. (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
  2542. expect(component.submitting()).toBeTrue();
  2543. component.startSelected();component.selectCoursePass(1);
  2544. expect(component.coursePass()).toBe(0);
  2545. expect(post.calls.allArgs().filter(([action])=>action==='e_add_words').length).toBe(1);
  2546. response.next({retcode:1,result:{affected:100}});response.complete();await harness.fixture.whenStable();
  2547. expect(post.calls.allArgs().filter(([action])=>action==='e_add_words').length).toBe(1);
  2548. });
  2549. it('preserves an ambiguous committed batch key by freezing decisions until retry resolves', async () => {
  2550. const {api,harness,component}=await teacherSelection(205);
  2551. for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();}
  2552. const original=api.post.bind(api),completed=new Map<string,Observable<ApiEnvelope<any>>>(),requests:Record<string,unknown>[]=[];let lost=false;
  2553. spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  2554. if(action!=='e_add_words')return original(action,params);
  2555. requests.push(params);const key=String(params['requestKey']);
  2556. if(completed.has(key))return completed.get(key)!;
  2557. const saved=original(action,params);completed.set(key,saved);
  2558. if(!lost){lost=true;return throwError(()=>new Error('服务端已保存但响应丢失'));}
  2559. return saved;
  2560. }) as typeof api.post);
  2561. const first=component.words()[0];
  2562. (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2563. for (const word of component.bulkCandidates().slice(100)) component.toggleWord(word);
  2564. harness.fixture.detectChanges();
  2565. (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();
  2566. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2567. expect(harness.routeNativeElement!.querySelector('.selection-retry-note')?.textContent).toContain('尚待确认');
  2568. expect(harness.routeNativeElement!.querySelector('.selection-header .primary-button')?.textContent).toContain('重试保存');
  2569. expect(api.courseIncrements.size).toBe(100);
  2570. component.markWordUnknown(first);component.toggleWord(first);component.selectCoursePass(1);component.loadMoreSelection();
  2571. expect(component.selectionAssessment()[first.id]).toBe('known');expect(component.selected().size).toBe(0);expect(component.coursePass()).toBe(0);
  2572. component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2573. expect(requests[1]['requestKey']).toBe(requests[0]['requestKey']);
  2574. expect(requests[1]['wordsId']).toBe(requests[0]['wordsId']);
  2575. expect(api.courseIncrements.size).toBe(205);expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue();
  2576. expect(component.coursePass()).toBe(1);expect(harness.routeNativeElement!.querySelector('.selection-retry-note')).toBeNull();
  2577. });
  2578. it('rebases pending pages after saving known words without skipping remaining vocabulary or selections', async () => {
  2579. const {api,harness,component}=await teacherSelection(250);
  2580. const view=()=>harness.routeNativeElement!;
  2581. const checks=()=>view().querySelectorAll<HTMLButtonElement>('.word-select-check');
  2582. checks()[99].click(); // Preserve an existing individual learning choice.
  2583. (view().querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2584. for(let i=60;i<100;i++)checks()[i].click();harness.fixture.detectChanges();
  2585. (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2586. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');expect(api.courseIncrements.size).toBe(60);
  2587. const get=api.get.bind(api);let failed=false;
  2588. spyOn(api,'get').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  2589. if(action==='e_ck_list'&&Number(params['cpage'])===1&&!failed){failed=true;return throwError(()=>new Error('刷新第一页失败'));}
  2590. return get(action,params);
  2591. }) as typeof api.get);
  2592. component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2593. expect(component.error()).toBe('');expect(view().querySelector('.word-refine-item')).not.toBeNull();
  2594. component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2595. component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2596. expect(component.selectionTotal()).toBe(190);
  2597. expect(component.visibleCourseWords().map(w=>Number(w.wordId)).sort((a,b)=>a-b)).toEqual(Array.from({length:190},(_,i)=>1061+i));
  2598. expect(component.selected().has('1100')).toBeTrue();
  2599. });
  2600. it('treats an individual unknown decision as an exception to bulk known', async () => {
  2601. const {api,harness}=await teacherSelection(3);
  2602. (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2603. (harness.routeNativeElement!.querySelector('.word-refine-item .unknown-button') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2604. expect(harness.routeNativeElement!.querySelector('.word-select-check')?.getAttribute('aria-pressed')).toBe('false');
  2605. (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2606. const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2607. expect(learning.words().map(w=>w.wordId)).toEqual(['1001']);expect([...api.courseIncrements.keys()].sort()).toEqual(['1002','1003']);
  2608. });
  2609. it('starts a valid bulk unknown selection without marking those words learned', async () => {
  2610. const {api,harness}=await teacherSelection(3);
  2611. (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2612. (harness.routeNativeElement!.querySelector('.bulk-assessment .unknown-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2613. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
  2614. const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2615. expect(learning.words().length).toBe(3);expect(api.courseIncrements.size).toBe(0);
  2616. });
  2617. for(const pass of [1,2]) {
  2618. it('bulk assesses learned pass '+pass+' and advances only recognized words by one pass', async () => {
  2619. const {api,harness,component}=await teacherSelection(4,pass);
  2620. const view=()=>harness.routeNativeElement!;
  2621. expect(view().querySelector('.bulk-select-toggle')).not.toBeNull();
  2622. expect(view().querySelectorAll('.word-refine-main .known-button').length).toBe(4);
  2623. (view().querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
  2624. expect(view().querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(4);
  2625. (view().querySelectorAll<HTMLButtonElement>('.word-select-check')[3]).click();harness.fixture.detectChanges();
  2626. (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2627. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
  2628. const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2629. expect(learning.words().map(w=>w.wordId)).toEqual(['1004']);
  2630. expect([...api.courseIncrements.keys()].sort()).toEqual(['1001','1002','1003']);
  2631. expect(component.coursePassCount(pass)).toBe(1);expect(component.coursePassCount(pass+1)).toBe(3);
  2632. expect(component.firstPassProgress()).toBe(100);
  2633. });
  2634. }
  2635. it('marks only the requested 20 course words known without learning the other 80', async () => {
  2636. const {api,harness,component} = await teacherSelection(100);
  2637. spyOn(TestBed.inject(PlatformService), 'toast');
  2638. const picker = harness.routeNativeElement!.querySelector<HTMLSelectElement>('.bulk-select-size')!;
  2639. picker.value='20';picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges();
  2640. (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2641. (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();
  2642. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2643. expect(api.courseIncrements.size).toBe(20);expect(component.selected().size).toBe(0);
  2644. expect(component.coursePassCounts()).toEqual([80,20,0,0]);
  2645. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
  2646. });
  2647. it('preserves individual wordbook choices when a quantity selection request fails', async () => {
  2648. spyOn(TestBed.inject(PlatformService), 'toast');
  2649. const api=TestBed.inject(ApiService), original=api.get.bind(api), pending=new Subject<ApiEnvelope<unknown>>();
  2650. let reads=0;
  2651. spyOn(api,'get').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  2652. if(action!=='e_words_list')return original(action,params);
  2653. if(reads++)return pending;
  2654. return of({retcode:0,result:[{GeneralID:601,detail:[{GeneralID:701,Title:'apple',sy:'苹果'}]}],page:{itemCount:1,pendingCount:1,completedCount:0}});
  2655. }) as typeof api.get);
  2656. const harness=await RouterTestingHarness.create('/pages/member/starshop');
  2657. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2658. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2659. (harness.routeNativeElement!.querySelector('.word-select-list > button') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2660. (harness.routeNativeElement!.querySelector('.selection-header .quiet-button') as HTMLButtonElement).click();harness.fixture.detectChanges();
  2661. expect(component.selectionLoading()).toBeTrue();
  2662. pending.error(new Error('temporary failure'));harness.fixture.detectChanges();
  2663. expect([...component.selected()]).toEqual(['701']);expect(component.selectionLoading()).toBeFalse();
  2664. });
  2665. for (const entry of ['course-0', 'course-1', 'course-2', 'coach-wordbook', 'student-wordbook']) for (const startCount of [20,50,100]) {
  2666. it(`selects only the requested quantity and starts ${startCount} words for ${entry}`, async () => {
  2667. spyOn(TestBed.inject(PlatformService), 'toast');
  2668. let harness: RouterTestingHarness;
  2669. if (entry.startsWith('course-')) {
  2670. harness = (await teacherSelection(100, Number(entry.slice(-1)), entry === 'course-0' ? '901' : '')).harness;
  2671. } else {
  2672. if (entry === 'coach-wordbook') {
  2673. user.set({userId:300,groupId:3});session.groupId.set(3);
  2674. }
  2675. const api = TestBed.inject(ApiService), original = api.get.bind(api);
  2676. spyOn(api, 'get').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
  2677. if (action === 'e_user_list') return of({retcode:0,result:[{userId:100,honeyName:'学员甲'}]});
  2678. if (action === 'e_words_list') {
  2679. const all = Array.from({length:100}, (_,i) => ({GeneralID:601+i,detail:[{GeneralID:1001+i,Title:'word-'+i,sy:'释义 '+i}]}));
  2680. const size = Number(params['psize']), page = Number(params['cpage']);
  2681. return of({retcode:0,result:all.slice((page-1)*size,page*size),page:{itemCount:100,pendingCount:100,completedCount:0,pageCount:Math.ceil(100/size)}});
  2682. }
  2683. return original(action,params);
  2684. }) as typeof api.get);
  2685. harness = await RouterTestingHarness.create('/pages/member/starshop');
  2686. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2687. }
  2688. const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2689. const picker = harness.routeNativeElement!.querySelector<HTMLSelectElement>('.bulk-select-size');
  2690. expect(picker).withContext('every editable selection entry has a quantity picker').not.toBeNull();
  2691. if (!picker) return;
  2692. expect(Array.from(picker.options).map(option => option.value)).toEqual(['20','50','100']);
  2693. expect(picker.value).toBe('100');
  2694. const clickAll = () => {
  2695. (harness.routeNativeElement!.querySelector('.bulk-select-toggle, .quiet-button') as HTMLButtonElement).click();
  2696. harness.fixture.detectChanges();
  2697. };
  2698. for (const count of [20,50,100]) {
  2699. picker.value = String(count);picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges();
  2700. clickAll();
  2701. expect((component.bulkSelection() ?? component.selected()).size).toBe(count);
  2702. if (count < 100) {clickAll();expect((component.bulkSelection() ?? component.selected()).size).toBe(0);}
  2703. }
  2704. if (startCount !== 100) {
  2705. clickAll();picker.value = String(startCount);picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges();clickAll();
  2706. }
  2707. const button = entry.startsWith('course-') ? '.bulk-assessment .unknown-button' : '.selection-header .primary-button';
  2708. (harness.routeNativeElement!.querySelector(button) as HTMLButtonElement).click();
  2709. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2710. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
  2711. const pages = JSON.parse(localStorage.getItem(entry === 'coach-wordbook'
  2712. ? 'xiaoshu.learning.300.100.wordbook._page_word_orilist'
  2713. : entry === 'student-wordbook' ? 'xiaoshu.learning.100.100.practice._page_word_orilist'
  2714. : entry === 'course-0' ? 'xiaoshu.learning.300.100.901.unbound._page_word_orilist'
  2715. : 'xiaoshu.learning.300.100.practice._page_word_orilist') || '[]');
  2716. expect(pages.flat().length).toBe(startCount);
  2717. });
  2718. }
  2719. for (const entry of ['course', 'coach-wordbook', 'student-wordbook'] as const) {
  2720. it(`shows hints immediately, resets all three rounds and preserves the practice flow for ${entry}`, async () => {
  2721. let harness: RouterTestingHarness;
  2722. const platform = TestBed.inject(PlatformService);
  2723. spyOn(platform, 'toast');
  2724. if (entry === 'course') {
  2725. const selection = await teacherSelection(2,0,'901');
  2726. harness = selection.harness;
  2727. selection.component.selected.set(new Set(selection.component.words().map(word => word.id)));
  2728. selection.component.startSelected();
  2729. } else {
  2730. if (entry === 'coach-wordbook') {
  2731. user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
  2732. }
  2733. const api = TestBed.inject(ApiService), original = api.get.bind(api);
  2734. spyOn(api, 'get').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
  2735. if (action === 'e_user_list') return of({retcode:0,result:[{userId:100,honeyName:'学员甲'}]});
  2736. if (action === 'e_words_list') return of({retcode:0,result:Array.from({length:2},(_,i)=>({GeneralID:601+i,detail:[{GeneralID:701+i,Title:'word-'+i,sy:'释义'+i,yb:'/wɜːd/'}]}))});
  2737. return original(action,params);
  2738. }) as typeof api.get);
  2739. harness = await RouterTestingHarness.create('/pages/member/starshop' + (entry === 'coach-wordbook' ? '?wordbookStudentId=100' : ''));
  2740. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2741. for (const button of Array.from(harness.routeNativeElement!.querySelectorAll<HTMLButtonElement>('.word-select-list > button'))) button.click();
  2742. harness.fixture.detectChanges();
  2743. (harness.routeNativeElement!.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
  2744. }
  2745. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2746. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
  2747. const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2748. expect(component.words().length).toBe(2);
  2749. component.words.update(words => words.map(word => ({...word,phonetic:word.phonetic || '/wɜːd/',audio:'course.mp3'})));
  2750. const play=spyOn(platform,'play').and.resolveTo('played');
  2751. for (const step of [1,2,3] as const) {
  2752. expect(component.studyStep()).toBe(step);harness.fixture.detectChanges();
  2753. const first = component.words()[0], row = harness.routeNativeElement!.querySelector<HTMLButtonElement>('.repeat-list > button')!;
  2754. const answer = () => row.querySelector('.study-answer, small:not(.study-phonetic)');
  2755. expect(row.querySelector('strong')?.textContent).toBe(step===3 ? first.meaning : first.word);
  2756. expect(answer()).toBeNull();
  2757. row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();
  2758. play.calls.mostRecent().args[2]!.onEnded();harness.fixture.detectChanges();expect(answer()).toBeNull();
  2759. if(step!==3)expect(row.querySelector('.study-phonetic')?.textContent).toBe(first.phonetic);
  2760. row.click();harness.fixture.detectChanges();expect(answer()?.textContent).toBe(step===3 ? first.word : first.meaning);
  2761. const lateEnded=play.calls.mostRecent().args[2]!.onEnded;
  2762. lateEnded();harness.fixture.detectChanges();expect(answer()?.textContent).toBe(step===3 ? first.word : first.meaning);
  2763. row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();expect(row.querySelectorAll('.repeat-dots .done').length).toBe(3);
  2764. if(step!==3)expect(row.querySelector('.study-phonetic')?.textContent).toBe(first.phonetic);
  2765. lateEnded();harness.fixture.detectChanges();expect(answer()).toBeNull();
  2766. row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();expect(component.repetitions()[first.id]).toBe(3);
  2767. for(const [index,word] of component.words().entries())if(index>0)for(let tap=0;tap<3;tap++)component.repeatWord(word,index);
  2768. if(step<3){expect(component.repetitions()).toEqual({});harness.fixture.detectChanges();expect(harness.routeNativeElement!.querySelector('.study-answer')).toBeNull();}
  2769. }
  2770. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2771. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu3');
  2772. const stage=()=>harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2773. for(const word of [...stage().matchLeft()]){stage().chooseMatch('left',word);stage().chooseMatch('right',word);}
  2774. stage().continueAfterStage();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2775. expect(TestBed.inject(Router).url).toContain('/pages/stu/stu4');
  2776. for(let i=0;i<2;i++)stage().chooseMeaning(stage().current()!);
  2777. stage().continueAfterStage();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2778. expect(TestBed.inject(Router).url).toContain(entry==='student-wordbook'?'/pages/stu/stu5':'/pages/stu/pl_stu5');
  2779. });
  2780. }
  2781. it('reveals Chinese with a held left swipe without consuming a tap and resets it on shuffle', async () => {
  2782. const {harness,component:selection}=await teacherSelection(2,0,'901');
  2783. selection.selected.set(new Set(selection.words().map(word=>word.id)));selection.startSelected();
  2784. await harness.fixture.whenStable();harness.fixture.detectChanges();
  2785. const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
  2786. spyOn(TestBed.inject(PlatformService),'play').and.resolveTo('played');
  2787. component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'})));
  2788. const row=()=>harness.routeNativeElement!.querySelector<HTMLButtonElement>('.repeat-list > button')!;
  2789. const swipe=()=>{
  2790. const target=row();
  2791. for(const [type,x,time] of [['pointerdown',150,0],['pointermove',100,500],['pointerup',100,510]] as const){
  2792. const event=new PointerEvent(type,{bubbles:true,button:0,pointerId:1,isPrimary:true,clientX:x,clientY:30});
  2793. Object.defineProperty(event,'timeStamp',{value:time});target.dispatchEvent(event);
  2794. }
  2795. target.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true,detail:1}));harness.fixture.detectChanges();
  2796. };
  2797. swipe();expect(row().querySelector('.study-answer')?.textContent).toBe(component.words()[0].meaning);
  2798. expect(component.repetitions()).toEqual({});expect(component.studyStep()).toBe(1);
  2799. row().click();harness.fixture.detectChanges();expect(row().querySelector('.study-answer')).toBeNull();
  2800. expect(component.repetitions()[component.words()[0].id]).toBe(1);
  2801. swipe();component.shuffleStudyWords();harness.fixture.detectChanges();
  2802. expect(row().querySelector('.study-answer')).toBeNull();expect(component.repetitions()).toEqual({});expect(component.studyStep()).toBe(1);
  2803. component.studyStep.set(3);harness.fixture.detectChanges();swipe();
  2804. expect(row().querySelector('.study-answer')).toBeNull();expect(component.repetitions()).toEqual({});
  2805. });
  2806. it('uses different save identities for consecutive learned passes and leaves the third pass read-only', async () => {
  2807. const {api,harness,component}=await teacherSelection(2,1);
  2808. spyOn(TestBed.inject(PlatformService),'toast'); // Two consecutive saves test state, not toast dismissal timers.
  2809. const original=api.post.bind(api),saved=new Map<string,Observable<ApiEnvelope<any>>>(),keys:string[]=[];
  2810. spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
  2811. if(action!=='e_add_words')return original(action,params);
  2812. const key=String(params['requestKey']);keys.push(key);if(saved.has(key))return saved.get(key)!;
  2813. const result=original(action,params);saved.set(key,result);return result;
  2814. }) as typeof api.post);
  2815. for(const expectedPass of [2,3]){
  2816. (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
  2817. (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();await harness.fixture.whenStable();harness.fixture.detectChanges();
  2818. expect(component.coursePass()).toBe(expectedPass);expect(component.visibleCourseWords().length).toBe(2);
  2819. }
  2820. expect(keys.length).toBe(2);expect(new Set(keys).size).toBe(2);
  2821. expect([...api.courseIncrements.values()]).toEqual([2,2]);
  2822. expect(component.coursePassCount(3)).toBe(2);expect(component.coursePassCount(0)).toBe(0);
  2823. expect(harness.routeNativeElement!.querySelector('.bulk-select-toggle')).toBeNull();
  2824. expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull();
  2825. expect(harness.routeNativeElement!.querySelector('.word-assessment')).toBeNull();
  2826. component.toggleBulkSelection();component.applyBulkAssessment('known');
  2827. expect(keys.length).toBe(2);
  2828. });
  2829. });