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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
|
### Ran Playwright code
```js
await (// Functional end-to-end persistence checks. Run with Playwright CLI run-code.
async page => {
const checks=[],errors=[];
const check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
const state=()=>page.evaluate(()=>window.pureWorkbench.snapshot());
const onDialog=dialog=>dialog.accept(), onError=error=>errors.push(error.message);
page.on('dialog',onDialog);page.on('pageerror',onError);
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
const client=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y};},{x,y});
const move=async(x,y)=>{const p=await client(x,y);await page.mouse.move(p.x,p.y);};
const drag=async(x,y,nx,ny)=>{await page.locator('#scene').scrollIntoViewIfNeeded();await move(x,y);await page.mouse.down();await move(nx,ny);await page.mouse.up();};
const upload=async(text,name='layout.json')=>{
await page.locator('#file-input').evaluate((input,{text,name})=>{
const transfer=new DataTransfer();transfer.items.add(new File([text],name,{type:'application/json'}));
input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
},{text,name});
};
try {
await page.setViewportSize({width:1440,height:1000});
await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
check(await page.locator('#download-layout').isDisabled(),'placement save requires an applied definition');
await page.locator('#example').selectOption('routed-layout');await page.locator('#load-example').click();await page.locator('#apply').click();
const source=await page.locator('#source').inputValue();
check(await page.locator('[data-point-input="drag"]').count()===3,'one authored pointer input connects all three view targets');
check(await page.locator('[data-point-target]').count()===3,'DOM destinations retain distinct target identities');
await drag(90,130,160,180);await drag(290,170,360,220);await drag(460,230,530,270);
check((await page.locator('#last-event').textContent()).includes('drag.up → amber_box'),'last input identifies the released target within its group');
await page.keyboard.press('ArrowLeft');
let s=await state();
check(Math.abs(s.states['green.x']-150)<0.1&&Math.abs(s.states['blue.x']-350)<0.1&&Math.abs(s.states['amber.x']-510)<0.1,'three independently authored targets move and selection directs keyboard adjustment');
const expected={states:s.states,views:s.views};
const draft=source+'\nstate unfinished =';
await page.locator('#source').fill(draft);
const pending=page.waitForEvent('download');await page.locator('#download-layout').click();
const download=await pending;const saved='output/playwright/pure-routed-layout-saved.json';await download.saveAs(saved);
check(download.suggestedFilename()==='my-layout.pure.json','placement download is a separate document file');
check((await state()).source===draft&&(await state()).appliedSource===source,'saving neither applies nor discards the unfinished draft');
await page.locator('#reset').click();
check((await state()).states['green.x']===80,'reset returns to authored initial positions');
await page.locator('#file-input').setInputFiles(saved);
await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
s=await state();
check(same(s.states,expected.states)&&same(s.views,expected.views),'downloaded file restores all states and visible positions');
check(s.source===draft&&s.appliedSource===source&&s.dirty,'the draft remains separate from the restored executable definition');
check(!s.running&&!s.held.length&&s.tick===0&&s.successfulEvents===0,'restored work starts stopped with no replayed inputs or clock');
await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
check(Math.abs((await state()).states['amber.x']-(expected.states['amber.x']+10))<0.1,'restored selection and bindings remain editable');
// Use a fresh page/runtime, not just preservation inside the original instance.
const fresh=await page.context().newPage();await fresh.goto(page.url());
await fresh.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
await fresh.locator('#file-input').setInputFiles(saved);
await fresh.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
const reopened=await fresh.evaluate(()=>window.pureWorkbench.snapshot());
check(same(reopened.states,expected.states)&&same(reopened.views,expected.views)&&reopened.source===draft,'a fresh browser page reconstructs the saved work without old runtime state');
await fresh.close();await page.bringToFront();
const before=await state();
await upload(JSON.stringify({format:'pure-workbench',version:2,source:'state changed=99',draft:'replacement',states:[]}));
await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('상태 목록'));
s=await state();
check(same(s.states,before.states)&&s.source===before.source&&s.appliedSource===before.appliedSource&&s.revision===before.revision,'invalid saved state cannot partially replace code, draft or scene');
// Delay File.text, then edit before it completes. Restoring must not overwrite the edit.
await page.locator('#file-input').evaluate(input=>{
const document={format:'pure-workbench',version:2,source:'state other=4',draft:'state other=4',states:[{name:'other',value:55}]};
const transfer=new DataTransfer(),file=new File([JSON.stringify(document)],'delayed.json',{type:'application/json'});
const text=JSON.stringify(document);file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
});
await page.locator('#source').fill(draft+'\n# edit during file read');
await page.evaluate(()=>window.__finishLayoutRead());
await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
s=await state();check(s.source.endsWith('# edit during file read')&&same(s.states,before.states),'a delayed file read cannot overwrite a newer draft');
// Repeat with an input event while file content is pending.
await page.locator('#file-input').evaluate(input=>{
const text=JSON.stringify({format:'pure-workbench',version:2,source:'state other=4',states:[{name:'other',value:55}]});
const file=new File([text],'delayed.json',{type:'application/json'}),transfer=new DataTransfer();
file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
});
await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');const changed=await state();
await page.evaluate(()=>window.__finishLayoutRead());
await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
check(same((await state()).states,changed.states),'a delayed restore cannot overwrite placement changes made while reading');
// UI-only run state is deliberately not persisted.
await page.locator('#play').click();await page.locator('#download-layout').click();
check((await state()).diagnostic.includes('정지한 뒤'),'saving while time advances requests a stopped document');
await page.locator('#play').click();
// Refuse while a logical input remains held, with no lost source or partial save.
await page.locator('#scene').focus();await page.keyboard.down('ArrowRight');
await page.locator('#download-layout').click();
check((await state()).diagnostic.includes('조작을 끝낸 뒤'),'saving with a held input is refused');
await page.keyboard.up('ArrowRight');
await page.locator('#file-input').setInputFiles(saved);
await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
// One source edit affects every bound target, without editing input adapters.
const edited=source.replace('t.x = clamp(t.x + 10,','t.x = clamp(t.x + 25,');
await page.locator('#source').fill(edited);await page.locator('#apply').click();
const beforeEdit=await state();
await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
check(Math.abs((await state()).states['amber.x']-(beforeEdit.states['amber.x']+25))<0.1,'editing the shared rule changes the selected amber target');
await drag(beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10,beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10);
const blueBefore=(await state()).states['blue.x'];await page.keyboard.press('ArrowRight');
check(Math.abs((await state()).states['blue.x']-(blueBefore+25))<0.1,'the same single rule edit also changes the blue target');
const lines=edited.split('\n'),items=lines.filter(line=>line.startsWith('item ')).reverse();
const reordered=lines.map(line=>line.startsWith('item ')?items.shift():line).join('\n');
const beforeReorder=await state();await page.locator('#source').fill(reordered);await page.locator('#apply').click();
const afterReorder=await state();
check(Object.entries(beforeReorder.states).every(([name,value])=>afterReorder.states[name]===value),'reordering instance declarations preserves every field by qualified name');
await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
check(Math.abs((await state()).states['blue.x']-(afterReorder.states['blue.x']+25))<0.1,'rebuilt parameter maps still send selection input to blue after reorder');
// Two contacts execute the same body with different bound fields.
await page.locator('#source').fill(source);await page.locator('#apply').click();await page.locator('#reset').click();
await page.locator('#scene').scrollIntoViewIfNeeded();
const session=await page.context().newCDPSession(page);
try {
await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
const p1={id:1,...await client(90,130)},p2={id:2,...await client(290,170)};
await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:1,...await client(160,180)},{id:2,...await client(360,220)}]});
await page.waitForFunction(()=>{const s=window.pureWorkbench.snapshot().states;return Math.abs(s['green.x']-150)<0.1&&Math.abs(s['blue.x']-350)<0.1;});
const touch=await state();
check(Math.abs(touch.states['green.x']-150)<0.1&&Math.abs(touch.states['blue.x']-350)<0.1&&touch.held.length===2,'simultaneous touches share code but retain independent field bindings');
await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
const canceled=await state();
check(canceled.states['green.x']===80&&canceled.states['blue.x']===280&&!canceled.held.length,'shared cancel restores each contact to its own start position');
// A real second contact on the same view must not steal its owner.
await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,{id:2,...await client(100,140)}]});
check((await state()).held.length===1,'a second touch on the same target cannot steal ownership');
await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
// End one actual contact and continue the other in the same logical input.
await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
const m1={id:1,...await client(160,180)},m2={id:2,...await client(360,220)};
await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[m1,m2]});
// In this Chromium, touchEnd's supplied point identifies the contact ending.
// touch-reproduction.js records the resulting pointerup and retained blue owner.
await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[m1]});
let partial=await state();
check(partial.held.length===1&&partial.states['green.held']===0&&partial.states['blue.held']===1,'releasing green keeps blue captured within the same input');
await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:2,...await client(400,250)}]});
await page.waitForFunction(()=>Math.abs(window.pureWorkbench.snapshot().states['blue.x']-390)<0.1);
partial=await state();
check(Math.abs(partial.states['green.x']-150)<0.1&&Math.abs(partial.states['blue.x']-390)<0.1,'the remaining contact keeps moving only its own target');
await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
partial=await state();
check(Math.abs(partial.states['green.x']-150)<0.1&&partial.states['blue.x']===280&&!partial.held.length,'canceling the remaining target preserves the already released position');
} finally {await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();}
await page.locator('#file-input').setInputFiles(saved);
await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
await page.screenshot({path:'output/playwright/pure-routed-layout-desktop.png',fullPage:true});
await page.setViewportSize({width:390,height:844});
check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'extra persistence control fits a mobile viewport');
await page.screenshot({path:'output/playwright/pure-routed-layout-mobile.png',fullPage:true});
check(errors.length===0,'no uncaught page errors');
const result={checks,errors,saved,expected,source,draft};
await page.evaluate(result=>{window.__pureRoutedInputEvidence=result;},result);
return {checks:checks.length,errors};
} catch(error) {
await page.evaluate(result=>{window.__pureRoutedInputEvidence=result;},{failed:String(error),checks,errors});throw error;
} finally {page.off('dialog',onDialog);page.off('pageerror',onError);}
}
)(page);
```
### Page
- Page URL: http://127.0.0.1:8766/
### Modal state
- ["confirm" dialog with message "현재 코드와 배치를 파일의 내용으로 복원할까요? 저장하지 않은 작업은 사라집니다."]: can be handled by dialog-accept or dialog-dismiss
|