Pārlūkot izejas kodu

fix(mobile): invalidate timetable snapshots after lesson completion

彭峰 3 dienas atpakaļ
vecāks
revīzija
0632aef862

+ 24 - 0
docs/qa/course-status-cache-2026-10-29.md

@@ -0,0 +1,24 @@
+# 结课后陪练课表仍显示未开始
+
+## 原因
+
+2026-10-29 钱老师的两节高中语法写作课(08:00,预约编号 `839154386031627`;09:00,预约编号 `839154386031629`)已完成结课。生产数据库和老师本人会话读取的 `app_companion_overview` 都返回 `dszt=30`,当天合计已结课 3 节、未结课 3 节。
+
+移动端 `HomeDailyCacheService` 将课表按北京时间的日期、筛选和分页持久化。教学写入原来仅清除 API 层正在合并的请求,没有清除这层当天快照。返回课表甚至重新加载页面,都可能继续显示结课之前的 `dszt=0`。
+
+## 修复
+
+- API 成功写入或收到其他标签页的变更广播时,清除所有课表快照,并持久化清理结果;首页其他每日汇总继续复用。
+- 清除课表正在等待的读取,并拒绝结课前的延迟响应重新写入缓存。
+- 当前打开的课表收到变更后重新读取,保留日期、筛选、页码;离开后取消监听。
+- 课表缓存格式升级,首次加载新版时忽略旧版课表快照,读取真实课程状态。
+- 普通切换继续复用有效缓存,不新增轮询。不修改结课记录、课时或工资数据。
+
+## 验证
+
+- 从 Git 暂存区导出独立源码构建,排除工作区其他正在进行的功能。
+- 移动端 298 项测试通过,生产构建通过;保留原有样式体积及 CommonJS 警告。
+- `node scripts/check-course-status-cache-browser.mjs` 验证:旧版当天缓存升级、写作课结课后返回课表、另一标签页结课后的课表更新、重新加载后的状态持久性。
+- 线上后端只读核验两条预约均为已结课,无需修补真实业务数据。
+
+生产发布、资源哈希回读及老师课表浏览器验收记录保存在本机受限目录 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-29-course-status-cache/`。浏览器回读仅允许课表、会话校验和版本读取;测试结课操作使用隔离接口数据。

+ 22 - 0
projects/xiaoshu-mobile/src/app/core/api.service.spec.ts

@@ -5,6 +5,7 @@ import { Router } from '@angular/router';
 import { API_CONFIG } from './app.constants';
 import { ApiError, ApiService } from './api.service';
 import { SessionService } from './session.service';
+import { HomeDailyCacheService } from './home-daily-cache.service';
 
 describe('ApiService', () => {
   let api: ApiService;
@@ -51,6 +52,27 @@ describe('ApiService', () => {
     expect(changes).toBe(0);
   });
 
+  for (const action of ['e_order_update_v2', 'app_lesson_complete', 'app_lesson_feedback_update']) {
+    it(`invalidates timetable snapshots before notifying pages after ${action} succeeds`, () => {
+      const invalidate = spyOn(TestBed.inject(HomeDailyCacheService), 'invalidateCompanion');
+      const notification = jasmine.createSpy('notification').and.callFake(() => expect(invalidate).toHaveBeenCalledTimes(1));
+      const subscription = api.dataChanges$.subscribe(notification);
+      api.post(action, { orderId: 901 }).subscribe();
+      const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+      expect(invalidate).not.toHaveBeenCalled();
+      request.flush({ retcode: 0, result: { status: 30 } });
+      expect(notification).toHaveBeenCalledTimes(1);
+      subscription.unsubscribe();
+    });
+  }
+
+  it('retains timetable snapshots when ending a lesson fails', () => {
+    const invalidate = spyOn(TestBed.inject(HomeDailyCacheService), 'invalidateCompanion');
+    api.post('app_lesson_complete', { orderId: 901 }).subscribe({ error: () => {} });
+    http.expectOne(API_CONFIG.cloudFunctionUrl).flush({ retcode: -1, retmsg: '结课未成功' });
+    expect(invalidate).not.toHaveBeenCalled();
+  });
+
   it('coalesces concurrent overview reads but fetches fresh data after completion', () => {
     sessionToken = 'r:session-token';
     const first: unknown[] = [];

+ 4 - 1
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -1,4 +1,5 @@
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { HomeDailyCacheService } from './home-daily-cache.service';
 import { inject, Injectable } from '@angular/core';
 import { Router } from '@angular/router';
 import { catchError, finalize, from, map, Observable, retry, shareReplay, Subject, switchMap, tap, throwError, timer, timeout } from 'rxjs';
@@ -18,6 +19,7 @@ export class ApiError extends Error {
 export class ApiService {
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(SessionService);
+  private readonly dailyCache = inject(HomeDailyCacheService);
   private readonly router = inject(Router, { optional: true });
   dataRevision = 0;
   readonly dataChanges$ = new Subject<{ source: 'local' | 'broadcast'; changedAt: string }>();
@@ -149,12 +151,13 @@ export class ApiService {
   private createDataChannel(): BroadcastChannel | null {
     if (typeof globalThis.BroadcastChannel !== 'function') return null;
     const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
-    channel.addEventListener('message', () => { this.dataRevision++; this.invalidateOverviews(); this.dataChanges$.next({ source: 'broadcast', changedAt: new Date().toISOString() }); });
+    channel.addEventListener('message', () => { this.dataRevision++; this.invalidateOverviews(); this.dailyCache.invalidateCompanion(); this.dataChanges$.next({ source: 'broadcast', changedAt: new Date().toISOString() }); });
     return channel;
   }
 
   private announceDataChange(): void {
     this.dataRevision++;
+    this.dailyCache.invalidateCompanion();
     const event = { source: 'local' as const, changedAt: new Date().toISOString() };
     this.dataChanges$.next(event);
     this.dataChannel?.postMessage(event);

+ 42 - 0
projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.spec.ts

@@ -107,6 +107,48 @@ describe('HomeDailyCacheService', () => {
     expect(refresh).toHaveBeenCalledTimes(2);
   }));
 
+  it('invalidates every companion page in memory and storage while retaining other daily summaries', async () => {
+    const sections = ['companion:today-page1', 'companion:today-page2', 'companion:tomorrow-student2'] as const;
+    for (const key of sections) await firstValueFrom(cache.load(key, () => of({ courses: [], schedules: [{ dszt: 0 }] })));
+    await firstValueFrom(cache.load('stats', () => of({ totalIncome: 25 })));
+    cache.invalidateCompanion();
+    const restored = TestBed.runInInjectionContext(() => new HomeDailyCacheService());
+    const fetch = jasmine.createSpy('fetch').and.returnValue(of({ courses: [], schedules: [{ dszt: 30 }] }));
+    for (const key of sections) {
+      expect((await firstValueFrom(restored.load<{ schedules: { dszt: number }[] }>(key, fetch))).value.schedules[0].dszt).toBe(30);
+    }
+    expect(fetch).toHaveBeenCalledTimes(3);
+    const statsFetch = jasmine.createSpy('statsFetch');
+    expect((await firstValueFrom(cache.load<{ totalIncome: number }>('stats', statsFetch))).value.totalIncome).toBe(25);
+    expect(statsFetch).not.toHaveBeenCalled();
+    expect((await firstValueFrom(cache.load<{ schedules: { dszt: number }[] }>('companion:today-page1', fetch))).value.schedules[0].dszt).toBe(30);
+    expect(fetch).toHaveBeenCalledTimes(4);
+  });
+
+  it('discards an in-flight timetable read after a write and lets a fresh read replace it', async () => {
+    const old = new Subject<{ courses: unknown[]; schedules: { dszt: number }[] }>();
+    const first = firstValueFrom(cache.load('companion:today', () => old), { defaultValue: null });
+    cache.invalidateCompanion();
+    const fresh = { courses: [], schedules: [{ dszt: 30 }] };
+    await firstValueFrom(cache.load('companion:today', () => of(fresh)));
+    old.next({ courses: [], schedules: [{ dszt: 0 }] });
+    expect(await first).toBeNull();
+    const fetch = jasmine.createSpy('fetch');
+    expect((await firstValueFrom(cache.load('companion:today', fetch))).value).toEqual(fresh);
+    expect(fetch).not.toHaveBeenCalled();
+  });
+
+  it('drops persisted companion snapshots from before the fix on the first upgraded visit', async () => {
+    await firstValueFrom(cache.load('companion:today', () => of({ courses: [], schedules: [{ dszt: 0 }] })));
+    const saved = JSON.parse(localStorage.getItem(storageKey)!);
+    delete saved.companionCacheVersion;
+    localStorage.setItem(storageKey, JSON.stringify(saved));
+    const upgraded = TestBed.runInInjectionContext(() => new HomeDailyCacheService());
+    const fetch = jasmine.createSpy('fetch').and.returnValue(of({ courses: [], schedules: [{ dszt: 30 }] }));
+    expect((await firstValueFrom(upgraded.load<{ schedules: { dszt: number }[] }>('companion:today', fetch))).value.schedules[0].dszt).toBe(30);
+    expect(fetch).toHaveBeenCalledTimes(1);
+  });
+
   it('does not cache failures and retains the last success after a failed manual refresh', async () => {
     const fetch = jasmine.createSpy('fetch').and.returnValue(throwError(() => new Error('offline')));
     await expectAsync(firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch))).toBeRejectedWithError('offline');

+ 26 - 2
projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.ts

@@ -7,6 +7,7 @@ const STORAGE_KEY = 'xiaoshu.learning.homeDaily.v1';
 type Section = 'stats' | 'review' | 'balances' | 'account' | `companion:${string}`;
 const FIXED_SECTIONS = ['stats', 'review', 'balances', 'account'];
 const MAX_COMPANION_PAGES = 80;
+const COMPANION_CACHE_VERSION = 2;
 export interface HomeSnapshot<T> { value: T; updatedAt: string; }
 
 /** Daily read snapshots for main tabs. Detail pages keep their current-data checks. */
@@ -18,9 +19,24 @@ export class HomeDailyCacheService {
   private token = '';
   private values: Partial<Record<Section, HomeSnapshot<unknown>>> = {};
   private pending = new Map<Section, Observable<HomeSnapshot<unknown>>>();
+  private companionRevision = 0;
+
+  /** A successful write invalidates every cached date, filter and page of the timetable. */
+  invalidateCompanion(): void {
+    const identity = this.ensureIdentity();
+    this.companionRevision++;
+    for (const section of Object.keys(this.values) as Section[]) {
+      if (section.startsWith('companion:')) delete this.values[section];
+    }
+    for (const section of this.pending.keys()) {
+      if (section.startsWith('companion:')) this.pending.delete(section);
+    }
+    this.persist(identity);
+  }
 
   load<T>(section: Section, fetch: () => Observable<T>, force = false): Observable<HomeSnapshot<T>> {
     const identity = this.ensureIdentity(), token = this.token;
+    const companionRevision = this.companionRevision;
     const cached = this.values[section];
     if (cached && !force) return of(cached as HomeSnapshot<T>);
     const pending = this.pending.get(section);
@@ -29,6 +45,8 @@ export class HomeDailyCacheService {
       take(1),
       // Ignore responses belonging to a previous account, login or Beijing day.
       filter(() => this.ensureIdentity() === identity && this.token === token),
+      // A read started before a write must not restore the obsolete timetable.
+      filter(() => !section.startsWith('companion:') || companionRevision === this.companionRevision),
       tap(value => {
         if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('页面数据暂时无法加载');
       }),
@@ -37,8 +55,7 @@ export class HomeDailyCacheService {
       tap(snapshot => {
         this.values[section] = snapshot;
         this.trimCompanionPages();
-        try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ identity, values: this.values })); }
-        catch { /* In-memory caching still works when storage is unavailable. */ }
+        this.persist(identity);
       }),
       finalize(() => { if (this.pending.get(section) === request) this.pending.delete(section); }),
       shareReplay({ bufferSize: 1, refCount: false }),
@@ -75,6 +92,11 @@ export class HomeDailyCacheService {
     for (const key of pages.slice(0, Math.max(0, pages.length - MAX_COMPANION_PAGES))) delete this.values[key];
   }
 
+  private persist(identity: string): void {
+    try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ identity, companionCacheVersion: COMPANION_CACHE_VERSION, values: this.values })); }
+    catch { /* In-memory caching still works when storage is unavailable. */ }
+  }
+
   private ensureIdentity(): string {
     const identity = `${this.session.user()?.userId}|${this.session.user()?.groupId}|${beijingToday()}`;
     if (identity !== this.identity || this.token !== this.session.token()) {
@@ -87,6 +109,8 @@ export class HomeDailyCacheService {
         if (saved?.identity === identity) {
           for (const section of Object.keys(saved.values || {}) as Section[]) {
             if (!FIXED_SECTIONS.includes(section) && !section.startsWith('companion:')) continue;
+            // Discard timetable snapshots written before mutation invalidation existed.
+            if (section.startsWith('companion:') && saved.companionCacheVersion !== COMPANION_CACHE_VERSION) continue;
             const snapshot = saved.values?.[section];
             if (snapshot?.value && typeof snapshot.value === 'object' && !Array.isArray(snapshot.value) && Number.isFinite(Date.parse(snapshot.updatedAt))) {
               if (section === 'review' && (!Array.isArray(snapshot.value.items) || !Array.isArray(snapshot.value.dates) || !snapshot.value.summary)) continue;

+ 37 - 2
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -10,16 +10,18 @@ import { ApiService, ApiError } from './api.service';
 import { SessionService } from './session.service';
 import { PlatformService } from './platform.service';
 import { appointmentDate, beijingDate } from './appointment-time';
+import { HomeDailyCacheService } from './home-daily-cache.service';
 
 @Component({standalone:true,template:'课堂内容'}) class ClassroomStub {}
 
 describe('appointment workflow screens',()=>{
+ let dataChanges:Subject<{source:'local'|'broadcast';changedAt:string}>;
  let failFeedback:boolean;
  let order:Record<string,unknown>,failDetail:boolean,failCourses:boolean,courses:Record<string,unknown>[]|null,delayedUnits:Subject<unknown>|null,calls:Array<{action:string;params:Record<string,unknown>}>;
  const user=signal({userId:30,groupId:3}),student=signal<unknown>(null),groupId=signal(3);
  const response=(result:unknown)=>of({retcode:0,result});
  beforeEach(()=>{
-  localStorage.clear();sessionStorage.clear();calls=[];failFeedback=false;failDetail=false;failCourses=false;courses=null;delayedUnits=null;user.set({userId:30,groupId:3});groupId.set(3);
+  localStorage.clear();sessionStorage.clear();calls=[];failFeedback=false;failDetail=false;failCourses=false;courses=null;delayedUnits=null;dataChanges=new Subject();user.set({userId:30,groupId:3});groupId.set(3);
   order={GeneralID:901,szyh:20,pl:30,HoneyName:'测试学员',kcid:801,bindingId:'binding-1',courseCategoryKey:'word',yykcmc:'词表',dszt:0,bxrq:'2099-10-11',sdsd:'19:00',allowedActions:['start'],updatedAt:'2026-10-11T00:00:00Z'};
   const invoke=(action:string,params:Record<string,unknown>={})=>{
    calls.push({action,params});
@@ -43,7 +45,7 @@ describe('appointment workflow screens',()=>{
     {path:'pages/shop/stu_get_order',component:AppointmentPageComponent,data:{meta:{path:'pages/shop/stu_get_order'}}},
     {path:'pages/stu/stu_abc',component:ClassroomStub},
    ]),
-   {provide:ApiService,useValue:{get:invoke,post:invoke,uploadLessonAttachment:(orderId:string,file:File,contentType:string)=>{calls.push({action:'attachment-upload',params:{orderId,file,contentType}});return response({id:'file-'+file.name,name:file.name,url:'https://files.example/'+encodeURIComponent(file.name),contentType,size:file.size});}}},
+   {provide:ApiService,useValue:{get:invoke,post:invoke,dataChanges$:dataChanges,uploadLessonAttachment:(orderId:string,file:File,contentType:string)=>{calls.push({action:'attachment-upload',params:{orderId,file,contentType}});return response({id:'file-'+file.name,name:file.name,url:'https://files.example/'+encodeURIComponent(file.name),contentType,size:file.size});}}},
    {provide:SessionService,useValue:{user,student,groupId,token:signal('r:test'),isLoggedIn:()=>true,setStudent:(s:unknown)=>student.set(s)}},
    {provide:PlatformService,useValue:{loading:signal(false),toast:()=>{},confirm:async()=>true}},
   ]});
@@ -68,6 +70,39 @@ describe('appointment workflow screens',()=>{
   (harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement).click();
   harness.fixture.detectChanges();expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);expect(component.status()).toBe(10);
  });
+ it('reloads a cached timetable after a writing lesson ends and keeps the selected day on return',async()=>{
+  order={...order,bxrq:beijingDate(),kcid:0,bindingId:'',courseCategoryKey:'high_writing'};
+  const {harness}=await screen('/pages/home/shop');
+  let center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+  expect(center.statusText(center.schedules()[0])).toBe('未开始');
+  await harness.navigateByUrl('/pages/shop/get_order?id=901');await harness.fixture.whenStable();harness.fixture.detectChanges();
+  const detail=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
+  detail.startOrContinue();await harness.fixture.whenStable();
+  expect(detail.status()).toBe(10);
+  await detail.endLesson();await harness.fixture.whenStable();
+  expect(detail.status()).toBe(30);
+  // ApiService invalidates snapshots before publishing a successful write (covered with real HTTP in api.service.spec).
+  TestBed.inject(HomeDailyCacheService).invalidateCompanion();
+  dataChanges.next({source:'local',changedAt:new Date().toISOString()});
+  await harness.navigateByUrl('/pages/home/shop');harness.fixture.detectChanges();
+  center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+  expect(center.selectedDate()).toBe(beijingDate());
+  expect(center.statusText(center.schedules()[0])).toBe('已结课');
+  expect(calls.filter(c=>c.action==='app_companion_overview').length).toBe(2);
+ });
+ it('refreshes an open timetable when another tab announces a write and removes its listener on leave',async()=>{
+  order={...order,bxrq:beijingDate()};
+  const {harness}=await screen('/pages/home/shop');
+  const center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+  order={...order,dszt:30};
+  TestBed.inject(HomeDailyCacheService).invalidateCompanion();
+  dataChanges.next({source:'broadcast',changedAt:new Date().toISOString()});harness.fixture.detectChanges();
+  expect(center.statusText(center.schedules()[0])).toBe('已结课');
+  expect(calls.filter(c=>c.action==='app_companion_overview').length).toBe(2);
+  await harness.navigateByUrl('/pages/shop/get_order?id=901');
+  dataChanges.next({source:'broadcast',changedAt:new Date().toISOString()});
+  expect(calls.filter(c=>c.action==='app_companion_overview').length).toBe(2);
+ });
  for(const stage of ['primary','middle','high'])for(const duration of [30,60]) {
   it(`closes a ${duration}-minute ${stage} writing lesson without enrollment or vocabulary before saving teacher feedback`,async()=>{
    order={...order,kcid:0,bindingId:'',courseCategoryKey:stage+'_writing',durationMinutes:duration,yykcmc:'阅读与写作',bxrq:beijingDate()};

+ 3 - 0
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -126,12 +126,14 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   private initialScheduleLoaded = false;
   private historyFilterInitialized = false;
   private stopLiveRefresh?: () => void;
+  private dataChanges?: Subscription;
   private syncNoticeTimer?: ReturnType<typeof setTimeout>;
   private hasFreshLegacyAppointments = false;
 
   ngOnInit(): void {
     this.restoredScheduleState = this.restoreScheduleState();
     this.load();
+    if (!this.isHistory) this.dataChanges = this.api.dataChanges$?.subscribe(() => this.load(true, true, true));
     this.stopLiveRefresh = this.isHistory ? this.liveData.watch({
       scopes: ['courses', 'appointments'],
       studentId: () => this.isCoach && this.selectedStudent() === 'all' ? undefined : Number(this.selectedStudent() === 'all' ? this.session.user()?.userId : this.selectedStudent()) || undefined,
@@ -152,6 +154,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
 
   ngOnDestroy(): void {
     this.stopLiveRefresh?.();
+    this.dataChanges?.unsubscribe();
     for (const timer of this.scrollTimers) clearTimeout(timer);
     if (this.scrollFrame !== undefined) cancelAnimationFrame(this.scrollFrame);
     if (this.syncNoticeTimer) clearTimeout(this.syncNoticeTimer);

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/fmode-ai.service.spec.ts

@@ -13,7 +13,7 @@ describe('FmodeAiService', () => {
     TestBed.configureTestingModule({ providers: [
       provideHttpClient(),
       provideHttpClientTesting(),
-      { provide: SessionService, useValue: { token: () => 'r:mobile-session' } },
+      { provide: SessionService, useValue: { token: () => 'r:mobile-session', user: () => ({ userId: 42, groupId: 1 }) } },
     ] });
     service = TestBed.inject(FmodeAiService);
     http = TestBed.inject(HttpTestingController);

+ 81 - 0
scripts/check-course-status-cache-browser.mjs

@@ -0,0 +1,81 @@
+#!/usr/bin/env node
+// Local production bundle with isolated API fixtures; never touches online lessons.
+import { chromium } from 'playwright';
+import { createServer } from 'node:http';
+import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
+import { resolve, extname } from 'node:path';
+import assert from 'node:assert/strict';
+
+const root = resolve(process.env.XIAOSHU_BROWSER_BUILD || 'dist/xiaoshu-mobile/browser');
+const out = resolve(process.env.XIAOSHU_BROWSER_OUTPUT || 'tmp/course-status-cache-qa');
+mkdirSync(out, { recursive: true });
+const server = createServer((req, res) => {
+  let file = resolve(root, '.' + decodeURIComponent(new URL(req.url, 'http://local').pathname));
+  if (!file.startsWith(root + '/') || !existsSync(file) || !extname(file)) file = root + '/index.html';
+  res.setHeader('Content-Type', ({ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png' })[extname(file)] || 'application/octet-stream');
+  res.end(readFileSync(file));
+});
+await new Promise(done => server.listen(0, '127.0.0.1', done));
+const origin = 'http://127.0.0.1:' + server.address().port;
+const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
+const statuses = new Map([[901, 0], [902, 0]]), calls = [], errors = [];
+const order = id => ({ GeneralID: id, Title: '测试学员(高中语法写作课)', szyh: 100, pl: 300, plxm: '测试老师', kcid: 0, bindingId: '', courseCategoryKey: 'high_writing', courseCategoryName: '高中语法写作课', durationMinutes: 60, dszt: statuses.get(id), bxrq: today, yysj: today + (id === 901 ? ' 08:00' : ' 09:00'), sdsd: id === 901 ? '08:00' : '09:00', updatedAt: '2026-10-29T00:00:00Z', allowedActions: statuses.get(id) === 0 ? ['start'] : statuses.get(id) === 10 ? ['complete'] : ['editFeedback'], feedback: statuses.get(id) === 30 ? { feedbackStatus: 'pending' } : null });
+const browser = await chromium.launch({ headless: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' });
+try {
+  const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
+  await context.addInitScript(() => {
+    if (!localStorage.getItem('xiaoshu.angular.session')) localStorage.setItem('xiaoshu.angular.session', JSON.stringify({ token: 'r:isolated-status-fixture', user: { userId: 300, groupId: 3, honeyName: '测试老师' } }));
+  });
+  await context.route('**/*', async route => {
+    const url = new URL(route.request().url());
+    if (url.origin !== origin) return route.abort();
+    if (!url.pathname.startsWith('/cloud-functions/')) return route.continue();
+    const { params: p } = route.request().postDataJSON(); calls.push(p);
+    let result = [];
+    if (p.action === 'app_companion_overview') result = { courses: [], schedules: [order(901), order(902)], dates: [{ date: today, finished: [...statuses.values()].filter(s => s >= 30).length, planned: [...statuses.values()].filter(s => s < 30).length }], studentOptions: [], page: { itemCount: 2, pageCount: 1 } };
+    if (p.action === 'e_order_detail') result = [order(Number(p.id))];
+    if (p.action === 'app_live_versions') result = { version: JSON.stringify([...statuses]), scopeVersions: {}, syncStatus: { state: 'current' } };
+    if (p.action === 'user_get') result = { userId: 100, groupId: 1, honeyName: '测试学员' };
+    if (p.action === 'e_order_update_v2') { statuses.set(Number(JSON.parse(p.content).GeneralID), 10); result = { status: 10 }; }
+    if (p.action === 'app_lesson_complete') { statuses.set(Number(p.orderId), 30); result = { status: 30 }; }
+    await route.fulfill({ json: { retcode: 0, result } });
+  });
+  const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
+  page.on('dialog', dialog => dialog.accept());
+  const rows = page.locator('article[data-schedule-id]');
+  await page.goto(origin + '/pages/home/shop');
+  await rows.filter({ hasText: '未开始' }).nth(1).waitFor();
+  // Simulate the old persistent snapshot while the database already has both completions.
+  await page.evaluate(() => {
+    const saved = JSON.parse(localStorage.getItem('xiaoshu.learning.homeDaily.v1'));
+    delete saved.companionCacheVersion;
+    localStorage.setItem('xiaoshu.learning.homeDaily.v1', JSON.stringify(saved));
+  });
+  statuses.set(901, 30); statuses.set(902, 30);
+  await page.reload(); await rows.filter({ hasText: '已结课' }).nth(1).waitFor();
+  await page.screenshot({ path: out + '/upgraded-cache.png', fullPage: true });
+  statuses.set(901, 0); statuses.set(902, 0);
+  await page.evaluate(() => localStorage.removeItem('xiaoshu.learning.homeDaily.v1'));
+  await page.reload(); await rows.filter({ hasText: '未开始' }).nth(1).waitFor();
+  await rows.first().click();
+  await page.getByRole('button', { name: '开始课程', exact: true }).click();
+  await page.getByRole('button', { name: '结束课程', exact: true }).click();
+  await page.locator('.appointment-actions').getByRole('button', { name: '补充反馈', exact: true }).waitFor();
+  await page.goBack(); await rows.filter({ hasText: '已结课' }).waitFor();
+  assert.equal(await rows.filter({ hasText: '未开始' }).count(), 1);
+  // End the second lesson in another tab while the first tab keeps the timetable open.
+  const other = await context.newPage(); other.on('dialog', dialog => dialog.accept());
+  await other.goto(origin + '/pages/shop/get_order?id=902');
+  await other.getByRole('button', { name: '开始课程', exact: true }).click();
+  await other.getByRole('button', { name: '结束课程', exact: true }).click();
+  await other.locator('.appointment-actions').getByRole('button', { name: '补充反馈', exact: true }).waitFor();
+  await rows.filter({ hasText: '已结课' }).nth(1).waitFor();
+  await other.close();
+  await page.reload(); await rows.filter({ hasText: '已结课' }).nth(1).waitFor();
+  await page.screenshot({ path: out + '/completed-after-return.png', fullPage: true });
+  assert.deepEqual(errors, []);
+  assert.equal(calls.filter(p => p.action === 'app_lesson_complete').length, 2);
+  const result = { passed: true, checks: ['旧版当天缓存升级后读取最新已结课状态', '写作课结课返回列表立即更新', '另一标签页结课后打开的课表更新', '整页刷新后两个课次仍显示已结课'], environment: 'local production bundle + isolated API fixtures' };
+  writeFileSync(out + '/result.json', JSON.stringify(result, null, 2));
+  console.log(JSON.stringify(result));
+} finally { await browser.close(); await new Promise(done => server.close(done)); }