diff --git a/YLErpWeb/fe-tests/swapPriceInput.component.test.js b/YLErpWeb/fe-tests/swapPriceInput.component.test.js new file mode 100644 index 00000000..7080f8cd --- /dev/null +++ b/YLErpWeb/fe-tests/swapPriceInput.component.test.js @@ -0,0 +1,100 @@ +/** + * swapPriceInput.component.test.js — vue-swap-price-input 事件派发回归守卫 + * ============================================================================ + * 目的:锁定债券三字段输入组件「粘贴 / 重输原值 不派发 input/enter」的 bug。 + * 症状:粘贴或重输原值后,点空白(标REV/清其他标识)与回车(触发计算)均无反应。 + * 根因:组件把 input/enter 全部锁在原生 change 事件里,而 change 仅在「值相对聚焦时 + * 变动」才触发;onPaste 甚至完全不派发任何事件。 + * + * 做法:直接实例化组件 options 对象(createVueInputComponent),用 mock this + $emit 间谍 + * 驱动 onPaste/onKeydown/onChange,断言事件派发。不依赖 Vue 运行时挂载、不改动源文件。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest swapPriceInput.component + * 设 SWAP_PRICE_HELPER 指向其它版本文件可做对照(如改之前的旧代码应使用例变红)。 + */ + +const fs = require('fs'); +const path = require('path'); + +function loadComponent() { + const rel = process.env.SWAP_PRICE_HELPER + || '../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js'; + const file = path.resolve(__dirname, rel); + const code = fs.readFileSync(file, 'utf8'); + // swapPricePrecisionHelper.js: var swapPricePrecision = (function(global){...})(window); + // 在受控作用域求值并取出 createVueInputComponent(IIFE 不挂 window,故用 new Function 取返回值)。 + const factory = new Function('window', 'global', 'console', code + '\n;return swapPricePrecision;'); + const sandbox = {}; + const swapPricePrecision = factory(sandbox, sandbox, console); + return swapPricePrecision.createVueInputComponent(); +} + +function makeCtx(component, opts) { + opts = opts || {}; + const emitted = []; + const ctx = { + text: opts.text != null ? String(opts.text) : '', + enterPressed: false, + formatSnapshot: '{}', + format: opts.format || { integerDigits: 2, precision: 4, percent: true }, + $emit: function (name, payload) { emitted.push({ name: name, payload: payload }); }, + }; + Object.keys(component.methods).forEach(function (m) { + ctx[m] = component.methods[m].bind(ctx); + }); + return { ctx: ctx, emitted: emitted }; +} + +describe('vue-swap-price-input 事件派发(粘贴 / 重输原值场景)', () => { + const component = loadComponent(); + + test('粘贴原值应派发 input(点空白标REV / 清其他标识)', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: true } }); + const evt = { preventDefault() {}, clipboardData: { getData: () => '3.5' }, target: { value: '' } }; + ctx.onPaste(evt); + const inputs = emitted.filter(e => e.name === 'input'); + expect(inputs.length).toBe(1); + expect(inputs[0].payload).toBe('0.035'); // percent:true → ÷100 + }); + + test('回车(原值未变)应派发 enter(触发自动计算)', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: true } }); + const evt = { which: 13, keyCode: 13, key: 'Enter', target: { blur() {}, value: '3.5' } }; + ctx.onKeydown(evt); + const enters = emitted.filter(e => e.name === 'enter'); + expect(enters.length).toBe(1); + expect(enters[0].payload).toBe('0.035'); + }); + + test('回车(值已改)应派发 input+enter 各一次,且不重复派发 enter', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.6', format: { percent: true } }); + const evt = { which: 13, keyCode: 13, key: 'Enter', target: { blur() {}, value: '3.6' } }; + ctx.onKeydown(evt); + expect(emitted.filter(e => e.name === 'input').length).toBe(1); + expect(emitted.filter(e => e.name === 'enter').length).toBe(1); + }); + + test('非 Enter 按键不应派发 enter / input', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { percent: true } }); + const evt = { which: 65, keyCode: 65, key: 'a', target: { blur() {} } }; + ctx.onKeydown(evt); + expect(emitted.filter(e => e.name === 'enter').length).toBe(0); + expect(emitted.filter(e => e.name === 'input').length).toBe(0); + }); + + test('失焦(onChange)即使值未变也应派发 input(点空白标REV),且不再派发 enter', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { percent: true } }); + const evt = { target: { value: '3.5' } }; + ctx.onChange(evt); + expect(emitted.filter(e => e.name === 'input').length).toBe(1); + expect(emitted.filter(e => e.name === 'enter').length).toBe(0); + }); + + test('粘贴但格式为 percent:false 也应派发 input(原值透传)', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: false } }); + const evt = { preventDefault() {}, clipboardData: { getData: () => '3.5' }, target: { value: '' } }; + ctx.onPaste(evt); + expect(emitted.filter(e => e.name === 'input').length).toBe(1); + expect(emitted.filter(e => e.name === 'input')[0].payload).toBe('3.5'); + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js index be9e3ed6..7bacf7a3 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js @@ -208,22 +208,32 @@ var swapPricePrecision = (function (global) { event.preventDefault(); this.updateValue(clipboard.getData('text'), true, false); event.target.value = this.text; + // 粘贴即显式赋值意图:直接派发 input,使「单击空白处标REV/其余清空」「回车前已标记」等行为生效。 + // 原实现只把粘贴内容写回DOM、等失焦时的原生change才派发,而原值粘贴时change不触发→静默无反应。 + this.$emit('input', this.toModel(this.text)); }, onKeydown: function (event) { this.enterPressed = false; this.$emit('keydown', event); const keyCode = event.which || event.keyCode; if (event.key !== 'Enter' && keyCode !== 13 && keyCode !== 108) return; - this.enterPressed = true; - event.target.blur(); + // 回车=用户显式提交意图:无论值是否相对聚焦时变化,都直接派发 input(标REV) + enter(触发计算)。 + // 原实现把 enter 锁在原生 change 里,而 change 仅在值变化时触发, + // 导致「重输/重贴原值 + 回车」静默不计算(QA 高优 Bug)。 + this.updateValue(this.text, true, false); + const value = this.toModel(this.text); + event.target.blur(); // 同步触发原生 change(值变化时再派发一次 input,幂等无害) + this.$emit('input', value); + this.$emit('enter', value); + this.enterPressed = false; }, onChange: function (event) { if (this.text.endsWith('.')) this.text = this.text.substring(0, this.text.length - 1); this.updateValue(this.text, true, false); const value = this.toModel(this.text); + // 失焦(非回车)→ 仅派发 input 标 REV;enter(计算) 改由 onKeydown 直接派发, + // 避免 change 不触发时丢失,也避免与 onKeydown 重复派发 enter 造成重复计算。 this.$emit('input', value); - if (this.enterPressed) this.$emit('enter', value); - this.enterPressed = false; event.target.value = this.text; } }, diff --git a/YLErpWeb/wwwroot/Statics/bundles/bundle.js b/YLErpWeb/wwwroot/Statics/bundles/bundle.js index cec1dbb7..915524de 100644 --- a/YLErpWeb/wwwroot/Statics/bundles/bundle.js +++ b/YLErpWeb/wwwroot/Statics/bundles/bundle.js @@ -14667,6 +14667,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru }; }(window.FastVue)); + //日期选择组件FastVue.DatePicker (function (global) { global.vueDatePicker = function () { @@ -15009,21 +15010,35 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru //依赖类库:lodash.js + jquery + jquery.validate + bootstrap.emodal.js // 统一调试日志工具:仅在 otcdebug 开启时输出,默认静默(生产零噪音)。 -// 开启方式:URL 加 ?otcdebug=1,或控制台 localStorage.setItem('otcdebug','1')。 -// 业务脚本逐步改用 otcDebug.banner / otcDebug.log 替代裸 console.log, -// 既能在排查时一键全开,又能避免版本标记散落硬编码(见 swapTradeEdit.js / fastVue.base.js 用法)。 -var __otcDiag = (window.ylotc && window.ylotc.__diag) || { debug: false }; +// 开启:URL 加 ?otcdebug=1(会自动写入 localStorage,同源 iframe/弹窗自动继承); +// 或控制台 localStorage.setItem('otcdebug','1')。关闭:localStorage.removeItem('otcdebug')。 +// 业务脚本用 otcDebug.banner / otcDebug.log 替代裸 console.log(见 swapTradeEdit.js / fastVue.base.js)。 +// +// 命名约定(避免 __私有对象 + otcDebug.API 大小写混搭): +// __diag —— 版本元数据对象(仅 _MainLayout 灌入 ylotc.__diag 时存在,仅供 banner 展示,不参与开关判定) +// __debug —— 调试开关布尔值(唯一真相来源:localStorage 优先 → URL 参数 → 兜底 __diag.debug) +var __diag = (window.ylotc && window.ylotc.__diag) || {}; +var __debug = (function () { + try { + // 1) URL 参数优先(排查最直观);命中即写入 localStorage,供同源 iframe/弹窗继承 + if (/[?&]otcdebug=1/.test(location.search)) { localStorage.setItem('otcdebug', '1'); return true; } + // 2) 已持久化的开关(父页面开过一次,_InfoLayout 弹窗自动生效) + if (localStorage.getItem('otcdebug') === '1') return true; + } catch (e) { /* localStorage 不可用时忽略,继续走兜底 */ } + // 3) 兜底:layout 灌入的 ylotc.__diag.debug(仅 _MainLayout 有) + return !!__diag.debug; +})(); window.otcDebug = { - // 模块加载横幅:F12 一眼看到模块名+bundle版本+git sha,用于排查"是不是加载了旧代码/旧缓存" + // 模块加载横幅:F12 一眼看到模块名+版本,用于排查"是不是加载了旧代码/旧缓存" banner: function (name, ver) { - if (!__otcDiag.debug || !window.console) return; - var git = (__otcDiag.git || '').slice(0, 7); - console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')', + if (!__debug || !window.console) return; + var git = (__diag.git || '').slice(0, 7); + console.log('%c[' + name + '] v' + ver + ' (bundle=' + __diag.jsVersion + ', git=' + git + ', built=' + __diag.built + ')', 'color:#06c;font-weight:bold'); }, // 普通调试日志:透传参数,仅 debug 开启时输出 log: function () { - if (!__otcDiag.debug || !window.console) return; + if (!__debug || !window.console) return; console.log.apply(console, arguments); } }; diff --git a/YLErpWeb/wwwroot/Statics/bundles/bundleV2.js b/YLErpWeb/wwwroot/Statics/bundles/bundleV2.js index ed738bb6..bee09b9e 100644 --- a/YLErpWeb/wwwroot/Statics/bundles/bundleV2.js +++ b/YLErpWeb/wwwroot/Statics/bundles/bundleV2.js @@ -802,21 +802,35 @@ An}();typeof define=="function"&&typeof define.amd=="object"&&define.amd?($n._=r //依赖类库:lodash.js + jquery + jquery.validate + bootstrap.emodal.js // 统一调试日志工具:仅在 otcdebug 开启时输出,默认静默(生产零噪音)。 -// 开启方式:URL 加 ?otcdebug=1,或控制台 localStorage.setItem('otcdebug','1')。 -// 业务脚本逐步改用 otcDebug.banner / otcDebug.log 替代裸 console.log, -// 既能在排查时一键全开,又能避免版本标记散落硬编码(见 swapTradeEdit.js / fastVue.base.js 用法)。 -var __otcDiag = (window.ylotc && window.ylotc.__diag) || { debug: false }; +// 开启:URL 加 ?otcdebug=1(会自动写入 localStorage,同源 iframe/弹窗自动继承); +// 或控制台 localStorage.setItem('otcdebug','1')。关闭:localStorage.removeItem('otcdebug')。 +// 业务脚本用 otcDebug.banner / otcDebug.log 替代裸 console.log(见 swapTradeEdit.js / fastVue.base.js)。 +// +// 命名约定(避免 __私有对象 + otcDebug.API 大小写混搭): +// __diag —— 版本元数据对象(仅 _MainLayout 灌入 ylotc.__diag 时存在,仅供 banner 展示,不参与开关判定) +// __debug —— 调试开关布尔值(唯一真相来源:localStorage 优先 → URL 参数 → 兜底 __diag.debug) +var __diag = (window.ylotc && window.ylotc.__diag) || {}; +var __debug = (function () { + try { + // 1) URL 参数优先(排查最直观);命中即写入 localStorage,供同源 iframe/弹窗继承 + if (/[?&]otcdebug=1/.test(location.search)) { localStorage.setItem('otcdebug', '1'); return true; } + // 2) 已持久化的开关(父页面开过一次,_InfoLayout 弹窗自动生效) + if (localStorage.getItem('otcdebug') === '1') return true; + } catch (e) { /* localStorage 不可用时忽略,继续走兜底 */ } + // 3) 兜底:layout 灌入的 ylotc.__diag.debug(仅 _MainLayout 有) + return !!__diag.debug; +})(); window.otcDebug = { - // 模块加载横幅:F12 一眼看到模块名+bundle版本+git sha,用于排查"是不是加载了旧代码/旧缓存" + // 模块加载横幅:F12 一眼看到模块名+版本,用于排查"是不是加载了旧代码/旧缓存" banner: function (name, ver) { - if (!__otcDiag.debug || !window.console) return; - var git = (__otcDiag.git || '').slice(0, 7); - console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')', + if (!__debug || !window.console) return; + var git = (__diag.git || '').slice(0, 7); + console.log('%c[' + name + '] v' + ver + ' (bundle=' + __diag.jsVersion + ', git=' + git + ', built=' + __diag.built + ')', 'color:#06c;font-weight:bold'); }, // 普通调试日志:透传参数,仅 debug 开启时输出 log: function () { - if (!__otcDiag.debug || !window.console) return; + if (!__debug || !window.console) return; console.log.apply(console, arguments); } };