Preserve isolated local drafts across navigation and reload

This commit is contained in:
aiagent
2026-10-19 22:47:17 -07:00
parent 9d69629bc8
commit 6a36a49290
16 changed files with 174 additions and 38 deletions
@@ -7,3 +7,4 @@ test('free text allowed with enumerated options; empty and invalid options rejec
test('old revision or conflict cannot resubmit without explicit confirmation',()=>{const d={...draft(),revision:1,option:'a',comment:'保留意见',conflict:true,attempt:{key:'old',signature:'old'}};assert.equal(submissionBody(decision,d),null);confirmDraft(decision,d);assert.equal(d.option,null);assert.equal(d.comment,'保留意见');assert.equal(d.attempt,undefined);assert.equal(submissionBody(decision,d).expected_revision,2)})
test('pending accepted completed read only; reconsideration permits new submission',()=>{for(const status of ['pending_review','accepted','completed'])assert.equal(submissionBody({...decision,status},{...draft(),option:'a'}),null);assert.ok(submissionBody({...decision,status:'needs_reconsideration'},{...draft(),option:'a'}));assert.equal(submissionBody(decision,{...draft(),option:'a',sending:true}),null)})
test('legacy response maps to pending review, never acceptance',()=>{for(const status of ['decided','pending_verification'])assert.equal(decisionStatus({status,response:{id:'r'}}),'pending_review')})
test('missing revision cannot be guessed for a legacy request',()=>{assert.equal(submissionBody({...decision,revision:undefined},{...draft(),revision:1,option:'a'}),null)})
@@ -0,0 +1,13 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { draftKey,readDraft,writeDraft,clearDraft,clearIfUnchanged } from '../src/lib/draft-storage.ts'
class Memory { data=new Map();get length(){return this.data.size}key(i){return [...this.data.keys()][i]??null}getItem(k){return this.data.get(k)??null}setItem(k,v){this.data.set(k,v)}removeItem(k){this.data.delete(k)} }
const valid=v=>!!v&&typeof v.text==='string';const empty={text:''}
test('remount/reload recovers text and same unknown-delivery attempt key',()=>{const s=new Memory(),scope=['instance','communication','human'];writeDraft(s,scope,{text:'hello',attempt:{key:'stable',text:'hello'}});assert.deepEqual(readDraft(s,scope,empty,valid),{text:'hello',attempt:{key:'stable',text:'hello'}})})
test('instance session decision channel and version isolate records',()=>{const s=new Memory(),a=['i','decision','d1','1'];writeDraft(s,a,{text:'private'});for(const b of [['other','decision','d1','1'],['i','decision','d2','1'],['i','consultation','d1','1'],['i','decision','d1','2']])assert.deepEqual(readDraft(s,b,empty,valid),empty)})
test('new revision recovers old content without changing its revision or attempt',()=>{const s=new Memory();writeDraft(s,['i','decision','d','1'],{text:'old',revision:1,attempt:{key:'same'}});assert.equal(readDraft(s,['i','decision','d','2'],empty,valid,true).revision,1);assert.equal(s.length,1)})
test('successful clearing does not clear consultation or restore old selection',()=>{const s=new Memory(),scope=['i','decision','d','2'];writeDraft(s,['i','decision','d','1'],{text:'old'});writeDraft(s,scope,{text:'sent'});writeDraft(s,['i','consultation','d','2'],{text:'chat'});clearIfUnchanged(s,scope,s.getItem(draftKey(scope)));assert.deepEqual(readDraft(s,scope,empty,valid,true),empty);assert.equal(readDraft(s,['i','consultation','d','2'],empty,valid).text,'chat')})
test('late success cannot remove a newer draft',()=>{const s=new Memory(),scope=['i','c','s'];writeDraft(s,scope,{text:'sent'});const expected=s.getItem(draftKey(scope));writeDraft(s,scope,{text:'new'});assert.equal(clearIfUnchanged(s,scope,expected),false);assert.equal(readDraft(s,scope,empty,valid).text,'new')})
test('explicit clear removes only selected decision versions',()=>{const s=new Memory();for(const id of ['a','b'])for(const version of ['1','2'])writeDraft(s,['i','decision',id,version],{text:id});clearDraft(s,['i','decision','a','2'],true);assert.equal(readDraft(s,['i','decision','b','1'],empty,valid).text,'b');assert.deepEqual(readDraft(s,['i','decision','a','2'],empty,valid,true),empty);assert.equal(s.getItem(draftKey(['i','decision','a','1'])),null)})
test('unavailable storage and corrupt data throw instead of claiming saved',()=>{const s=new Memory();s.setItem=()=>{throw Error('quota')};assert.throws(()=>writeDraft(s,['i'],{text:'x'}));s.getItem=()=>'{broken';assert.throws(()=>readDraft(s,['i'],empty,valid))})
test('transient sending/errors are not revived as a stuck pending UI',()=>{const s=new Memory();writeDraft(s,['i'],{text:'retry',sending:true,error:'offline',attempt:{key:'stable'}});const value=readDraft(s,['i'],empty,valid);assert.equal(value.sending,undefined);assert.equal(value.error,undefined);assert.equal(value.attempt.key,'stable')})