const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const ts = require('typescript'); function evaluate(source, globals) { return vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS }, }).outputText, globals); } function method(file, name, globals) { const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); let found; function visit(node) { if (ts.isMethodDeclaration(node) && node.name.getText(source) === name) found = node; else ts.forEachChild(node, visit); } visit(source); assert.ok(found, `${file}: ${name}`); return evaluate(`({ ${found.getText(source).replace(/^private\s+/, '')} }).${name};`, globals); } function fixture() { const state = { scene: { name: 'HomeScene' }, pulses: [], warnings: [] }; const cc = { Button: 'Button', Node: { EventType: { TOUCH_END: 'touchend' } }, director: { getScene: () => state.scene }, fx: { GameConfig: { GM_INFO: { vibrateOpen: true } } } }; const wx = { vibrateShort: options => state.pulses.push(options.type) }; cc.fx.GameTool = { setVibrate: method('assets/Script/module/Tool/GameTool.ts', 'setVibrate', { cc, wx }) }; const globals = { cc, exports: {}, console: { warn: (...args) => state.warnings.push(args) } }; evaluate(fs.readFileSync('assets/Script/module/Tool/HomeButtonVibration.ts', 'utf8'), globals); Object.assign(globals, globals.exports); const node = (parent = null, button = null) => ({ parent, getComponent: () => button }); const root = node(); const button = { interactable: true, enabledInHierarchy: true, _pressed: true }; const target = node(root, button); const event = { target, currentTarget: root }; return { state, cc, wx, globals, node, root, button, event, ...globals.exports }; } test('existing and late-mounted bundle buttons vibrate once, including child hit targets', () => { const f = fixture(); f.onHomeButtonTouchEnd(f.event); f.vibrateHomeButton(f.event); assert.deepEqual(f.state.pulses, ['light']); const popup = f.node(f.root); const dynamicButton = f.node(popup, { ...f.button }); f.onHomeButtonTouchEnd({ target: f.node(dynamicButton), currentTarget: f.root }); assert.deepEqual(f.state.pulses, ['light', 'light']); }); test('disabled buttons, cancelled presses and empty backgrounds do not vibrate', () => { for (const property of ['interactable', 'enabledInHierarchy', '_pressed']) { const f = fixture(); f.button[property] = false; f.onHomeButtonTouchEnd(f.event); assert.equal(f.state.pulses.length, 0, property); } const f = fixture(); f.onHomeButtonTouchEnd({ target: f.node(f.root), currentTarget: f.root }); assert.equal(f.state.pulses.length, 0); }); test('reused event objects vibrate on each new dispatch, but never twice per dispatch', () => { const f = fixture(); for (let i = 0; i < 2; i++) { f.onHomeButtonTouchEnd(f.event); f.vibrateHomeButton(f.event); assert.equal(f.state.pulses.length, i + 1); } }); test('respects the vibration switch and leaves GameScene and other scenes untouched', () => { const f = fixture(); f.cc.fx.GameConfig.GM_INFO.vibrateOpen = false; f.onHomeButtonTouchEnd(f.event); assert.equal(f.state.pulses.length, 0); f.cc.fx.GameConfig.GM_INFO.vibrateOpen = true; for (const scene of [{ name: 'GameScene' }, { name: 'LoadScene' }, null]) { f.state.scene = scene; f.onHomeButtonTouchEnd(f.event); assert.equal(f.vibrateHomeButton({ target: f.event.target }), false); assert.equal(f.state.pulses.length, 0); } }); test('legacy Home and Career callbacks do not double vibrate or alter popup request cancellation', () => { const f = fixture(); const homeClick = method('assets/Script/JiaZai.ts', 'vibrateButtonClick', f.globals); const careerClick = method('assets/career/script/CareerManager2.ts', 'vibrateButtonClick', f.globals); const home = { homePopupOpenRequest: 7 }; f.onHomeButtonTouchEnd(f.event); homeClick.call(home, f.event); careerClick(f.event); assert.equal(home.homePopupOpenRequest, 8); assert.deepEqual(f.state.pulses, ['light']); homeClick.call(home); assert.equal(home.homePopupOpenRequest, 8); f.state.scene = { name: 'GameScene' }; careerClick({}); assert.deepEqual(f.state.pulses, ['light', 'light'], 'preserves the legacy callback outside Home'); }); test('vibration API errors do not interrupt click handling or cause a duplicate attempt', () => { const f = fixture(); let attempts = 0; f.wx.vibrateShort = () => { attempts++; throw Error('unavailable'); }; assert.doesNotThrow(() => f.onHomeButtonTouchEnd(f.event)); assert.doesNotThrow(() => f.vibrateHomeButton(f.event)); assert.equal(attempts, 1); assert.equal(f.state.warnings.length, 1); }); test('Home component registers capture on enable and removes the same listener on disable', () => { const f = fixture(); const calls = []; const home = { node: { on: (...args) => calls.push(['on', ...args]), off: (...args) => calls.push(['off', ...args]) } }; method('assets/Script/JiaZai.ts', 'onEnable', f.globals).call(home); method('assets/Script/JiaZai.ts', 'onDisable', f.globals).call(home); assert.equal(calls[0][0], 'on'); assert.equal(calls[1][0], 'off'); assert.deepEqual(calls[0].slice(1), ['touchend', f.onHomeButtonTouchEnd, home, true]); assert.deepEqual(calls[0].slice(1), calls[1].slice(1)); }); test('touch-only Jungle close vibrates once and preserves confirmation guarding', () => { const f = fixture(); let closes = 0; const close = method('assets/jungle_treasure/script/JungleOver.ts', 'onClose', f.globals); const panel = { confirming: false, node: { emit: () => closes++ } }; const event = { target: f.node(f.root), currentTarget: f.root }; f.onHomeButtonTouchEnd(event); assert.equal(f.state.pulses.length, 0); close.call(panel, event); close.call(panel, event); assert.equal(closes, 1); assert.deepEqual(f.state.pulses, ['light']); }); test('seven-day cards only vibrate when the reward can be claimed', () => { const f = fixture(); let claims = 0; const click = method('assets/seven_day_gift/SevenDayGift.ts', 'onCardClicked', f.globals); const panel = { findReward: () => ({ claimed: false }), info: { canClaim: true, nextRewardDay: 2 }, claiming: false, claimHandler() {}, requestCurrentReward: () => claims++ }; click.call(panel, 1, {}); assert.equal(f.state.pulses.length, 0); click.call(panel, 2, {}); assert.equal(claims, 1); assert.deepEqual(f.state.pulses, ['light']); panel.claiming = true; click.call(panel, 2, {}); assert.equal(claims, 1); assert.equal(f.state.pulses.length, 1); });