dsh-wisp 1.37.0 → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +426 -8
- package/cordis.patch.yml +19 -19
- package/lib/client.js +722 -27
- package/lib/client.template.js +722 -27
- package/lib/index.js +312 -28
- package/package.json +2 -1
- package/tools/engine-probe.mjs +421 -0
- package/tools/smoke.mjs +272 -0
- package/verify-wisp.mjs +4751 -3719
package/tools/smoke.mjs
CHANGED
|
@@ -752,6 +752,278 @@ check(wanderMounted && wander.maxStep <= 400, 'no observed step exceeds twice th
|
|
|
752
752
|
check(wanderMounted && wander.gliding, 'and it is a glide, not a teleport', 'data-gliding was observed during the stroll')
|
|
753
753
|
await wanderBrowser.close()
|
|
754
754
|
|
|
755
|
+
/* ------------------------------------------------------ 10. 动作层(真引擎) --- */
|
|
756
|
+
head('10. the motion layer, in a real engine')
|
|
757
|
+
|
|
758
|
+
/* 假 DOM 只能证明"属性写对了",证明不了 **CSS 真的接住了它**:关键帧里的 var()、
|
|
759
|
+
属性选择器、以及 prefers-reduced-motion 的媒体查询,都得真引擎说一次才算数。
|
|
760
|
+
这一段就是 verify-wisp.mjs 里新增替身能力(matchMedia)在真实浏览器侧的对应检查。 */
|
|
761
|
+
const motionBrowser = await chromium.launch({ headless: !headed, executablePath })
|
|
762
|
+
const motionLogs = []
|
|
763
|
+
const motionPage = await motionBrowser.newPage({ viewport: { width: 1440, height: 900 } })
|
|
764
|
+
motionPage.on('console', (msg) => { if (msg.type() === 'error') motionLogs.push(msg.text()) })
|
|
765
|
+
motionPage.on('pageerror', (err) => motionLogs.push('pageerror: ' + err.message))
|
|
766
|
+
await motionPage.setContent('<!doctype html><html><head></head><body>' +
|
|
767
|
+
'<div id="under" style="position:fixed;inset:0;z-index:0">' +
|
|
768
|
+
'<button id="underBtn" style="position:absolute;inset:0;width:100%;height:100%;opacity:.02">under</button></div>' +
|
|
769
|
+
'</body></html>')
|
|
770
|
+
await motionPage.evaluate((src) => {
|
|
771
|
+
window.__clicks = 0
|
|
772
|
+
document.getElementById('underBtn').addEventListener('click', () => { window.__clicks++ })
|
|
773
|
+
window.__ML = { def: null }
|
|
774
|
+
window.__ModuleLoader__ = { load: (d) => { window.__ML.def = d } }
|
|
775
|
+
new Function(src)()
|
|
776
|
+
window.__ML.def.factory(() => ({})).apply({
|
|
777
|
+
get: (n) => (n === 'timer'
|
|
778
|
+
? { timeout: (cb, ms) => { const i = setTimeout(cb, ms); return () => clearTimeout(i) }, interval: (cb, ms) => { const i = setInterval(cb, ms); return () => clearInterval(i) } }
|
|
779
|
+
: n === 'styles' ? { insert: (css) => { const s = document.createElement('style'); s.textContent = css; document.head.append(s); return () => s.remove() } } : undefined),
|
|
780
|
+
on: () => () => {}, effect: () => () => {},
|
|
781
|
+
}, { wander: false, reactions: false, sleepAfterMs: 3600000, persist: false })
|
|
782
|
+
}, bundle)
|
|
783
|
+
await motionPage.waitForTimeout(1200)
|
|
784
|
+
|
|
785
|
+
const motionUp = await motionPage.evaluate(() =>
|
|
786
|
+
typeof window.__wisp === 'object' && window.__wisp !== null && document.querySelector('.wisp-lean') !== null)
|
|
787
|
+
check(motionUp, 'a motion-enabled mount comes up with both layers',
|
|
788
|
+
motionUp ? 'ok' : `console: ${motionLogs.join(' | ') || '(empty)'}`)
|
|
789
|
+
|
|
790
|
+
if (motionUp) {
|
|
791
|
+
/* 指针靠近 → .wisp-lean 的**计算后 transform** 真的转了。
|
|
792
|
+
这是替身给不了的东西:它的 style 是普通对象,没有级联,也没有 matrix。 */
|
|
793
|
+
const pointNear = () => motionPage.evaluate(() => {
|
|
794
|
+
const p = window.__wisp.position
|
|
795
|
+
window.dispatchEvent(new PointerEvent('pointermove', {
|
|
796
|
+
clientX: p.x + 280 + 60, clientY: p.y + 420, bubbles: true,
|
|
797
|
+
}))
|
|
798
|
+
})
|
|
799
|
+
await pointNear()
|
|
800
|
+
await motionPage.waitForTimeout(700) // 姿势有 0.5s 的过渡,读太早只会读到中途
|
|
801
|
+
const leanState = await motionPage.evaluate(() => {
|
|
802
|
+
const root = document.querySelector('.wisp-root')
|
|
803
|
+
const m = /matrix(?:3d)?\(([^)]+)\)/.exec(getComputedStyle(document.querySelector('.wisp-lean')).transform)
|
|
804
|
+
return {
|
|
805
|
+
tilt: root.style.getPropertyValue('--wisp-tilt'),
|
|
806
|
+
shear: m ? Math.abs(Number(m[1].split(',')[1])) : 0, // b = sin(θ)
|
|
807
|
+
amp: getComputedStyle(root).getPropertyValue('--wisp-amp').trim(),
|
|
808
|
+
}
|
|
809
|
+
})
|
|
810
|
+
check(leanState.tilt !== '0deg' && leanState.shear > 0.005,
|
|
811
|
+
'the pointer lean reaches the real cascade — the posture layer is genuinely rotated',
|
|
812
|
+
`--wisp-tilt=${leanState.tilt}, sin(θ)=${leanState.shear.toFixed(4)}`)
|
|
813
|
+
check(leanState.amp === '1', 'and the amplitude variable resolves on the root', `--wisp-amp=${leanState.amp}`)
|
|
814
|
+
|
|
815
|
+
/* 按下 → .wisp-motion 上真的有一条在跑的 CSS 动画 */
|
|
816
|
+
const pressRun = await motionPage.evaluate(async () => {
|
|
817
|
+
const p = window.__wisp.position
|
|
818
|
+
const body = document.querySelector('.wisp-body')
|
|
819
|
+
const motion = document.querySelector('.wisp-motion')
|
|
820
|
+
const ev = new PointerEvent('pointerdown', {
|
|
821
|
+
button: 0, clientX: p.x + 280, clientY: p.y + 420, bubbles: true, cancelable: true,
|
|
822
|
+
})
|
|
823
|
+
body.dispatchEvent(ev)
|
|
824
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
825
|
+
const names = motion.getAnimations().map((a) => a.animationName).filter(Boolean)
|
|
826
|
+
const accent = document.querySelector('.wisp-root').dataset.accent
|
|
827
|
+
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
|
828
|
+
return { names, accent, grabbed: ev.defaultPrevented }
|
|
829
|
+
})
|
|
830
|
+
check(pressRun.grabbed, 'a press on her body is grabbed in the real browser too')
|
|
831
|
+
check(pressRun.accent === 'press', 'and it sets the press accent', String(pressRun.accent))
|
|
832
|
+
check(pressRun.names.includes('wisp-press'),
|
|
833
|
+
'the engine really starts those keyframes — the attribute selector and the calc() in them both resolve',
|
|
834
|
+
pressRun.names.join(', ') || '(no running animation)')
|
|
835
|
+
|
|
836
|
+
/* 拖完松手 = 落地(顿一下)+ 姿势回正 */
|
|
837
|
+
const landRun = await motionPage.evaluate(async () => {
|
|
838
|
+
const p = window.__wisp.position
|
|
839
|
+
const body = document.querySelector('.wisp-body')
|
|
840
|
+
const motion = document.querySelector('.wisp-motion')
|
|
841
|
+
const root = () => document.querySelector('.wisp-root')
|
|
842
|
+
body.dispatchEvent(new PointerEvent('pointerdown', {
|
|
843
|
+
button: 0, clientX: p.x + 280, clientY: p.y + 420, bubbles: true, cancelable: true,
|
|
844
|
+
}))
|
|
845
|
+
window.dispatchEvent(new PointerEvent('pointermove', {
|
|
846
|
+
clientX: p.x + 280 + 40, clientY: p.y + 420, bubbles: true,
|
|
847
|
+
}))
|
|
848
|
+
const midTilt = root().style.getPropertyValue('--wisp-tilt')
|
|
849
|
+
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
|
850
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
851
|
+
return {
|
|
852
|
+
midTilt,
|
|
853
|
+
accent: root().dataset.accent,
|
|
854
|
+
names: motion.getAnimations().map((a) => a.animationName),
|
|
855
|
+
afterTilt: root().style.getPropertyValue('--wisp-tilt'),
|
|
856
|
+
}
|
|
857
|
+
})
|
|
858
|
+
check(Number.parseFloat(landRun.midTilt) < 0,
|
|
859
|
+
'dragging her to the right leans her top backwards — she lags the motion', landRun.midTilt)
|
|
860
|
+
check(landRun.accent === 'land' && landRun.names.includes('wisp-land'),
|
|
861
|
+
'releasing after a real drag lands her instead of teleporting upright',
|
|
862
|
+
`${landRun.accent} / ${landRun.names.join(', ') || '(none)'}`)
|
|
863
|
+
check(landRun.afterTilt === '0deg', 'and the posture goes back to upright on release', landRun.afterTilt)
|
|
864
|
+
|
|
865
|
+
/* 换表情会弹一下;但**同一个表情再来一次不许弹** —— 那是稳态重算走的路
|
|
866
|
+
(attn 每 1.2 秒重算一次),把它也弹起来就是 1.2 秒一抽。
|
|
867
|
+
手势路径反过来:戳两下必须弹两下,靠 a/b 交替让第二次真的重播。 */
|
|
868
|
+
const popRun = await motionPage.evaluate(async () => {
|
|
869
|
+
const motion = document.querySelector('.wisp-motion')
|
|
870
|
+
const root = () => document.querySelector('.wisp-root')
|
|
871
|
+
const body = document.querySelector('.wisp-body')
|
|
872
|
+
const wait = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
873
|
+
const names = () => motion.getAnimations().map((a) => a.animationName)
|
|
874
|
+
/* 先让上一步可能挂着的 flashHappy 计时器烧掉 —— 它到点会把 mood 打回 idle,
|
|
875
|
+
那会让"同一个表情"那一步其实又变了一次(这个假警报真出现过)。 */
|
|
876
|
+
await new Promise((r) => setTimeout(r, 1600))
|
|
877
|
+
window.__wisp.mood('idle')
|
|
878
|
+
await wait()
|
|
879
|
+
window.__wisp.mood('happy')
|
|
880
|
+
await wait()
|
|
881
|
+
const changed = { accent: root().dataset.accent, names: names(), mood: window.__wisp.currentMood }
|
|
882
|
+
await new Promise((r) => setTimeout(r, 600))
|
|
883
|
+
window.__wisp.mood('happy')
|
|
884
|
+
await wait()
|
|
885
|
+
const steady = { accent: root().dataset.accent ?? null, names: names(), mood: window.__wisp.currentMood }
|
|
886
|
+
const p = window.__wisp.position
|
|
887
|
+
const tap = async () => {
|
|
888
|
+
body.dispatchEvent(new PointerEvent('pointerdown', {
|
|
889
|
+
button: 0, clientX: p.x + 280, clientY: p.y + 420, bubbles: true, cancelable: true,
|
|
890
|
+
}))
|
|
891
|
+
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
|
892
|
+
await wait()
|
|
893
|
+
return { accent: root().dataset.accent, names: names() }
|
|
894
|
+
}
|
|
895
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
896
|
+
const tap1 = await tap()
|
|
897
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
898
|
+
const tap2 = await tap()
|
|
899
|
+
return { changed, steady, tap1, tap2 }
|
|
900
|
+
})
|
|
901
|
+
const popOk = (r) => /^pop-/.test(String(r.accent)) && r.names.includes('wisp-' + r.accent)
|
|
902
|
+
check(popOk(popRun.changed), 'a mood change pops instead of only cross-fading',
|
|
903
|
+
`${popRun.changed.accent}:${popRun.changed.names.join(',')}`)
|
|
904
|
+
check(popRun.steady.mood === 'happy' && popRun.steady.accent === null
|
|
905
|
+
&& !popRun.steady.names.some((n) => String(n).startsWith('wisp-pop')),
|
|
906
|
+
're-asserting the same mood stays silent — the attention poll takes that path every 1.2s',
|
|
907
|
+
`mood=${popRun.steady.mood} accent=${popRun.steady.accent} animations=${popRun.steady.names.join(',') || '(none)'}`)
|
|
908
|
+
check(popOk(popRun.tap1) && popOk(popRun.tap2) && popRun.tap1.accent !== popRun.tap2.accent,
|
|
909
|
+
'and two taps in a row both pop, with alternating names',
|
|
910
|
+
`${popRun.tap1.accent} -> ${popRun.tap2.accent}`)
|
|
911
|
+
|
|
912
|
+
/* 静止档:不只是新动作停了,连呼吸也停 */
|
|
913
|
+
await pointNear()
|
|
914
|
+
const beforeStill = await motionPage.evaluate(() =>
|
|
915
|
+
document.querySelector('.wisp-root').style.getPropertyValue('--wisp-tilt'))
|
|
916
|
+
const stillState = await motionPage.evaluate(() => {
|
|
917
|
+
window.__wisp.configure({ motion: 'off' })
|
|
918
|
+
const root = document.querySelector('.wisp-root')
|
|
919
|
+
const p = window.__wisp.position
|
|
920
|
+
window.dispatchEvent(new PointerEvent('pointermove', {
|
|
921
|
+
clientX: p.x + 280 + 60, clientY: p.y + 420, bubbles: true,
|
|
922
|
+
}))
|
|
923
|
+
return {
|
|
924
|
+
attr: root.dataset.motion,
|
|
925
|
+
amp: getComputedStyle(root).getPropertyValue('--wisp-amp').trim(),
|
|
926
|
+
bodyAnim: getComputedStyle(document.querySelector('.wisp-body')).animationName,
|
|
927
|
+
tilt: root.style.getPropertyValue('--wisp-tilt'),
|
|
928
|
+
}
|
|
929
|
+
})
|
|
930
|
+
/* 先把她推到一个非零角再切档 —— 否则"没动"与"被归零"在断言里长得一模一样。 */
|
|
931
|
+
check(beforeStill !== '0deg' && stillState.tilt === '0deg',
|
|
932
|
+
'"still" brings the posture back to upright at once, and then the pointer cannot move it',
|
|
933
|
+
`${beforeStill} -> ${stillState.tilt}`)
|
|
934
|
+
check(stillState.attr === 'off' && stillState.amp === '0',
|
|
935
|
+
'the level reaches the element and the amplitude variable', `${stillState.attr} / ${stillState.amp}`)
|
|
936
|
+
check(stillState.bodyAnim === 'none',
|
|
937
|
+
'and it stops the idle breathing too, so "still" means still', String(stillState.bodyAnim))
|
|
938
|
+
|
|
939
|
+
/* 系统级 reduced motion:真引擎里用 emulateMedia 模拟,而不是读 CSS 文本 */
|
|
940
|
+
await motionPage.evaluate(() => window.__wisp.configure({ motion: 'full' }))
|
|
941
|
+
await motionPage.emulateMedia({ reducedMotion: 'reduce' })
|
|
942
|
+
/* 等一拍再读:上一步可能还有一个动作挂在属性上,没跑完就断言会把它当成
|
|
943
|
+
"reduced motion 下新放的动作"(第一次跑就是这么误报的)。 */
|
|
944
|
+
await motionPage.waitForTimeout(750)
|
|
945
|
+
const rmState = await motionPage.evaluate(() => {
|
|
946
|
+
const root = document.querySelector('.wisp-root')
|
|
947
|
+
const p = window.__wisp.position
|
|
948
|
+
window.dispatchEvent(new PointerEvent('pointermove', {
|
|
949
|
+
clientX: p.x + 280 + 60, clientY: p.y + 420, bubbles: true,
|
|
950
|
+
}))
|
|
951
|
+
document.querySelector('.wisp-body').dispatchEvent(new PointerEvent('pointerdown', {
|
|
952
|
+
button: 0, clientX: p.x + 280, clientY: p.y + 420, bubbles: true, cancelable: true,
|
|
953
|
+
}))
|
|
954
|
+
const out = {
|
|
955
|
+
reduced: window.__wisp.doctor().motion.reduced,
|
|
956
|
+
tilt: root.style.getPropertyValue('--wisp-tilt'),
|
|
957
|
+
accent: root.dataset.accent ?? null,
|
|
958
|
+
bodyAnim: getComputedStyle(document.querySelector('.wisp-body')).animationName,
|
|
959
|
+
motionAnim: getComputedStyle(document.querySelector('.wisp-motion')).animationName,
|
|
960
|
+
}
|
|
961
|
+
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
|
962
|
+
return out
|
|
963
|
+
})
|
|
964
|
+
check(rmState.reduced === true, 'the plugin reads the system setting through matchMedia',
|
|
965
|
+
String(rmState.reduced))
|
|
966
|
+
check(rmState.tilt === '0deg' && rmState.accent === null,
|
|
967
|
+
'under reduced motion neither the posture nor an accent is applied — the media query cannot reach a JS-written variable, so this second guard is load-bearing',
|
|
968
|
+
`--wisp-tilt=${rmState.tilt}, accent=${rmState.accent}`)
|
|
969
|
+
check(rmState.bodyAnim === 'none' && rmState.motionAnim === 'none',
|
|
970
|
+
'and the media query stops everything in the real engine as well',
|
|
971
|
+
`${rmState.bodyAnim} / ${rmState.motionAnim}`)
|
|
972
|
+
|
|
973
|
+
/* ---- 命中判定:透明处必须整层穿透到下面的应用 ----
|
|
974
|
+
假 DOM 没有命中判定,所以"下面那个应用到底收没收到"只有真引擎能给答案。
|
|
975
|
+
这里用 elementFromPoint(它同时考虑 pointer-events 与 clip-path)加一次真点击。 */
|
|
976
|
+
const wasAt = await motionPage.evaluate(() => window.__wisp.position)
|
|
977
|
+
const picks = await motionPage.evaluate(() => {
|
|
978
|
+
window.__wisp.move(600, 150)
|
|
979
|
+
const img = document.querySelector('.wisp-img')
|
|
980
|
+
const c = document.createElement('canvas'); c.width = 96; c.height = 144
|
|
981
|
+
const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, 96, 144)
|
|
982
|
+
const data = ctx.getImageData(0, 0, 96, 144).data
|
|
983
|
+
const p = window.__wisp.position
|
|
984
|
+
const clear = [], solid = []
|
|
985
|
+
for (let row = 2; row < 142; row += 4) {
|
|
986
|
+
for (let col = 2; col < 94; col += 4) {
|
|
987
|
+
const a = data[(row * 96 + col) * 4 + 3]
|
|
988
|
+
const x = Math.round(p.x + (col + 0.5) * (560 / 96))
|
|
989
|
+
const y = Math.round(p.y + (row + 0.5) * (840 / 144))
|
|
990
|
+
if (a === 0) clear.push({ x, y })
|
|
991
|
+
else if (a > 240) solid.push({ x, y })
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return { clear, solid }
|
|
995
|
+
})
|
|
996
|
+
check(picks.clear.length > picks.solid.length,
|
|
997
|
+
'most of her box is transparent — which is why the box must not be the hit area',
|
|
998
|
+
`${picks.solid.length} opaque vs ${picks.clear.length} transparent samples`)
|
|
999
|
+
const clearPoint = picks.clear[Math.floor(picks.clear.length / 2)]
|
|
1000
|
+
const solidPoint = picks.solid[Math.floor(picks.solid.length / 2)]
|
|
1001
|
+
const topAt = (pt) => motionPage.evaluate(({ x, y }) => {
|
|
1002
|
+
const el = document.elementFromPoint(x, y)
|
|
1003
|
+
return el ? (el.id || el.className || el.tagName) : null
|
|
1004
|
+
}, pt)
|
|
1005
|
+
const clearTop = await topAt(clearPoint)
|
|
1006
|
+
check(clearTop === 'underBtn', 'at a transparent pixel the topmost element is the APP, not her', clearTop)
|
|
1007
|
+
const clicksBeforeClear = await motionPage.evaluate(() => window.__clicks)
|
|
1008
|
+
await motionPage.mouse.click(clearPoint.x, clearPoint.y)
|
|
1009
|
+
const clicksAfterClear = await motionPage.evaluate(() => window.__clicks)
|
|
1010
|
+
check(clicksAfterClear === clicksBeforeClear + 1, 'a real click there reaches the app underneath',
|
|
1011
|
+
`${clicksBeforeClear} → ${clicksAfterClear}`)
|
|
1012
|
+
const solidTop = await topAt(solidPoint)
|
|
1013
|
+
check(solidTop === 'wisp-hit', 'on her body the topmost element is her hit layer', solidTop)
|
|
1014
|
+
const clicksBeforeSolid = await motionPage.evaluate(() => window.__clicks)
|
|
1015
|
+
await motionPage.mouse.click(solidPoint.x, solidPoint.y)
|
|
1016
|
+
const clicksAfterSolid = await motionPage.evaluate(() => window.__clicks)
|
|
1017
|
+
check(clicksAfterSolid === clicksBeforeSolid, 'and clicking her does NOT fall through',
|
|
1018
|
+
`${clicksBeforeSolid} → ${clicksAfterSolid}`)
|
|
1019
|
+
const focusAfter = await motionPage.evaluate(() =>
|
|
1020
|
+
document.activeElement ? String(document.activeElement.className || document.activeElement.tagName) : null)
|
|
1021
|
+
check(focusAfter.includes('wisp-body'),
|
|
1022
|
+
'clicking her still hands her the keyboard focus (Enter still opens the menu)', focusAfter)
|
|
1023
|
+
await motionPage.evaluate(({ x, y }) => window.__wisp.move(x, y), wasAt)
|
|
1024
|
+
}
|
|
1025
|
+
await motionBrowser.close()
|
|
1026
|
+
|
|
755
1027
|
|
|
756
1028
|
|
|
757
1029
|
console.log(`\n${fail === 0 ? '=== SMOKE PASSED ===' : '=== SMOKE FAILED ==='}`)
|