Parcourir la source

feat(mobile): link completed readings to study records

彭峰 il y a 1 mois
Parent
commit
5f6ddc670f

+ 14 - 3
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -162,8 +162,8 @@ class LearningModulesApiService {
             con: `分页学习记录 ${index + 1}`,
           }))
         : [
-            { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', dqrq: '20260801', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) },
-            { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', dqrq: '20260804', con: '高中英语@Unit 2' },
+            { 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' },
+            { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', dqrq: '20260804', con: '高中英语@Unit 2', xxqs: JSON.stringify([{ Title: 'banana' }]) },
           ];
       const selected = String(params['date'] ?? '');
       return this.result<T>({
@@ -567,9 +567,20 @@ describe('home learning modules', () => {
     (harness.routeNativeElement?.querySelector('.record-actions button') as HTMLButtonElement).click();
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
-    (harness.routeNativeElement?.querySelector('.reading-button') as HTMLButtonElement).click();
+    const completedReading = harness.routeNativeElement?.querySelector('.reading-button.completed') as HTMLButtonElement;
+    expect(completedReading.textContent).toContain('已完成阅读');
+    expect(completedReading.getAttribute('aria-label')).toBe('查看已完成的历史阅读理解');
+    completedReading.click();
     await harness.fixture.whenStable();
     const router = TestBed.inject(Router);
+    expect(router.url).toContain('/words/reading');
+    expect(router.parseUrl(router.url).queryParams['sid']).toBe('readingarticle201');
+    expect(router.parseUrl(router.url).queryParams['returnUrl']).toBe('/pages/his/his');
+    await harness.navigateByUrl('/pages/his/his');
+    const newReading = harness.routeNativeElement?.querySelector('.reading-button:not(.completed)') as HTMLButtonElement;
+    expect(newReading.textContent).toContain('阅读');
+    newReading.click();
+    await harness.fixture.whenStable();
     expect(router.url).toContain('/words/go-reading');
     expect(router.parseUrl(router.url).queryParams['returnUrl']).toBe('/pages/his/his');
     expect(harness.routeNativeElement?.querySelector('.back-button')).not.toBeNull();

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.spec.ts

@@ -3,9 +3,9 @@ import {ReadingCloudService} from './reading-cloud.service';
 import {FmodeAiService} from './fmode-ai.service';
 import {ReadingArticle,ReadingRouteParams}from './reading.models';
 describe('reading article reuse',()=>{
- const params={level:'初一',type:'记叙文',theme:'校园',wordList:['apple','river'],renew:false} as ReadingRouteParams;
+ const params={sourceStudyId:'201',level:'初一',type:'记叙文',theme:'校园',wordList:['apple','river'],renew:false} as ReadingRouteParams;
  function setup(){const cloud={userPointer:{__type:'Pointer',className:'_User',objectId:'student'},query:jasmine.createSpy('query').and.resolveTo([]),create:jasmine.createSpy('create').and.callFake(async(_name:string,data:object)=>({...data,objectId:'new'}))};const s=new EnglishReadingService(cloud as unknown as ReadingCloudService,{} as FmodeAiService);spyOn(s as any,'formatPrompt').and.resolveTo('prompt');spyOn(s as any,'collectStream').and.resolveTo('A new story\napple river');return{s,cloud};}
- it('queries the complete normalized word set and reading configuration',async()=>{const{s,cloud}=setup();cloud.query.and.resolveTo([{objectId:'existing',content:'apple river'}]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('existing');const where=cloud.query.calls.first().args[1];expect(where['createOptions.params.wordListKey']).toBe('apple|river');expect(where['createOptions.params.level']).toBe('初一');expect(where['createOptions.params.type']).toBe('记叙文');expect(where['createOptions.params.theme']).toBe('校园');expect(where['createOptions.params.wordList']).toBeUndefined();expect(cloud.create).not.toHaveBeenCalled();});
+ it('queries the complete normalized word set, source study and reading configuration',async()=>{const{s,cloud}=setup();cloud.query.and.resolveTo([{objectId:'existing',content:'apple river'}]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('existing');const where=cloud.query.calls.first().args[1];expect(where['createOptions.params.wordListKey']).toBe('apple|river');expect(where['createOptions.params.sourceStudyId']).toBe('201');expect(where['createOptions.params.level']).toBe('初一');expect(where['createOptions.params.type']).toBe('记叙文');expect(where['createOptions.params.theme']).toBe('校园');expect(where['createOptions.params.wordList']).toBeUndefined();expect(cloud.create).not.toHaveBeenCalled();});
  it('does not reuse an article that only overlaps part of the requested vocabulary',async()=>{const{s,cloud}=setup();cloud.query.and.callFake(async(_n:string,w:Record<string,unknown>)=>w['createOptions.params.wordList']?[{objectId:'old-apple-tree'}]:[]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('new');expect(cloud.create).toHaveBeenCalled();});
  it('regenerates on explicit request without querying a reusable article',async()=>{const{s,cloud}=setup();const article=await s.createArticle({...params,renew:true},()=>{});expect(article.objectId).toBe('new');expect(cloud.query).not.toHaveBeenCalled();});
 });

+ 4 - 2
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts

@@ -64,7 +64,7 @@ export class EnglishReadingService {
       theme: params.theme,
     };
     if (!params.renew) {
-      const existing = await this.cloud.query<ReadingArticle>('SurveyItem', {
+      const where: Record<string, unknown> = {
         'createOptions.tpl': ARTICLE_TEMPLATE,
         'createOptions.params.level': params.level,
         'createOptions.params.type': params.type,
@@ -72,7 +72,9 @@ export class EnglishReadingService {
         'createOptions.params.wordListKey': [...new Set(params.wordList.map(w=>w.trim().toLowerCase()))].sort().join('|'),
         user: this.cloud.userPointer,
         isDeleted: { '$ne': true },
-      }, { order: '-createdAt', limit: 1 });
+      };
+      if (params.sourceStudyId) where['createOptions.params.sourceStudyId'] = params.sourceStudyId;
+      const existing = await this.cloud.query<ReadingArticle>('SurveyItem', where, { order: '-createdAt', limit: 1 });
       if (existing[0]) {
         onProgress({ task: 'article', message: '已加载历史文章', content: existing[0].content });
         return existing[0];

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -53,7 +53,7 @@
       @if (visibleItems().length) {
         <div class="record-list">
           @for (item of paginatedItems(); track item['GeneralID'] || $index) {
-            <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" [disabled]="readingLoadingId() === recordId(item)" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
+            <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs'] || completedReadingId(item)) { <button type="button" class="reading-button" [class.completed]="completedReadingId(item)" [disabled]="readingLoadingId() === recordId(item)" [attr.aria-label]="completedReadingId(item) ? '查看已完成的历史阅读理解' : '生成阅读理解'" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : completedReadingId(item) ? '已完成阅读' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
           }
         </div>
         @if (recordPageCount() > 1) {
@@ -93,7 +93,7 @@
         } @else if (meta.path === 'pages/member/ke_fuxi') {
           <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} {{ item['kywsj'] }}</p>@if(item['rewardStatus']==='historical'){<small>历史待核对,未自动计薪</small>}@else if(item['rewardStatus']==='exception'){<small>计薪异常待核对</small>}</div><b>¥{{ item['money'] || 0 }}</b></article>
         } @else {
-          <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" [disabled]="readingLoadingId() === recordId(item)" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
+          <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs'] || completedReadingId(item)) { <button type="button" class="reading-button" [class.completed]="completedReadingId(item)" [disabled]="readingLoadingId() === recordId(item)" [attr.aria-label]="completedReadingId(item) ? '查看已完成的历史阅读理解' : '生成阅读理解'" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : completedReadingId(item) ? '已完成阅读' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
         }
       }
     </div>

+ 3 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss

@@ -89,7 +89,9 @@
 .learning-item time, .history-item p, .progress-item p, .income-item p { margin: 0; color: var(--ink-soft); font-size: 12px; }
 .learning-item strong, .progress-item strong, .income-item strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
 .learning-item > span, .progress-item > span { color: var(--brand-strong); font-size: 12px; }
-.reading-button { min-width: 48px; min-height: 34px; padding: 0 10px; color: white; background: var(--brand-strong); border: 0; border-radius: 4px; font-size: 12px; }
+.reading-button { min-width: 48px; min-height: 34px; padding: 0 10px; color: white; background: var(--brand-strong); border: 1px solid transparent; border-radius: 4px; font-size: 12px; font-weight: 700; white-space: nowrap; }
+.reading-button.completed { min-width: 86px; color: #315d96; background: #eaf1fb; border-color: #b8cce7; border-radius: 999px; }
+.reading-button.completed:hover { background: #dce8f7; border-color: #91add1; }
 .reading-button:disabled { cursor: wait; opacity: .68; }
 .history-item { grid-template-columns: auto minmax(0, 1fr) auto; color: var(--ink-soft); }
 .history-item.complete { color: var(--brand-strong); }

+ 20 - 2
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -342,11 +342,25 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
 
   openLatestReading(): void {
     const item = this.latestReadingRecord();
-    if (item) this.navigateToReading(item);
+    if (item) this.navigateToReading(item, false);
   }
 
-  private navigateToReading(item: Item): void {
+  private navigateToReading(item: Item, openCompleted = true): void {
     const wordList = extractReadingWords(item['xxqs']);
+    const completedArticleId = this.completedReadingId(item);
+    if (openCompleted && completedArticleId) {
+      void this.router.navigate(['/words/reading'], {
+        queryParams: {
+          sid: completedArticleId,
+          wordList: wordList.join(','),
+          GeneralID: this.recordId(item),
+          studentId: this.studentId || this.session.user()?.userId,
+          returnUrl: this.router.url,
+          t: Date.now(),
+        },
+      });
+      return;
+    }
     if (wordList.length) {
       this.openReadingSetup(item, wordList);
       return;
@@ -374,6 +388,10 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
     return String(item['GeneralID'] ?? item['generalId'] ?? item['ID'] ?? item['id'] ?? '').trim();
   }
 
+  completedReadingId(item: Item): string {
+    return String(item['readingArticleId'] ?? '').trim();
+  }
+
   private openReadingSetup(item: Item, wordList: string[]): void {
     const params = {
       wordList: wordList.join(','),

+ 26 - 2
scripts/deploy-admin-functions.mjs

@@ -2687,6 +2687,12 @@ function appOverviewCourseRow(source) {
 function appOverviewStudyRow(row) {
   if(!row)return null;return{objectId:String(row.objectId||''),GeneralID:number(row.generalId||row.id),ID:number(row.generalId||row.id),Title:String(row.title||row.con||'学习记录'),CreateTime:firstValue(row.createTime,row.createdAt),pl:row.pl,con:row.con,djq:row.djq,ygg:row.ygg,dqrq:row.dqrq,dsid:row.dsid,fxrl:row.fxrl,xxqs:row.xxqs,szmdid:row.szmdid,UserID:row.userId,userId:row.userId,learned:row.learned};
 }
+async function appOverviewCompletedReadingMap(records,targetId) {
+  const studyIds=[...new Set(records.map((row)=>String(row&&row.GeneralID||'').trim()).filter(Boolean))];if(!studyIds.length)return new Map();
+  const learner=await legacyUserObject(targetId);if(!learner)return new Map();const ownerIds=[learner.id,'_User$'+learner.id];
+  const rows=await Psql.query('SELECT DISTINCT ON (a."createOptions"#>>\'{params,sourceStudyId}\') a."createOptions"#>>\'{params,sourceStudyId}\' AS "sourceStudyId",a."objectId" AS "readingArticleId",a."title" AS "readingTitle",completed."completedAt" AS "readingCompletedAt" FROM "SurveyItem" a JOIN LATERAL (SELECT COALESCE(l."createdAt",l."updatedAt") AS "completedAt" FROM "SurveyLog" l WHERE CAST(l."surveyItem" AS text)=ANY(ARRAY[CAST(a."objectId" AS text),\'SurveyItem$\'||CAST(a."objectId" AS text)]) AND CAST(l."user" AS text)=ANY($1::text[]) AND COALESCE(l."isDeleted",FALSE)=FALSE ORDER BY COALESCE(l."createdAt",l."updatedAt") DESC,l."objectId" DESC LIMIT 1) completed ON TRUE WHERE CAST(a."user" AS text)=ANY($1::text[]) AND a."type"=\'reading\' AND COALESCE(a."isDeleted",FALSE)=FALSE AND a."createOptions"#>>\'{params,sourceStudyId}\'=ANY($2::text[]) ORDER BY a."createOptions"#>>\'{params,sourceStudyId}\',completed."completedAt" DESC,COALESCE(a."createdAt",a."updatedAt") DESC',[ownerIds,studyIds]);
+  const map=new Map();for(const row of rows)map.set(String(row.sourceStudyId),{readingArticleId:String(row.readingArticleId||''),readingTitle:String(row.readingTitle||''),readingCompletedAt:row.readingCompletedAt});return map;
+}
 async function appLearningTarget(current,requestedId) {
   const ownId=ownLegacyId(current),targetId=number(requestedId||ownId);if(!targetId)fail(400,'缺少学员 ID');if(isAdminUser(current)||targetId===ownId)return targetId;
   if(number(current.get('legacyGroupId')||current.get('groupId'))!==3)fail(403,'仅关联老师可以查看学员学习记录');
@@ -2706,11 +2712,11 @@ async function appLearningOverview(input,current) {
     Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(b."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN b."kcid"::numeric ELSE -1 END WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(targetId)]),
     Psql.oneOrNone('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",d."id",d."pl",d."con",d."djq",d."ygg",d."dqrq",d."dsid",d."fxrl",d."xxqs",d."szmdid",d."userId",d."learned" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."userId"=$2 AND COALESCE(NULLIF(d."xxqs",\'\'),\'[]\')<>\'[]\' ORDER BY COALESCE(c."createTime",c."createdAt",d."updatedAt",d."createdAt") DESC LIMIT 1',[DEFAULT_COMPANY_ID,targetId])
   ]);
-  const normalizedRecords=records.map(appOverviewStudyRow);
+  const normalizedRecords=records.map(appOverviewStudyRow),normalizedLatestReading=appOverviewStudyRow(latestReading),completedReadingMap=await appOverviewCompletedReadingMap(normalizedLatestReading?[...normalizedRecords,normalizedLatestReading]:normalizedRecords,targetId),attachCompletedReading=(row)=>{if(!row)return row;const reading=completedReadingMap.get(String(row.GeneralID));return reading?{...row,...reading}:row;};
   const recordDateCounts={},scheduleDateCounts={},learnedDates=new Set();for(const row of dateRows){const date=appOverviewDateKey(row.date);if(date){recordDateCounts[date]=number(row.count);learnedDates.add(date);}}
   const today=appOverviewToday();for(const row of scheduleGroups){const date=appOverviewDateKey(row.bxrq||row.yysj);if(!date||learnedDates.has(date))continue;const count=number(row.count),status=number(row.dszt),value=scheduleDateCounts[date]||{planned:0,missed:0};if(status<30&&date<today)value.missed+=count;else value.planned+=count;scheduleDateCounts[date]=value;}
   const total=await Psql.one('SELECT COUNT(*)::int total FROM "DailyStudyRecord" d WHERE '+recordWhere.join(' AND '),recordValues);
-  return {page:mobilePageMeta(total.total,paging),...mobileSource(),records:normalizedRecords,latestReadingRecord:appOverviewStudyRow(latestReading),recordDateCounts,scheduleDateCounts,schedules:schedules.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),refreshedAt:new Date().toISOString()};
+  return {page:mobilePageMeta(total.total,paging),...mobileSource(),records:normalizedRecords.map(attachCompletedReading),latestReadingRecord:attachCompletedReading(normalizedLatestReading),recordDateCounts,scheduleDateCounts,schedules:schedules.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),refreshedAt:new Date().toISOString()};
 }
 async function appCompanionOverview(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,history=input.history===true,p=mobilePage(input);
@@ -3772,6 +3778,23 @@ async function ensureOperationsUserSchema() {
   console.log('updated _User schema (' + Object.keys(missingFields).join(', ') + ')');
 }
 
+async function ensureReadingLinkSchema() {
+  const desiredFields = {
+    operatorId: { type: 'String', required: false },
+    learnerLegacyId: { type: 'Number', required: false },
+    sourceStudyId: { type: 'String', required: false },
+  };
+  for (const className of ['SurveyItem', 'SurveyLog']) {
+    const schema = await parseRequest(`/schemas/${className}`);
+    const missingFields = Object.fromEntries(
+      Object.entries(desiredFields).filter(([field]) => !(schema.fields || {})[field]),
+    );
+    if (!Object.keys(missingFields).length) continue;
+    await parseRequest(`/schemas/${className}`, { method: 'PUT', body: JSON.stringify({ fields: missingFields }) });
+    console.log('updated ' + className + ' reading link schema (' + Object.keys(missingFields).join(', ') + ')');
+  }
+}
+
 async function ensurePayrollSchemas() {
   const schemas = {
     CourseAppointment: {
@@ -3884,6 +3907,7 @@ if (validateOnly) {
     await ensureOperationsUserSchema();
     await ensurePayrollSchemas();
   }
+  if (selectedDefinitions.some((definition) => definition.name === 'xiaoshu.app.gateway')) await ensureReadingLinkSchema();
   for (const definition of selectedDefinitions) {
     const result = await upsert(definition);
     console.log(`${result.action.padEnd(7)} ${definition.path} (${result.objectId})`);

+ 12 - 1
scripts/tests/mobile-teaching.test.mjs

@@ -1,5 +1,6 @@
-import {test} from 'node:test';import assert from 'node:assert/strict';import{readFileSync}from'node:fs';import{PGlite}from'@electric-sql/pglite';
+import {test} from 'node:test';import assert from 'node:assert/strict';import{readFileSync}from'node:fs';import vm from'node:vm';import{PGlite}from'@electric-sql/pglite';
 const sql=readFileSync(new URL('../sql/mobile-teaching.sql',import.meta.url),'utf8');
+const gatewaySource=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
 test('教学事务:重复及并发请求只增一次,关联记录同事务,失败整体回滚',async()=>{
  const db=new PGlite();try{
  await db.exec(`CREATE TABLE "PracticeRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"yhid" int,"scid" int,"xxcs" int);CREATE TABLE "DailyStudyRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,id bigint,"sourceKey" text);CREATE TABLE "CommonModel"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"itemId" bigint,"generalId" bigint,"sourceKey" text);`);await db.exec(sql);
@@ -25,3 +26,13 @@ test('完课确认:学生与老师依次确认,重复确认幂等,越权
  await db.exec('UPDATE "CommonModel" SET status=-2');await assert.rejects(confirm(30,20),/已取消/);
  }finally{await db.close();}
 });
+test('学习记录:仅关联已经提交答题记录的历史阅读',async()=>{
+ const db=new PGlite();try{
+  await db.exec('CREATE TABLE "SurveyItem"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,"user" text,"type" text,"title" text,"createOptions" jsonb,"isDeleted" boolean);CREATE TABLE "SurveyLog"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,"user" text,"surveyItem" text,"isDeleted" boolean);');
+  await db.query('INSERT INTO "SurveyItem" VALUES($1,now(),now(),$2,$3,$4,$5::jsonb,false),($6,now(),now(),$2,$3,$7,$8::jsonb,false)', ['article201','_User$learner','reading','已完成文章',JSON.stringify({params:{sourceStudyId:'201'}}),'article202','未完成文章',JSON.stringify({params:{sourceStudyId:'202'}})]);
+  await db.query('INSERT INTO "SurveyLog" VALUES($1,now(),now(),$2,$3,false)', ['log201','_User$learner','SurveyItem$article201']);
+  const source=gatewaySource.match(/async function appOverviewCompletedReadingMap[\s\S]*?\n}\nasync function appLearningTarget/)?.[0].replace(/\nasync function appLearningTarget[\s\S]*$/,'');assert.ok(source);
+  const context={Psql:{query:async(statement,args)=>(await db.query(statement,args)).rows},legacyUserObject:async()=>({id:'learner'})};vm.runInNewContext(source+';this.readingMap=appOverviewCompletedReadingMap',context);
+  const map=await context.readingMap([{GeneralID:201},{GeneralID:202}],100);assert.equal(map.get('201').readingArticleId,'article201');assert.equal(map.get('201').readingTitle,'已完成文章');assert.equal(map.has('202'),false);
+ }finally{await db.close();}
+});