Add vitest + 43 unit tests for tree.ts
Setup:
- vitest 2.x devDep; pnpm test / pnpm test:watch scripts.
- vite.config.ts test: block (node env, src/**/*.test.ts) via vitest/config.
Coverage in tree.test.ts:
- newLeaf / newSplit (defaults + provided props).
- replaceById (root/nested/no-match, immutability + sibling reuse).
- splitLeaf (orientation, inheritance, no-op on missing id, nested).
- closeLeaf (root -> null, sibling collapse, nested removal, no-op).
- findLeaf / leafCount / walkLeaves (order).
- changeDistro pins the invariant that it MUST swap the leaf id
({#key} remounts XtermPane → kills+respawns PTY).
- changeLabel / toggleBroadcast pin the inverse invariant: id MUST
remain stable (metadata-only mutations).
- All 5 presets: shape, distro propagation, fresh ids per call.
- serialize/deserialize roundtrip + invalid-input rejection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
547b47ded4
commit
b1412287be
5 changed files with 668 additions and 1 deletions
|
|
@ -32,6 +32,8 @@ Durable memory for this project. Read at session start, update before session en
|
|||
- [ ] **Idle detection: filter by "claude is foreground."** Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a `claude` response. Want to detect that `claude` (or any user-specified process) is actually running in the pane's shell before notifying. Needs a Rust-side probe over WSL: `wsl.exe -d <distro> ps --ppid <shell_pid> -o comm=`. Defer to a future polish pass.
|
||||
- [ ] **Native OS notifications.** Right now toasts only show while the app is focused. `tauri-plugin-notification` would push to Windows Action Center; useful for "claude finished" when the app is minimized. Worth adding if/when the user actually backgrounds the app while waiting for sessions.
|
||||
- [ ] **Configurable idle threshold.** Hardcoded 5000ms in `LeafPane.svelte`. Should move into a settings panel; M5 territory.
|
||||
- [x] ~~**Logic tests for `tree.ts`.**~~ Vitest, 43 cases, runs via `pnpm test`. Done 2026-05-22.
|
||||
- [ ] **Component-level tests** (vitest + jsdom + @testing-library/svelte) — would have caught the M4 active-border reactivity bug. Useful when the Svelte component surface stops being trivial; defer until/unless something else goes sideways.
|
||||
- [ ] **Multi-workspace tabs.** Several independent layouts the user can switch between. Saved as `workspaces.json` with `{ current: id, list: [{ id, name, tree }] }`. Not on the M0–M5 critical path; either bolt on after M5 ship or fold into a "tabs" minor milestone.
|
||||
- [ ] **M5 — Ship.** Replace placeholder icons, NSIS installer, Forgejo release. Copy `claude-usage-widget`'s release scripts.
|
||||
- [ ] **M5 — Ship.** Replace placeholder icons, NSIS installer, Forgejo release. Copy `claude-usage-widget`'s release scripts.
|
||||
|
|
@ -41,6 +43,13 @@ Durable memory for this project. Read at session start, update before session en
|
|||
|
||||
## Session log
|
||||
|
||||
### 2026-05-22 — Tests: vitest on tree.ts
|
||||
|
||||
- Added vitest 2.x as a devDep; `pnpm test` / `pnpm test:watch` scripts.
|
||||
- Extended `vite.config.ts` with a `test:` block (node environment, `src/**/*.test.ts`) using `vitest/config`-flavored defineConfig.
|
||||
- New `src/lib/layout/tree.test.ts`: 43 cases covering newLeaf/newSplit (defaults + props), replaceById (immutability + sibling preservation), splitLeaf (inheritance + no-op on miss), closeLeaf (root/sibling-collapse/nested), findLeaf, leafCount, walkLeaves (left-to-right order), changeDistro (**MUST** swap id), changeLabel (**MUST NOT** swap id, trim/clear), toggleBroadcast (**MUST NOT** swap id), all 5 presets (shape + distro propagation + fresh ids), serialize/deserialize roundtrip + invalid-input rejection.
|
||||
- Notable invariants the tests pin down: `changeDistro` swaps the leaf id (we rely on `{#key}` to remount XtermPane → kill the old PTY → spawn a fresh one); `changeLabel` and `toggleBroadcast` keep the same id (metadata-only, no respawn). Regressing either of those silently would break the UX in subtle ways — tests catch it.
|
||||
|
||||
### 2026-05-22 — M4 orchestration (broadcast + notifications + palette)
|
||||
|
||||
- `tree.ts`: added `broadcast?: boolean` to LeafNode; `walkLeaves` generator; `toggleBroadcast` helper (metadata-only, no id swap).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -22,6 +24,7 @@
|
|||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0"
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
276
pnpm-lock.yaml
generated
276
pnpm-lock.yaml
generated
|
|
@ -39,6 +39,9 @@ importers:
|
|||
vite:
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.21
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -445,6 +448,35 @@ packages:
|
|||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||
|
||||
'@vitest/mocker@2.1.9':
|
||||
resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
|
||||
|
||||
'@vitest/runner@2.1.9':
|
||||
resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
|
||||
|
||||
'@vitest/snapshot@2.1.9':
|
||||
resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
|
||||
|
||||
'@vitest/spy@2.1.9':
|
||||
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
|
||||
|
||||
'@vitest/utils@2.1.9':
|
||||
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
|
||||
|
||||
'@xterm/addon-fit@0.10.0':
|
||||
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -462,10 +494,26 @@ packages:
|
|||
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
axobject-query@4.1.0:
|
||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
check-error@2.1.3:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
|
@ -483,6 +531,10 @@ packages:
|
|||
supports-color:
|
||||
optional: true
|
||||
|
||||
deep-eql@5.0.2:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
deepmerge@4.3.1:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -490,6 +542,9 @@ packages:
|
|||
devalue@5.8.1:
|
||||
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -506,6 +561,13 @@ packages:
|
|||
'@typescript-eslint/types':
|
||||
optional: true
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -530,6 +592,9 @@ packages:
|
|||
locate-character@3.0.0:
|
||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
|
|
@ -545,6 +610,13 @@ packages:
|
|||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
pathe@1.1.2:
|
||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||
|
||||
pathval@2.0.1:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
|
|
@ -565,10 +637,19 @@ packages:
|
|||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
svelte-check@4.4.8:
|
||||
resolution: {integrity: sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
|
@ -581,11 +662,34 @@ packages:
|
|||
resolution: {integrity: sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
tinypool@1.1.1:
|
||||
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
||||
tinyrainbow@1.2.0:
|
||||
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyspy@3.0.2:
|
||||
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
|
||||
vite@5.4.21:
|
||||
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
|
@ -625,6 +729,36 @@ packages:
|
|||
vite:
|
||||
optional: true
|
||||
|
||||
vitest@2.1.9:
|
||||
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@types/node': ^18.0.0 || >=20.0.0
|
||||
'@vitest/browser': 2.1.9
|
||||
'@vitest/ui': 2.1.9
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
zimmerframe@1.1.4:
|
||||
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
|
||||
|
||||
|
|
@ -876,6 +1010,46 @@ snapshots:
|
|||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/mocker@2.1.9(vite@5.4.21)':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/runner@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/utils': 2.1.9
|
||||
pathe: 1.1.2
|
||||
|
||||
'@vitest/snapshot@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
|
||||
'@vitest/spy@2.1.9':
|
||||
dependencies:
|
||||
tinyspy: 3.0.2
|
||||
|
||||
'@vitest/utils@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
|
@ -886,8 +1060,22 @@ snapshots:
|
|||
|
||||
aria-query@5.3.1: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
axobject-query@4.1.0: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
check-error: 2.1.3
|
||||
deep-eql: 5.0.2
|
||||
loupe: 3.2.1
|
||||
pathval: 2.0.1
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
|
@ -898,10 +1086,14 @@ snapshots:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
devalue@5.8.1: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
|
|
@ -934,6 +1126,12 @@ snapshots:
|
|||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fdir@6.5.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
|
|
@ -947,6 +1145,8 @@ snapshots:
|
|||
|
||||
locate-character@3.0.0: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
|
@ -957,6 +1157,10 @@ snapshots:
|
|||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
pathe@1.1.2: {}
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
postcss@8.5.15:
|
||||
|
|
@ -1002,8 +1206,14 @@ snapshots:
|
|||
dependencies:
|
||||
mri: 1.2.0
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
svelte-check@4.4.8(svelte@5.55.9)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
|
@ -1037,8 +1247,36 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- '@typescript-eslint/types'
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinypool@1.1.1: {}
|
||||
|
||||
tinyrainbow@1.2.0: {}
|
||||
|
||||
tinyspy@3.0.2: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite-node@2.1.9:
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite@5.4.21:
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
|
|
@ -1051,4 +1289,42 @@ snapshots:
|
|||
optionalDependencies:
|
||||
vite: 5.4.21
|
||||
|
||||
vitest@2.1.9:
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21)
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
'@vitest/runner': 2.1.9
|
||||
'@vitest/snapshot': 2.1.9
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 1.2.0
|
||||
vite: 5.4.21
|
||||
vite-node: 2.1.9
|
||||
why-is-node-running: 2.3.0
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
zimmerframe@1.1.4: {}
|
||||
|
|
|
|||
374
src/lib/layout/tree.test.ts
Normal file
374
src/lib/layout/tree.test.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
newLeaf,
|
||||
newSplit,
|
||||
replaceById,
|
||||
splitLeaf,
|
||||
closeLeaf,
|
||||
findLeaf,
|
||||
leafCount,
|
||||
walkLeaves,
|
||||
changeDistro,
|
||||
changeLabel,
|
||||
toggleBroadcast,
|
||||
serialize,
|
||||
deserialize,
|
||||
presetSingle,
|
||||
presetTwoColumns,
|
||||
presetThreeColumns,
|
||||
presetTwoRows,
|
||||
presetTwoByTwo,
|
||||
type TreeNode,
|
||||
type LeafNode,
|
||||
type SplitNode,
|
||||
} from "./tree";
|
||||
|
||||
function leafIds(root: TreeNode): string[] {
|
||||
return Array.from(walkLeaves(root)).map((l) => l.id);
|
||||
}
|
||||
|
||||
function leafDistros(root: TreeNode): (string | undefined)[] {
|
||||
return Array.from(walkLeaves(root)).map((l) => l.distro);
|
||||
}
|
||||
|
||||
describe("newLeaf", () => {
|
||||
it("returns a leaf with a unique id and no extra metadata", () => {
|
||||
const a = newLeaf();
|
||||
const b = newLeaf();
|
||||
expect(a.kind).toBe("leaf");
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(a.id).not.toEqual(b.id);
|
||||
expect(a.distro).toBeUndefined();
|
||||
expect(a.cwd).toBeUndefined();
|
||||
expect(a.label).toBeUndefined();
|
||||
expect(a.broadcast).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies provided props", () => {
|
||||
const leaf = newLeaf({ distro: "Ubuntu", cwd: "/home", label: "ml" });
|
||||
expect(leaf.distro).toBe("Ubuntu");
|
||||
expect(leaf.cwd).toBe("/home");
|
||||
expect(leaf.label).toBe("ml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("newSplit", () => {
|
||||
it("defaults ratio to 0.5", () => {
|
||||
const split = newSplit("h", newLeaf(), newLeaf());
|
||||
expect(split.ratio).toBe(0.5);
|
||||
expect(split.kind).toBe("split");
|
||||
expect(split.orientation).toBe("h");
|
||||
});
|
||||
|
||||
it("respects an explicit ratio", () => {
|
||||
const split = newSplit("v", newLeaf(), newLeaf(), 0.33);
|
||||
expect(split.ratio).toBeCloseTo(0.33);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceById", () => {
|
||||
it("replaces the root if its id matches", () => {
|
||||
const a = newLeaf({ label: "old" });
|
||||
const next = replaceById(a, a.id, () => newLeaf({ label: "new" }));
|
||||
expect((next as LeafNode).label).toBe("new");
|
||||
});
|
||||
|
||||
it("returns the same root reference when no match", () => {
|
||||
const a = newSplit("h", newLeaf(), newLeaf());
|
||||
const same = replaceById(a, "no-such-id", () => newLeaf());
|
||||
expect(same).toBe(a);
|
||||
});
|
||||
|
||||
it("returns a new root when a nested node is replaced (immutability)", () => {
|
||||
const target = newLeaf({ label: "target" });
|
||||
const sibling = newLeaf({ label: "sibling" });
|
||||
const root = newSplit("h", target, sibling);
|
||||
const next = replaceById(root, target.id, (n) => ({
|
||||
...(n as LeafNode),
|
||||
label: "edited",
|
||||
}));
|
||||
expect(next).not.toBe(root);
|
||||
expect((next as SplitNode).b).toBe(sibling); // untouched branch reused
|
||||
expect(((next as SplitNode).a as LeafNode).label).toBe("edited");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitLeaf", () => {
|
||||
it("replaces a leaf with a horizontal split", () => {
|
||||
const leaf = newLeaf({ distro: "Ubuntu" });
|
||||
const next = splitLeaf(leaf, leaf.id, "h");
|
||||
expect(next.kind).toBe("split");
|
||||
const s = next as SplitNode;
|
||||
expect(s.orientation).toBe("h");
|
||||
expect(s.a).toBe(leaf);
|
||||
expect((s.b as LeafNode).kind).toBe("leaf");
|
||||
});
|
||||
|
||||
it("propagates inherited distro / cwd to the new leaf", () => {
|
||||
const leaf = newLeaf({ distro: "Ubuntu", cwd: "/projects/x" });
|
||||
const next = splitLeaf(leaf, leaf.id, "v", {
|
||||
distro: leaf.distro,
|
||||
cwd: leaf.cwd,
|
||||
});
|
||||
const newSide = (next as SplitNode).b as LeafNode;
|
||||
expect(newSide.distro).toBe("Ubuntu");
|
||||
expect(newSide.cwd).toBe("/projects/x");
|
||||
});
|
||||
|
||||
it("is a no-op when the leaf id is not found", () => {
|
||||
const leaf = newLeaf();
|
||||
const same = splitLeaf(leaf, "no-such-id", "h");
|
||||
expect(same).toBe(leaf);
|
||||
});
|
||||
|
||||
it("preserves siblings when splitting a nested leaf", () => {
|
||||
const target = newLeaf();
|
||||
const sibling = newLeaf();
|
||||
const root = newSplit("h", target, sibling);
|
||||
const next = splitLeaf(root, target.id, "v") as SplitNode;
|
||||
expect(next.b).toBe(sibling);
|
||||
expect(next.a.kind).toBe("split");
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeLeaf", () => {
|
||||
it("returns null when closing the only (root) leaf", () => {
|
||||
const leaf = newLeaf();
|
||||
expect(closeLeaf(leaf, leaf.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses to the sibling when closing one of two children", () => {
|
||||
const target = newLeaf();
|
||||
const sibling = newLeaf();
|
||||
const root = newSplit("h", target, sibling);
|
||||
expect(closeLeaf(root, target.id)).toBe(sibling);
|
||||
expect(closeLeaf(root, sibling.id)).toBe(target);
|
||||
});
|
||||
|
||||
it("preserves the rest of the tree when removing a nested leaf", () => {
|
||||
const target = newLeaf({ label: "close-me" });
|
||||
const sib = newLeaf({ label: "stay" });
|
||||
const farRight = newLeaf({ label: "right" });
|
||||
const root = newSplit("h", newSplit("v", target, sib), farRight);
|
||||
const next = closeLeaf(root, target.id) as SplitNode;
|
||||
// The inner split collapses to `sib`; the outer split is rebuilt.
|
||||
expect(next.kind).toBe("split");
|
||||
expect(next.b).toBe(farRight);
|
||||
expect((next.a as LeafNode).label).toBe("stay");
|
||||
});
|
||||
|
||||
it("is a no-op when the leaf id is not found", () => {
|
||||
const a = newLeaf();
|
||||
const b = newLeaf();
|
||||
const root = newSplit("h", a, b);
|
||||
expect(closeLeaf(root, "no-such-id")).toBe(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLeaf", () => {
|
||||
it("finds the root leaf", () => {
|
||||
const leaf = newLeaf({ label: "x" });
|
||||
expect(findLeaf(leaf, leaf.id)).toBe(leaf);
|
||||
});
|
||||
|
||||
it("finds a deeply nested leaf", () => {
|
||||
const target = newLeaf({ label: "deep" });
|
||||
const root = newSplit(
|
||||
"h",
|
||||
newSplit("v", newLeaf(), newSplit("h", target, newLeaf())),
|
||||
newLeaf(),
|
||||
);
|
||||
const found = findLeaf(root, target.id);
|
||||
expect(found).toBe(target);
|
||||
});
|
||||
|
||||
it("returns null for unknown id", () => {
|
||||
const root = newSplit("h", newLeaf(), newLeaf());
|
||||
expect(findLeaf(root, "no-such-id")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when searching for a split node's id", () => {
|
||||
const root = newSplit("h", newLeaf(), newLeaf());
|
||||
expect(findLeaf(root, root.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("leafCount", () => {
|
||||
it("counts a single leaf as 1", () => {
|
||||
expect(leafCount(newLeaf())).toBe(1);
|
||||
});
|
||||
|
||||
it("counts leaves across nested splits", () => {
|
||||
const tree = newSplit(
|
||||
"h",
|
||||
newSplit("v", newLeaf(), newLeaf()),
|
||||
newSplit("h", newLeaf(), newSplit("v", newLeaf(), newLeaf())),
|
||||
);
|
||||
expect(leafCount(tree)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("walkLeaves", () => {
|
||||
it("yields a single leaf for a leaf root", () => {
|
||||
const leaf = newLeaf();
|
||||
expect(Array.from(walkLeaves(leaf))).toEqual([leaf]);
|
||||
});
|
||||
|
||||
it("yields leaves in left-to-right (a-first) order", () => {
|
||||
const l1 = newLeaf({ label: "1" });
|
||||
const l2 = newLeaf({ label: "2" });
|
||||
const l3 = newLeaf({ label: "3" });
|
||||
const l4 = newLeaf({ label: "4" });
|
||||
const root = newSplit("h", newSplit("v", l1, l2), newSplit("h", l3, l4));
|
||||
const labels = Array.from(walkLeaves(root)).map((l) => l.label);
|
||||
expect(labels).toEqual(["1", "2", "3", "4"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeDistro", () => {
|
||||
it("sets the distro on the leaf", () => {
|
||||
const leaf = newLeaf({ distro: "Ubuntu" });
|
||||
const next = changeDistro(leaf, leaf.id, "Debian");
|
||||
expect((next as LeafNode).distro).toBe("Debian");
|
||||
});
|
||||
|
||||
it("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => {
|
||||
const leaf = newLeaf({ distro: "Ubuntu" });
|
||||
const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode;
|
||||
expect(next.id).not.toBe(leaf.id);
|
||||
});
|
||||
|
||||
it("preserves other leaves in a nested tree", () => {
|
||||
const target = newLeaf({ distro: "Ubuntu" });
|
||||
const other = newLeaf({ distro: "Ubuntu" });
|
||||
const root = newSplit("h", target, other);
|
||||
const next = changeDistro(root, target.id, "Debian") as SplitNode;
|
||||
expect(next.b).toBe(other);
|
||||
expect((next.a as LeafNode).distro).toBe("Debian");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeLabel", () => {
|
||||
it("sets a label", () => {
|
||||
const leaf = newLeaf();
|
||||
const next = changeLabel(leaf, leaf.id, "my pane") as LeafNode;
|
||||
expect(next.label).toBe("my pane");
|
||||
});
|
||||
|
||||
it("MUST NOT swap the leaf id (metadata-only — pane should not respawn)", () => {
|
||||
const leaf = newLeaf({ label: "old" });
|
||||
const next = changeLabel(leaf, leaf.id, "new") as LeafNode;
|
||||
expect(next.id).toBe(leaf.id);
|
||||
});
|
||||
|
||||
it("trims whitespace from the label", () => {
|
||||
const leaf = newLeaf();
|
||||
const next = changeLabel(leaf, leaf.id, " spaced ") as LeafNode;
|
||||
expect(next.label).toBe("spaced");
|
||||
});
|
||||
|
||||
it("clears the label when given empty / whitespace / undefined", () => {
|
||||
const leaf = newLeaf({ label: "had-a-name" });
|
||||
expect((changeLabel(leaf, leaf.id, "") as LeafNode).label).toBeUndefined();
|
||||
expect((changeLabel(leaf, leaf.id, " ") as LeafNode).label).toBeUndefined();
|
||||
expect(
|
||||
(changeLabel(leaf, leaf.id, undefined) as LeafNode).label,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleBroadcast", () => {
|
||||
it("toggles undefined -> true", () => {
|
||||
const leaf = newLeaf();
|
||||
const next = toggleBroadcast(leaf, leaf.id) as LeafNode;
|
||||
expect(next.broadcast).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles true -> false", () => {
|
||||
const leaf = newLeaf({ distro: "Ubuntu" });
|
||||
const on = toggleBroadcast(leaf, leaf.id) as LeafNode;
|
||||
const off = toggleBroadcast(on, on.id) as LeafNode;
|
||||
expect(off.broadcast).toBe(false);
|
||||
});
|
||||
|
||||
it("MUST NOT swap the leaf id (metadata-only)", () => {
|
||||
const leaf = newLeaf();
|
||||
const next = toggleBroadcast(leaf, leaf.id) as LeafNode;
|
||||
expect(next.id).toBe(leaf.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("presets", () => {
|
||||
it("presetSingle returns a single leaf with the provided distro", () => {
|
||||
const t = presetSingle({ distro: "Ubuntu" });
|
||||
expect(t.kind).toBe("leaf");
|
||||
expect((t as LeafNode).distro).toBe("Ubuntu");
|
||||
});
|
||||
|
||||
it("presetTwoColumns returns a horizontal split with two leaves", () => {
|
||||
const t = presetTwoColumns({ distro: "Ubuntu" }) as SplitNode;
|
||||
expect(t.kind).toBe("split");
|
||||
expect(t.orientation).toBe("h");
|
||||
expect(leafCount(t)).toBe(2);
|
||||
expect(leafDistros(t)).toEqual(["Ubuntu", "Ubuntu"]);
|
||||
});
|
||||
|
||||
it("presetThreeColumns has 3 leaves with the outer split at 1/3", () => {
|
||||
const t = presetThreeColumns({ distro: "Ubuntu" }) as SplitNode;
|
||||
expect(leafCount(t)).toBe(3);
|
||||
expect(t.orientation).toBe("h");
|
||||
expect(t.ratio).toBeCloseTo(1 / 3);
|
||||
});
|
||||
|
||||
it("presetTwoRows returns a vertical split", () => {
|
||||
const t = presetTwoRows() as SplitNode;
|
||||
expect(t.orientation).toBe("v");
|
||||
expect(leafCount(t)).toBe(2);
|
||||
});
|
||||
|
||||
it("presetTwoByTwo returns 4 leaves in a v(h, h) shape, distro propagated", () => {
|
||||
const t = presetTwoByTwo({ distro: "Ubuntu" }) as SplitNode;
|
||||
expect(leafCount(t)).toBe(4);
|
||||
expect(t.orientation).toBe("v");
|
||||
expect((t.a as SplitNode).orientation).toBe("h");
|
||||
expect((t.b as SplitNode).orientation).toBe("h");
|
||||
expect(leafDistros(t)).toEqual(["Ubuntu", "Ubuntu", "Ubuntu", "Ubuntu"]);
|
||||
});
|
||||
|
||||
it("each preset call yields fresh ids (no collisions across applications)", () => {
|
||||
const a = presetTwoByTwo();
|
||||
const b = presetTwoByTwo();
|
||||
const intersection = leafIds(a).filter((id) => leafIds(b).includes(id));
|
||||
expect(intersection).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize / deserialize", () => {
|
||||
it("roundtrips a complex tree", () => {
|
||||
const leaf1 = newLeaf({ distro: "Ubuntu", label: "left", broadcast: true });
|
||||
const leaf2 = newLeaf({ distro: "Debian", cwd: "/projects/y" });
|
||||
const leaf3 = newLeaf();
|
||||
const tree = newSplit("h", leaf1, newSplit("v", leaf2, leaf3, 0.7), 0.4);
|
||||
const back = deserialize(serialize(tree));
|
||||
expect(back).toEqual(tree);
|
||||
});
|
||||
|
||||
it("returns null on syntactically invalid JSON", () => {
|
||||
expect(deserialize("not json")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on JSON that doesn't match the tree shape", () => {
|
||||
expect(deserialize('{"not": "a tree"}')).toBeNull();
|
||||
expect(deserialize('{"kind": "leaf"}')).toBeNull(); // missing id
|
||||
expect(
|
||||
deserialize('{"kind": "split", "id": "x", "orientation": "h"}'),
|
||||
).toBeNull(); // missing ratio + children
|
||||
});
|
||||
|
||||
it("accepts a minimal leaf shape", () => {
|
||||
expect(deserialize('{"kind": "leaf", "id": "x"}')).toEqual({
|
||||
kind: "leaf",
|
||||
id: "x",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
|
|
@ -16,4 +17,8 @@ export default defineConfig(async () => ({
|
|||
minify: "esbuild",
|
||||
sourcemap: false,
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue