feat(base): 列表组件增加筛选和过滤排序功能,支持普通/分组/树形模式及属性联动(by AI)
This commit is contained in:
parent
93f6953f4c
commit
2da203c513
@ -250,6 +250,8 @@
|
|||||||
- **`collapsible`** (Boolean): 仅在 `tree` 模式下有效,开启树形节点的展开/收起能力。
|
- **`collapsible`** (Boolean): 仅在 `tree` 模式下有效,开启树形节点的展开/收起能力。
|
||||||
- **`auto-select`** (Boolean): 开启后,点击列表项时自动将该项的 `id` 记录至 `state.selectedItem`,如果重复点击则会置空。
|
- **`auto-select`** (Boolean): 开启后,点击列表项时自动将该项的 `id` 记录至 `state.selectedItem`,如果重复点击则会置空。
|
||||||
- **`auto-select-group`** (Boolean): 开启后,点击分组时自动将分组的 `id` 记录至 `state.selectedGroup`。
|
- **`auto-select-group`** (Boolean): 开启后,点击分组时自动将分组的 `id` 记录至 `state.selectedGroup`。
|
||||||
|
- **`filter`** (String): 筛选关键字。支持通过 attribute 传入以自动同步至 `state.filter`。
|
||||||
|
- **`order`** (String): 排序规则。支持通过 attribute 传入以自动同步至 `state.order`。
|
||||||
|
|
||||||
### 4. 内部状态模型 (`State`)
|
### 4. 内部状态模型 (`State`)
|
||||||
- `list` (读写): 原始列表数据数组。
|
- `list` (读写): 原始列表数据数组。
|
||||||
@ -257,6 +259,8 @@
|
|||||||
- `collapsed` (读写): 仅在 `tree` 且 `collapsible` 时使用,存储节点 ID 折叠状态的 Map Proxy。
|
- `collapsed` (读写): 仅在 `tree` 且 `collapsible` 时使用,存储节点 ID 折叠状态的 Map Proxy。
|
||||||
- `selectedItem` (读写): 当前选中的列表项 ID。
|
- `selectedItem` (读写): 当前选中的列表项 ID。
|
||||||
- `selectedGroup` (读写): 当前选中的分组 ID。
|
- `selectedGroup` (读写): 当前选中的分组 ID。
|
||||||
|
- `filter` (读写): 筛选关键字(进行 label 和 summary 的不区分大小写模糊匹配)或自定义过滤函数 `(item) => boolean`。
|
||||||
|
- `order` (读写): 排序规则。可为排序字段名(例如 `'name'`;前缀 `-` 表示降序,如 `'-name'`;或者后缀 `'desc'`/`'asc'` 语法,如 `'name desc'`),也可以为自定义排序函数 `(a, b) => number`。
|
||||||
- `_flatList` (只读): 列表核心逻辑处理后的扁平化数组。
|
- `_flatList` (只读): 列表核心逻辑处理后的扁平化数组。
|
||||||
- `_renderedList` (只读): 实际在 DOM 中遍历渲染的数组片段(若开启虚拟滚动,则只包含可视区切片)。
|
- `_renderedList` (只读): 实际在 DOM 中遍历渲染的数组片段(若开启虚拟滚动,则只包含可视区切片)。
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.22] - 2026-06-28
|
||||||
|
### Added
|
||||||
|
- **List**: Added internal filtering and sorting capabilities on the `List` component across all three layout modes (`normal`, `group`, `tree`). Supports `filter` and `order` attributes and state fields. In `tree` mode, filtering correctly propagates up to preserve parent-descendant hierarchy. Added a MutationObserver to listen to attribute changes and sync them reactively to internal component state.
|
||||||
|
|
||||||
## [1.0.21] - 2026-06-27
|
## [1.0.21] - 2026-06-27
|
||||||
### Fixed
|
### Fixed
|
||||||
- **API**: Preserved declarative preset fields injected through `$.request.*` when the `API` component initializes its internal reactive request state.
|
- **API**: Preserved declarative preset fields injected through `$.request.*` when the `API` component initializes its internal reactive request state.
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
<!-- 2. Bootstrap 适配层 -->
|
<!-- 2. Bootstrap 适配层 -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.9/dist/bootstrap.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.9/dist/bootstrap.min.js"></script>
|
||||||
<!-- 3. 本业务组件库 -->
|
<!-- 3. 本业务组件库 -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.21/dist/base.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.22/dist/base.min.js"></script>
|
||||||
```
|
```
|
||||||
<script>
|
<script>
|
||||||
// 4. 数据先行 (在 body 解析前定义)
|
// 4. 数据先行 (在 body 解析前定义)
|
||||||
@ -205,6 +205,8 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
|
|||||||
```
|
```
|
||||||
* **AI 核心要点**:
|
* **AI 核心要点**:
|
||||||
* **属性默认值**:当数据结构符合默认字段命名(`id`, `label`, `summary`, `parent`, `group`)时,**无须填写**任何映射属性(如 `idfield`, `labelfield`, `parentfield` 等),实际编写代码时应当将其省略。
|
* **属性默认值**:当数据结构符合默认字段命名(`id`, `label`, `summary`, `parent`, `group`)时,**无须填写**任何映射属性(如 `idfield`, `labelfield`, `parentfield` 等),实际编写代码时应当将其省略。
|
||||||
|
* **筛选与过滤 (`filter`)**:可绑定筛选关键字字符串(对 label 和 summary 进行不区分大小写匹配,如 `$filter="State.searchKey || ''"`,加 `|| ''` 可方便地在输入清空时彻底重置)或自定义过滤函数 `(item) => boolean`。在 `tree` 树形模式下,过滤匹配会自动向上保留所有的祖先节点链,且仅保留匹配项分支;在 `group` 分组模式下,若分组自身或其下有任意项匹配则显示分组。
|
||||||
|
* **排序与整理 (`order`)**:可绑定排序字段名字符串(如 `'name'`;前缀 `-` 表示降序,如 `'-name'`;或者后缀 `'desc'`/`'asc'` 语法,如 `'name desc'`),也可以绑定自定义排序比较函数 `(a, b) => number`。在 `tree` 树形模式下对同级子节点进行排序。
|
||||||
* 虚拟滚动 (`fast`) 强制容器必须声明为 `overflow-auto` 类。
|
* 虚拟滚动 (`fast`) 强制容器必须声明为 `overflow-auto` 类。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
38
TEST.md
38
TEST.md
@ -1,21 +1,23 @@
|
|||||||
# base Benchmark & Test Results
|
# base Test Notes
|
||||||
|
|
||||||
## 基准测试 (Benchmark)
|
当前测试只保留可维护的 Playwright 冒烟用例,统一直接打开 `test/` 里的 UMD 示例页,不再依赖历史上的浏览器内自测脚本或混合 ESM 入口。
|
||||||
*测试环境: Playwright / Chromium*
|
|
||||||
|
|
||||||
| 指标 | v1.0.0 | v1.0.1 | v1.0.3 | v1.0.4 | v1.0.7 |
|
## 运行方式
|
||||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
||||||
| **FastList Render & Scroll (10k items)** | ~535ms | ~473ms | ~1513ms | ~54ms | ~53ms |
|
|
||||||
| **FastGroupedList Render & Scroll (10k)** | ~705ms | ~51ms | ~51ms | ~1550ms | ~1560ms |
|
|
||||||
| **FastTree Render & Scroll (10k items)** | ~927ms | ~50ms | ~51ms | ~1560ms | ~1570ms |
|
|
||||||
| **CollapseTree Render & Scroll (1.2k)** | ~51ms | ~50ms | ~50ms | ~51ms | ~51ms |
|
|
||||||
|
|
||||||
## 测试覆盖 (Coverage)
|
```bash
|
||||||
- [x] HTTP Request (GET/POST)
|
npm test
|
||||||
- [x] API Component (Auto mode)
|
```
|
||||||
- [x] Modal / Dialog / Toast UI
|
|
||||||
- [x] AutoForm (Rendering & Validation)
|
测试命令会先执行 `npm run build`,然后自动把最新的 `state/bootstrap/base` 产物同步到 `test/lib`,最后再跑 Playwright。这样可以避免 `test/lib` 副本过期,但又不依赖跨目录静态服务配置。
|
||||||
- [x] DatePicker / ColorPicker / IconPicker (New)
|
|
||||||
- [x] FastList (Virtual Scrolling)
|
Playwright 会自动启动本地 Vite 静态服务,并执行以下用例:
|
||||||
- [x] Tree / Nav / Resizer
|
|
||||||
- [x] Virtual List dynamic sizing check
|
- `test/api.spec.js`:验证 `<API>` 会保留声明式 `$.request.*` 预设字段。
|
||||||
|
- `test/form.spec.js`:验证 `AutoForm` 的垂直、水平、强制水平、inline 等布局。
|
||||||
|
- `test/list.spec.js`:验证普通列表、分组列表、树形列表和虚拟滚动列表。
|
||||||
|
- `test/capability.spec.js`:验证示例页里的 `Nav` 切换和主要区域渲染。
|
||||||
|
|
||||||
|
## 取舍原则
|
||||||
|
|
||||||
|
- 删除了端口写死、依赖旧 ESM 入口、只输出调试信息却没有稳定断言价值的历史用例。
|
||||||
|
- 不再把性能 benchmark 当成默认测试流程的一部分,避免未来维护被脆弱用例卡住。
|
||||||
|
|||||||
158
dist/base.js
vendored
158
dist/base.js
vendored
@ -762,37 +762,142 @@
|
|||||||
container.collapsed = globalThis.NewState({});
|
container.collapsed = globalThis.NewState({});
|
||||||
container.state.renderedList = [];
|
container.state.renderedList = [];
|
||||||
const updateFlatList = () => {
|
const updateFlatList = () => {
|
||||||
|
var _a;
|
||||||
globalThis.Util.updateDefaults(container, defaultSets);
|
globalThis.Util.updateDefaults(container, defaultSets);
|
||||||
const list = container.state.list || [], flatList = [];
|
let list = container.state.list || [];
|
||||||
|
const filter = container.state.filter;
|
||||||
|
const order = container.state.order;
|
||||||
|
if (order) {
|
||||||
|
if (typeof order === "function") {
|
||||||
|
list = [...list].sort(order);
|
||||||
|
} else if (typeof order === "string") {
|
||||||
|
const parts = order.trim().split(/\s+/);
|
||||||
|
const field = parts[0];
|
||||||
|
const isDesc = ((_a = parts[1]) == null ? void 0 : _a.toLowerCase()) === "desc" || field.startsWith("-");
|
||||||
|
const cleanField = field.startsWith("-") ? field.slice(1) : field;
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
const valA = a[cleanField];
|
||||||
|
const valB = b[cleanField];
|
||||||
|
if (valA === void 0 || valA === null) return isDesc ? 1 : -1;
|
||||||
|
if (valB === void 0 || valB === null) return isDesc ? -1 : 1;
|
||||||
|
if (typeof valA === "string" && typeof valB === "string") {
|
||||||
|
return isDesc ? valB.localeCompare(valA) : valA.localeCompare(valB);
|
||||||
|
}
|
||||||
|
return isDesc ? valB > valA ? 1 : -1 : valA > valB ? 1 : -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const flatList = [];
|
||||||
if (container.mode === "group") {
|
if (container.mode === "group") {
|
||||||
|
let filteredList = list;
|
||||||
|
if (filter) {
|
||||||
|
if (typeof filter === "function") {
|
||||||
|
filteredList = list.filter(filter);
|
||||||
|
} else if (typeof filter === "string") {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
filteredList = list.filter((item) => {
|
||||||
|
const label = String(item[container.labelfield] || "").toLowerCase();
|
||||||
|
const summary = String(item[container.summaryfield] || "").toLowerCase();
|
||||||
|
return label.includes(q) || summary.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const itemMap = {};
|
const itemMap = {};
|
||||||
list.forEach((item) => {
|
filteredList.forEach((item) => {
|
||||||
var _a;
|
var _a2;
|
||||||
return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).push(item);
|
return (itemMap[_a2 = item[container.groupfield]] ?? (itemMap[_a2] = [])).push(item);
|
||||||
});
|
});
|
||||||
(container.state.groups || []).forEach((group) => {
|
(container.state.groups || []).forEach((group) => {
|
||||||
flatList.push({ type: "group", ...group });
|
|
||||||
const items = itemMap[group[container.groupidfield]];
|
const items = itemMap[group[container.groupidfield]];
|
||||||
if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
|
const groupLabel = String(group[container.grouplabelfield] || "").toLowerCase();
|
||||||
|
const groupSummary = String(group[container.groupsummaryfield] || "").toLowerCase();
|
||||||
|
const q = typeof filter === "string" ? filter.trim().toLowerCase() : "";
|
||||||
|
const groupMatches = q ? groupLabel.includes(q) || groupSummary.includes(q) : false;
|
||||||
|
if (items && items.length > 0 || groupMatches || !filter) {
|
||||||
|
flatList.push({ type: "group", ...group });
|
||||||
|
if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else if (container.mode === "tree") {
|
} else if (container.mode === "tree") {
|
||||||
|
const itemMap = {};
|
||||||
|
list.forEach((item) => {
|
||||||
|
itemMap[item[container.idfield]] = item;
|
||||||
|
});
|
||||||
|
const matches = /* @__PURE__ */ new Set();
|
||||||
|
list.forEach((item) => {
|
||||||
|
let match = false;
|
||||||
|
if (!filter) {
|
||||||
|
match = true;
|
||||||
|
} else if (typeof filter === "function") {
|
||||||
|
match = filter(item);
|
||||||
|
} else if (typeof filter === "string") {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
match = true;
|
||||||
|
} else {
|
||||||
|
const label = String(item[container.labelfield] || "").toLowerCase();
|
||||||
|
const summary = String(item[container.summaryfield] || "").toLowerCase();
|
||||||
|
match = label.includes(q) || summary.includes(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
matches.add(item[container.idfield]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const keep = new Set(matches);
|
||||||
|
if (filter) {
|
||||||
|
matches.forEach((id) => {
|
||||||
|
let curr = itemMap[id];
|
||||||
|
while (curr) {
|
||||||
|
const parentId = curr[container.parentfield];
|
||||||
|
if (!parentId) break;
|
||||||
|
if (keep.has(parentId)) break;
|
||||||
|
keep.add(parentId);
|
||||||
|
curr = itemMap[parentId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
const childrenMap = {};
|
const childrenMap = {};
|
||||||
list.forEach((item) => {
|
list.forEach((item) => {
|
||||||
var _a;
|
if (!filter || keep.has(item[container.idfield])) {
|
||||||
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
|
const parentId = item[container.parentfield] || "";
|
||||||
|
(childrenMap[parentId] ?? (childrenMap[parentId] = [])).push(item);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const traverse = (items, level, parents) => items.forEach((item) => {
|
const traverse = (items, level, parents) => items.forEach((item) => {
|
||||||
var _a;
|
var _a2;
|
||||||
const id = item[container.idfield], hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
|
const id = item[container.idfield];
|
||||||
|
const hasChildren = !!((_a2 = childrenMap[id]) == null ? void 0 : _a2.length);
|
||||||
const isCollapsed = container.collapsed[id];
|
const isCollapsed = container.collapsed[id];
|
||||||
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
|
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
|
||||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
|
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
|
||||||
});
|
});
|
||||||
traverse(childrenMap[""] || [], 0, []);
|
traverse(childrenMap[""] || [], 0, []);
|
||||||
} else list.forEach((item) => flatList.push({ type: "item", ...item }));
|
} else {
|
||||||
|
let filteredList = list;
|
||||||
|
if (filter) {
|
||||||
|
if (typeof filter === "function") {
|
||||||
|
filteredList = list.filter(filter);
|
||||||
|
} else if (typeof filter === "string") {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
filteredList = list.filter((item) => {
|
||||||
|
const label = String(item[container.labelfield] || "").toLowerCase();
|
||||||
|
const summary = String(item[container.summaryfield] || "").toLowerCase();
|
||||||
|
return label.includes(q) || summary.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filteredList.forEach((item) => flatList.push({ type: "item", ...item }));
|
||||||
|
}
|
||||||
container.state.flatList = flatList;
|
container.state.flatList = flatList;
|
||||||
};
|
};
|
||||||
container.state.__watch("list", updateFlatList);
|
container.state.__watch("list", updateFlatList);
|
||||||
|
container.state.__watch("filter", updateFlatList);
|
||||||
|
container.state.__watch("order", updateFlatList);
|
||||||
const vs = container.fast ? globalThis.VirtualScroll() : null;
|
const vs = container.fast ? globalThis.VirtualScroll() : null;
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
container.refresh = () => {
|
container.refresh = () => {
|
||||||
@ -840,6 +945,31 @@
|
|||||||
updateFlatList();
|
updateFlatList();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Object.defineProperty(container, "filter", {
|
||||||
|
get: () => container.state.filter,
|
||||||
|
set: (v) => {
|
||||||
|
container.state.filter = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.defineProperty(container, "order", {
|
||||||
|
get: () => container.state.order,
|
||||||
|
set: (v) => {
|
||||||
|
container.state.order = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const filterAttr = container.getAttribute("filter");
|
||||||
|
if (typeof container.state.filter !== "function" && filterAttr !== container.state.filter) {
|
||||||
|
container.state.filter = filterAttr || void 0;
|
||||||
|
}
|
||||||
|
const orderAttr = container.getAttribute("order");
|
||||||
|
if (typeof container.state.order !== "function" && orderAttr !== container.state.order) {
|
||||||
|
container.state.order = orderAttr || void 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(container, { attributes: true, attributeFilter: ["filter", "order"] });
|
||||||
|
container.state.filter = container.getAttribute("filter") || void 0;
|
||||||
|
container.state.order = container.getAttribute("order") || void 0;
|
||||||
updateFlatList();
|
updateFlatList();
|
||||||
}, globalThis.Util.makeDom(
|
}, globalThis.Util.makeDom(
|
||||||
/*html*/
|
/*html*/
|
||||||
@ -900,13 +1030,13 @@
|
|||||||
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
|
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<template $if="this.state?.brand?.image">
|
<template $if="this.state?.brand?.image">
|
||||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px">
|
||||||
</template>
|
</template>
|
||||||
<template $if="this.state?.brand?.icon">
|
<template $if="this.state?.brand?.icon">
|
||||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'me-2'}"></i>
|
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i>
|
||||||
</template>
|
</template>
|
||||||
<template $if="this.state?.brand?.label">
|
<template $if="this.state?.brand?.label">
|
||||||
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'me-2'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
|
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
|
||||||
|
|||||||
2
dist/base.min.js
vendored
2
dist/base.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@apigo.cc/base",
|
"name": "@apigo.cc/base",
|
||||||
"version": "1.0.21",
|
"version": "1.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/base.js",
|
"main": "dist/base.js",
|
||||||
"files": [
|
"files": [
|
||||||
@ -9,7 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"test": "playwright test",
|
"test": "npm run build && node scripts/prepare-test-lib.js && playwright test",
|
||||||
"pub": "node scripts/publish.js"
|
"pub": "node scripts/publish.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -3,12 +3,13 @@ import { defineConfig } from '@playwright/test';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './test',
|
testDir: './test',
|
||||||
testMatch: '**/*.spec.js',
|
testMatch: '**/*.spec.js',
|
||||||
|
reporter: 'line',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:8082',
|
baseURL: 'http://127.0.0.1:8082',
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npx vite --port 8082 --host 127.0.0.1',
|
command: 'npx vite --port 8082 --host 127.0.0.1',
|
||||||
url: 'http://127.0.0.1:8082/test/index.html',
|
url: 'http://127.0.0.1:8082/test/form_test.html',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
|
|||||||
22
scripts/prepare-test-lib.js
Normal file
22
scripts/prepare-test-lib.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const testLibDir = path.join(root, 'test', 'lib');
|
||||||
|
|
||||||
|
const copies = [
|
||||||
|
['../../state/dist/state.js', 'state.js'],
|
||||||
|
['../../bootstrap/dist/bootstrap.js', 'bootstrap.js'],
|
||||||
|
['../dist/base.js', 'base.js']
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.mkdirSync(testLibDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const [sourceRelPath, targetName] of copies) {
|
||||||
|
const source = path.resolve(__dirname, sourceRelPath);
|
||||||
|
const target = path.join(testLibDir, targetName);
|
||||||
|
if (!fs.existsSync(source)) throw new Error(`Missing test dependency: ${source}`);
|
||||||
|
fs.copyFileSync(source, target);
|
||||||
|
}
|
||||||
150
src/list.js
150
src/list.js
@ -19,30 +19,142 @@ globalThis.Component.register('List', container => {
|
|||||||
|
|
||||||
const updateFlatList = () => {
|
const updateFlatList = () => {
|
||||||
globalThis.Util.updateDefaults(container, defaultSets)
|
globalThis.Util.updateDefaults(container, defaultSets)
|
||||||
const list = container.state.list || [], flatList = []
|
let list = container.state.list || []
|
||||||
|
const filter = container.state.filter
|
||||||
|
const order = container.state.order
|
||||||
|
|
||||||
|
if (order) {
|
||||||
|
if (typeof order === 'function') {
|
||||||
|
list = [...list].sort(order)
|
||||||
|
} else if (typeof order === 'string') {
|
||||||
|
const parts = order.trim().split(/\s+/)
|
||||||
|
const field = parts[0]
|
||||||
|
const isDesc = parts[1]?.toLowerCase() === 'desc' || field.startsWith('-')
|
||||||
|
const cleanField = field.startsWith('-') ? field.slice(1) : field
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
const valA = a[cleanField]
|
||||||
|
const valB = b[cleanField]
|
||||||
|
if (valA === undefined || valA === null) return isDesc ? 1 : -1
|
||||||
|
if (valB === undefined || valB === null) return isDesc ? -1 : 1
|
||||||
|
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||||
|
return isDesc ? valB.localeCompare(valA) : valA.localeCompare(valB)
|
||||||
|
}
|
||||||
|
return isDesc ? (valB > valA ? 1 : -1) : (valA > valB ? 1 : -1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatList = []
|
||||||
if (container.mode === 'group') {
|
if (container.mode === 'group') {
|
||||||
|
let filteredList = list
|
||||||
|
if (filter) {
|
||||||
|
if (typeof filter === 'function') {
|
||||||
|
filteredList = list.filter(filter)
|
||||||
|
} else if (typeof filter === 'string') {
|
||||||
|
const q = filter.trim().toLowerCase()
|
||||||
|
if (q) {
|
||||||
|
filteredList = list.filter(item => {
|
||||||
|
const label = String(item[container.labelfield] || '').toLowerCase()
|
||||||
|
const summary = String(item[container.summaryfield] || '').toLowerCase()
|
||||||
|
return label.includes(q) || summary.includes(q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const itemMap = {}
|
const itemMap = {}
|
||||||
list.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
filteredList.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
||||||
(container.state.groups || []).forEach(group => {
|
(container.state.groups || []).forEach(group => {
|
||||||
flatList.push({ type: 'group', ...group })
|
|
||||||
const items = itemMap[group[container.groupidfield]]
|
const items = itemMap[group[container.groupidfield]]
|
||||||
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
const groupLabel = String(group[container.grouplabelfield] || '').toLowerCase()
|
||||||
|
const groupSummary = String(group[container.groupsummaryfield] || '').toLowerCase()
|
||||||
|
const q = (typeof filter === 'string') ? filter.trim().toLowerCase() : ''
|
||||||
|
const groupMatches = q ? (groupLabel.includes(q) || groupSummary.includes(q)) : false
|
||||||
|
|
||||||
|
if ((items && items.length > 0) || groupMatches || !filter) {
|
||||||
|
flatList.push({ type: 'group', ...group })
|
||||||
|
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} else if (container.mode === 'tree') {
|
} else if (container.mode === 'tree') {
|
||||||
|
const itemMap = {}
|
||||||
|
list.forEach(item => { itemMap[item[container.idfield]] = item })
|
||||||
|
|
||||||
|
const matches = new Set()
|
||||||
|
list.forEach(item => {
|
||||||
|
let match = false
|
||||||
|
if (!filter) {
|
||||||
|
match = true
|
||||||
|
} else if (typeof filter === 'function') {
|
||||||
|
match = filter(item)
|
||||||
|
} else if (typeof filter === 'string') {
|
||||||
|
const q = filter.trim().toLowerCase()
|
||||||
|
if (!q) {
|
||||||
|
match = true
|
||||||
|
} else {
|
||||||
|
const label = String(item[container.labelfield] || '').toLowerCase()
|
||||||
|
const summary = String(item[container.summaryfield] || '').toLowerCase()
|
||||||
|
match = label.includes(q) || summary.includes(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
matches.add(item[container.idfield])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const keep = new Set(matches)
|
||||||
|
if (filter) {
|
||||||
|
matches.forEach(id => {
|
||||||
|
let curr = itemMap[id]
|
||||||
|
while (curr) {
|
||||||
|
const parentId = curr[container.parentfield]
|
||||||
|
if (!parentId) break
|
||||||
|
if (keep.has(parentId)) break
|
||||||
|
keep.add(parentId)
|
||||||
|
curr = itemMap[parentId]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const childrenMap = {}
|
const childrenMap = {}
|
||||||
list.forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
list.forEach(item => {
|
||||||
|
if (!filter || keep.has(item[container.idfield])) {
|
||||||
|
const parentId = item[container.parentfield] || ''
|
||||||
|
;(childrenMap[parentId] ??= []).push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const traverse = (items, level, parents) => items.forEach(item => {
|
const traverse = (items, level, parents) => items.forEach(item => {
|
||||||
const id = item[container.idfield], hasChildren = !!childrenMap[id]?.length
|
const id = item[container.idfield]
|
||||||
|
const hasChildren = !!childrenMap[id]?.length
|
||||||
const isCollapsed = container.collapsed[id]
|
const isCollapsed = container.collapsed[id]
|
||||||
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||||
})
|
})
|
||||||
traverse(childrenMap[''] || [], 0, [])
|
traverse(childrenMap[''] || [], 0, [])
|
||||||
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
|
} else {
|
||||||
|
let filteredList = list
|
||||||
|
if (filter) {
|
||||||
|
if (typeof filter === 'function') {
|
||||||
|
filteredList = list.filter(filter)
|
||||||
|
} else if (typeof filter === 'string') {
|
||||||
|
const q = filter.trim().toLowerCase()
|
||||||
|
if (q) {
|
||||||
|
filteredList = list.filter(item => {
|
||||||
|
const label = String(item[container.labelfield] || '').toLowerCase()
|
||||||
|
const summary = String(item[container.summaryfield] || '').toLowerCase()
|
||||||
|
return label.includes(q) || summary.includes(q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filteredList.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||||
|
}
|
||||||
container.state.flatList = flatList
|
container.state.flatList = flatList
|
||||||
}
|
}
|
||||||
|
|
||||||
container.state.__watch('list', updateFlatList)
|
container.state.__watch('list', updateFlatList)
|
||||||
|
container.state.__watch('filter', updateFlatList)
|
||||||
|
container.state.__watch('order', updateFlatList)
|
||||||
const vs = container.fast ? globalThis.VirtualScroll() : null
|
const vs = container.fast ? globalThis.VirtualScroll() : null
|
||||||
|
|
||||||
let refreshing = false
|
let refreshing = false
|
||||||
@ -84,6 +196,30 @@ globalThis.Component.register('List', container => {
|
|||||||
}
|
}
|
||||||
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
|
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
|
||||||
|
|
||||||
|
Object.defineProperty(container, 'filter', {
|
||||||
|
get: () => container.state.filter,
|
||||||
|
set: v => { container.state.filter = v; }
|
||||||
|
})
|
||||||
|
Object.defineProperty(container, 'order', {
|
||||||
|
get: () => container.state.order,
|
||||||
|
set: v => { container.state.order = v; }
|
||||||
|
})
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const filterAttr = container.getAttribute('filter')
|
||||||
|
if (typeof container.state.filter !== 'function' && filterAttr !== container.state.filter) {
|
||||||
|
container.state.filter = filterAttr || undefined
|
||||||
|
}
|
||||||
|
const orderAttr = container.getAttribute('order')
|
||||||
|
if (typeof container.state.order !== 'function' && orderAttr !== container.state.order) {
|
||||||
|
container.state.order = orderAttr || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observer.observe(container, { attributes: true, attributeFilter: ['filter', 'order'] })
|
||||||
|
|
||||||
|
container.state.filter = container.getAttribute('filter') || undefined
|
||||||
|
container.state.order = container.getAttribute('order') || undefined
|
||||||
|
|
||||||
updateFlatList()
|
updateFlatList()
|
||||||
}, globalThis.Util.makeDom(/*html*/`
|
}, globalThis.Util.makeDom(/*html*/`
|
||||||
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
||||||
|
|||||||
@ -19,13 +19,13 @@ globalThis.Component.register('Nav', container => {
|
|||||||
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
|
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<template $if="this.state?.brand?.image">
|
<template $if="this.state?.brand?.image">
|
||||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px">
|
||||||
</template>
|
</template>
|
||||||
<template $if="this.state?.brand?.icon">
|
<template $if="this.state?.brand?.icon">
|
||||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'me-2'}"></i>
|
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i>
|
||||||
</template>
|
</template>
|
||||||
<template $if="this.state?.brand?.label">
|
<template $if="this.state?.brand?.label">
|
||||||
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'me-2'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
|
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
- Following Playwright test failed.
|
|
||||||
- Explain why, be concise, respect Playwright best practices.
|
|
||||||
- Provide a snippet of code with the fix, if possible.
|
|
||||||
|
|
||||||
# Test info
|
|
||||||
|
|
||||||
- Name: atomic_check.spec.js >> Capability demo page atomic tests verification
|
|
||||||
- Location: test/atomic_check.spec.js:3:1
|
|
||||||
|
|
||||||
# Error details
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: expect(received).toContain(expected) // indexOf
|
|
||||||
|
|
||||||
Expected value: "Synchronous State Ready"
|
|
||||||
Received array: []
|
|
||||||
```
|
|
||||||
|
|
||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=e2]:
|
|
||||||
- navigation [ref=e4]
|
|
||||||
- generic [ref=e7]:
|
|
||||||
- heading [level=2]
|
|
||||||
- button "切换主题" [ref=e9] [cursor=pointer]: 切换主题
|
|
||||||
```
|
|
||||||
|
|
||||||
# Test source
|
|
||||||
|
|
||||||
```ts
|
|
||||||
1 | import { test, expect } from '@playwright/test';
|
|
||||||
2 |
|
|
||||||
3 | test('Capability demo page atomic tests verification', async ({ page }) => {
|
|
||||||
4 | page.on('console', msg => console.log('BROWSER:', msg.text()));
|
|
||||||
5 |
|
|
||||||
6 | await page.goto('/test/capability.html');
|
|
||||||
7 | await page.waitForTimeout(3000);
|
|
||||||
8 |
|
|
||||||
9 | const testResults = await page.evaluate(() => {
|
|
||||||
10 | const getTexts = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.textContent.trim());
|
|
||||||
11 | const getColors = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.style.color);
|
|
||||||
12 |
|
|
||||||
13 | return {
|
|
||||||
14 | textBinding: getTexts('p.fs-4'),
|
|
||||||
15 | textColor: getColors('p.fs-4'),
|
|
||||||
16 | ifTrue: document.querySelector('.alert-success')?.textContent.trim(),
|
|
||||||
17 | eachItems: getTexts('.card-body .list-group-item'),
|
|
||||||
18 | nestedEachInIf: getTexts('.badge.bg-secondary'),
|
|
||||||
19 | nestedIfInEach: getTexts('.card-body .gap-2 .p-2 span')
|
|
||||||
20 | };
|
|
||||||
21 | });
|
|
||||||
22 |
|
|
||||||
23 | console.log('Atomic Test Results:', JSON.stringify(testResults, null, 2));
|
|
||||||
24 |
|
|
||||||
> 25 | expect(testResults.textBinding).toContain('Synchronous State Ready');
|
|
||||||
| ^ Error: expect(received).toContain(expected) // indexOf
|
|
||||||
26 | expect(testResults.ifTrue).toBe('已激活 ($if="true")');
|
|
||||||
27 | expect(testResults.eachItems).toHaveLength(3);
|
|
||||||
28 | expect(testResults.nestedEachInIf).toHaveLength(3);
|
|
||||||
29 | expect(testResults.nestedIfInEach).toHaveLength(5); // Members 1-5
|
|
||||||
30 | });
|
|
||||||
31 |
|
|
||||||
```
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('base project comprehensive tests and scrolling benchmarks', async ({ page }) => {
|
|
||||||
test.setTimeout(60000);
|
|
||||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
|
||||||
page.on('pageerror', err => console.log('BROWSER EXCEPTION:', err.message, err.stack));
|
|
||||||
await page.goto('http://127.0.0.1:8082/test/index.html');
|
|
||||||
|
|
||||||
// Wait for testStatus to be set (includes basic unit tests and scrolling refresh test)
|
|
||||||
await page.waitForFunction(() => window.testStatus !== undefined, { timeout: 60000 });
|
|
||||||
|
|
||||||
const status = await page.evaluate(() => window.testStatus);
|
|
||||||
expect(status).toBe('passed');
|
|
||||||
|
|
||||||
// Simulate real scrolling and check dynamic rendering for FastList
|
|
||||||
console.log('Simulating real scroll on FastList...');
|
|
||||||
const scrollInfo = await page.evaluate(async () => {
|
|
||||||
window.switchTab?.('ll');
|
|
||||||
await new Promise(r => setTimeout(r, 200)); // wait for render
|
|
||||||
const el = document.getElementById('ll');
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
const getRenderedCount = () => el.querySelectorAll('.border-primary').length;
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id: 'initial',
|
|
||||||
scrollTop: el.scrollTop,
|
|
||||||
scrollHeight: el.scrollHeight,
|
|
||||||
clientHeight: el.clientHeight,
|
|
||||||
renderedCount: getRenderedCount(),
|
|
||||||
renderedListLen: el.state.renderedList?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll to middle
|
|
||||||
el.scrollTop = 50000;
|
|
||||||
el.refresh?.();
|
|
||||||
await new Promise(r => setTimeout(r, 500));
|
|
||||||
const midCount = getRenderedCount();
|
|
||||||
const midHtml = el.innerHTML.length;
|
|
||||||
results.push({
|
|
||||||
id: 'deep-middle',
|
|
||||||
scrollTop: el.scrollTop,
|
|
||||||
renderedCount: midCount,
|
|
||||||
htmlLen: midHtml,
|
|
||||||
prevHeight: el.state.prevHeight,
|
|
||||||
postHeight: el.state.postHeight,
|
|
||||||
renderedListLen: el.state._renderedList?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll to end
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
el.refresh?.();
|
|
||||||
await new Promise(r => setTimeout(r, 500));
|
|
||||||
results.push({
|
|
||||||
id: 'end',
|
|
||||||
scrollTop: el.scrollTop,
|
|
||||||
renderedCount: getRenderedCount(),
|
|
||||||
scrollHeight: el.scrollHeight,
|
|
||||||
prevHeight: el.state.prevHeight,
|
|
||||||
postHeight: el.state.postHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Scroll Details:', JSON.stringify(scrollInfo, null, 2));
|
|
||||||
expect(scrollInfo[0].renderedCount).toBeGreaterThan(0);
|
|
||||||
expect(scrollInfo[1].scrollTop).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const bench = await page.evaluate(() => window.benchResults);
|
|
||||||
if (bench) {
|
|
||||||
Object.entries(bench).forEach(([name, time]) => {
|
|
||||||
console.log(`BENCHMARK [${name}]: ${time.toFixed(2)}ms`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
36
test/api.spec.js
Normal file
36
test/api.spec.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('API keeps declarative preset request fields', async ({ page }) => {
|
||||||
|
let capturedBody = null;
|
||||||
|
await page.route('**/echo', async route => {
|
||||||
|
capturedBody = route.request().postData();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ ok: true })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/test/api_test.html');
|
||||||
|
await page.waitForFunction(() => typeof globalThis.presetApi?.do === 'function' && !!globalThis.presetApi?.request);
|
||||||
|
|
||||||
|
const requestInfo = await page.evaluate(() => ({
|
||||||
|
url: presetApi.request?.url,
|
||||||
|
method: presetApi.request?.method,
|
||||||
|
secret: presetApi.request?.data?.secret
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(requestInfo).toEqual({
|
||||||
|
url: '/echo',
|
||||||
|
method: 'POST',
|
||||||
|
secret: 'playwright-secret'
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await page.evaluate(async () => {
|
||||||
|
const resp = await presetApi.do({ noui: true });
|
||||||
|
return resp.ok;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(capturedBody).toBe(JSON.stringify({ secret: 'playwright-secret' }));
|
||||||
|
});
|
||||||
20
test/api_test.html
Normal file
20
test/api_test.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>API Declarative Test</title>
|
||||||
|
<script src="./lib/state.js"></script>
|
||||||
|
<script src="./lib/bootstrap.js"></script>
|
||||||
|
<script src="./lib/base.js"></script>
|
||||||
|
<script>
|
||||||
|
LocalStorage.secret = 'playwright-secret';
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<API id="presetApi"
|
||||||
|
$.request.url="'/echo'"
|
||||||
|
$.request.method="'POST'"
|
||||||
|
$.request.data.secret="LocalStorage.secret || ''">
|
||||||
|
</API>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('Capability demo page atomic tests verification', async ({ page }) => {
|
|
||||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
|
||||||
|
|
||||||
await page.goto('http://localhost:5173/test/capability.html');
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const testResults = await page.evaluate(() => {
|
|
||||||
const getTexts = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.textContent.trim());
|
|
||||||
const getColors = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.style.color);
|
|
||||||
|
|
||||||
return {
|
|
||||||
textBinding: getTexts('p[$text="DemoState.testTitle"]'),
|
|
||||||
textColor: getColors('p[$text="DemoState.testTitle"]'),
|
|
||||||
ifTrue: document.querySelector('.alert-success')?.textContent.trim(),
|
|
||||||
ifFalse: document.querySelector('.alert-danger')?.textContent.trim(),
|
|
||||||
eachItems: getTexts('li.list-group-item'),
|
|
||||||
nestedEachInIf: getTexts('.badge.bg-primary'),
|
|
||||||
nestedIfInEach: getTexts('.bg-white')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Atomic Test Results:', JSON.stringify(testResults, null, 2));
|
|
||||||
|
|
||||||
expect(testResults.textBinding).toContain('Framework Is Active');
|
|
||||||
expect(testResults.ifTrue).toBe('显示的内容 (True)');
|
|
||||||
expect(testResults.ifFalse).toBeUndefined();
|
|
||||||
expect(testResults.eachItems).toHaveLength(3);
|
|
||||||
expect(testResults.nestedEachInIf).toHaveLength(3);
|
|
||||||
expect(testResults.nestedIfInEach).toHaveLength(2); // Item A and C are visible
|
|
||||||
});
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
import { HTTP, UI, State } from '@apigo.cc/base';
|
|
||||||
|
|
||||||
export async function runTests() {
|
|
||||||
console.log('Starting comprehensive Base.js tests...');
|
|
||||||
|
|
||||||
// 1. HTTP Test
|
|
||||||
console.log('Testing HTTP (local check)...');
|
|
||||||
if (typeof HTTP.request !== 'function') throw new Error('HTTP.request missing');
|
|
||||||
|
|
||||||
// 2. State Test
|
|
||||||
console.log('Testing State...');
|
|
||||||
State.exitBlocks = 1;
|
|
||||||
if (State.exitBlocks !== 1) throw new Error('State update failed');
|
|
||||||
State.exitBlocks = 0;
|
|
||||||
|
|
||||||
// 3. UI Namespace Test
|
|
||||||
console.log('Testing UI...');
|
|
||||||
if (typeof UI.alert !== 'function') throw new Error('UI.alert missing');
|
|
||||||
if (typeof UI.toast !== 'function') throw new Error('UI.toast missing');
|
|
||||||
|
|
||||||
// 4. API Component Test
|
|
||||||
console.log('Testing API Component...');
|
|
||||||
const api = document.createElement('API');
|
|
||||||
document.body.appendChild(api);
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
api.request.url = '../package.json';
|
|
||||||
const apiResp = await api.do();
|
|
||||||
if (!apiResp.ok) throw new Error('API component request failed');
|
|
||||||
api.remove();
|
|
||||||
|
|
||||||
console.log('Testing API declarative preset request fields...');
|
|
||||||
LocalStorage.secret = 'api-secret-token';
|
|
||||||
const presetApi = document.createElement('API');
|
|
||||||
presetApi.setAttribute('$.request.url', "'/echo'");
|
|
||||||
presetApi.setAttribute('$.request.method', "'POST'");
|
|
||||||
presetApi.setAttribute('$.request.data.secret', "LocalStorage.secret || ''");
|
|
||||||
document.body.appendChild(presetApi);
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
let capturedBody = null;
|
|
||||||
globalThis.fetch = async (_url, options = {}) => {
|
|
||||||
capturedBody = options.body;
|
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const presetResp = await presetApi.do();
|
|
||||||
if (!presetResp.ok) throw new Error('Declarative API preset request failed');
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
presetApi.remove();
|
|
||||||
}
|
|
||||||
if (capturedBody !== JSON.stringify({ secret: 'api-secret-token' })) {
|
|
||||||
throw new Error('API declarative preset request.data.secret was not preserved');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. AutoForm & TagsInput Test
|
|
||||||
console.log('Testing AutoForm...');
|
|
||||||
const { NewState } = await import('@apigo.cc/state');
|
|
||||||
const form = document.createElement('AutoForm');
|
|
||||||
document.body.appendChild(form);
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
|
|
||||||
// Test dynamic schema and visibility
|
|
||||||
form.data = NewState({ showName: false, name: 'Gemini' });
|
|
||||||
form.state.schema = [
|
|
||||||
{ name: 'tags', type: 'TagsInput', label: '标签' },
|
|
||||||
{ name: 'showName', type: 'switch', label: 'Show Name' },
|
|
||||||
{ name: 'name', type: 'text', label: 'Name', if: 'this.data.showName' }
|
|
||||||
];
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
if (!form.querySelector('TagsInput')) throw new Error('TagsInput not rendered');
|
|
||||||
if (form.querySelector('[name="name"]')) throw new Error('Name field should be hidden');
|
|
||||||
|
|
||||||
form.data.showName = true;
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
if (!form.querySelector('[name="name"]')) throw new Error('Name field should be visible');
|
|
||||||
form.remove();
|
|
||||||
|
|
||||||
// 6. New Controls (DatePicker, ColorPicker, IconPicker) Test
|
|
||||||
console.log('Testing New Controls...');
|
|
||||||
const controlForm = document.createElement('AutoForm');
|
|
||||||
document.body.appendChild(controlForm);
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
controlForm.state.schema = [
|
|
||||||
{ name: 'startDate', type: 'DatePicker', setting: { rangeEnd: 'endDate' } },
|
|
||||||
{ name: 'endDate', type: 'date' },
|
|
||||||
{ name: 'color', type: 'ColorPicker' },
|
|
||||||
{ name: 'icon', type: 'IconPicker' }
|
|
||||||
];
|
|
||||||
controlForm.data = { startDate: '2026-05-01', endDate: '2026-05-31', color: '#ff0000', icon: 'gear' };
|
|
||||||
await new Promise(r => setTimeout(r, 400));
|
|
||||||
|
|
||||||
const dp = controlForm.querySelector('DatePicker');
|
|
||||||
const cp = controlForm.querySelector('ColorPicker');
|
|
||||||
const ip = controlForm.querySelector('IconPicker');
|
|
||||||
|
|
||||||
if (!dp) throw new Error('DatePicker not rendered');
|
|
||||||
if (!cp) throw new Error('ColorPicker not rendered');
|
|
||||||
if (!ip) throw new Error('IconPicker not rendered');
|
|
||||||
|
|
||||||
// Test DatePicker range sync
|
|
||||||
dp.updateEnd('2026-06-01');
|
|
||||||
if (controlForm.data.endDate !== '2026-06-01') throw new Error('DatePicker rangeEnd sync failed');
|
|
||||||
|
|
||||||
// Test ColorPicker
|
|
||||||
cp.updateValue('#00ff00');
|
|
||||||
if (controlForm.data.color !== '#00ff00') throw new Error('ColorPicker sync failed');
|
|
||||||
|
|
||||||
// Test IconPicker
|
|
||||||
ip.selectIcon('star');
|
|
||||||
if (controlForm.data.icon !== 'star') throw new Error('IconPicker sync failed');
|
|
||||||
|
|
||||||
controlForm.remove();
|
|
||||||
|
|
||||||
// 7. List Components Basic Verification
|
|
||||||
console.log('Verifying List Components...');
|
|
||||||
const { Component } = await import('@apigo.cc/state');
|
|
||||||
console.log('FastList exists:', Component.exists('FastList'));
|
|
||||||
|
|
||||||
const listIds = ['ll', 'gl', 'tt', 'ct'];
|
|
||||||
for (const id of listIds) {
|
|
||||||
window.switchTab?.(id);
|
|
||||||
await new Promise(r => setTimeout(r, 100)); // wait for render
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) throw new Error(`Component #${id} not found`);
|
|
||||||
console.log(`Component #${id} tagName:`, el.tagName, 'has refresh:', !!el.refresh, 'setupFunc exists:', !!Component.getSetupFunction(el.tagName));
|
|
||||||
if (!el.state.list || el.state.list.length === 0) throw new Error(`Component #${id} list data not bound`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Benchmarks
|
|
||||||
console.log('Recording list benchmarks...');
|
|
||||||
window.benchResults = {};
|
|
||||||
|
|
||||||
const measure = async (id, name) => {
|
|
||||||
window.switchTab?.(id);
|
|
||||||
await new Promise(r => setTimeout(r, 100)); // wait for render
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
const start = performance.now();
|
|
||||||
console.log(`[MEASURE] ${name} setting scrollTop to 5000`);
|
|
||||||
el.scrollTop = 5000;
|
|
||||||
console.log(`[MEASURE] ${name} calling refresh`);
|
|
||||||
el.refresh?.();
|
|
||||||
console.log(`[MEASURE] ${name} refresh called, waiting 50ms`);
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
console.log(`[MEASURE] ${name} wait done`);
|
|
||||||
const time = performance.now() - start;
|
|
||||||
window.benchResults[name] = time;
|
|
||||||
console.log(`BENCHMARK: ${name} scroll & refresh: ${time.toFixed(2)}ms`);
|
|
||||||
console.log(`DEBUG [${id}]: _renderedList len: ${el.state._renderedList?.length}, _flatList len: ${el.state._flatList?.length}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
await measure('ll', 'FastList');
|
|
||||||
await measure('gl', 'FastGroupedList');
|
|
||||||
await measure('tt', 'FastTree');
|
|
||||||
await measure('ct', 'CollapseTree');
|
|
||||||
|
|
||||||
console.log('All Base.js unit tests completed.');
|
|
||||||
}
|
|
||||||
14
test/capability.spec.js
Normal file
14
test/capability.spec.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('capability demo switches sections through Nav', async ({ page }) => {
|
||||||
|
await page.goto('/test/capability.html');
|
||||||
|
|
||||||
|
await expect(page.locator('#mainNav')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: '项目概览' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '表单与控件' }).click();
|
||||||
|
await expect(page.locator('#demoForm')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '增强列表' }).click();
|
||||||
|
await expect(page.locator('#demoList')).toBeVisible();
|
||||||
|
});
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('Capability demo page deep DOM dump', async ({ page }) => {
|
|
||||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
|
||||||
|
|
||||||
await page.goto('http://localhost:5173/test/capability.html');
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const dump = await page.evaluate(() => {
|
|
||||||
const nav = document.getElementById('mainNav');
|
|
||||||
const form = document.getElementById('demoForm');
|
|
||||||
return {
|
|
||||||
nav: {
|
|
||||||
tagName: nav?.tagName,
|
|
||||||
childCount: nav?.children.length,
|
|
||||||
innerHTML: nav?.innerHTML
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
tagName: form?.tagName,
|
|
||||||
childCount: form?.children.length,
|
|
||||||
innerHTML: form?.innerHTML
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Deep DOM Dump:', JSON.stringify(dump, null, 2));
|
|
||||||
|
|
||||||
expect(dump.nav.childCount).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
21
test/form.spec.js
Normal file
21
test/form.spec.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('AutoForm fixtures render in supported layouts', async ({ page }) => {
|
||||||
|
await page.goto('/test/form_test.html');
|
||||||
|
|
||||||
|
await expect(page.locator('#formV')).toBeVisible();
|
||||||
|
await expect(page.locator('#formH')).toBeVisible();
|
||||||
|
await expect(page.locator('#formFH')).toBeVisible();
|
||||||
|
await expect(page.locator('#formSearch')).toBeVisible();
|
||||||
|
await expect(page.locator('#formCompact')).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('#formV input[name="t"]')).toHaveValue('Apigo');
|
||||||
|
|
||||||
|
const horizontalDisplay = await page.locator('#formH form').evaluate(node => getComputedStyle(node).display);
|
||||||
|
expect(horizontalDisplay).toBe('grid');
|
||||||
|
|
||||||
|
await expect(page.locator('#formV DatePicker')).toHaveCount(2);
|
||||||
|
await expect(page.locator('#formV ColorPicker')).toBeVisible();
|
||||||
|
await expect(page.locator('#formV TagsInput')).toBeVisible();
|
||||||
|
await expect(page.locator('#formCompact button[type="submit"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
116
test/index.html
116
test/index.html
@ -1,116 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Base.js Modular & Performance Tests</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"@apigo.cc/state": "../../state/src/index.js",
|
|
||||||
"@apigo.cc/base": "../src/index.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body class="d-flex flex-column vh-100">
|
|
||||||
<script>
|
|
||||||
window.addEventListener('error', e => console.log('GLOBAL ERROR:', e.message, e.filename, e.lineno, e.colno, e.error));
|
|
||||||
window.addEventListener('unhandledrejection', e => console.log('UNHANDLED REJECTION:', e.reason));
|
|
||||||
</script>
|
|
||||||
<div id="results" class="p-2 bg-light border-bottom">Running tests...</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const list_data = []
|
|
||||||
for (let i = 0; i < 10000; i++) {
|
|
||||||
list_data.push({ id: i, label: `item ${i}`, index: i, group: i % 1000, summary: `item ${i} summary`, parent: Math.floor(i / 10) })
|
|
||||||
}
|
|
||||||
const group_list = []
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
group_list.push({ id: i, label: `group ${i}`, summary: `group ${i} summary` })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
function switchTab(tabId) {
|
|
||||||
document.querySelectorAll('.list-container').forEach(el => el.style.display = 'none');
|
|
||||||
const target = document.getElementById('container-' + tabId);
|
|
||||||
if (target) target.style.display = 'flex';
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
|
||||||
const btn = document.getElementById('btn-' + tabId);
|
|
||||||
if (btn) btn.classList.add('active');
|
|
||||||
window.activeTab = tabId;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<div class="p-2 border-bottom d-flex gap-2">
|
|
||||||
<button id="btn-ll" class="tab-btn btn btn-sm btn-outline-primary active" onclick="switchTab('ll')">Fast List</button>
|
|
||||||
<button id="btn-gl" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('gl')">Fast Grouped</button>
|
|
||||||
<button id="btn-tt" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('tt')">Fast Tree</button>
|
|
||||||
<button id="btn-ct" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('ct')">Normal Tree</button>
|
|
||||||
<button id="btn-form" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('form')">AutoForm Controls</button>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-fill p-2 overflow-hidden">
|
|
||||||
<div id="container-ll" class="list-container flex-fill d-flex flex-column overflow-hidden">
|
|
||||||
<h5>Fast List (Variable Height)</h5>
|
|
||||||
<List fast id="ll" auto-select class="flex-fill d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)">
|
|
||||||
<template slot="item">
|
|
||||||
<div class="d-flex justify-content-center align-items-center border border-primary rounded" $text="item.label" $.style.height="${(item.index%10)*5+40}px"></div>
|
|
||||||
</template>
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
<div id="container-gl" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
|
|
||||||
<h5>Fast Grouped List</h5>
|
|
||||||
<List fast mode="group" id="gl" auto-select auto-select-group class="flex-fill d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data"
|
|
||||||
$ongroupclick="console.log(index, item)">
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
<div id="container-tt" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
|
|
||||||
<h5>Fast Tree List</h5>
|
|
||||||
<List fast mode="tree" id="tt" auto-select class="flex-fill d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></List>
|
|
||||||
</div>
|
|
||||||
<div id="container-ct" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
|
|
||||||
<h5>Normal Tree List (Collapsible)</h5>
|
|
||||||
<List mode="tree" collapsible id="ct" auto-select class="flex-fill d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)">
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
<div id="container-form" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
|
|
||||||
<h5>AutoForm Controls</h5>
|
|
||||||
<AutoForm id="test-form" class="border p-3 rounded bg-light" $onsubmit="UI.toast('Form Submitted: ' + JSON.stringify(event.detail))"></AutoForm>
|
|
||||||
<script>
|
|
||||||
setTimeout(() => {
|
|
||||||
const form = document.getElementById('test-form');
|
|
||||||
if (form) {
|
|
||||||
form.state.schema = [
|
|
||||||
{ name: 'startDate', label: 'Date Range', type: 'DatePicker', setting: { rangeEnd: 'endDate' } },
|
|
||||||
{ name: 'endDate', label: 'End Date (Shadow)', type: 'date' },
|
|
||||||
{ name: 'themeColor', label: 'Theme Color', type: 'ColorPicker' },
|
|
||||||
{ name: 'appIcon', label: 'App Icon', type: 'IconPicker' }
|
|
||||||
];
|
|
||||||
form.data = { startDate: '2026-05-01', endDate: '2026-05-31', themeColor: '#ff0000', appIcon: 'gear' };
|
|
||||||
}
|
|
||||||
}, 600);
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
import { runTests } from './base.test.js';
|
|
||||||
|
|
||||||
async function runAll() {
|
|
||||||
const results = document.getElementById('results');
|
|
||||||
try {
|
|
||||||
// Wait for components to initialize
|
|
||||||
await new Promise(r => setTimeout(r, 500));
|
|
||||||
await runTests();
|
|
||||||
results.innerHTML = '<h1 style="color: green; font-size: 1.2rem">All Tests Passed 🎉</h1>';
|
|
||||||
window.testStatus = 'passed';
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
results.innerHTML = '<h1 style="color: red; font-size: 1.2rem">Tests Failed: ' + e.message + '</h1>';
|
|
||||||
window.testStatus = 'failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runAll();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
333
test/lib/base.js
333
test/lib/base.js
@ -30,11 +30,24 @@
|
|||||||
}
|
}
|
||||||
options.body = data;
|
options.body = data;
|
||||||
}
|
}
|
||||||
|
for (const name of HTTP.keepHeaders) {
|
||||||
|
if (HTTP._keepHeadersCache[name] && !headers[name]) headers[name] = HTTP._keepHeadersCache[name];
|
||||||
|
}
|
||||||
if (Object.keys(headers).length) options.headers = headers;
|
if (Object.keys(headers).length) options.headers = headers;
|
||||||
const response = { error: null, ok: null, status: 0, headers: {}, responseType: "", result: null };
|
const response = { error: null, ok: null, status: 0, headers: {}, responseType: "", result: null };
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, options);
|
const resp = await fetch(url, options);
|
||||||
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) });
|
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) });
|
||||||
|
for (const name of HTTP.keepHeaders) {
|
||||||
|
const val = resp.headers.get(name);
|
||||||
|
if (val) {
|
||||||
|
HTTP._keepHeadersCache[name] = val;
|
||||||
|
try {
|
||||||
|
localStorage.setItem("_http_keep_" + name, val);
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!responseType) {
|
if (!responseType) {
|
||||||
const contentType = resp.headers.get("Content-Type") || "";
|
const contentType = resp.headers.get("Content-Type") || "";
|
||||||
if (contentType.includes("application/json")) responseType = "json";
|
if (contentType.includes("application/json")) responseType = "json";
|
||||||
@ -51,6 +64,15 @@
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
HTTP.keepHeaders = ["Session-Id", "Device-Id"];
|
||||||
|
HTTP._keepHeadersCache = {};
|
||||||
|
for (const name of HTTP.keepHeaders) {
|
||||||
|
try {
|
||||||
|
const val = localStorage.getItem("_http_keep_" + name);
|
||||||
|
if (val !== null) HTTP._keepHeadersCache[name] = val;
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
globalThis.HTTP = HTTP;
|
globalThis.HTTP = HTTP;
|
||||||
const UI = {};
|
const UI = {};
|
||||||
globalThis.Component.register("Toast", (container) => {
|
globalThis.Component.register("Toast", (container) => {
|
||||||
@ -127,14 +149,14 @@
|
|||||||
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
|
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100">
|
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100" style="padding: 0 0.75rem; min-height: calc(2.25rem + 2px);">
|
||||||
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
|
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
|
||||||
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options?opt:'on'" $bind="this.data[item.name]">
|
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options ? (opt.value !== undefined ? opt.value : opt) : 'on'" $bind="this.data[item.name]">
|
||||||
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
|
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding-left:0; min-height:0; display:flex !important;">
|
<div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding: 0 0.75rem; min-height: calc(2.25rem + 2px); display:flex !important;">
|
||||||
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" type="checkbox" $bind="this.data[item.name]">
|
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" type="checkbox" $bind="this.data[item.name]">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -377,9 +399,31 @@
|
|||||||
}
|
}
|
||||||
globalThis.MouseMover = MouseMover;
|
globalThis.MouseMover = MouseMover;
|
||||||
const APIComponent = globalThis.Component.register("API", (container) => {
|
const APIComponent = globalThis.Component.register("API", (container) => {
|
||||||
container.request = globalThis.NewState({ url: "", method: "GET", headers: {}, data: null, timeout: 1e4, responseType: "" });
|
const presetRequest = container.request && typeof container.request === "object" ? container.request : {};
|
||||||
container.response = globalThis.NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: "", result: null });
|
const presetResponse = container.response && typeof container.response === "object" ? container.response : {};
|
||||||
container.result = globalThis.NewState();
|
const presetResult = container.result && typeof container.result === "object" ? container.result : {};
|
||||||
|
container.request = globalThis.NewState({
|
||||||
|
url: "",
|
||||||
|
method: "GET",
|
||||||
|
headers: {},
|
||||||
|
data: null,
|
||||||
|
timeout: 1e4,
|
||||||
|
responseType: "",
|
||||||
|
...presetRequest,
|
||||||
|
headers: { ...presetRequest.headers || {} }
|
||||||
|
});
|
||||||
|
container.response = globalThis.NewState({
|
||||||
|
loading: false,
|
||||||
|
ok: null,
|
||||||
|
status: null,
|
||||||
|
error: null,
|
||||||
|
headers: {},
|
||||||
|
responseType: "",
|
||||||
|
result: null,
|
||||||
|
...presetResponse,
|
||||||
|
headers: { ...presetResponse.headers || {} }
|
||||||
|
});
|
||||||
|
container.result = globalThis.NewState(presetResult);
|
||||||
container.do = (opt = {}) => {
|
container.do = (opt = {}) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = { ...container.request, ...opt };
|
const req = { ...container.request, ...opt };
|
||||||
@ -513,7 +557,7 @@
|
|||||||
}
|
}
|
||||||
})}' $text="item"></button>
|
})}' $text="item"></button>
|
||||||
</template>
|
</template>
|
||||||
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown='${globalThis.Util.getFunctionBody(function(event) {
|
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onchange="event.stopPropagation()" $oninput="event.stopPropagation()" $onkeydown='${globalThis.Util.getFunctionBody(function(event) {
|
||||||
if (event.isComposing) return;
|
if (event.isComposing) return;
|
||||||
if (["Enter", ",", " "].includes(event.key)) {
|
if (["Enter", ",", " "].includes(event.key)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -718,37 +762,142 @@
|
|||||||
container.collapsed = globalThis.NewState({});
|
container.collapsed = globalThis.NewState({});
|
||||||
container.state.renderedList = [];
|
container.state.renderedList = [];
|
||||||
const updateFlatList = () => {
|
const updateFlatList = () => {
|
||||||
|
var _a;
|
||||||
globalThis.Util.updateDefaults(container, defaultSets);
|
globalThis.Util.updateDefaults(container, defaultSets);
|
||||||
const list = container.state.list || [], flatList = [];
|
let list = container.state.list || [];
|
||||||
|
const filter = container.state.filter;
|
||||||
|
const order = container.state.order;
|
||||||
|
if (order) {
|
||||||
|
if (typeof order === "function") {
|
||||||
|
list = [...list].sort(order);
|
||||||
|
} else if (typeof order === "string") {
|
||||||
|
const parts = order.trim().split(/\s+/);
|
||||||
|
const field = parts[0];
|
||||||
|
const isDesc = ((_a = parts[1]) == null ? void 0 : _a.toLowerCase()) === "desc" || field.startsWith("-");
|
||||||
|
const cleanField = field.startsWith("-") ? field.slice(1) : field;
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
const valA = a[cleanField];
|
||||||
|
const valB = b[cleanField];
|
||||||
|
if (valA === void 0 || valA === null) return isDesc ? 1 : -1;
|
||||||
|
if (valB === void 0 || valB === null) return isDesc ? -1 : 1;
|
||||||
|
if (typeof valA === "string" && typeof valB === "string") {
|
||||||
|
return isDesc ? valB.localeCompare(valA) : valA.localeCompare(valB);
|
||||||
|
}
|
||||||
|
return isDesc ? valB > valA ? 1 : -1 : valA > valB ? 1 : -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const flatList = [];
|
||||||
if (container.mode === "group") {
|
if (container.mode === "group") {
|
||||||
|
let filteredList = list;
|
||||||
|
if (filter) {
|
||||||
|
if (typeof filter === "function") {
|
||||||
|
filteredList = list.filter(filter);
|
||||||
|
} else if (typeof filter === "string") {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
filteredList = list.filter((item) => {
|
||||||
|
const label = String(item[container.labelfield] || "").toLowerCase();
|
||||||
|
const summary = String(item[container.summaryfield] || "").toLowerCase();
|
||||||
|
return label.includes(q) || summary.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const itemMap = {};
|
const itemMap = {};
|
||||||
list.forEach((item) => {
|
filteredList.forEach((item) => {
|
||||||
var _a;
|
var _a2;
|
||||||
return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).push(item);
|
return (itemMap[_a2 = item[container.groupfield]] ?? (itemMap[_a2] = [])).push(item);
|
||||||
});
|
});
|
||||||
(container.state.groups || []).forEach((group) => {
|
(container.state.groups || []).forEach((group) => {
|
||||||
flatList.push({ type: "group", ...group });
|
|
||||||
const items = itemMap[group[container.groupidfield]];
|
const items = itemMap[group[container.groupidfield]];
|
||||||
if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
|
const groupLabel = String(group[container.grouplabelfield] || "").toLowerCase();
|
||||||
|
const groupSummary = String(group[container.groupsummaryfield] || "").toLowerCase();
|
||||||
|
const q = typeof filter === "string" ? filter.trim().toLowerCase() : "";
|
||||||
|
const groupMatches = q ? groupLabel.includes(q) || groupSummary.includes(q) : false;
|
||||||
|
if (items && items.length > 0 || groupMatches || !filter) {
|
||||||
|
flatList.push({ type: "group", ...group });
|
||||||
|
if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else if (container.mode === "tree") {
|
} else if (container.mode === "tree") {
|
||||||
|
const itemMap = {};
|
||||||
|
list.forEach((item) => {
|
||||||
|
itemMap[item[container.idfield]] = item;
|
||||||
|
});
|
||||||
|
const matches = /* @__PURE__ */ new Set();
|
||||||
|
list.forEach((item) => {
|
||||||
|
let match = false;
|
||||||
|
if (!filter) {
|
||||||
|
match = true;
|
||||||
|
} else if (typeof filter === "function") {
|
||||||
|
match = filter(item);
|
||||||
|
} else if (typeof filter === "string") {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
match = true;
|
||||||
|
} else {
|
||||||
|
const label = String(item[container.labelfield] || "").toLowerCase();
|
||||||
|
const summary = String(item[container.summaryfield] || "").toLowerCase();
|
||||||
|
match = label.includes(q) || summary.includes(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
matches.add(item[container.idfield]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const keep = new Set(matches);
|
||||||
|
if (filter) {
|
||||||
|
matches.forEach((id) => {
|
||||||
|
let curr = itemMap[id];
|
||||||
|
while (curr) {
|
||||||
|
const parentId = curr[container.parentfield];
|
||||||
|
if (!parentId) break;
|
||||||
|
if (keep.has(parentId)) break;
|
||||||
|
keep.add(parentId);
|
||||||
|
curr = itemMap[parentId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
const childrenMap = {};
|
const childrenMap = {};
|
||||||
list.forEach((item) => {
|
list.forEach((item) => {
|
||||||
var _a;
|
if (!filter || keep.has(item[container.idfield])) {
|
||||||
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
|
const parentId = item[container.parentfield] || "";
|
||||||
|
(childrenMap[parentId] ?? (childrenMap[parentId] = [])).push(item);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const traverse = (items, level, parents) => items.forEach((item) => {
|
const traverse = (items, level, parents) => items.forEach((item) => {
|
||||||
var _a;
|
var _a2;
|
||||||
const id = item[container.idfield], hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
|
const id = item[container.idfield];
|
||||||
|
const hasChildren = !!((_a2 = childrenMap[id]) == null ? void 0 : _a2.length);
|
||||||
const isCollapsed = container.collapsed[id];
|
const isCollapsed = container.collapsed[id];
|
||||||
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
|
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
|
||||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
|
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
|
||||||
});
|
});
|
||||||
traverse(childrenMap[""] || [], 0, []);
|
traverse(childrenMap[""] || [], 0, []);
|
||||||
} else list.forEach((item) => flatList.push({ type: "item", ...item }));
|
} else {
|
||||||
|
let filteredList = list;
|
||||||
|
if (filter) {
|
||||||
|
if (typeof filter === "function") {
|
||||||
|
filteredList = list.filter(filter);
|
||||||
|
} else if (typeof filter === "string") {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
filteredList = list.filter((item) => {
|
||||||
|
const label = String(item[container.labelfield] || "").toLowerCase();
|
||||||
|
const summary = String(item[container.summaryfield] || "").toLowerCase();
|
||||||
|
return label.includes(q) || summary.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filteredList.forEach((item) => flatList.push({ type: "item", ...item }));
|
||||||
|
}
|
||||||
container.state.flatList = flatList;
|
container.state.flatList = flatList;
|
||||||
};
|
};
|
||||||
container.state.__watch("list", updateFlatList);
|
container.state.__watch("list", updateFlatList);
|
||||||
|
container.state.__watch("filter", updateFlatList);
|
||||||
|
container.state.__watch("order", updateFlatList);
|
||||||
const vs = container.fast ? globalThis.VirtualScroll() : null;
|
const vs = container.fast ? globalThis.VirtualScroll() : null;
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
container.refresh = () => {
|
container.refresh = () => {
|
||||||
@ -796,6 +945,31 @@
|
|||||||
updateFlatList();
|
updateFlatList();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Object.defineProperty(container, "filter", {
|
||||||
|
get: () => container.state.filter,
|
||||||
|
set: (v) => {
|
||||||
|
container.state.filter = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.defineProperty(container, "order", {
|
||||||
|
get: () => container.state.order,
|
||||||
|
set: (v) => {
|
||||||
|
container.state.order = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const filterAttr = container.getAttribute("filter");
|
||||||
|
if (typeof container.state.filter !== "function" && filterAttr !== container.state.filter) {
|
||||||
|
container.state.filter = filterAttr || void 0;
|
||||||
|
}
|
||||||
|
const orderAttr = container.getAttribute("order");
|
||||||
|
if (typeof container.state.order !== "function" && orderAttr !== container.state.order) {
|
||||||
|
container.state.order = orderAttr || void 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(container, { attributes: true, attributeFilter: ["filter", "order"] });
|
||||||
|
container.state.filter = container.getAttribute("filter") || void 0;
|
||||||
|
container.state.order = container.getAttribute("order") || void 0;
|
||||||
updateFlatList();
|
updateFlatList();
|
||||||
}, globalThis.Util.makeDom(
|
}, globalThis.Util.makeDom(
|
||||||
/*html*/
|
/*html*/
|
||||||
@ -838,55 +1012,104 @@
|
|||||||
));
|
));
|
||||||
globalThis.Component.register("Nav", (container) => {
|
globalThis.Component.register("Nav", (container) => {
|
||||||
container.vertical = container.hasAttribute("vertical");
|
container.vertical = container.hasAttribute("vertical");
|
||||||
|
container.state.openName = container.state.openName || null;
|
||||||
container.click = (item, noselect) => {
|
container.click = (item, noselect) => {
|
||||||
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name;
|
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name;
|
||||||
container.dispatchEvent(new CustomEvent("nav", { detail: { item }, bubbles: false }));
|
container.dispatchEvent(new CustomEvent("nav", { detail: { item }, bubbles: false }));
|
||||||
};
|
};
|
||||||
|
container.clickSubitem = (item) => {
|
||||||
|
container.dispatchEvent(new CustomEvent("nav", { detail: { item }, bubbles: false }));
|
||||||
|
};
|
||||||
|
container.toggleGroup = (item) => {
|
||||||
|
container.state.openName = container.state.openName === item.name ? null : item.name;
|
||||||
|
container.dispatchEvent(new CustomEvent("nav", { detail: { item, open: container.state.openName === item.name }, bubbles: false }));
|
||||||
|
};
|
||||||
}, globalThis.Util.makeDom(
|
}, globalThis.Util.makeDom(
|
||||||
/*html*/
|
/*html*/
|
||||||
`
|
`
|
||||||
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-3 \${this.vertical ? 'py-3' : 'pb-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}">
|
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
<template $if="this.state?.brand?.image">
|
<template $if="this.state?.brand?.image">
|
||||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px">
|
||||||
</template>
|
</template>
|
||||||
<template $if="this.state?.brand?.icon">
|
<template $if="this.state?.brand?.icon">
|
||||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i>
|
||||||
</template>
|
</template>
|
||||||
<template $if="this.state?.brand?.label">
|
<template $if="this.state?.brand?.label">
|
||||||
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
||||||
</template>
|
|
||||||
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></div>
|
|
||||||
<template $each="this.state?.list || []">
|
|
||||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
|
||||||
<template $if="item.type==='button'">
|
|
||||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
|
|
||||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template $if="item.type==='dropdown'">
|
|
||||||
<div class="dropdown">
|
|
||||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" data-bs-toggle="dropdown">
|
|
||||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
|
||||||
</button>
|
|
||||||
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
|
||||||
<template $each="item.list" as="subitem">
|
|
||||||
<template $if="subitem.type==='button'">
|
|
||||||
<button class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
|
||||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template $if="subitem.type==='switch'">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
|
||||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
|
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
|
||||||
|
<template $each="this.state?.list || []">
|
||||||
|
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
|
||||||
|
<template $if="item.type==='label'">
|
||||||
|
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
|
||||||
|
</template>
|
||||||
|
<template $if="item.type==='button'">
|
||||||
|
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'w-100 text-start py-1 px-2' : ''} \${item.class || ''}" $onclick="this.click(item)">
|
||||||
|
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? 'text-truncate' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template $if="item.type==='dropdown'">
|
||||||
|
<template $if="this.vertical">
|
||||||
|
<div class="w-100">
|
||||||
|
<button type="button" $class="nav-link w-100 text-start py-1 px-2 d-flex align-items-center justify-content-between \${item.class || ''}" $onclick="this.toggleGroup(item)">
|
||||||
|
<span class="d-inline-flex align-items-center">
|
||||||
|
<i $class="bi bi-\${item.icon} me-2"></i><span class="text-truncate" $text="item.label"></span>
|
||||||
|
</span>
|
||||||
|
<i $class="bi \${this.state.openName===item.name?'bi-chevron-down':'bi-chevron-right'} small ms-2"></i>
|
||||||
|
</button>
|
||||||
|
<template $if="this.state.openName===item.name">
|
||||||
|
<div class="d-flex flex-column w-100 ps-2 mt-1">
|
||||||
|
<template $each="item.list" as="subitem">
|
||||||
|
<template $if="subitem.type==='label'">
|
||||||
|
<div $class="small text-uppercase text-body-secondary fw-semibold px-2 py-1 \${subitem.class || ''}" $text="subitem.label"></div>
|
||||||
|
</template>
|
||||||
|
<template $if="subitem.type==='button'">
|
||||||
|
<button $class="nav-link w-100 text-start py-1 px-2 \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
|
||||||
|
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template $if="subitem.type==='switch'">
|
||||||
|
<div $class="d-flex align-items-center px-2 py-2 \${subitem.class || ''}">
|
||||||
|
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||||
|
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template $if="!this.vertical">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" data-bs-toggle="dropdown">
|
||||||
|
<i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
|
||||||
|
<template $each="item.list" as="subitem">
|
||||||
|
<template $if="subitem.type==='label'">
|
||||||
|
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
|
||||||
|
</template>
|
||||||
|
<template $if="subitem.type==='button'">
|
||||||
|
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
|
||||||
|
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template $if="subitem.type==='switch'">
|
||||||
|
<div $class="d-flex align-items-center px-3 py-2 \${subitem.class || ''}">
|
||||||
|
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||||
|
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
));
|
));
|
||||||
|
|||||||
20
test/lib/bootstrap.js
vendored
20
test/lib/bootstrap.js
vendored
@ -2121,7 +2121,7 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
(function(global, factory) {
|
(function(global, factory) {
|
||||||
typeof exports === "object" && typeof module !== "undefined" ? factory(exports, require("@apigo.cc/state")) : typeof define === "function" && define.amd ? define(["exports", "@apigo.cc/state"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoBootstrap = {}));
|
typeof exports === "object" && typeof module !== "undefined" ? factory(exports, require("@apigo.cc/state")) : typeof define === "function" && define.amd ? define(["exports", "@apigo.cc/state"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoBootstrap = global.ApigoBootstrap || {}));
|
||||||
})(this, function(exports2) {
|
})(this, function(exports2) {
|
||||||
"use strict";
|
"use strict";
|
||||||
var top = "top";
|
var top = "top";
|
||||||
@ -7233,6 +7233,24 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
border-color: var(--bs-primary) !important;
|
border-color: var(--bs-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 导航与下拉菜单 (Nav & Dropdown) */
|
||||||
|
.nav {
|
||||||
|
--bs-nav-link-color: var(--bs-body-color) !important;
|
||||||
|
--bs-nav-link-hover-color: var(--bs-primary) !important;
|
||||||
|
}
|
||||||
|
.nav-pills {
|
||||||
|
--bs-nav-pills-link-active-bg: var(--bs-primary) !important;
|
||||||
|
--bs-nav-pills-link-active-color: #fff !important;
|
||||||
|
}
|
||||||
|
.dropdown-menu {
|
||||||
|
--bs-dropdown-link-active-bg: var(--bs-primary) !important;
|
||||||
|
--bs-dropdown-link-active-color: #fff !important;
|
||||||
|
}
|
||||||
|
.navbar-nav .nav-link.active,
|
||||||
|
.navbar-nav .nav-link.show {
|
||||||
|
color: var(--bs-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 列表组 (List Group) */
|
/* 列表组 (List Group) */
|
||||||
.list-group-item.active {
|
.list-group-item.active {
|
||||||
background-color: var(--bs-primary) !important;
|
background-color: var(--bs-primary) !important;
|
||||||
|
|||||||
@ -385,6 +385,7 @@
|
|||||||
if (existingNodes) {
|
if (existingNodes) {
|
||||||
node._keyedNodes.delete(keyVal);
|
node._keyedNodes.delete(keyVal);
|
||||||
existingNodes.forEach((child) => {
|
existingNodes.forEach((child) => {
|
||||||
|
node.parentNode.insertBefore(child, node);
|
||||||
child._ref[indexName] = k;
|
child._ref[indexName] = k;
|
||||||
child._ref[asName] = item;
|
child._ref[asName] = item;
|
||||||
_scanTree(child);
|
_scanTree(child);
|
||||||
|
|||||||
90
test/list.spec.js
Normal file
90
test/list.spec.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('List fixtures cover normal, grouped, tree and virtual modes', async ({ page }) => {
|
||||||
|
await page.goto('/test/list_test.html');
|
||||||
|
|
||||||
|
await expect(page.locator('#listStd .list-group-item').first()).toBeVisible();
|
||||||
|
await expect(page.locator('#listGrp .list-group-item').first()).toBeVisible();
|
||||||
|
await expect(page.locator('#listTree .list-group-item').first()).toBeVisible();
|
||||||
|
await expect(page.locator('#listFast .list-group-item').first()).toBeVisible();
|
||||||
|
|
||||||
|
const treeBefore = await page.locator('#listTree .list-group-item').count();
|
||||||
|
await page.locator('#listTree .bi-caret-down-fill').first().click();
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const treeAfter = await page.locator('#listTree .list-group-item').count();
|
||||||
|
expect(treeAfter).toBeLessThan(treeBefore);
|
||||||
|
|
||||||
|
const fastInfo = await page.locator('#listFast').evaluate(async node => {
|
||||||
|
const visibleText = () => Array.from(node.querySelectorAll('.list-group-item .fw-bold')).map(el => el.textContent.trim());
|
||||||
|
const initial = visibleText();
|
||||||
|
node.scrollTop = node.scrollHeight;
|
||||||
|
node.dispatchEvent(new Event('scroll'));
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
const final = visibleText();
|
||||||
|
return {
|
||||||
|
count: node.querySelectorAll('.list-group-item').length,
|
||||||
|
scrollTop: node.scrollTop,
|
||||||
|
initialFirst: initial[0],
|
||||||
|
finalFirst: final[0],
|
||||||
|
finalLast: final[final.length - 1]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fastInfo.count).toBeGreaterThan(0);
|
||||||
|
expect(fastInfo.count).toBeLessThan(400);
|
||||||
|
expect(fastInfo.scrollTop).toBeGreaterThan(0);
|
||||||
|
expect(fastInfo.initialFirst).not.toBe(fastInfo.finalFirst);
|
||||||
|
expect(fastInfo.finalLast).toContain('Virtual Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('List component supports internal filtering and sorting', async ({ page }) => {
|
||||||
|
await page.goto('/test/list_test.html');
|
||||||
|
|
||||||
|
// 1. Test Standard List Filtering
|
||||||
|
await page.fill('#searchInput', 'Item 5');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const itemsText = await page.locator('#listStd .list-group-item').allTextContents();
|
||||||
|
// Item 5, Item 50 should be there, but not Item 1, 2, etc.
|
||||||
|
expect(itemsText.every(text => text.includes('Item 5'))).toBe(true);
|
||||||
|
expect(itemsText.length).toBe(2); // Item 5 and Item 50
|
||||||
|
|
||||||
|
// Clear filter
|
||||||
|
await page.fill('#searchInput', '');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// 2. Test Standard List Sorting
|
||||||
|
// Default is: Item 1, Item 2...
|
||||||
|
await page.selectOption('#sortSelect', '-label');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const sortedText = await page.locator('#listStd .list-group-item').first().textContent();
|
||||||
|
expect(sortedText.trim()).toContain('Item 9'); // Lexicographically descending
|
||||||
|
|
||||||
|
// 3. Test Group List Filtering
|
||||||
|
await page.fill('#grpSearchInput', 'Member 3');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
// Since grpItems have label 'Member X' and group fields. Only member matching '3' or '30' or '31' etc. will show.
|
||||||
|
const grpItemsCount = await page.locator('#listGrp .list-group-item').count();
|
||||||
|
// Should be filtered down considerably
|
||||||
|
expect(grpItemsCount).toBeLessThan(40);
|
||||||
|
|
||||||
|
// 4. Test Tree List Filtering (Crucial check for ancestry propagation)
|
||||||
|
await page.fill('#treeSearchInput', 'UI Team');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// UI Team should be visible
|
||||||
|
const uiTeam = page.locator('#listTree .list-group-item', { hasText: 'UI Team' });
|
||||||
|
await expect(uiTeam).toBeVisible();
|
||||||
|
|
||||||
|
// Ancestor "Company HQ" and "R&D Division" must also be visible
|
||||||
|
const parent1 = page.locator('#listTree .list-group-item', { hasText: 'Company HQ' });
|
||||||
|
const parent2 = page.locator('#listTree .list-group-item', { hasText: 'R&D Division' });
|
||||||
|
await expect(parent1).toBeVisible();
|
||||||
|
await expect(parent2).toBeVisible();
|
||||||
|
|
||||||
|
// Sibling node "Operations" or "Cloud Ops" should NOT be visible
|
||||||
|
const sibling1 = page.locator('#listTree .list-group-item', { hasText: 'Operations' });
|
||||||
|
const sibling2 = page.locator('#listTree .list-group-item', { hasText: 'Cloud Ops' });
|
||||||
|
await expect(sibling1).not.toBeVisible();
|
||||||
|
await expect(sibling2).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
@ -16,16 +16,32 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
<div class="list-card">
|
<div class="list-card">
|
||||||
<h5>1. Standard List (Normal)</h5>
|
<h5 class="d-flex align-items-center justify-content-between">
|
||||||
<List id="listStd" class="flex-fill" $.state.list="State.stdItems" auto-select></List>
|
<span>1. Standard List (Normal)</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<input type="text" id="searchInput" class="form-control form-control-sm" style="width: 120px;" placeholder="Search..." $bind="State.stdFilter">
|
||||||
|
<select id="sortSelect" class="form-select form-select-sm" style="width: 100px;" $bind="State.stdOrder">
|
||||||
|
<option value="">No Sort</option>
|
||||||
|
<option value="label">Label Asc</option>
|
||||||
|
<option value="-label">Label Desc</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</h5>
|
||||||
|
<List id="listStd" class="flex-fill" $.state.list="State.stdItems" $filter="State.stdFilter || ''" $order="State.stdOrder" auto-select></List>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-card">
|
<div class="list-card">
|
||||||
<h5>2. Group List (Mode: Group)</h5>
|
<h5 class="d-flex align-items-center justify-content-between">
|
||||||
<List id="listGrp" mode="group" class="flex-fill" $.state.list="State.grpItems" $.state.groups="State.groups"></List>
|
<span>2. Group List (Mode: Group)</span>
|
||||||
|
<input type="text" id="grpSearchInput" class="form-control form-control-sm" style="width: 120px;" placeholder="Search..." $bind="State.grpFilter">
|
||||||
|
</h5>
|
||||||
|
<List id="listGrp" mode="group" class="flex-fill" $.state.list="State.grpItems" $.state.groups="State.groups" $filter="State.grpFilter || ''"></List>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-card">
|
<div class="list-card">
|
||||||
<h5>3. Tree List (Mode: Tree + Collapsible)</h5>
|
<h5 class="d-flex align-items-center justify-content-between">
|
||||||
<List id="listTree" mode="tree" collapsible class="flex-fill" $.state.list="State.treeItems"></List>
|
<span>3. Tree List (Mode: Tree + Collapsible)</span>
|
||||||
|
<input type="text" id="treeSearchInput" class="form-control form-control-sm" style="width: 120px;" placeholder="Search..." $bind="State.treeFilter">
|
||||||
|
</h5>
|
||||||
|
<List id="listTree" mode="tree" collapsible class="flex-fill" $.state.list="State.treeItems" $filter="State.treeFilter || ''"></List>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-card">
|
<div class="list-card">
|
||||||
<h5>4. FAST Virtual List (10,000 Items + Dynamic Height)</h5>
|
<h5>4. FAST Virtual List (10,000 Items + Dynamic Height)</h5>
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('Empirical Mega Verification', async ({ page }) => {
|
|
||||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
|
||||||
|
|
||||||
// 1. Verify AutoForm
|
|
||||||
console.log('--- Verifying AutoForm Mega ---');
|
|
||||||
await page.goto('http://localhost:5173/test/form_test.html');
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const checkForm = async (id) => {
|
|
||||||
return await page.evaluate((fid) => {
|
|
||||||
const form = document.getElementById(fid);
|
|
||||||
const inputs = form.querySelectorAll('input, select, textarea');
|
|
||||||
const labels = form.querySelectorAll('label');
|
|
||||||
return {
|
|
||||||
id: fid,
|
|
||||||
inputCount: inputs.length,
|
|
||||||
labelCount: labels.length,
|
|
||||||
html: form.innerHTML.substring(0, 100)
|
|
||||||
};
|
|
||||||
}, id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const vResult = await checkForm('formV');
|
|
||||||
const hResult = await checkForm('formH');
|
|
||||||
const iResult = await checkForm('formI');
|
|
||||||
|
|
||||||
console.log('Form results:', { vResult, hResult, iResult });
|
|
||||||
expect(vResult.inputCount).toBeGreaterThan(5);
|
|
||||||
expect(hResult.inputCount).toBeGreaterThan(5);
|
|
||||||
|
|
||||||
// 2. Verify List
|
|
||||||
console.log('--- Verifying List Mega ---');
|
|
||||||
await page.goto('http://localhost:5173/test/list_test.html');
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const checkList = async (id) => {
|
|
||||||
return await page.evaluate((lid) => {
|
|
||||||
const list = document.getElementById(lid);
|
|
||||||
const items = list.querySelectorAll('.list-group-item');
|
|
||||||
return {
|
|
||||||
id: lid,
|
|
||||||
itemCount: items.length,
|
|
||||||
labels: Array.from(items).map(i => i.textContent.trim())
|
|
||||||
};
|
|
||||||
}, id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stdResult = await checkList('listStd');
|
|
||||||
const grpResult = await checkList('listGrp');
|
|
||||||
const treeResult = await checkList('listTree');
|
|
||||||
|
|
||||||
console.log('List results:', { stdResult, grpResult, treeResult });
|
|
||||||
|
|
||||||
expect(stdResult.itemCount).toBeGreaterThan(0);
|
|
||||||
expect(grpResult.itemCount).toBeGreaterThan(2);
|
|
||||||
expect(treeResult.itemCount).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('Stress test: Virtual List should handle 10,000 items with dynamic height', async ({ page }) => {
|
|
||||||
test.setTimeout(60000);
|
|
||||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
|
||||||
|
|
||||||
await page.goto('http://localhost:5174/test/list_test.html');
|
|
||||||
|
|
||||||
const listFast = page.locator('#listFast');
|
|
||||||
await expect(listFast).toBeVisible();
|
|
||||||
await page.waitForFunction(() => document.querySelectorAll('#listFast .list-group-item').length > 0);
|
|
||||||
|
|
||||||
// Check initial state
|
|
||||||
const scrollTop = await listFast.evaluate(e => e.scrollTop);
|
|
||||||
const scrollHeight = await listFast.evaluate(e => e.scrollHeight);
|
|
||||||
const clientHeight = await listFast.evaluate(e => e.clientHeight);
|
|
||||||
console.log(`Initial: scrollTop=${scrollTop}, scrollHeight=${scrollHeight}, clientHeight=${clientHeight}`);
|
|
||||||
|
|
||||||
// Attach event listener to see if scroll fires
|
|
||||||
await listFast.evaluate(e => {
|
|
||||||
e.addEventListener('scroll', () => console.log('SCROLL EVENT FIRED! new scrollTop:', e.scrollTop));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll to the very end
|
|
||||||
await listFast.evaluate(e => e.scrollTop = e.scrollHeight);
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
const finalScrollTop = await listFast.evaluate(e => e.scrollTop);
|
|
||||||
console.log(`Final: scrollTop=${finalScrollTop}`);
|
|
||||||
|
|
||||||
const lastItemText = await listFast.locator('.list-group-item').last().textContent();
|
|
||||||
console.log('Last rendered item text:', lastItemText);
|
|
||||||
expect(lastItemText).toContain('Virtual Item 10000');
|
|
||||||
});
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('Check styles and globals', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:5174/test/form_test.html');
|
|
||||||
|
|
||||||
// Check if auto-grid-form is grid (horizontal mode)
|
|
||||||
const formH = page.locator('#formH form');
|
|
||||||
const display = await formH.evaluate(el => window.getComputedStyle(el).display);
|
|
||||||
console.log('formH display:', display);
|
|
||||||
expect(display).toBe('grid');
|
|
||||||
|
|
||||||
// Get color picker input to check height
|
|
||||||
const colorInput = page.locator('#formH input[type="color"]');
|
|
||||||
if (await colorInput.count() > 0) {
|
|
||||||
const height = await colorInput.evaluate(el => window.getComputedStyle(el).height);
|
|
||||||
console.log('colorInput height:', height);
|
|
||||||
// It shouldn't be very small (like 0 or 2px)
|
|
||||||
expect(parseInt(height)).toBeGreaterThan(20);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user