File size: 6,767 Bytes
3ad81a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60d86b6
3ad81a7
 
 
 
 
60d86b6
3ad81a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60d86b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';

const script = readFileSync(new URL('../static/app.js', import.meta.url), 'utf8');
const presets = Object.fromEntries(['english', 'chinese'].map(name => [name,
  JSON.parse(readFileSync(new URL(`../examples/${name}.json`, import.meta.url), 'utf8'))]));

// Run the real editor against a small DOM/fetch adapter; no models or browser packages needed.
function editor() {
  const elements = new Map(), requests = [], decoded = [];
  let nextId = 0, failExample = null;
  const element = id => {
    if(!elements.has(id)) elements.set(id, {value:'', dataset:{}, style:{setProperty(){}},
      classList:{toggle(){},add(){},remove(){}}, setAttribute(name,value){this.attributes??={};this.attributes[name]=value;}, removeAttribute(name){delete this.attributes?.[name];}, addEventListener(){},
      getContext(){return {drawImage(){}};}, children:[], focus(){}});
    return elements.get(id);
  };
  const examples = ['english','chinese'].map(example => ({...element(example),dataset:{example}}));
  const models = ['meanflow','teacher'].map(model => ({...element(model),dataset:{model}}));
  const context = vm.createContext({structuredClone, URLSearchParams, console, setTimeout:(fn,ms)=>setTimeout(fn,ms).unref(), clearTimeout,
    document:{getElementById:element,addEventListener(){},querySelectorAll(selector){
      if(selector==='[data-example]') return examples;
      if(selector==='[data-model]') return models;
      return [];
    }},
    Image:class {async decode(){decoded.push(this.src);this.complete=true;}},
    location:{pathname:'/',hash:''}, history:{replaceState(){}},
    fetch:async(url,options) => {
      if(url==='/api/config') return {ok:true,json:async()=>({transport:'http'})};
      assert.equal(url,'/api/session');
      const {example}=JSON.parse(options.body);requests.push(example);
      if(example===failExample){failExample=null;throw new Error('Network unavailable');}
      return {ok:true,json:async()=>({id:`session-${++nextId}`,example,width:1024,height:1024,
        image:`/examples/${example}-preview.webp?v=release`,
        regions:presets[example].map((r,i)=>({...r,id:`example-${i}`,enabled:true,origin:'example'})),
        settings:{variant:'meanflow',steps:4,seed:42,guidance:2.6,text_threshold:.5}})};
    }});
  vm.runInContext(script,context);
  const run=code=>vm.runInContext(code,context);
  return {run,requests,decoded,element,failNext(example){failExample=example;}};
}

async function ready(app) {
  await app.run("Promise.all([prepareExample('english'),prepareExample('chinese')])");
  await new Promise(resolve=>setImmediate(resolve));
}

test('switching and restoring example edits reuse prepared sessions and decoded images', async()=>{
  const app=editor();await ready(app);
  assert.equal(app.requests.length,2);
  assert.equal(app.decoded.length,2);
  for(const example of ['chinese','english','chinese','english']) {
    await app.run(`loadExample('${example}')`);
    assert.equal(app.run('state.regions.length'),presets[example].length);
    assert.equal(app.element('guidance').value,2.6);
    assert.equal(app.element('refineButton').disabled,false);
    app.run("state.regions[0].text='edited'; state.regions[0].bbox[0]=0; state.regions[0].enabled=false;");
  }
  await app.run("loadExample('english')");
  assert.equal(app.run('state.regions[0].text'),presets.english[0].text);
  assert.equal(app.run('state.regions[0].bbox[0]'),presets.english[0].bbox[0]);
  assert.equal(app.run('state.regions[0].enabled'),true);
  assert.equal(app.requests.length,2);
  assert.equal(app.decoded.length,2);
});

test('concurrent preparation shares one request and an expired session is renewed',async()=>{
  const app=editor();await ready(app);
  app.run("exampleCache.get('chinese').expires=0");
  await app.run("Promise.all([prepareExample('chinese'),prepareExample('chinese')])");
  assert.equal(app.requests.filter(x=>x==='chinese').length,2);
});

test('failed preparation is retryable without replacing the current editor',async()=>{
  const app=editor();await ready(app);
  await app.run("loadExample('english')");
  const current=app.run('state.session.id');
  app.run("exampleCache.get('chinese').expires=0");app.failNext('chinese');
  await app.run("loadExample('chinese')");
  assert.equal(app.run('state.session.id'),current);
  assert.equal(app.run('state.busy'),false);
  await app.run("loadExample('chinese')");
  assert.equal(app.run('state.regions.length'),13);
});


test('detection immediately shows central activity and keeps unknown progress indeterminate',async()=>{
  const app=editor();await ready(app);
  app.run("setBusy(true,'Preparing detection','detect'); updateActivity('Detecting characters and words',.3,true)");
  assert.equal(app.element('jobProgress').hidden,false);
  assert.equal(app.element('jobTitle').textContent,'Detecting text');
  assert.equal(app.element('jobPercent').hidden,true);
  assert.equal(app.element('progressTrack').attributes['aria-valuenow'],undefined);
  assert.equal(app.element('detectButton').attributes['aria-busy'],'true');
});

test('refinement displays reported progress, then leaves a nonblocking completion state',async()=>{
  const app=editor();await ready(app);
  app.run("setBusy(true,'Waiting for GPU','refine');updateActivity('Refining · step 2 of 4',.55,false)");
  assert.equal(app.element('jobTitle').textContent,'Refining text');
  assert.equal(app.element('jobPercent').textContent,'55%');
  assert.equal(app.element('jobPercent').hidden,false);
  assert.equal(app.element('progressTrack').attributes['aria-valuenow'],'55');
  app.run("finishActivity('done','4 regions · 2.7s');setBusy(false)");
  assert.equal(app.element('jobProgress').dataset.state,'done');
  assert.equal(app.element('jobProgress').hidden,false);
  assert.equal(app.element('refineButton').attributes['aria-busy'],'false');
  app.run("setBusy(true,'Preparing detection','detect')");
  assert.equal(app.element('jobProgress').dataset.state,'running');
  assert.equal(app.element('jobTitle').textContent,'Detecting text');
});

test('a job error remains visible after controls are released and can be dismissed',async()=>{
  const app=editor();await ready(app);
  app.run("setBusy(true,'Waiting for GPU','refine');finishActivity('error','GPU quota exceeded');setBusy(false)");
  assert.equal(app.element('jobProgress').hidden,false);
  assert.equal(app.element('jobProgress').dataset.state,'error');
  assert.equal(app.element('jobMessage').textContent,'GPU quota exceeded');
  assert.equal(app.element('closeProgress').hidden,false);
  app.run('dismissActivity()');assert.equal(app.element('jobProgress').hidden,true);
});