clickgo 6.7.2 → 6.8.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.
@@ -0,0 +1,261 @@
1
+ // Run: node --experimental-vm-modules test/dom-style.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { SourceTextModule, SyntheticModule } from 'node:vm';
5
+ import { JSDOM } from 'jsdom';
6
+ import ts from 'typescript';
7
+
8
+ const documentDom = new JSDOM('<!doctype html><html><body></body></html>');
9
+ for (const name of ['window', 'document', 'HTMLElement', 'Element', 'Node']) {
10
+ Object.defineProperty(globalThis, name, { 'configurable': true, 'value': documentDom.window[name] });
11
+ }
12
+ window.setTimeout = () => 0;
13
+ globalThis.ResizeObserver = class { observe() {} unobserve() {} };
14
+ globalThis.requestAnimationFrame = () => 0;
15
+ const values = new WeakMap();
16
+ const reads = [];
17
+ let computedCalls = 0;
18
+ globalThis.getComputedStyle = (el) => {
19
+ ++computedCalls;
20
+ return new Proxy({}, {
21
+ get(_, name) {
22
+ reads.push([el, name]);
23
+ return values.get(el)?.[name] ?? '';
24
+ },
25
+ });
26
+ };
27
+ let focus = 'form-a';
28
+ let activePanels = [];
29
+ const formModule = new SyntheticModule(['getFocus', 'getActivePanel'], function() {
30
+ this.setExport('getFocus', () => focus);
31
+ this.setExport('getActivePanel', () => activePanels);
32
+ });
33
+ const emptyModule = new SyntheticModule([], function() {});
34
+ const source = await readFile(new URL('../dist/lib/dom.ts', import.meta.url), 'utf8');
35
+ const domModule = new SourceTextModule(ts.transpileModule(`${source}\nexport { watchTimerHandler };`, {
36
+ 'compilerOptions': { 'target': ts.ScriptTarget.ES2022, 'module': ts.ModuleKind.ESNext },
37
+ }).outputText);
38
+ await domModule.link((specifier) => specifier === './form' ? formModule : emptyModule);
39
+ await domModule.evaluate();
40
+ const api = domModule.namespace;
41
+ const tick = api.watchTimerHandler;
42
+ const formA = document.createElement('div');
43
+ formA.dataset.formId = 'form-a';
44
+ document.body.append(formA);
45
+ const formB = document.createElement('div');
46
+ formB.dataset.formId = 'form-b';
47
+ document.body.append(formB);
48
+
49
+ /** --- 创建有独立计算样式值的测试元素 --- */
50
+ function element(parent = formA, style = { 'font-size': '13px' }) {
51
+ const el = document.createElement('div');
52
+ parent.append(el);
53
+ values.set(el, style);
54
+ return el;
55
+ }
56
+ const events = [];
57
+ const first = element();
58
+ const second = element();
59
+ const firstHandler = (name, value, old) => {
60
+ events.push(['first', name, value, old, reads.length]);
61
+ values.get(second)['font-size'] = '30px';
62
+ };
63
+ api.watchStyle(first, 'font-size', firstHandler, true);
64
+ assert.deepEqual(events[0].slice(0, 4), ['first', 'font-size', '13px', '']);
65
+ values.get(second)['font-size'] = '13px';
66
+ api.watchStyle(second, 'font-size', (name, value, old) => {
67
+ events.push(['second', name, value, old, reads.length]);
68
+ });
69
+ const extraEvents = [];
70
+ const extraHandler = (...args) => extraEvents.push(args);
71
+ const beforeCalls = computedCalls;
72
+ api.watchStyle(first, 'font-size', extraHandler);
73
+ assert.equal(computedCalls, beforeCalls, 'repeat registration reuses the live style object');
74
+ values.get(first)['font-size'] = '14px';
75
+ values.get(second)['font-size'] = '15px';
76
+ reads.length = 0;
77
+ events.length = 0;
78
+ tick();
79
+ assert.equal(reads.length, 2, 'each changed property is read once, including multiple subscribers');
80
+ assert.deepEqual(events.map(event => event.slice(0, 4)), [
81
+ ['first', 'font-size', '14px', '13px'],
82
+ ['second', 'font-size', '15px', '13px'],
83
+ ]);
84
+ assert.ok(events.every(event => event[4] === 2), 'all style reads finish before any callback');
85
+ assert.deepEqual(extraEvents, [['font-size', '14px', '13px']]);
86
+ events.length = 0;
87
+ tick();
88
+ assert.deepEqual(events[0].slice(0, 4), ['second', 'font-size', '30px', '15px'], 'callback writes are detected in the following poll');
89
+ events.length = 0;
90
+ tick();
91
+ assert.equal(events.length, 0, 'unchanged values do not notify');
92
+ api.unwatchStyle(first, undefined, firstHandler);
93
+ values.get(first)['font-size'] = '16px';
94
+ tick();
95
+ assert.equal(events.length, 0, 'removing one callback preserves the other subscriber');
96
+ assert.deepEqual(extraEvents.at(-1), ['font-size', '16px', '14px']);
97
+ api.unwatchStyle(first);
98
+ assert.equal(api.isWatchStyle(first), false);
99
+ assert.equal(first.dataset.cgStyleindex, undefined);
100
+ api.unwatchStyle(second);
101
+
102
+ // Copied DOM attributes must never reuse another element's subscription.
103
+ const original = element();
104
+ api.watchStyle(original, 'font-size', () => {});
105
+ const copy = original.cloneNode();
106
+ formA.append(copy);
107
+ values.set(copy, { 'font-size': '20px' });
108
+ assert.equal(api.isWatchStyle(copy), false);
109
+ api.watchStyle(copy, 'font-size', () => {});
110
+ assert.notEqual(copy.dataset.cgStyleindex, original.dataset.cgStyleindex);
111
+ original.remove();
112
+ activePanels = ['nonexistent'];
113
+ tick();
114
+ assert.equal(api.isWatchStyle(original), false);
115
+ assert.equal(original.dataset.cgStyleindex, undefined);
116
+ formA.append(original);
117
+ api.watchStyle(original, 'font-size', () => {});
118
+ assert.equal(api.isWatchStyle(original), true, 'a detached and reinserted element can be registered again');
119
+ api.clearWatchStyle('form-a');
120
+ assert.equal(api.isWatchStyle(copy), false);
121
+ assert.equal(api.isWatchStyle(original), false);
122
+
123
+ // Keep focused Form and active Panel filtering, including delayed changes on activation.
124
+ const panel = document.createElement('div');
125
+ panel.dataset.panelId = 'panel-a';
126
+ formA.append(panel);
127
+ const main = element();
128
+ const panelItem = element(panel);
129
+ const otherFormItem = element(formB);
130
+ const scopedEvents = [];
131
+ for (const [label, el] of [['main', main], ['panel', panelItem], ['other', otherFormItem]]) {
132
+ api.watchStyle(el, 'font-size', () => scopedEvents.push(label));
133
+ values.get(el)['font-size'] = '18px';
134
+ }
135
+ activePanels = [];
136
+ tick();
137
+ assert.deepEqual(scopedEvents, ['main']);
138
+ activePanels = ['panel-a'];
139
+ tick();
140
+ assert.deepEqual(scopedEvents, ['main', 'panel']);
141
+ focus = 'form-b';
142
+ tick();
143
+ assert.deepEqual(scopedEvents, ['main', 'panel', 'other']);
144
+ api.clearWatchStyle('form-a', 'panel-a');
145
+ assert.equal(api.isWatchStyle(main), true);
146
+ assert.equal(api.isWatchStyle(panelItem), false);
147
+ api.clearWatchStyle('form-a');
148
+ api.clearWatchStyle('form-b');
149
+ focus = 'form-a';
150
+ activePanels = ['panel-a'];
151
+
152
+ // An earlier notification may cancel records already collected for this poll.
153
+ const cancelFirst = element();
154
+ const cancelSecond = element();
155
+ let cancelledCalls = 0;
156
+ api.watchStyle(cancelFirst, 'font-size', () => api.clearWatchStyle('form-a'));
157
+ api.watchStyle(cancelSecond, 'font-size', () => ++cancelledCalls);
158
+ values.get(cancelFirst)['font-size'] = '22px';
159
+ values.get(cancelSecond)['font-size'] = '22px';
160
+ tick();
161
+ assert.equal(cancelledCalls, 0, 'clearing a Form cancels queued notifications');
162
+ assert.equal(api.isWatchStyle(cancelSecond), false);
163
+
164
+ const named = element(formA, { 'font-size': '13px', 'font-family': 'sans-serif' });
165
+ api.watchStyle(named, ['font-size', 'font-family'], () => {});
166
+ api.unwatchStyle(named, 'font-size');
167
+ assert.equal(api.isWatchStyle(named), true);
168
+ assert.deepEqual(api.getWatchInfo().default.unknown.style.list, ['font-family']);
169
+ api.unwatchStyle(named, 'font-family');
170
+ assert.equal(api.isWatchStyle(named), false);
171
+
172
+ // Removing a later queued element also suppresses its notification and releases its index.
173
+ const removeFirst = element();
174
+ const removeSecond = element();
175
+ api.watchStyle(removeFirst, 'font-size', () => removeSecond.remove());
176
+ api.watchStyle(removeSecond, 'font-size', () => ++cancelledCalls);
177
+ values.get(removeFirst)['font-size'] = '19px';
178
+ values.get(removeSecond)['font-size'] = '19px';
179
+ tick();
180
+ assert.equal(cancelledCalls, 0);
181
+ assert.equal(api.isWatchStyle(removeSecond), false);
182
+ api.unwatchStyle(removeFirst);
183
+ const immediateCancel = element(formA, { 'font-size': '13px', 'font-family': 'sans-serif' });
184
+ const immediateNames = [];
185
+ api.watchStyle(immediateCancel, ['font-size', 'font-family'], name => {
186
+ immediateNames.push(name);
187
+ api.unwatchStyle(immediateCancel);
188
+ }, true);
189
+ assert.deepEqual(immediateNames, ['font-size']);
190
+ assert.equal(api.isWatchStyle(immediateCancel), false);
191
+
192
+ // Reentrant registration retains per-property subscription order and cancellation.
193
+ const reentrant = element();
194
+ let registeredCalls = 0;
195
+ const registered = () => ++registeredCalls;
196
+ api.watchStyle(reentrant, 'font-size', () => api.watchStyle(reentrant, 'font-size', registered));
197
+ values.get(reentrant)['font-size'] = '14px';
198
+ tick();
199
+ assert.equal(registeredCalls, 0, 'a callback registered during dispatch does not receive an old queued change');
200
+ values.get(reentrant)['font-size'] = '15px';
201
+ tick();
202
+ assert.equal(registeredCalls, 1);
203
+ api.unwatchStyle(reentrant);
204
+
205
+ // A failed subscriber must not discard collected changes or stop subsequent polls.
206
+ const failing = element();
207
+ const following = element();
208
+ const followingValues = [];
209
+ api.watchStyle(failing, 'font-size', () => { throw new Error('test subscriber failure'); });
210
+ api.watchStyle(following, 'font-size', (name, value) => followingValues.push(value));
211
+ values.get(failing)['font-size'] = '17px';
212
+ values.get(following)['font-size'] = '17px';
213
+ tick();
214
+ values.get(following)['font-size'] = '18px';
215
+ tick();
216
+ assert.deepEqual(followingValues, ['17px', '18px']);
217
+ api.clearWatchStyle('form-a');
218
+
219
+ // Stab coalesces repeated requests and discards pending measurements on unmount.
220
+ class AbstractControl {
221
+ constructor() {
222
+ this.element = element();
223
+ }
224
+ nextTick() { return Promise.resolve(); }
225
+ watch(name, callback, options) { if (options?.immediate) callback(); }
226
+ }
227
+ const stopped = [];
228
+ const clickgoModule = new SyntheticModule(['control', 'dom'], function() {
229
+ this.setExport('control', { AbstractControl });
230
+ this.setExport('dom', {
231
+ 'watchSizeMulti': () => true,
232
+ 'unwatchSizeMulti': (...args) => stopped.push(args),
233
+ 'watchStyle': api.watchStyle,
234
+ 'unwatchStyle': api.unwatchStyle,
235
+ });
236
+ });
237
+ const stabSource = await readFile(new URL('../dist/sources/control/stab/code.ts', import.meta.url), 'utf8');
238
+ const stabModule = new SourceTextModule(ts.transpileModule(stabSource, {
239
+ 'compilerOptions': { 'target': ts.ScriptTarget.ES2022, 'module': ts.ModuleKind.ESNext },
240
+ }).outputText);
241
+ await stabModule.link(() => clickgoModule);
242
+ await stabModule.evaluate();
243
+ const stab = new stabModule.namespace.default();
244
+ stab.props.type = 'rect';
245
+ let measurements = 0;
246
+ stab.resize = () => ++measurements;
247
+ stab.onMounted();
248
+ for (let i = 0; i < 9; ++i) stab.requestResize();
249
+ await Promise.resolve();
250
+ assert.equal(measurements, 1);
251
+ const unrelated = () => {};
252
+ api.watchStyle(stab.element, 'font-size', unrelated);
253
+ stab.requestResize();
254
+ stab.onBeforeUnmount();
255
+ await Promise.resolve();
256
+ assert.equal(measurements, 1, 'unmount cancels pending geometry measurement');
257
+ assert.equal(stopped.length, 1);
258
+ assert.equal(api.isWatchStyle(stab.element), true, 'unmount preserves unrelated style subscribers');
259
+ assert.deepEqual(api.getWatchInfo().default.unknown.style.list, ['font-size']);
260
+ api.unwatchStyle(stab.element);
261
+ console.log('Style read batching, callback order/cancellation, DOM identity, Form/Panel isolation and stab resize coalescing passed.');
@@ -0,0 +1,102 @@
1
+ // Run: node test/form-launcher.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import vm from 'node:vm';
5
+ import { JSDOM } from 'jsdom';
6
+ import ts from 'typescript';
7
+
8
+ const dom = new JSDOM('<!doctype html><html><body><div id="cg-launcher"></div></body></html>');
9
+ for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Event', 'CustomEvent']) {
10
+ Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name] });
11
+ }
12
+ const errors = [];
13
+ window.addEventListener('error', e => errors.push(e.error));
14
+ const vue = await import('vue');
15
+ const pointer = await import('../node_modules/@litert/pointer/dist/index.esm.js');
16
+ const source = await readFile(process.argv[2] ?? new URL('../dist/lib/form.ts', import.meta.url), 'utf8');
17
+ const tree = ts.createSourceFile('form.ts', source, ts.ScriptTarget.ES2022, true);
18
+ let config;
19
+ function find(node) {
20
+ if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'launcherApp') {
21
+ config = node.initializer.arguments[0].getText(tree);
22
+ }
23
+ ts.forEachChild(node, find);
24
+ }
25
+ find(tree);
26
+ assert.ok(config, 'load the actual launcher template and handlers');
27
+ let hidden = 0;
28
+ const runs = [];
29
+ const exports = {};
30
+ const scope = {
31
+ exports, document,
32
+ clickgo: { modules: { pointer } },
33
+ info: { locale: { en: { search: 'Search' } } },
34
+ lCore: { config: { locale: 'en', 'launcher.list': [
35
+ { name: 'App', path: '/app', icon: '' },
36
+ { name: 'Folder', list: [{ name: 'Child', path: '/child', icon: '' }] },
37
+ ] } },
38
+ lTask: { async run(...args) { runs.push(args); } },
39
+ sysId: 'system',
40
+ hideLauncher() { ++hidden; },
41
+ requestAnimationFrame(callback) { callback(); },
42
+ setTimeout() { return 0; },
43
+ launcherRoot: null,
44
+ };
45
+ vm.runInNewContext(ts.transpileModule(`export default ${config};`, {
46
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
47
+ }).outputText, scope);
48
+ const app = vue.createApp(exports.default);
49
+ const root = app.mount('#cg-launcher');
50
+ const list = document.querySelector('.cg-launcher-list');
51
+ const search = document.querySelector('.cg-launcher-search');
52
+
53
+ function event(target, type, x, y, button = 0) {
54
+ const e = new window.MouseEvent(type, { bubbles: true, clientX: x, clientY: y, button, view: window });
55
+ Object.defineProperties(e, { pointerId: { value: 1 }, pointerType: { value: 'mouse' } });
56
+ target.dispatchEvent(e);
57
+ }
58
+ async function click(target, { start = [100, 200], end = start, cancel = false, button = 0 } = {}) {
59
+ event(target, 'pointerdown', ...start, button);
60
+ event(target, cancel ? 'pointercancel' : 'pointerup', ...end, button);
61
+ if (!cancel) { event(target, 'click', ...end, button); }
62
+ await vue.nextTick();
63
+ }
64
+ await click(list);
65
+ assert.equal(hidden, 1, 'stationary list blank click closes');
66
+ await click(list, { end: [101, 201] });
67
+ assert.equal(hidden, 2, 'minor pointer movement still counts as a click');
68
+ await click(list, { start: [100.4, 200.4], end: [100, 200] });
69
+ assert.equal(hidden, 3, 'fractional pointer coordinates do not reject a click');
70
+ await click(list, { end: [120, 180] });
71
+ await click(list, { cancel: true });
72
+ await click(list, { button: 2 });
73
+ assert.equal(hidden, 3, 'diagonal drag, cancellation and right click do not close');
74
+ await click(search);
75
+ await click(document.querySelector('.cg-launcher-space'));
76
+ assert.equal(hidden, 5, 'search padding and item spacers close');
77
+ await click(document.querySelector('input'));
78
+ assert.equal(hidden, 5, 'search input stays interactive');
79
+ await click(document.querySelector('.cg-launcher-icon'), { end: [101, 201] });
80
+ assert.equal(hidden, 6);
81
+ assert.deepEqual(runs.map(args => args[1]), ['/app'], 'app runs once without a bubbled blank click');
82
+ const folder = document.querySelector('.cg-launcher-folder > div');
83
+ for (const target of folder.querySelectorAll('.cg-launcher-icon, .cg-launcher-inner, .cg-launcher-item, .cg-launcher-space')) {
84
+ await click(target);
85
+ assert.equal(root.folderName, 'Folder', 'every collapsed preview target opens its folder');
86
+ assert.equal(hidden, 6, 'collapsed previews do not dismiss the launcher');
87
+ assert.deepEqual(runs.map(args => args[1]), ['/app'], 'collapsed previews never start a child app');
88
+ await click(search);
89
+ }
90
+ await click(folder, { end: [101, 201] });
91
+ assert.equal(root.folderName, 'Folder');
92
+ await click(search);
93
+ assert.equal(root.folderName, '', 'blank click closes the folder before the launcher');
94
+ assert.equal(hidden, 6);
95
+ await click(folder);
96
+ await click(folder.querySelector('.cg-launcher-icon'), { end: [101, 201] });
97
+ assert.equal(hidden, 7);
98
+ assert.deepEqual(runs.map(args => args[1]), ['/app', '/child'], 'folder child runs once');
99
+ assert.deepEqual(errors, [], 'event handlers do not throw');
100
+ app.unmount();
101
+ dom.window.close();
102
+ console.log('Launcher blank areas, pointer tolerance, drag/cancel, input, folders and single app launch passed.');
@@ -0,0 +1,115 @@
1
+ // Run after Sass compilation: node test/form-native-regions.mjs (requires an Electron display).
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { fileURLToPath } from 'node:url';
6
+ import electron from 'electron';
7
+ import { createSSRApp } from 'vue';
8
+ import { renderToString } from '@vue/server-renderer';
9
+
10
+ if (!process.versions.electron) {
11
+ const child = spawn(electron, [fileURLToPath(import.meta.url), '--ozone-platform=x11'], {
12
+ 'env': { ...process.env, 'ELECTRON_DEBUG_DRAGGABLE_REGIONS': '1' },
13
+ 'stdio': ['ignore', 'ignore', 'pipe'],
14
+ });
15
+ let output = '';
16
+ child.stderr.setEncoding('utf8');
17
+ child.stderr.on('data', chunk => { output += chunk; });
18
+ const timeout = setTimeout(() => child.kill(), 20000);
19
+ const code = await new Promise((resolve, reject) => {
20
+ child.on('error', reject);
21
+ child.on('exit', resolve);
22
+ });
23
+ clearTimeout(timeout);
24
+ assert.equal(code, 0, output);
25
+ let region;
26
+ const cases = new Map();
27
+ for (const line of output.split('\n')) {
28
+ const update = /hit-test region computed in .*?: (\d+) rect\(s\), bounds (\S+ \S+)/.exec(line);
29
+ if (update) {
30
+ region = { 'rects': Number(update[1]), 'bounds': update[2] };
31
+ }
32
+ const completed = /CASE-END (\S+)/.exec(line);
33
+ if (completed) {
34
+ assert.ok(region, 'Electron must report an actual native drag region');
35
+ cases.set(completed[1], region);
36
+ }
37
+ }
38
+ assert.equal(cases.size, 8, output);
39
+ assert.ok(cases.get('baseline').rects > 0);
40
+ for (const name of ['bottom', 'hidden', 'minimized', 'bottom-restored', 'nested', 'closed']) {
41
+ assert.deepEqual(cases.get(name), cases.get('baseline'), `${name} must preserve the native title drag region`);
42
+ }
43
+ assert.equal(cases.get('foreground').rects, 0, `an overlapping foreground Form must still block native dragging\n${output}`);
44
+ console.log('Electron native drag regions passed: bottomMost, hidden, minimized, nested, foreground and close.');
45
+ }
46
+ else {
47
+ const { app, BrowserWindow } = electron;
48
+ app.commandLine.appendSwitch('enable-logging');
49
+ /** --- 等待 Chromium 将布局更新发送给 Native 命中区域计算器 --- */
50
+ const settle = () => new Promise(resolve => setTimeout(resolve, 350));
51
+ app.whenReady().then(async () => {
52
+ const win = new BrowserWindow({
53
+ 'show': false, 'frame': false, 'width': 400, 'height': 300,
54
+ 'webPreferences': { 'backgroundThrottling': false },
55
+ });
56
+ try {
57
+ const template = await readFile(new URL('../dist/sources/control/form/layout.html', import.meta.url), 'utf8');
58
+ const style = await readFile(new URL('../dist/sources/control/form/style.css', import.meta.url), 'utf8');
59
+ /**
60
+ * --- 用实际 Form 模板生成 Native 主窗体与后创建的背景窗体 ---
61
+ * @param nativeFirst 是否为 Native 主窗体
62
+ * @returns 已渲染的 Form HTML
63
+ */
64
+ async function render(nativeFirst) {
65
+ const form = createSSRApp({
66
+ template,
67
+ data: () => ({
68
+ 'border': nativeFirst ? 'normal' : 'none',
69
+ 'isNative': true, 'isNativeNoFrameFirst': nativeFirst,
70
+ 'flashTimer': undefined, 'stateMinData': false, 'stateMaxData': false,
71
+ 'taskPosition': 'bottom', 'isShow': true, 'isInside': false,
72
+ 'formFocus': nativeFirst, 'widthData': 400, 'heightData': 300,
73
+ 'leftData': 0, 'topData': 0, 'zIndex': nativeFirst ? 10 : 1,
74
+ 'isMask': false, 'isResize': false, 'iconDataUrl': '', 'title': 'Native',
75
+ 'isMin': true, 'isMax': true, 'isClose': true, 'direction': 'v',
76
+ 'background': '', 'contentPadding': '', 'stepShowData': false,
77
+ 'stepData': [], 'stepValue': '', 'isLoading': false,
78
+ }),
79
+ });
80
+ form.component('cg-step', { 'render': () => null });
81
+ form.component('cg-loading', { 'render': () => null });
82
+ return await renderToString(form);
83
+ }
84
+ const native = await render(true);
85
+ const background = await render(false);
86
+ await win.loadURL(`data:text/html,${encodeURIComponent(`<style>body{margin:0}${style}</style><div id="native">${native}</div><div id="background"></div>`)}`);
87
+ await settle();
88
+ console.error('CASE-END baseline');
89
+ const scripts = [
90
+ ['bottom', `document.querySelector('#background').innerHTML=${JSON.stringify(background)};const bg=document.querySelector('#background>.wrap');bg.dataset.cgBottomMost='';`],
91
+ ['hidden', `const bg=document.querySelector('#background>.wrap');delete bg.dataset.cgBottomMost;bg.classList.remove('show');`],
92
+ ['minimized', `const bg=document.querySelector('#background>.wrap');bg.classList.add('show');bg.dataset.cgMin='';`],
93
+ ['foreground', `const bg=document.querySelector('#background>.wrap');delete bg.dataset.cgMin;bg.style.zIndex='20';`],
94
+ ['bottom-restored', `const bg=document.querySelector('#background>.wrap');bg.style.zIndex='1';bg.dataset.cgBottomMost='';`],
95
+ ['nested', `document.querySelector('#background .content').innerHTML=${JSON.stringify(background)};`],
96
+ ['closed', `document.querySelector('#background').replaceChildren();`],
97
+ ];
98
+ for (const [name, script] of scripts) {
99
+ await win.webContents.executeJavaScript(`{${script}}`);
100
+ // --- 隐藏测试窗口也必须完成布局和绘制,不能读取上一帧的命中区域 ---
101
+ await win.webContents.capturePage();
102
+ await settle();
103
+ console.error(`CASE-END ${name}`);
104
+ }
105
+ }
106
+ catch (error) {
107
+ console.error(error);
108
+ app.exit(1);
109
+ }
110
+ finally {
111
+ win.destroy();
112
+ app.quit();
113
+ }
114
+ }).catch(error => { console.error(error); app.exit(1); });
115
+ }
@@ -5,20 +5,27 @@ import vm from 'node:vm';
5
5
  import ts from 'typescript';
6
6
 
7
7
  let nativeMode = false;
8
+ let area = { left: 0, top: 0, width: 640, height: 400, owidth: 640, oheight: 440 };
9
+ const bool = value => value === true || value === '' || value === 'true';
8
10
  const clickgo = {
9
11
  isNative: () => nativeMode,
10
12
  control: {
11
13
  AbstractControl: class {
12
- emit() {}
14
+ constructor() { this.rootForm = { bottomMost: false }; this.parent = { controlName: 'root' }; this.watches = {}; this.events = []; }
15
+ emit(...event) { this.events.push(event); }
16
+ propBoolean(name) { return bool(this.props[name]); }
17
+ watch(name, callback, options) { this.watches[name] = callback; if(options?.immediate) callback(); }
13
18
  propInt(name) { return Number(this.props[name]); }
14
19
  trigger() { return Promise.resolve(); }
15
20
  },
16
21
  },
22
+ core: { getAvailArea: () => area },
23
+ dom: { watchSize() {} },
17
24
  native: {
18
25
  restore: async () => {},
19
26
  size: async () => {},
20
27
  },
21
- tool: { sleep: () => Promise.resolve() },
28
+ tool: { sleep: () => Promise.resolve(), getBoolean: bool },
22
29
  };
23
30
  const source = await readFile(new URL('../dist/sources/control/form/code.ts', import.meta.url), 'utf8');
24
31
  const compiled = ts.transpileModule(source, {
@@ -48,3 +55,73 @@ assert.deepEqual(restore({ width: 600, height: 40, left: 40, top: 400 }), [600,
48
55
  assert.deepEqual(restore({ width: 900, height: 700, left: 500, top: 300 }, 'native-framed'), [900, 700, 500, 300]);
49
56
  assert.deepEqual(restore({ width: 900, height: 700, left: 500, top: 300 }, 'native-frameless'), [900, 700, 500, 300]);
50
57
  console.log('Form restore stays visible, edge-positioned Forms stay anchored, and Native geometry is unchanged.');
58
+
59
+
60
+ nativeMode = false;
61
+ function create(viewport = false, initialMax = false) {
62
+ const form = new Form();
63
+ form.props.viewport = viewport;
64
+ form.props.stateMax = initialMax;
65
+ form.props.padding = 'safe';
66
+ form.element = { style: {}, dataset: {}, offsetWidth: 300, offsetHeight: 200, removeAttribute() {} };
67
+ form.onMounted();
68
+ if (!initialMax) form.maxMethod();
69
+ return form;
70
+ }
71
+ function rect(form) { return [form.leftData, form.topData, form.widthData, form.heightData]; }
72
+ const full = create(true, true);
73
+ assert.deepEqual(rect(full), [0, 0, 640, 440], 'initial state-max uses the full viewport');
74
+ const history = JSON.stringify(full.historyLocation);
75
+ for (const a of [
76
+ { left: 0, top: 0, width: 640, height: 400, owidth: 640, oheight: 440, padding: '0px 0px 40px 0px' },
77
+ { left: 60, top: 0, width: 580, height: 440, owidth: 640, oheight: 440, padding: '0px 0px 0px 60px' },
78
+ { left: 0, top: 40, width: 640, height: 400, owidth: 640, oheight: 440, padding: '40px 0px 0px 0px' },
79
+ { left: 0, top: 0, width: 580, height: 440, owidth: 640, oheight: 440, padding: '0px 60px 0px 0px' },
80
+ { left: 0, top: 0, width: 800, height: 520, owidth: 800, oheight: 560, padding: '0px 0px 40px 0px' },
81
+ { left: 0, top: 0, width: 800, height: 560, owidth: 800, oheight: 560, padding: '0px 0px 0px 0px' }
82
+ ]) {
83
+ area = a;
84
+ full.refreshMaxPosition();
85
+ assert.deepEqual(rect(full), [0, 0, a.owidth, a.oheight]);
86
+ assert.equal(full.contentPadding, a.padding);
87
+ assert.equal(JSON.stringify(full.historyLocation), history, 'refresh never overwrites restore geometry');
88
+ }
89
+ area = { left: 60, top: 0, width: 580, height: 440, owidth: 640, oheight: 440 };
90
+ full.props.viewport = false;
91
+ full.watches.viewport();
92
+ assert.deepEqual(rect(full), [60, 0, 580, 440]);
93
+ assert.equal(full.contentPadding, '0px 0px 0px 0px', 'available-area maximization does not reserve the taskbar twice');
94
+ full.props.viewport = 'true';
95
+ full.watches.viewport();
96
+ assert.deepEqual(rect(full), [0, 0, 640, 440]);
97
+ full.maxMethod();
98
+ assert.equal(full.contentPadding, undefined, 'safe padding is removed when restoring');
99
+ full.props.padding = '4 8';
100
+ assert.equal(full.contentPadding, '4px 8px', 'numeric padding is unchanged');
101
+ const normal = create();
102
+ assert.deepEqual(rect(normal), [60, 0, 580, 440], 'default maximization still avoids the taskbar');
103
+ const bottom = create();
104
+ bottom.rootForm.bottomMost = true;
105
+ bottom.refreshMaxPosition();
106
+ assert.deepEqual(rect(bottom), [60, 0, 580, 440], 'bottomMost alone does not change the layout boundary');
107
+ assert.equal(bottom.contentPadding, '0px 0px 0px 0px');
108
+ bottom.props.viewport = true;
109
+ bottom.watches.viewport();
110
+ assert.deepEqual(rect(bottom), [0, 0, 640, 440], 'bottomMost uses the full viewport only when explicitly enabled');
111
+ assert.equal(bottom.contentPadding, '0px 0px 0px 60px');
112
+ bottom.props.viewport = false;
113
+ bottom.watches.viewport();
114
+ assert.deepEqual(rect(bottom), [60, 0, 580, 440], 'disabling viewport also restores taskbar exclusion for bottomMost');
115
+ const inline = create();
116
+ inline.isInside = true;
117
+ const previous = rect(inline);
118
+ inline.props.viewport = true;
119
+ inline.refreshMaxPosition();
120
+ assert.deepEqual(rect(inline), previous, 'inline Forms do not resize to the viewport');
121
+ const automatic = create();
122
+ automatic.props.width = 0;
123
+ automatic.props.height = 0;
124
+ automatic.events = [];
125
+ automatic.refreshMaxPosition();
126
+ assert.ok(automatic.events.every(([name]) => !['update:width', 'update:height'].includes(name)));
127
+ console.log('Form viewport bounds, four taskbar edges, safe padding, live switching, restore and bottomMost layout independence passed.');
@@ -22,7 +22,7 @@ const commonControls = new Set([
22
22
  'select', 'sidebar-toggle', 'text', 'toolbox',
23
23
  ]);
24
24
  /** --- 全部内置主题 --- */
25
- const themes = ['admin', 'cyber', 'dark', 'light', 'modern', 'modern-light', 'compact-dark', 'compact-light'];
25
+ const themes = ['admin', 'cyber', 'dark', 'light', 'modern', 'modern-light', 'software-dark', 'software-light'];
26
26
  /** --- CGA 格式常量,与编译器的公开包格式保持一致 --- */
27
27
  const cgaHeaderLength = 106;
28
28
  const cgaKeyContext = Buffer.from('ClickGo/Application/Package');
Binary file
Binary file