mirror of
https://github.com/kuaifan/dootask.git
synced 2026-09-14 16:08:35 +00:00
新增到期、排期、逾期与未排期分组,按项目缓存显示选择并至少保留一项。 统一分组复选下拉菜单,接入 Cascader 桌面悬停展开,增加勾选后的滚动定位校正。 升级 view-design-hi 至 4.7.0-81,同步多语言、知识库和回归测试。
303 lines
21 KiB
JavaScript
303 lines
21 KiB
JavaScript
// Isolated UI fixture: real panel markup, Dropdown and styles; no backend or dev server.
|
|
// PLAYWRIGHT_MODULE=/path/to/playwright BROWSER_PATH=/path/to/chrome node tests/project-table-groups.browser.cjs
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const {parse} = require('@babel/parser');
|
|
const compiler = require('vue-template-compiler');
|
|
const sass = require('sass');
|
|
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
|
const root = path.resolve(__dirname, '..');
|
|
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
|
const sfc = compiler.parseComponent(read('resources/assets/js/pages/manage/components/ProjectPanel.vue'));
|
|
const sourceTemplate = sfc.template.content.trim();
|
|
const templateAst = compiler.compile(sourceTemplate, {outputSourceRange: true}).ast;
|
|
function find(node, predicate) {
|
|
if (predicate(node)) return node;
|
|
for (const child of node.children || []) {
|
|
const match = find(child, predicate);
|
|
if (match) return match;
|
|
}
|
|
for (const branch of (node.ifConditions || []).slice(1)) {
|
|
const match = find(branch.block, predicate);
|
|
if (match) return match;
|
|
}
|
|
}
|
|
const switchNode = find(templateAst, node => node.attrsMap?.class === 'project-switch-button');
|
|
const settingsNode = find(templateAst, node => node.attrsMap?.class === 'project-table-settings');
|
|
const flowNode = find(templateAst, node => node.attrsMap?.class === 'project-select');
|
|
const tableNode = find(templateAst, node => node.attrsMap?.class === 'project-table');
|
|
const switchTemplate = sourceTemplate.slice(switchNode.start, switchNode.end);
|
|
const settingsTemplate = sourceTemplate.slice(settingsNode.start, settingsNode.end);
|
|
const flowTemplate = sourceTemplate.slice(flowNode.start, flowNode.end);
|
|
const tableTemplate = sourceTemplate.slice(tableNode.start, tableNode.end).replace('v-else-if=', 'v-if=');
|
|
const template = `<div class="project-panel"><div class="project-subbox"><div class="project-subtitle"></div><div class="project-switch">${flowTemplate}${settingsTemplate}${switchTemplate}</div></div>${tableTemplate}</div>`;
|
|
const scriptAst = parse(sfc.script.content, {sourceType: 'module'});
|
|
let script = sfc.script.content;
|
|
const imports = [];
|
|
for (const node of [...scriptAst.program.body].reverse()) {
|
|
if (node.type !== 'ImportDeclaration') continue;
|
|
if (!['projectTableGroups', 'scrollToStableTarget'].some(name => node.source.value.endsWith(name))) {
|
|
imports.push(...node.specifiers.map(specifier => specifier.local.name));
|
|
}
|
|
script = script.slice(0, node.start) + script.slice(node.end);
|
|
}
|
|
const declarations = imports.map(name => `const ${name} = ${['mapState', 'mapGetters'].includes(name) ? '()=>({})' : '{}'};`).join('\n');
|
|
const factory = declarations + read('resources/assets/js/utils/projectTableGroups.js').replace(/export /g, '') + read('resources/assets/js/utils/scrollToStableTarget.js').replace(/export /g, '') + script.replace('export default', 'return');
|
|
const dropdownComponent = compiler.parseComponent(read('resources/assets/js/pages/manage/components/ProjectTableGroupDropdown.vue'));
|
|
let dropdownScript = dropdownComponent.script.content;
|
|
for (const node of [...parse(dropdownScript, {sourceType: 'module'}).program.body].reverse()) {
|
|
if (node.type === 'ImportDeclaration') dropdownScript = dropdownScript.slice(0, node.start) + dropdownScript.slice(node.end);
|
|
}
|
|
const dropdownFactory = read('resources/assets/js/utils/projectTableGroups.js').replace(/export /g, '') + dropdownScript.replace('export default', 'return');
|
|
const css = sass.compileString(read('resources/assets/sass/var.scss') + read('resources/assets/sass/pages/components/project-panel.scss')).css;
|
|
const output = path.join(root, 'tests/playwright-results/project-table-groups');
|
|
fs.mkdirSync(output, {recursive: true});
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({executablePath: process.env.BROWSER_PATH, headless: true, args: ['--no-sandbox']});
|
|
try {
|
|
for (const mobile of [false, true]) {
|
|
const context = await browser.newContext({viewport: mobile ? {width: 390, height: 844} : {width: 1440, height: 900}, hasTouch: mobile});
|
|
const page = await context.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
await page.setContent('<html><body><div id="app"></div></body></html>');
|
|
await page.addStyleTag({path: path.join(root, 'node_modules/view-design-hi/dist/styles/iview.css')});
|
|
await page.addStyleTag({content: css + '\nbody{font-family:Arial,sans-serif}.project-panel{height:100vh}.project-subbox{padding-top:24px!important}.project-table{overflow:auto}'});
|
|
const font = fs.readFileSync(path.join(root, 'resources/assets/statics/public/css/fonts/taskfont/iconfont.woff2')).toString('base64');
|
|
await page.addStyleTag({content: `@font-face{font-family:taskfont;src:url(data:font/woff2;base64,${font}) format('woff2')}.taskfont{font-family:taskfont!important;font-style:normal}`});
|
|
const iconFont = fs.readFileSync(path.join(root, 'node_modules/view-design-hi/dist/styles/fonts/ionicons.woff2')).toString('base64');
|
|
await page.addStyleTag({content: `@font-face{font-family:Ionicons;src:url(data:font/woff2;base64,${iconFont}) format('woff2')}`});
|
|
await page.addScriptTag({path: path.join(root, 'node_modules/vue/dist/vue.js')});
|
|
await page.addScriptTag({path: path.join(root, 'node_modules/dayjs/dayjs.min.js')});
|
|
await page.addScriptTag({path: path.join(root, 'node_modules/view-design-hi/dist/iview.min.js')});
|
|
await page.evaluate(({factory, template, mobile, dropdownFactory, dropdownTemplate}) => {
|
|
Vue.use(iview);
|
|
Vue.prototype.$L = value => value;
|
|
Vue.prototype.$isEEUIApp = false;
|
|
const helpers = {
|
|
dayjs, daytz: value => value === undefined ? dayjs() : dayjs.unix(value),
|
|
leftExists: (value, prefix) => String(value || '').startsWith(prefix),
|
|
sortDay: (a, b) => dayjs(a).valueOf() - dayjs(b).valueOf(),
|
|
sortFloat: (a, b) => a - b,
|
|
};
|
|
const options = new Function('$A', factory)(helpers);
|
|
const dropdown = {...new Function(dropdownFactory)(), template: dropdownTemplate};
|
|
const computed = {};
|
|
for (const key of ['tableGroupOptions', 'visibleTableGroups', 'tableWeekRanges', 'additionalTableGroups', 'tabTypeActive', 'tabTypeStyle', 'panelTask']) computed[key] = options.computed[key];
|
|
window.fixture = new Vue({
|
|
el: '#app', template,
|
|
data: {
|
|
...options.data(), windowTouch: mobile, projectId: 12,
|
|
flowTitle: '全部', flowData: [{value: 'all', label: '全部'}],
|
|
projectData: {task_num: 0, cacheParameter: {menuType: 'column', showMy: true, showHelp: true, showUndone: true, showCompleted: false}},
|
|
myList: [], helpList: [], unList: [], completedList: [], parentTask: [], isDepartmentReadonly: false,
|
|
},
|
|
computed,
|
|
watch: {tabTypeActive: options.watch.tabTypeActive},
|
|
components: {
|
|
ProjectTableGroupDropdown: dropdown,
|
|
Scrollbar: {template: '<div><slot/></div>', methods: {scrollElement() {return this.$el;}}},
|
|
TaskRow: {props: ['list'], template: '<div class="task-rows"><div v-for="task in list" :key="task.id">{{task.name}}</div></div>'},
|
|
},
|
|
methods: {
|
|
setTableGroupVisible: options.methods.setTableGroupVisible,
|
|
scrollToTableGroup: options.methods.scrollToTableGroup,
|
|
cancelTableGroupScroll: options.methods.cancelTableGroupScroll,
|
|
tabTypeChange: options.methods.tabTypeChange,
|
|
flowChange: options.methods.flowChange,
|
|
handleColumnDebounce() {},
|
|
toggleParameter(update) {
|
|
const patch = typeof update === 'string' ? {[update]: !this.projectData.cacheParameter[update]} : typeof update.key === 'string' ? {[update.key]: update.value} : update.key;
|
|
this.projectData.cacheParameter = {...this.projectData.cacheParameter, ...patch};
|
|
},
|
|
transforTasks: list => list,
|
|
addTaskOpen() {}, handleTaskScroll() {}, onSort() {},
|
|
},
|
|
});
|
|
}, {factory, template, mobile, dropdownFactory, dropdownTemplate: dropdownComponent.template.content});
|
|
const flow = page.locator('.project-flow');
|
|
const flowDropdown = page.locator('.project-panel-flow-cascader:visible');
|
|
await flow.hover();
|
|
if (mobile) {
|
|
assert.equal(await flowDropdown.count(), 0);
|
|
await flow.tap();
|
|
}
|
|
await flowDropdown.waitFor({state: 'visible'});
|
|
const surfaceStyle = element => {
|
|
const style = getComputedStyle(element);
|
|
return {background: style.backgroundColor, radius: style.borderRadius, shadow: style.boxShadow};
|
|
};
|
|
const flowSurface = await flowDropdown.evaluate(surfaceStyle);
|
|
if (!mobile) {
|
|
await flow.click();
|
|
await flowDropdown.hover();
|
|
await page.waitForTimeout(250);
|
|
assert.equal(await flowDropdown.count(), 1);
|
|
await page.mouse.move(1, 1);
|
|
await flowDropdown.waitFor({state: 'hidden'});
|
|
await flow.hover();
|
|
await flowDropdown.waitFor({state: 'visible'});
|
|
}
|
|
await flowDropdown.locator('.ivu-cascader-menu-item:visible').getByText('全部', {exact: true}).click();
|
|
await flowDropdown.waitFor({state: 'hidden'});
|
|
assert.equal(await page.evaluate(() => fixture.flowInfo.value), 'all');
|
|
await page.evaluate(() => { fixture.flowInfo = {}; });
|
|
const icon = page.locator('.project-switch-button > div').nth(2);
|
|
const settings = page.locator('.project-table-settings-icon');
|
|
const dropdown = page.locator('.project-table-groups-dropdown:visible');
|
|
await icon.hover();
|
|
await page.waitForTimeout(250);
|
|
assert.equal(await dropdown.count(), 0);
|
|
assert.equal(await settings.count(), 0);
|
|
await icon.click();
|
|
await settings.waitFor({state: 'visible'});
|
|
await icon.hover();
|
|
await icon.click();
|
|
await page.waitForTimeout(250);
|
|
assert.equal(await dropdown.count(), 0);
|
|
if (mobile) await settings.tap();
|
|
else await settings.hover();
|
|
await dropdown.waitFor({state: 'visible'});
|
|
assert.deepEqual(await dropdown.evaluate(surfaceStyle), flowSurface);
|
|
assert.equal(await dropdown.locator('.ivu-poptip-arrow, .ivu-poptip-title').count(), 0);
|
|
const settingsBounds = await settings.boundingBox();
|
|
assert.equal(await page.locator('.project-switch-button').count(), 1, JSON.stringify({errors, html: await page.locator('.project-switch').innerHTML()}));
|
|
const switchBounds = await page.locator('.project-switch-button').boundingBox();
|
|
assert.ok(settingsBounds.x + settingsBounds.width <= switchBounds.x);
|
|
const boxes = dropdown.locator('input[type=checkbox]');
|
|
assert.equal(await boxes.count(), 10);
|
|
assert.equal(await dropdown.locator('input:checked').count(), 3);
|
|
await dropdown.getByText('本周到期', {exact: true}).click();
|
|
assert.equal(await boxes.nth(4).isChecked(), true);
|
|
assert.equal(await page.locator('.project-table-body').count(), 4);
|
|
assert.equal(await page.locator('.project-table-empty').count(), 4);
|
|
for (const label of ['本周排期', '下周排期', '已逾期', '未排期']) {
|
|
await dropdown.getByText(label, {exact: true}).click();
|
|
assert.equal(await page.locator('.row-h1').getByText(label, {exact: true}).count(), 1);
|
|
await dropdown.getByText(label, {exact: true}).click();
|
|
assert.equal(await page.locator('.row-h1').getByText(label, {exact: true}).count(), 0);
|
|
}
|
|
for (const label of ['我的任务', '协助的任务', '未完成任务']) await dropdown.getByText(label, {exact: true}).click();
|
|
assert.equal(await boxes.nth(4).isDisabled(), true);
|
|
assert.equal(await page.locator('.project-table-body').count(), 1);
|
|
await page.waitForFunction(() => fixture.visibleTableGroups.length === 1);
|
|
const bounds = await dropdown.boundingBox();
|
|
const viewport = page.viewportSize();
|
|
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= viewport.width + 1 && bounds.y + bounds.height <= viewport.height);
|
|
await page.screenshot({path: path.join(output, mobile ? 'mobile.png' : 'desktop.png'), animations: 'disabled'});
|
|
if (mobile) await icon.tap();
|
|
else await icon.hover();
|
|
await dropdown.waitFor({state: 'hidden'});
|
|
assert.equal(await dropdown.count(), 0);
|
|
assert.equal(await page.evaluate(() => fixture.tableGroupsOpen), false);
|
|
if (mobile) await settings.tap();
|
|
else await settings.hover();
|
|
await dropdown.waitFor({state: 'visible'});
|
|
assert.equal(await dropdown.locator('input:checked').count(), 1);
|
|
await dropdown.getByText('已完成任务', {exact: true}).click();
|
|
assert.equal(await page.evaluate(() => fixture.projectData.cacheParameter.showCompleted), true);
|
|
await page.locator('.row-title .taskfont').first().click();
|
|
assert.equal(await page.evaluate(() => fixture.projectData.cacheParameter.showCompleted), false);
|
|
await page.evaluate(async mobile => {
|
|
fixture.myList = Array.from({length: 80}, (_, id) => ({id, name: `Task ${id + 1}`}));
|
|
fixture.projectData.cacheParameter = {...fixture.projectData.cacheParameter, tableGroups: ['my'], showMy: true};
|
|
await fixture.$nextTick();
|
|
const container = fixture.$refs.projectTableScroll.scrollElement();
|
|
container.scrollTop = 0;
|
|
container.scrollLeft = mobile ? 48 : 0;
|
|
window.scrollTest = {left: container.scrollLeft, pageTop: window.scrollY, calls: []};
|
|
const scrollTo = container.scrollTo.bind(container);
|
|
container.scrollTo = options => {scrollTest.calls.push(options); scrollTo(options);};
|
|
}, mobile);
|
|
if (mobile) await settings.tap();
|
|
else await settings.hover();
|
|
await dropdown.waitFor({state: 'visible'});
|
|
for (const [label, key] of [['未排期', 'unscheduled'], ['已完成任务', 'completed']]) {
|
|
if (!mobile) await dropdown.hover();
|
|
const before = await page.evaluate(() => scrollTest.calls.length);
|
|
await dropdown.getByText(label, {exact: true}).click();
|
|
await page.waitForFunction(key => {
|
|
const container = fixture.$refs.projectTableScroll.scrollElement();
|
|
const target = container.querySelector(`[data-table-group="${key}"]`);
|
|
const bounds = container.getBoundingClientRect();
|
|
const heading = target?.querySelector('.row-title').getBoundingClientRect();
|
|
return container.scrollTop > 0 && heading && heading.top >= bounds.top - 1 && heading.bottom <= bounds.top + container.clientHeight + 1;
|
|
}, key);
|
|
await page.waitForTimeout(1000);
|
|
assert.ok(await page.evaluate(before => scrollTest.calls.length >= before + 1, before));
|
|
assert.equal(await dropdown.count(), 1, JSON.stringify(await page.evaluate(() => ({open: fixture.tableGroupsOpen, pageTop: window.scrollY, before: scrollTest, settings: document.querySelector('.project-table-settings').getBoundingClientRect().toJSON(), container: fixture.$refs.projectTableScroll.scrollElement().getBoundingClientRect().toJSON()}))));
|
|
assert.ok(await page.evaluate(() => {
|
|
const container = fixture.$refs.projectTableScroll.scrollElement();
|
|
return container.scrollLeft === scrollTest.left && window.scrollY === scrollTest.pageTop;
|
|
}));
|
|
if (key === 'unscheduled') await page.screenshot({path: path.join(output, mobile ? 'mobile-scroll.png' : 'desktop-scroll.png'), animations: 'disabled'});
|
|
const settled = await page.evaluate(() => scrollTest.calls.length);
|
|
await dropdown.getByText(label, {exact: true}).click();
|
|
await page.waitForTimeout(200);
|
|
assert.equal(await page.evaluate(() => scrollTest.calls.length), settled);
|
|
}
|
|
await page.evaluate(async () => {
|
|
fixture.projectData.cacheParameter = {...fixture.projectData.cacheParameter, tableGroups: ['my', 'unscheduled'], showUnscheduled: true};
|
|
await fixture.$nextTick();
|
|
const container = fixture.$refs.projectTableScroll.scrollElement();
|
|
const spacer = document.createElement('div');
|
|
spacer.style.height = '1000px';
|
|
container.appendChild(spacer);
|
|
const rows = container.querySelector('[data-table-group="my"] .task-rows');
|
|
window.lazyScrollTest = {renders: 0};
|
|
fixture.handleTaskScroll = async () => {
|
|
lazyScrollTest.renders++;
|
|
if (lazyScrollTest.renders === 3) rows.style.paddingTop = '350px';
|
|
if (lazyScrollTest.renders === 9) rows.style.paddingTop = '180px';
|
|
await fixture.$nextTick();
|
|
};
|
|
container.scrollTop = 0;
|
|
fixture.scrollToTableGroup('unscheduled');
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const container = fixture.$refs.projectTableScroll.scrollElement();
|
|
const target = container.querySelector('[data-table-group="unscheduled"]');
|
|
return lazyScrollTest.renders >= 10 && Math.abs(target.getBoundingClientRect().top - container.getBoundingClientRect().top - container.clientTop) <= 1;
|
|
});
|
|
await page.waitForTimeout(350);
|
|
assert.ok(await page.evaluate(() => {
|
|
const container = fixture.$refs.projectTableScroll.scrollElement();
|
|
return Math.abs(container.querySelector('[data-table-group="unscheduled"]').getBoundingClientRect().top - container.getBoundingClientRect().top - container.clientTop) <= 1;
|
|
}));
|
|
await page.screenshot({path: path.join(output, mobile ? 'mobile-corrected.png' : 'desktop-corrected.png'), animations: 'disabled'});
|
|
await page.evaluate(() => {
|
|
fixture.handleTaskScroll = async () => fixture.$nextTick();
|
|
fixture.$refs.projectTableScroll.scrollElement().scrollTop = 0;
|
|
fixture.scrollToTableGroup('unscheduled');
|
|
});
|
|
await page.waitForTimeout(100);
|
|
if (mobile) {
|
|
await page.locator('.project-table').dispatchEvent('touchstart');
|
|
} else {
|
|
const bounds = await page.locator('.project-table').boundingBox();
|
|
await page.mouse.move(bounds.x + 10, bounds.y + bounds.height / 2);
|
|
await page.mouse.wheel(0, -100);
|
|
}
|
|
await page.waitForTimeout(200);
|
|
const interrupted = await page.evaluate(() => ({calls: scrollTest.calls.length, top: fixture.$refs.projectTableScroll.scrollElement().scrollTop}));
|
|
await page.waitForTimeout(800);
|
|
assert.deepEqual(await page.evaluate(() => ({calls: scrollTest.calls.length, top: fixture.$refs.projectTableScroll.scrollElement().scrollTop})), interrupted);
|
|
for (const mode of ['column', 'gantt']) {
|
|
await page.evaluate(mode => fixture.tabTypeChange(mode), mode);
|
|
await icon.hover();
|
|
await page.waitForTimeout(250);
|
|
assert.equal(await dropdown.count(), 0);
|
|
assert.equal(await settings.count(), 0);
|
|
assert.equal(await page.evaluate(() => fixture.tableGroupsOpen), false);
|
|
}
|
|
assert.deepEqual(errors, []);
|
|
console.log(`${mobile ? 'Mobile' : 'Desktop'}: Cascader trigger and selection, group selection, relayout correction, manual cancellation, unchanged horizontal/page position and viewport bounds passed`);
|
|
await context.close();
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
})().catch(error => {console.error(error); process.exitCode = 1;});
|