aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/workbench/qa-pointer.js
blob: 0b0c5914b9930f08de1bc329dfc7c7ff1854380a (plain)
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
// Playwright CLI run-code: real mouse / CDP touch input; no timing benchmark.
async (page) => {
  const checks = [], errors = [];
  const check = (condition, label) => { if (!condition) throw new Error(label); checks.push(label); };
  const close = (a, b) => Math.abs(a - b) < 0.1;
  const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
  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 start = async (x,y) => { await move(x,y); await page.mouse.down(); };
  const reset = async () => { await page.locator('#reset').click(); await page.locator('#scene').scrollIntoViewIfNeeded(); };
  const apply = async source => { await page.locator('#source').fill(source); await page.locator('#apply').click(); await page.locator('#scene').scrollIntoViewIfNeeded(); };
  const onError = error => errors.push(error.message);
  const onDialog = dialog => dialog.accept();
  page.on('pageerror', onError); page.on('dialog', onDialog);
  let session;
  try {
    await page.setViewportSize({width:1440,height:1000});
    await page.reload();
    await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
    await page.locator('#example').selectOption('direct');
    await page.locator('#load-example').click();
    const source = await page.locator('#source').inputValue();
    check((await state()).views.length === 0, 'loading the pointer example leaves the running program unchanged until Apply');
    await page.locator('#apply').click(); await page.locator('#scene').scrollIntoViewIfNeeded();
    check(await page.locator('[data-point-input="drag"]').count() === 1, 'authored pointer declaration binds the visible target');
    check(await page.locator('#input-controls button').count() === 2, 'keyboard alternatives remain available without a fake pointer button');
    await page.evaluate(() => {
      window.__pointerTile = document.querySelector('[data-point-input="drag"]');
      document.querySelector('#scene').addEventListener('pointerdown', e => { window.__pointerID = e.pointerId; });
    });
    await start(90,170);
    let s = await state();
    check(s.states.dragging === 1 && s.held.includes('drag'), 'real mouse down reaches the authored rule');
    await move(370,230); s = await state();
    check(close(s.states.x,360) && close(s.states.y,220), 'drag retains grab offset in scene coordinates');
    check(s.views.find(v => v.id === 'region').value === 1 && await page.locator('[data-view-id="region"]').textContent() === '오른쪽 영역: 1', 'linked output updates when the authored boundary is crossed');
    check(await page.evaluate(() => window.__pointerTile === document.querySelector('[data-point-input="drag"]')), 'drag keeps its bound DOM target');
    // Physical mouse outside the SVG still reaches the stable captured scene.
    await move(750,400); s = await state();
    check(close(s.states.x,564) && close(s.states.y,276), 'capture delivers movement outside the scene and authored clamp applies');
    await page.mouse.up(); s = await state();
    check(s.held.length === 0 && s.states.dragging === 0 && s.lastEvent.phase === 'up', 'release outside the scene ends the gesture once');
    await reset();
    await start(90,170); await move(380,240);
    await page.evaluate(() => window.dispatchEvent(new Event('blur')));
    s = await state();
    check(close(s.states.x,80) && close(s.states.y,160) && s.states.dragging === 0 && s.held.length === 0 && s.lastEvent.phase === 'cancel', 'focus loss invokes authored rollback and clears ownership');
    const ended = s.successfulEvents;
    await page.mouse.up();
    check((await state()).successfulEvents === ended, 'late mouse release after cancel does not dispatch a second terminal event');
    await start(90,170); await move(350,220);
    await page.evaluate(() => document.querySelector('#scene').releasePointerCapture(window.__pointerID));
    await move(360,230); s = await state();
    check(close(s.states.x,80) && s.held.length === 0 && s.lastEvent.phase === 'cancel', 'explicit capture loss cancels instead of committing a drop');
    await page.mouse.up();
    await start(90,170); await move(300,220);
    await page.evaluate(() => document.querySelector('#scene').dispatchEvent(new PointerEvent('pointercancel', {pointerId:window.__pointerID,bubbles:true})));
    check((await state()).held.length === 0 && close((await state()).states.x,80), 'browser cancellation routes to the authored cancel handler');
    await page.mouse.up();
    // A source edit, not a renderer option, changes the movement constraint.
    await apply(source.replaceAll('20, 564','20, 200'));
    await start(90,170); await move(500,220); await page.mouse.up(); s = await state();
    check(close(s.states.x,200), 'editing the authored rule changes the drag limit');
    await apply(source + '\non drag.move { x = 1 / 0 }\n'); await reset();
    await start(90,170); await move(300,220); s = await state();
    check(s.lastError === 4 && close(s.states.x,80) && close(s.states.y,160), 'later handler failure rolls back all earlier movement writes');
    await page.evaluate(() => window.dispatchEvent(new Event('blur'))); await page.mouse.up();
    s = await state();
    check(s.lastError === 4 && s.held.length === 0 && s.states.dragging === 0, 'cleanup releases a failed gesture while preserving its error');
    await apply(source); await reset();
    await page.screenshot({path:'output/playwright/pure-pointer-desktop.png',fullPage:true});

    await page.setViewportSize({width:390,height:844});
    await page.locator('#scene').scrollIntoViewIfNeeded();
    session = await page.context().newCDPSession(page);
    await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
    const touch = async (type, points) => session.send('Input.dispatchTouchEvent',{type,touchPoints:points});
    const a = await client(90,170), b = await client(270,210);
    const scroll = await page.evaluate(() => window.scrollY);
    await touch('touchStart',[{id:1,...a}]);
    check((await state()).states.dragging === 1, 'touch contact starts the same authored interaction');
    await page.evaluate(() => window.dispatchEvent(new MouseEvent('mouseup', {button:0,buttons:0,bubbles:true})));
    check((await state()).held.includes('drag'), 'mouse release fallback cannot terminate an active touch contact');
    await touch('touchMove',[{id:1,...b}]); s = await state();
    check(close(s.states.x,260) && close(s.states.y,200), 'mobile scaled SVG maps touch coordinates back to world coordinates');
    check(await page.evaluate(() => window.scrollY) === scroll, 'touch drag manipulates the target without scrolling the page');
    await touch('touchEnd',[]); s = await state();
    check(s.held.length === 0 && s.states.dragging === 0 && s.lastEvent.phase === 'up', 'touch release clears capture ownership');
    await page.screenshot({path:'output/playwright/pure-pointer-mobile.png',fullPage:true});
    // Independent contacts are not collapsed into one global drag state.
    const two = 'state x=0\nstate y=0\ninput a=pointer(one)\ninput b=pointer(two)\nbox one(20,100,100,80,"#c9f08b")\nbox two(300,100,100,80,"#c9f08b")\non a.move { x=event_x }\non b.move { y=event_y }';
    await apply(two);
    const p1 = {id:1,...await client(50,140)}, p2 = {id:2,...await client(330,140)};
    await touch('touchStart',[p1]); await touch('touchStart',[p1,p2]);
    check((await state()).held.length === 2, 'two contacts on different authored targets retain separate owners');
    await touch('touchMove',[{id:1,...await client(180,160)},{id:2,...await client(430,200)}]); s = await state();
    check(close(s.states.x,180) && close(s.states.y,200), 'independent touch payloads reach their own rules');
    await touch('touchCancel',[]);
    check((await state()).held.length === 0, 'touch cancellation releases both targets');
    // A second contact on the same target cannot steal the first one.
    await touch('touchStart',[p1]);
    await touch('touchStart',[p1,{id:2,...await client(70,150)}]);
    check((await state()).held.length === 1, 'second contact on an already owned target does not steal it');
    await touch('touchCancel',[]);
    check((await state()).held.length === 0, 'ignored second contact cannot strand a logical input');
    check(errors.length === 0,'no browser page errors');
    await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});
    await session.detach(); session = null;
    await page.setViewportSize({width:1440,height:1000}); await apply(source); await reset();
    await page.evaluate(result => { window.__purePointerEvidence = result; }, {checks,errors,kind:'functional input/output checks; no performance benchmark'});
    return {checks:checks.length,errors};
  } catch (error) {
    await page.evaluate(result => { window.__purePointerEvidence = result; }, {failed:String(error),checks,errors});
    throw error;
  } finally {
    if (session) { await session.send('Emulation.setTouchEmulationEnabled',{enabled:false}); await session.detach(); }
    page.off('pageerror',onError); page.off('dialog',onDialog);
  }
}