diff --git a/memory.md b/memory.md index 832fa3c..4af0a2d 100644 --- a/memory.md +++ b/memory.md @@ -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 ps --ppid -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). diff --git a/package.json b/package.json index b91dcd7..bee0afb 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99dab6e..745c1ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts new file mode 100644 index 0000000..9dae5b8 --- /dev/null +++ b/src/lib/layout/tree.test.ts @@ -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", + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 415c23a..4c0d7a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// 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"], + }, }));