Add unit tests for autoSaveService to handle document dirty states and periodic saves

- Implement tests for various scenarios including user edits, initial dirty state, and document changes.
- Ensure proper flagging of uncommitted changes and silent saves for documents opened in a dirty state.
- Test debounce behavior for document change notifications and version clearing functionality.
- Validate suspension and resumption of auto-saving during versioning processes.
This commit is contained in:
Suphonchai Phoonsawat 2026-11-03 13:34:21 +07:00
parent 0b6ee8cc84
commit 454c90d17e
17 changed files with 1167 additions and 33 deletions

View file

@ -5,8 +5,13 @@
* (version เกิดจากปุ่ม FR-2 เท่านั้น — กัน spam history และรักษา concept ของ FR-13)
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\autoSaveService.ts
*
* - debounce 3 วิ ผ่าน `notifyDocumentChanged()` (ยังไม่มี caller จริงใน Word — ต่อพ่วงไว้ให้ service อื่น)
* - periodic ทุก 30 วิ: อ่าน `document.saved` ก่อน — dirty → ตั้ง flag FR-3 + save, สะอาด → ข้าม
* - debounce 3 วิ ผ่าน `notifyDocumentChanged()` — caller จริงคือ service ที่แก้เอกสาร
* (เครื่องมือ template) หลัง operation สำเร็จ: ตั้ง flag FR-3 ทันที + debounce save
* - periodic ทุก 30 วิ: อ่าน `document.saved` ก่อน — dirty → save, สะอาด → ข้าม
* ตั้ง flag FR-3 เฉพาะ **clean→dirty** (เคยเห็นสะอาดก่อน): ไฟล์ที่เปิดมา dirty ตั้งแต่แรก —
* quirk ของ Word ที่รายงาน saved=false ค้างหลัง stamp properties/custom XML part
* หรือแก้ค้างจาก session ก่อน — ไม่ถูกนับเป็น "ผู้ใช้แก้" กัน banner FR-3 เด้งทั้งที่ผู้ใช้
* ไม่ได้ทำอะไรเลย (คำขอผู้ใช้ 2026-11-03; ทุกกรณียัง save ให้เหมือนเดิม — ปลอดภัยไว้ก่อน)
* - ช่วง save เดียวเท่านั้น (saving lock): มีเรียกซ้อน mark pending แล้วเก็บอีกรอบเมื่อรอบปัจจุบันจบ
* - flow ที่ยุ่งกับไฟล์เอง (FR-2/FR-18) ใช้คู่ `suspend()`/`resume()` — ซ้อนกันได้ (นับ depth)
* - เริ่มที่ Office.onReady (App mount) ไม่รอ sign-in · ล้ม → `onError` (banner ไม่บล็อก) ไม่ throw ทิ้ง
@ -39,6 +44,8 @@ class WordAutoSaveService {
private idleWaiters: Array<() => void> = [];
private started = false;
private uncommitted = false;
/** เคยเห็น document สะอาด (saved=true) อย่างน้อยหนึ่งจุดตั้งแต่ start — clean→dirty ถัดไปคือ "ผู้ใช้แก้จริง" */
private seenClean = false;
/** auto-save ล้ม (timer-driven) — App ต่อเป็น banner เตือนแบบไม่บล็อก */
onError: ((error: unknown) => void) | undefined;
@ -63,6 +70,7 @@ class WordAutoSaveService {
}
this.started = true;
this.startTimers();
void this.seedBaseline();
}
stop(): void {
@ -87,11 +95,14 @@ class WordAutoSaveService {
}
}
/** FR-17 debounce — service ที่แก้เอกสารเรียกหลัง operation สำเร็จ */
/** FR-17 debounce — service ที่แก้เอกสาร (เครื่องมือ template) เรียกหลัง operation สำเร็จ:
* การแก้ไขเกิดจริงเสมอจึงตั้ง flag FR-3 ทันที ไม่รอ clean→dirty ของ periodic tick */
notifyDocumentChanged(): void {
if (!autoSaveConfig.enabled) {
return;
}
this.uncommitted = true;
this.notifyState();
this.clearDebounce();
this.debounceTimer = setTimeout(() => {
this.debounceTimer = undefined;
@ -168,19 +179,34 @@ class WordAutoSaveService {
return; // มี save หรือ flow อื่นถือไม้ตายอยู่ — รอรอบหน้า
}
try {
// FR-3 — periodic ทำหน้าที่ reminder ด้วย: เห็น dirty ก่อน save ตั้ง flag
// (ระบบ reminder เดิมที่ poll แยกถูกแทนด้วย tick นี้)
if (!(await isDocumentDirty())) {
const dirty = await isDocumentDirty();
if (!dirty) {
// จุดที่เห็นสะอาด — clean→dirty ของ tick ถัดไปคือการแก้ไขจริงของผู้ใช้
this.seenClean = true;
return;
}
this.uncommitted = true;
this.notifyState();
// FR-3 — ตั้ง flag เฉพาะ clean→dirty: dirty ตั้งแต่เปิดไฟล์/ติดจาก quirk หลัง stamp
// ไม่ใช่ฝีมือผู้ใช้ จึงไม่เด้ง banner ให้งง (ยัง save ตามปกติ)
if (this.seenClean) {
this.uncommitted = true;
this.notifyState();
}
await this.save();
} catch (error) {
this.reportError(error);
}
}
/** จุดตั้งต้นตอน start — ไฟล์ที่เปิดมา dirty ตั้งแต่แรกไม่นับเป็น "ผู้ใช้แก้" (ดูหัวไฟล์)
* อ่านไม่ได้ = เงียบไว้ (ค่า false เป็นค่าอนุรักษ์นิยม: ไม่ติด flag จนกว่าจะเห็น clean จริง) */
private async seedBaseline(): Promise<void> {
try {
this.seenClean = !(await isDocumentDirty());
} catch {
this.seenClean = false;
}
}
private waitIdle(): Promise<void> {
return this.saving
? new Promise<void>((resolve) => this.idleWaiters.push(resolve))