import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import ts from 'typescript' import * as vue from 'vue' import { compileScript, parse } from '@vue/compiler-sfc' const { descriptor } = parse(readFileSync('src/components/core/tables/ArtTableHeader.vue', 'utf8')) const componentSource = compileScript(descriptor, { id: 'table-header-drag-test', inlineTemplate: true }).content class HostElement { constructor(tag, text = '') { this.tag = tag this.text = text this.props = {} this.children = [] this.parentNode = null } get isConnected() { return this.tag === 'root' || Boolean(this.parentNode?.isConnected) } removeChild(child) { const index = this.children.indexOf(child) assert.notEqual(index, -1) this.children.splice(index, 1) child.parentNode = null } } function evaluate(source, imports) { const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', compiled)( (name) => { assert.ok(imports[name], `Unexpected dependency: ${name}`) return imports[name] }, module, module.exports ) return module.exports } function mountHeader(t) { const previousGlobals = { HTMLElement: globalThis.HTMLElement, document: globalThis.document } globalThis.HTMLElement = HostElement globalThis.document = { addEventListener() {}, removeEventListener() {} } const controls = [] const store = vue.reactive({ tableSize: 'default', isZebra: false, isBorder: false, isHeaderBackground: false }) const slotComponent = (name) => vue.defineComponent({ name, setup: (_, { slots, attrs }) => () => vue.h(name, attrs, [slots.reference?.(), slots.default?.(), slots.dropdown?.()]) }) const elements = Object.fromEntries( ['ElPopover', 'ElCheckbox', 'ElDropdown', 'ElDropdownMenu', 'ElDropdownItem'].map((name) => [ name, slotComponent(name) ]) ) const component = evaluate(componentSource, { vue, pinia: { storeToRefs: (value) => vue.toRefs(value) }, 'element-plus': elements, 'vue-i18n': { useI18n: () => ({ t: (key) => key }) }, '@/enums/formEnum': { TableSizeEnum: { SMALL: 'small', DEFAULT: 'default', LARGE: 'large' } }, '@/store/modules/table': { useTableStore: () => store }, 'vue-draggable-plus': { useDraggable: (target, model, options) => { const control = { target, model, options, starts: [], active: false, destroys: 0, start(element = target.value) { assert.ok( element instanceof HostElement && element.isConnected, 'Sortable needs a connected native element' ) this.starts.push(element) this.active = true }, destroy() { this.destroys += 1 this.active = false } } controls.push(control) vue.onMounted(() => { if (options.immediate !== false) control.start() }) return control } } }).default const renderer = vue.createRenderer({ createElement: (tag) => new HostElement(tag), createText: (text) => new HostElement('#text', text), createComment: (text) => new HostElement('#comment', text), setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text node.children = [] }, parentNode: (node) => node.parentNode, nextSibling: (node) => { const siblings = node.parentNode?.children || [] return siblings[siblings.indexOf(node) + 1] || null }, patchProp: (node, key, _, value) => { node.props[key] = value }, insert: (node, parent, anchor) => { node.parentNode?.removeChild(node) const index = anchor ? parent.children.indexOf(anchor) : parent.children.length assert.notEqual(index, -1) parent.children.splice(index, 0, node) node.parentNode = parent }, remove: (node) => node.parentNode?.removeChild(node) }) const columns = vue.ref([ { prop: 'phone', label: '手机号', checked: true }, { prop: 'shop_name', label: '店铺', checked: false } ]) const active = vue.ref(true) const root = new HostElement('root') const app = renderer.createApp({ render: () => vue.h(vue.KeepAlive, null, { default: () => active.value ? vue.h(component, { columns: columns.value, 'onUpdate:columns': (value) => { columns.value = value } }) : vue.h('placeholder') }) }) for (const [name, element] of Object.entries(elements)) app.component(name, element) app.mount(root) let mounted = true const unmount = () => { if (mounted) { app.unmount() mounted = false } } t.after(() => { unmount() for (const [key, value] of Object.entries(previousGlobals)) { if (value === undefined) delete globalThis[key] else globalThis[key] = value } }) const find = (node, tag) => node.tag === tag ? node : node.children.map((child) => find(child, tag)).find(Boolean) return { control: controls[0], columns, active, unmount, popover: find(root, 'ElPopover') } } const openPopover = (popover) => { popover.props['onUpdate:visible'](true) popover.props.onAfterEnter() } test('table header does not auto-start Sortable until the column popover has entered', async (t) => { const { control, popover } = mountHeader(t) await vue.nextTick() assert.equal(control.options.immediate, false) assert.equal(control.starts.length, 0) popover.props.onAfterEnter() assert.equal(control.starts.length, 0, 'a stale enter without opening is ignored') openPopover(popover) assert.equal(control.starts.length, 1) assert.equal(control.starts[0], control.target.value) }) test('closing and reopening column settings destroys the drag instance and rejects a late enter', async (t) => { const { control, popover } = mountHeader(t) openPopover(popover) popover.props.onBeforeLeave() assert.equal(control.active, false) assert.equal(control.destroys, 1) popover.props.onAfterEnter() assert.equal(control.starts.length, 1) openPopover(popover) assert.equal(control.starts.length, 2) }) test('missing, non-element and detached column targets never start Sortable', async (t) => { const { control, popover } = mountHeader(t) const original = control.target.value for (const target of [null, undefined, {}, new HostElement('detached')]) { control.target.value = target openPopover(popover) assert.equal(control.starts.length, 0) } control.target.value = original openPopover(popover) assert.equal(control.starts.length, 1) }) test('unmounting before or after opening safely cleans up and rejects queued popover callbacks', async (t) => { for (const opened of [false, true]) { const { control, popover, unmount } = mountHeader(t) if (opened) openPopover(popover) const count = control.starts.length unmount() await vue.nextTick() assert.equal(control.active, false) assert.ok(control.destroys > 0) popover.props.onAfterEnter() assert.equal(control.starts.length, count) } }) test('cached page deactivation cleans up; returning preserves column order and checked state', async (t) => { const { control, popover, active, columns } = mountHeader(t) openPopover(popover) control.model.value = [...control.model.value].reverse() await vue.nextTick() assert.deepEqual( columns.value.map((item) => item.prop), ['shop_name', 'phone'] ) active.value = false await vue.nextTick() assert.equal(control.active, false) popover.props.onAfterEnter() assert.equal(control.starts.length, 1) active.value = true await vue.nextTick() openPopover(popover) assert.equal(control.starts.length, 2) assert.equal(columns.value[0].checked, false) assert.equal(columns.value[1].checked, true) })