Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0a2d77228 | |||
| 3647868172 | |||
| c9df5ba8f3 | |||
| be6bfae4ac | |||
| e5077aeefa |
@@ -1,34 +1,143 @@
|
|||||||
# trans-form-middleware — Design (Q1–Q61)
|
# @windychen/trans-form-middleware — Design (Q1–Q61)
|
||||||
|
|
||||||
Status: **implementation scaffold** (v0.1.0)
|
> 设计基线:grill-me 61 题访谈结论(2026-08-06)。配套包:`@windychen/trans-form-component`(Vue 3 动态查询表单)。
|
||||||
|
|
||||||
Companion package: `@windychen/trans-form-component` (Vue 3 dynamic query form).
|
## 角色定位
|
||||||
|
|
||||||
## Role
|
接收组件输出的 filter 数组,解析为结构化条件 `{ field, query, op, value, logic?, values?, empty? }[]`。**不生成 SQL** —— 由上层 ORM/DAO 适配器消费输出。
|
||||||
|
|
||||||
Receive component filter output → structured `{ field, query, op, value, logic?, values?, empty? }[]`. **No SQL generation.**
|
```js
|
||||||
|
import { parseFilters } from '@windychen/trans-form-middleware';
|
||||||
|
|
||||||
## Key decisions
|
const filters = parseFilters(
|
||||||
|
[
|
||||||
|
{ field: 'name', value: 'xx', query: 'EQ' },
|
||||||
|
{ field: 'age', value: '18,30', query: 'BETWEEN', logic: 'AND' }
|
||||||
|
],
|
||||||
|
{ allowedFields: ['name', 'age'] }
|
||||||
|
);
|
||||||
|
// → [{ field: 'name', query: 'EQ', op: '=', value: 'xx' },
|
||||||
|
// { field: 'age', query: 'BETWEEN', op: 'BETWEEN', value: '18,30', values: ['18', '30'], logic: 'AND' }]
|
||||||
|
```
|
||||||
|
|
||||||
| # | Decision |
|
---
|
||||||
|---|----------|
|
|
||||||
| 1–2 | Node.js pure JS; structured filters only |
|
|
||||||
| 8 | Dual fields: `query` (code) + `op` (symbol) |
|
|
||||||
| 14 | `mode`: pass-through (default) / flat / tree |
|
|
||||||
| 24–25 | Zero deps; ESM only; no bundling (`src/index.js`) |
|
|
||||||
| 38 | `empty: true` preserved by default |
|
|
||||||
| 39 | Built-in `DEFAULT_OPERATOR_MAP`, merge via `operatorMap` |
|
|
||||||
| 41 | Multi-function exports: `parseFilters`, `toTree`, `mapOperators`, … |
|
|
||||||
| 44 | `strict` default false |
|
|
||||||
| 46 | Multi-value: `value` (string) + `values` (parsed array) |
|
|
||||||
| 49–50 | `allowedFields` required; `unknownField: 'error'` default |
|
|
||||||
| 60 | Component emits all state flags; middleware skips `disabled` by default |
|
|
||||||
| 61 | Also skips `hidden`; `skipEmpty` option controls empty items |
|
|
||||||
|
|
||||||
## Output example
|
## 一、架构与发布
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|------|
|
||||||
|
| 1 | middleware:**Node.js 纯 JS** |
|
||||||
|
| 2 | middleware 输出:**结构化过滤器**,不碰 SQL |
|
||||||
|
| 3 | **两个独立 Gitea 仓库**,各自 CI、各自发版 |
|
||||||
|
| 24 | middleware:**零依赖**;后续另做 Knex/TypeORM 适配包 |
|
||||||
|
| 25 | middleware 发布:**ESM only** |
|
||||||
|
| 27 | middleware 构建:**不打包**,`package.json` 指向 `src/index.js` |
|
||||||
|
| 28 | 测试:**Vitest**,CI 里 `npm test` 通过才发布 |
|
||||||
|
| 29 | 版本号:**完全独立**,兼容性写 README |
|
||||||
|
| 21 | CI:**复制 windychen-utils 模式**(三关键词 → Verdaccio + Gitea Packages + Release + 钉钉) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、解析规则
|
||||||
|
|
||||||
|
### 输出形态
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|------|
|
||||||
|
| 8 | 输出同时保留 **`query`(原始码)和 `op`(标准符号)** |
|
||||||
|
| 14 | `parseOptions.mode`:flat / tree / pass-through,**默认透传 logic** |
|
||||||
|
| 46 | 多值:同时保留 **`value`(原串)和 `values`(解析数组)** |
|
||||||
|
|
||||||
|
### 过滤与校验
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|------|
|
||||||
|
| 44 | `parseOptions.strict`,**默认 false(宽松)** |
|
||||||
|
| 49 | 必须传 **`parseOptions.allowedFields` 白名单** |
|
||||||
|
| 50 | `unknownField: 'error' \| 'skip'`,**默认 `'error'`** |
|
||||||
|
|
||||||
|
### 状态标记处理(Q60–Q61)
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|------|
|
||||||
|
| 60 | 组件 filters **全部带状态标记**输出;middleware **默认忽略 disabled**,readOnly 正常解析 |
|
||||||
|
| 61 | `hidden: true` **默认跳过**(与 disabled 一致);`empty: true` **默认保留**(原样透传),可通过 `parseOptions.skipEmpty: true/false` 控制是否跳过 |
|
||||||
|
|
||||||
|
### 空值
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|------|
|
||||||
|
| 38 | `empty: true`:**原样保留**,调用方自行过滤(`skipEmpty` 可覆盖) |
|
||||||
|
| 9 | 组件侧空 value 带 `empty: true` 标记输出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、操作符
|
||||||
|
|
||||||
|
### 内置码表(Q39)
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|------|
|
||||||
|
| 39 | 内置默认 **`DEFAULT_OPERATOR_MAP`**,可 merge/覆盖(`operatorMap` 选项) |
|
||||||
|
| 12 | v1 操作符:比较 + LIKE/IN/NOT_IN + BETWEEN/IS_NULL/IS_NOT_NULL |
|
||||||
|
|
||||||
|
| 码 | 符号 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| `EQ` | `=` | 等于 |
|
||||||
|
| `NE` | `<>` | 不等于 |
|
||||||
|
| `GT` | `>` | 大于 |
|
||||||
|
| `GTE` | `>=` | 大于等于 |
|
||||||
|
| `LT` | `<` | 小于 |
|
||||||
|
| `LTE` | `<=` | 小于等于 |
|
||||||
|
| `LIKE` | `LIKE` | 模糊 |
|
||||||
|
| `IN` | `IN` | 属于 |
|
||||||
|
| `NOT_IN` | `NOT IN` | 不属于 |
|
||||||
|
| `BETWEEN` | `BETWEEN` | 区间 |
|
||||||
|
| `IS_NULL` | `IS NULL` | 为空 |
|
||||||
|
| `IS_NOT_NULL` | `IS NOT NULL` | 非空 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、API 表面
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|------|
|
||||||
|
| 40 | 回填能力在 **component 侧**(`setFilters()` / `loadFilters()` / `reset()` ref API,见 component DESIGN 输出协议);middleware 只做单向解析 |
|
||||||
|
| 41 | 多函数导出:`parseFilters` / `toTree` / `mapOperators` / `splitMultiValue` 等 |
|
||||||
|
|
||||||
|
### 导出函数
|
||||||
|
|
||||||
|
| 函数 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `parseFilters(filters, parseOptions?)` | 主入口:校验白名单、映射操作符、处理状态标记、多值拆分 |
|
||||||
|
| `toTree(filters, parseOptions?)` | 按 `logic` 组装嵌套分组树 |
|
||||||
|
| `mapOperators(filters, operatorMap?)` | 只做操作符码 → 符号映射 |
|
||||||
|
| `splitMultiValue(value, separator?)` | 逗号分隔字符串 → 数组 |
|
||||||
|
|
||||||
|
### parseOptions 汇总
|
||||||
|
|
||||||
|
| 选项 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `allowedFields` | **必传** | 字段白名单 |
|
||||||
|
| `unknownField` | `'error'` | 未知字段:报错或跳过 |
|
||||||
|
| `strict` | `false` | 宽松/严格模式 |
|
||||||
|
| `mode` | `pass-through` | flat / tree / pass-through |
|
||||||
|
| `operatorMap` | `DEFAULT_OPERATOR_MAP` | 合并/覆盖码表 |
|
||||||
|
| `skipEmpty` | `false` | 是否跳过 `empty: true` 项 |
|
||||||
|
| `skipFlags` | `['disabled', 'hidden']` | 默认跳过的状态标记 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、输出示例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
"field": "name",
|
||||||
|
"query": "EQ",
|
||||||
|
"op": "=",
|
||||||
|
"value": "xx"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"field": "tags",
|
"field": "tags",
|
||||||
"query": "IN",
|
"query": "IN",
|
||||||
@@ -36,14 +145,35 @@ Receive component filter output → structured `{ field, query, op, value, logic
|
|||||||
"value": "a,b,c",
|
"value": "a,b,c",
|
||||||
"values": ["a", "b", "c"],
|
"values": ["a", "b", "c"],
|
||||||
"logic": "OR"
|
"logic": "OR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "status",
|
||||||
|
"query": "IS_NULL",
|
||||||
|
"op": "IS NULL",
|
||||||
|
"value": "",
|
||||||
|
"empty": true,
|
||||||
|
"logic": "AND"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operator map
|
---
|
||||||
|
|
||||||
`EQ→=`, `NE→<>`, `GT→>`, `GTE→>=`, `LT→<`, `LTE→<=`, `LIKE→LIKE`, `IN→IN`, `NOT_IN→NOT IN`, `BETWEEN→BETWEEN`, `IS_NULL→IS NULL`, `IS_NOT_NULL→IS NOT NULL`
|
## 六、与 component 的协议对齐
|
||||||
|
|
||||||
## CI
|
| 项 | 约定 |
|
||||||
|
|----|------|
|
||||||
|
| 输入 | component `getFilters()` / `@change` 输出 |
|
||||||
|
| 输出字段 | `field`、`value`、`query`、`op`、`logic?`、`values?`、`empty?` |
|
||||||
|
| 状态标记 | `disabled` / `hidden` 默认跳过;`readOnly` 正常解析 |
|
||||||
|
| 空值 | `empty: true` 默认保留,`skipEmpty` 可配 |
|
||||||
|
|
||||||
Copy of windychen-utils pattern: commit message must contain `chore` + `版本` + `更新` to trigger publish.
|
---
|
||||||
|
|
||||||
|
## 七、CI 触发约定
|
||||||
|
|
||||||
|
commit message 必须同时包含 `chore` + `版本` + `更新` 才触发构建发布;否则 job 显示 skipped(设计行为,防误触)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "chore: 版本 0.1.0 更新 ..."
|
||||||
|
```
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ See `DESIGN.md` for the full protocol and grill-me decisions (Q1–Q61).
|
|||||||
| `skipFlags` | `['disabled','hidden']` | Skip items with these truthy flags |
|
| `skipFlags` | `['disabled','hidden']` | Skip items with these truthy flags |
|
||||||
| `skipEmpty` | `false` | Skip items with `empty: true` |
|
| `skipEmpty` | `false` | Skip items with `empty: true` |
|
||||||
|
|
||||||
|
### 状态标记默认行为(Q60/Q61)
|
||||||
|
|
||||||
|
| 标记 | 默认处理 |
|
||||||
|
|------|----------|
|
||||||
|
| `disabled: true` | 跳过 |
|
||||||
|
| `hidden: true` | 跳过 |
|
||||||
|
| `readOnly: true` | 正常解析 |
|
||||||
|
| `empty: true` | 保留(`skipEmpty: true` 时跳过) |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+62
-8
@@ -1,15 +1,69 @@
|
|||||||
/**
|
/**
|
||||||
|
* Normalize an item: unwrap explicit nested group shapes.
|
||||||
|
*
|
||||||
|
* Supported input shapes:
|
||||||
|
* - plain leaf: { field, query, op, value, ... }
|
||||||
|
* - inline children: { logic, children: [...] }
|
||||||
|
* - group key: { group: [...], logic? }
|
||||||
|
*
|
||||||
|
* Nested children are recursively built into their own tree so inner
|
||||||
|
* groups honor Q6 (nested and/or groups).
|
||||||
|
*
|
||||||
|
* @param {*} item
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
function normalize(item) {
|
||||||
|
if (Array.isArray(item?.children)) {
|
||||||
|
return { ...item, children: toTree(item.children) };
|
||||||
|
}
|
||||||
|
if (Array.isArray(item?.group)) {
|
||||||
|
const { group, ...rest } = item;
|
||||||
|
return { ...rest, children: toTree(group) };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a nested condition tree from a flat filter list.
|
||||||
|
*
|
||||||
|
* Semantics (aligned with component output, Q7/Q54):
|
||||||
|
* - the first item carries no `logic`; each following item's `logic`
|
||||||
|
* expresses how it joins the previous accumulated node
|
||||||
|
* - adjacent items sharing the same `logic` are merged into one
|
||||||
|
* `{ logic, children: [...] }` node (Q6: nested groups)
|
||||||
|
* - mixed logic is left-associative: A AND B OR C -> (A AND B) OR C
|
||||||
|
*
|
||||||
* @param {Array<object>} flat
|
* @param {Array<object>} flat
|
||||||
|
* @param {{ mergeSameLogic?: boolean }} [options]
|
||||||
* @returns {Array<object>}
|
* @returns {Array<object>}
|
||||||
*/
|
*/
|
||||||
export function toTree(flat) {
|
export function toTree(flat, options = {}) {
|
||||||
if (!flat.length) return [];
|
const { mergeSameLogic = true } = options;
|
||||||
if (flat.length === 1) return [flat[0]];
|
if (!Array.isArray(flat) || flat.length === 0) return [];
|
||||||
|
|
||||||
let acc = flat[0];
|
const items = flat.map(normalize);
|
||||||
for (let i = 1; i < flat.length; i++) {
|
if (items.length === 1) return items;
|
||||||
const logic = flat[i].logic || 'AND';
|
|
||||||
acc = { logic, children: [acc, flat[i]] };
|
const tree = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (tree.length === 0) {
|
||||||
|
tree.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logic = (item.logic || 'AND').toUpperCase();
|
||||||
|
const prev = tree[tree.length - 1];
|
||||||
|
|
||||||
|
if (mergeSameLogic && prev.logic === logic && Array.isArray(prev.children)) {
|
||||||
|
prev.children.push(item);
|
||||||
|
} else if (mergeSameLogic && prev.logic === logic) {
|
||||||
|
tree[tree.length - 1] = { logic, children: [prev, item] };
|
||||||
|
} else {
|
||||||
|
const joined = { logic, children: [tree.pop(), item] };
|
||||||
|
tree.push(joined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [acc];
|
|
||||||
|
return tree;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,80 @@ describe('parseFilters', () => {
|
|||||||
);
|
);
|
||||||
expect(out[0].logic).toBeUndefined();
|
expect(out[0].logic).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports tree mode', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[
|
||||||
|
{ field: 'name', value: 'a', query: 'EQ' },
|
||||||
|
{ field: 'status', value: 'b', query: 'EQ', logic: 'OR' },
|
||||||
|
],
|
||||||
|
{ allowedFields, mode: 'tree' },
|
||||||
|
);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].logic).toBe('OR');
|
||||||
|
expect(out[0].children).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips unknown fields when unknownField is skip', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[
|
||||||
|
{ field: 'unknown', value: 'x', query: 'EQ' },
|
||||||
|
{ field: 'name', value: 'a', query: 'EQ' },
|
||||||
|
],
|
||||||
|
{ allowedFields, unknownField: 'skip' },
|
||||||
|
);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].field).toBe('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws aggregated errors in strict mode', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFilters(
|
||||||
|
[
|
||||||
|
{ value: 'x', query: 'EQ' }, // missing field
|
||||||
|
'not-an-object',
|
||||||
|
],
|
||||||
|
{ allowedFields, strict: true },
|
||||||
|
),
|
||||||
|
).toThrow(/validation error/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges custom operatorMap', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[{ field: 'name', value: 'x', query: 'EQ' }],
|
||||||
|
{ allowedFields, operatorMap: { EQ: 'equals' } },
|
||||||
|
);
|
||||||
|
expect(out[0].op).toBe('equals');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom skipFlags', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[
|
||||||
|
{ field: 'name', value: 'a', query: 'EQ', readOnly: true },
|
||||||
|
{ field: 'status', value: 'b', query: 'EQ' },
|
||||||
|
],
|
||||||
|
{ allowedFields, skipFlags: ['readOnly'] },
|
||||||
|
);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].field).toBe('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit values for scalar queries', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[{ field: 'name', value: 'xx', query: 'EQ' }],
|
||||||
|
{ allowedFields },
|
||||||
|
);
|
||||||
|
expect(out[0].values).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses IS_NULL with null value without values', () => {
|
||||||
|
const out = parseFilters(
|
||||||
|
[{ field: 'status', value: null, query: 'IS_NULL' }],
|
||||||
|
{ allowedFields },
|
||||||
|
);
|
||||||
|
expect(out[0]).toMatchObject({ field: 'status', query: 'IS_NULL', op: 'IS NULL', value: null });
|
||||||
|
expect(out[0].values).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toTree', () => {
|
describe('toTree', () => {
|
||||||
@@ -119,6 +193,73 @@ describe('toTree', () => {
|
|||||||
logic: 'OR',
|
logic: 'OR',
|
||||||
children: expect.any(Array),
|
children: expect.any(Array),
|
||||||
});
|
});
|
||||||
|
expect(tree[0].children).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges adjacent same-logic leaves into one node', () => {
|
||||||
|
const flat = [
|
||||||
|
{ field: 'a', query: 'EQ', op: '=', value: '1' },
|
||||||
|
{ field: 'b', query: 'EQ', op: '=', value: '2', logic: 'AND' },
|
||||||
|
{ field: 'c', query: 'EQ', op: '=', value: '3', logic: 'AND' },
|
||||||
|
];
|
||||||
|
const tree = toTree(flat);
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
expect(tree[0].logic).toBe('AND');
|
||||||
|
expect(tree[0].children).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('left-associates mixed logic: A AND B OR C -> (A AND B) OR C', () => {
|
||||||
|
const flat = [
|
||||||
|
{ field: 'a', query: 'EQ', op: '=', value: '1' },
|
||||||
|
{ field: 'b', query: 'EQ', op: '=', value: '2', logic: 'AND' },
|
||||||
|
{ field: 'c', query: 'EQ', op: '=', value: '3', logic: 'OR' },
|
||||||
|
];
|
||||||
|
const tree = toTree(flat);
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
expect(tree[0].logic).toBe('OR');
|
||||||
|
expect(tree[0].children).toHaveLength(2);
|
||||||
|
expect(tree[0].children[0].logic).toBe('AND');
|
||||||
|
expect(tree[0].children[0].children).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports explicit nested group via children key', () => {
|
||||||
|
const flat = [
|
||||||
|
{ field: 'a', query: 'EQ', op: '=', value: '1' },
|
||||||
|
{
|
||||||
|
logic: 'OR',
|
||||||
|
children: [
|
||||||
|
{ field: 'b', query: 'EQ', op: '=', value: '2' },
|
||||||
|
{ field: 'c', query: 'EQ', op: '=', value: '3', logic: 'AND' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const tree = toTree(flat);
|
||||||
|
// a OR (b AND c)
|
||||||
|
expect(tree[0].logic).toBe('OR');
|
||||||
|
expect(tree[0].children).toHaveLength(2);
|
||||||
|
const inner = tree[0].children[1].children;
|
||||||
|
expect(inner).toHaveLength(1);
|
||||||
|
expect(inner[0].logic).toBe('AND');
|
||||||
|
expect(inner[0].children).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports group key shorthand', () => {
|
||||||
|
const flat = [
|
||||||
|
{ field: 'a', query: 'EQ', op: '=', value: '1' },
|
||||||
|
{
|
||||||
|
logic: 'OR',
|
||||||
|
group: [{ field: 'b', query: 'EQ', op: '=', value: '2' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const tree = toTree(flat);
|
||||||
|
expect(tree[0].logic).toBe('OR');
|
||||||
|
expect(tree[0].children).toHaveLength(2);
|
||||||
|
expect(tree[0].children[1].children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(toTree([])).toEqual([]);
|
||||||
|
expect(toTree(null)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user