From a8fe97828eb00c90df73ca08904d1e262996570e Mon Sep 17 00:00:00 2001 From: Aniket-508 Date: Mon, 6 Apr 2026 13:03:24 +0530 Subject: [PATCH 1/6] chore(test): add Vitest and ink-testing tooling - Add vitest, @vitest/coverage-v8, ink-testing devDependencies - Add test, test:watch, and test:coverage scripts - Configure Vitest with Node env and registry __tests__ glob Made-with: Cursor --- package.json | 8 +- pnpm-lock.yaml | 355 ++++++++++++++++++++++++++++++++++++++++++++++- vitest.config.ts | 16 +++ 3 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index bd4933a..ce29c6d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "typecheck": "tsc --noEmit", "registry:build": "shadcn build registry.json --output ./public/r", "postinstall": "fumadocs-mdx", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "check": "ultracite check", "fix": "ultracite fix", "prepare": "lefthook install" @@ -60,13 +63,16 @@ "@types/node": "^25", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "@vitest/coverage-v8": "^4.1.2", + "ink-testing": "^0.2.0", "lefthook": "^2.1.4", "oxfmt": "^0.43.0", "oxlint": "^1.58.0", "tailwindcss": "^4.2.2", "tw-animate-css": "^1.4.0", "typescript": "^6", - "ultracite": "7.4.3" + "ultracite": "7.4.3", + "vitest": "^4.1.2" }, "engines": { "node": ">=20.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b30d55..a2c9183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,12 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.14) + '@vitest/coverage-v8': + specifier: ^4.1.2 + version: 4.1.2(vitest@4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1))) + ink-testing: + specifier: ^0.2.0 + version: 0.2.0(@types/react@19.2.14)(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) lefthook: specifier: ^2.1.4 version: 2.1.4 @@ -159,6 +165,9 @@ importers: ultracite: specifier: 7.4.3 version: 7.4.3(oxlint@1.58.0) + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)) packages: @@ -338,6 +347,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@clack/core@1.2.0': resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} @@ -2098,9 +2111,15 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2174,6 +2193,44 @@ packages: vue-router: optional: true + '@vitest/coverage-v8@4.1.2': + resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==} + peerDependencies: + '@vitest/browser': 4.1.2 + vitest: 4.1.2 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -2236,10 +2293,17 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -2307,6 +2371,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2578,6 +2646,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2660,6 +2731,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.3.2: resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} engines: {node: '>= 16'} @@ -2899,6 +2974,10 @@ packages: resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2941,6 +3020,9 @@ packages: resolution: {integrity: sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==} engines: {node: '>=16.9.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -2984,6 +3066,22 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink-testing@0.2.0: + resolution: {integrity: sha512-yMWorDfNFB1C52g0nl0T7C+Kkcz4Mee/P4KTzXkO/WbdSlGpye4tu6bk3LptXZFNZJIINkZV9semA/wms1qGHA==} + engines: {node: '>=18.0.0'} + peerDependencies: + ink: '>=6.0.0' + react: '>=19.0.0' + ink-web@0.2.0: resolution: {integrity: sha512-WPLK/RuW8LgPcZeBqoNZkKer3K3QPr846wFUOyx7K2WcCb3TT+lqjOhcoDaWioYX6r5OeasPmLJnKDfghtmQ6w==} engines: {node: '>=18.0.0'} @@ -3122,6 +3220,18 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3147,6 +3257,9 @@ packages: react: optional: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3340,6 +3453,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -3655,6 +3775,9 @@ packages: obliterator@2.0.5: resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -4050,6 +4173,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4089,10 +4215,16 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -4158,6 +4290,10 @@ packages: babel-plugin-macros: optional: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -4179,6 +4315,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.4: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} @@ -4191,6 +4330,10 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.19: resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} @@ -4394,6 +4537,41 @@ packages: yaml: optional: true + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -4411,6 +4589,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@6.0.0: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} @@ -4740,6 +4923,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@clack/core@1.2.0': dependencies: fast-wrap-ansi: 0.1.6 @@ -6251,10 +6436,17 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -6300,6 +6492,62 @@ snapshots: next: 16.2.2(@babel/core@7.28.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + '@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.2 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)) + + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.2(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.7(@types/node@25.5.2)(typescript@6.0.2) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1) + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@xterm/addon-fit@0.11.0': {} '@xterm/xterm@6.0.0': {} @@ -6348,10 +6596,18 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} auto-bind@5.0.1: {} @@ -6416,6 +6672,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -6620,6 +6878,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6751,6 +7011,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.3.0: {} + express-rate-limit@8.3.2(express@5.2.1): dependencies: express: 5.2.1 @@ -6990,6 +7252,8 @@ snapshots: graphql@16.12.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} hasown@2.0.2: @@ -7095,6 +7359,8 @@ snapshots: hono@4.12.10: {} + html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -7133,6 +7399,19 @@ snapshots: inherits@2.0.4: {} + ink-testing-library@4.0.0(@types/react@19.2.14): + optionalDependencies: + '@types/react': 19.2.14 + + ink-testing@0.2.0(@types/react@19.2.14)(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): + dependencies: + ink: 6.8.0(@types/react@19.2.14)(react@19.2.4) + ink-testing-library: 4.0.0(@types/react@19.2.14) + react: 19.2.4 + strip-ansi: 7.2.0 + transitivePeerDependencies: + - '@types/react' + ink-web@0.2.0(@xterm/xterm@6.0.0)(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react-reconciler@0.33.0(react@19.2.4))(react@19.2.4)(scheduler@0.27.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)): dependencies: '@xterm/addon-fit': 0.11.0 @@ -7248,6 +7527,19 @@ snapshots: isexe@3.1.1: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.6.1: {} jose@6.1.3: {} @@ -7259,6 +7551,8 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -7402,6 +7696,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -7969,6 +8273,8 @@ snapshots: obliterator@2.0.5: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -8469,8 +8775,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} send@1.2.1: dependencies: @@ -8619,6 +8924,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -8647,8 +8954,12 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.0.0: {} + stdin-discarder@0.2.2: {} strict-event-emitter@0.5.1: {} @@ -8710,6 +9021,10 @@ snapshots: optionalDependencies: '@babel/core': 7.28.5 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -8722,6 +9037,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + tinyexec@1.0.4: {} tinyglobby@0.2.15: @@ -8731,6 +9048,8 @@ snapshots: tinypool@2.1.0: {} + tinyrainbow@3.1.0: {} + tldts-core@7.0.19: {} tldts@7.0.19: @@ -8914,6 +9233,33 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + vitest@4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.2 + transitivePeerDependencies: + - msw + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} @@ -8926,6 +9272,11 @@ snapshots: dependencies: isexe: 3.1.1 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@6.0.0: dependencies: string-width: 8.2.0 diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5ba0b5c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { resolve } from "node:path"; + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": resolve(import.meta.dirname, "."), + }, + }, + test: { + environment: "node", + globals: true, + include: ["registry/**/__tests__/**/*.test.{ts,tsx}"], + }, +}); From cc8eb40ca2bf8af1e0ce34a250f47b9684432e70 Mon Sep 17 00:00:00 2001 From: Aniket-508 Date: Mon, 6 Apr 2026 13:03:26 +0530 Subject: [PATCH 2/6] test(ui): add ThemeProvider wrapper for ink-testing - Re-export ink-testing helpers and KEY from a single entry point - Wrap renderTui with registry ThemeProvider for useTheme() in tests Made-with: Cursor --- registry/ui/__tests__/render-tui.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 registry/ui/__tests__/render-tui.tsx diff --git a/registry/ui/__tests__/render-tui.tsx b/registry/ui/__tests__/render-tui.tsx new file mode 100644 index 0000000..c44c9a4 --- /dev/null +++ b/registry/ui/__tests__/render-tui.tsx @@ -0,0 +1,11 @@ +import { renderTui as baseRenderTui, cleanup } from "ink-testing"; +import type { TuiInstance } from "ink-testing"; +import React from "react"; + +import { ThemeProvider } from "../theme-provider"; + +export { cleanup, KEY } from "ink-testing"; +export type { TuiInstance, Screen, KeySender } from "ink-testing"; + +export const renderTui = (ui: React.ReactElement): TuiInstance => + baseRenderTui({ui}); From 5aabe54e959b6c3ccf053ab3f39349f5a8dad6b0 Mon Sep 17 00:00:00 2001 From: Aniket-508 Date: Mon, 6 Apr 2026 13:03:32 +0530 Subject: [PATCH 3/6] test(ui): add Ink tests for charts, progress, and time - Cover bar/line/pie charts, heat map, gauge, sparkline, token usage - Cover spinner, skeleton, clocks, timers, stopwatch, toasts, streaming text Made-with: Cursor --- registry/ui/__tests__/bar-chart.test.tsx | 64 +++++++++++++ registry/ui/__tests__/big-text.test.tsx | 49 ++++++++++ registry/ui/__tests__/clock.test.tsx | 61 +++++++++++++ registry/ui/__tests__/digits.test.tsx | 48 ++++++++++ registry/ui/__tests__/gauge.test.tsx | 55 ++++++++++++ registry/ui/__tests__/heat-map.test.tsx | 61 +++++++++++++ registry/ui/__tests__/line-chart.test.tsx | 47 ++++++++++ registry/ui/__tests__/multi-progress.test.tsx | 48 ++++++++++ registry/ui/__tests__/pie-chart.test.tsx | 54 +++++++++++ registry/ui/__tests__/progress-bar.test.tsx | 49 ++++++++++ .../ui/__tests__/progress-circle.test.tsx | 49 ++++++++++ registry/ui/__tests__/skeleton.test.tsx | 46 ++++++++++ registry/ui/__tests__/sparkline.test.tsx | 29 ++++++ registry/ui/__tests__/spinner.test.tsx | 57 ++++++++++++ registry/ui/__tests__/status-message.test.tsx | 77 ++++++++++++++++ registry/ui/__tests__/stopwatch.test.tsx | 57 ++++++++++++ registry/ui/__tests__/streaming-text.test.tsx | 65 ++++++++++++++ registry/ui/__tests__/timer.test.tsx | 90 +++++++++++++++++++ registry/ui/__tests__/toast.test.tsx | 79 ++++++++++++++++ registry/ui/__tests__/token-usage.test.tsx | 43 +++++++++ 20 files changed, 1128 insertions(+) create mode 100644 registry/ui/__tests__/bar-chart.test.tsx create mode 100644 registry/ui/__tests__/big-text.test.tsx create mode 100644 registry/ui/__tests__/clock.test.tsx create mode 100644 registry/ui/__tests__/digits.test.tsx create mode 100644 registry/ui/__tests__/gauge.test.tsx create mode 100644 registry/ui/__tests__/heat-map.test.tsx create mode 100644 registry/ui/__tests__/line-chart.test.tsx create mode 100644 registry/ui/__tests__/multi-progress.test.tsx create mode 100644 registry/ui/__tests__/pie-chart.test.tsx create mode 100644 registry/ui/__tests__/progress-bar.test.tsx create mode 100644 registry/ui/__tests__/progress-circle.test.tsx create mode 100644 registry/ui/__tests__/skeleton.test.tsx create mode 100644 registry/ui/__tests__/sparkline.test.tsx create mode 100644 registry/ui/__tests__/spinner.test.tsx create mode 100644 registry/ui/__tests__/status-message.test.tsx create mode 100644 registry/ui/__tests__/stopwatch.test.tsx create mode 100644 registry/ui/__tests__/streaming-text.test.tsx create mode 100644 registry/ui/__tests__/timer.test.tsx create mode 100644 registry/ui/__tests__/toast.test.tsx create mode 100644 registry/ui/__tests__/token-usage.test.tsx diff --git a/registry/ui/__tests__/bar-chart.test.tsx b/registry/ui/__tests__/bar-chart.test.tsx new file mode 100644 index 0000000..2944cec --- /dev/null +++ b/registry/ui/__tests__/bar-chart.test.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { BarChart } from "../bar-chart"; +import { renderTui } from "./render-tui"; + +describe("BarChart", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const sampleData = [ + { label: "A", value: 10 }, + { label: "B", value: 20 }, + { label: "C", value: 15 }, + ]; + + it("renders bar characters for horizontal direction", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + }); + + it("renders labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("A")).toBe(true); + expect(tui.screen.contains("B")).toBe(true); + expect(tui.screen.contains("C")).toBe(true); + }); + + it("renders values when showValues is true", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("10")).toBe(true); + expect(tui.screen.contains("20")).toBe(true); + }); + + it("hides values when showValues is false", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("999")).toBe(false); + }); + + it("renders title", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Sales Report")).toBe(true); + }); + + it("renders empty state", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("No data")).toBe(true); + }); + + it("renders vertical direction", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("A")).toBe(true); + expect(tui.screen.contains("B")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/big-text.test.tsx b/registry/ui/__tests__/big-text.test.tsx new file mode 100644 index 0000000..671799a --- /dev/null +++ b/registry/ui/__tests__/big-text.test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { BigText } from "../big-text"; +import { renderTui } from "./render-tui"; + +describe("BigText", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders block font with filled characters", () => { + const tui = renderTui(A); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + }); + + it("renders multiple characters", () => { + const tui = renderTui(HI); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + const lines = tui.screen.lines(); + expect(lines.length).toBeGreaterThanOrEqual(5); + }); + + it("renders slim font with box drawing chars", () => { + const tui = renderTui(A); + ({ unmount } = tui); + expect(tui.screen.contains("╔")).toBe(true); + }); + + it("renders shade font", () => { + const tui = renderTui(A); + ({ unmount } = tui); + expect(tui.screen.contains("▓")).toBe(true); + }); + + it("renders simple font same as block", () => { + const tui = renderTui(T); + ({ unmount } = tui); + const lines = tui.screen.lines(); + expect(lines.length).toBeGreaterThanOrEqual(5); + }); + + it("handles lowercase by converting to uppercase", () => { + const tui = renderTui(abc); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/clock.test.tsx b/registry/ui/__tests__/clock.test.tsx new file mode 100644 index 0000000..94467ff --- /dev/null +++ b/registry/ui/__tests__/clock.test.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { Clock } from "../clock"; +import { renderTui } from "./render-tui"; + +describe("Clock", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T14:30:45")); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders time in 24h format with seconds", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("14:30:45")).toBe(true); + }); + + it("renders time in 12h format with AM/PM", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("02:30:45")).toBe(true); + expect(tui.screen.contains("PM")).toBe(true); + }); + + it("hides seconds when showSeconds is false", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("14:30")).toBe(true); + expect(tui.screen.contains("14:30:45")).toBe(false); + }); + + it("shows date when showDate is true", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("2025")).toBe(true); + expect(tui.screen.contains("Jun")).toBe(true); + }); + + it("re-renders on interval tick", async () => { + const tui = renderTui(); + ({ unmount } = tui); + const framesBefore = tui.screen.frames().length; + await vi.advanceTimersByTimeAsync(2000); + const framesAfter = tui.screen.frames().length; + expect(framesAfter).toBeGreaterThan(framesBefore); + }); + + it("renders large size with big digits", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("╔")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/digits.test.tsx b/registry/ui/__tests__/digits.test.tsx new file mode 100644 index 0000000..b0b3525 --- /dev/null +++ b/registry/ui/__tests__/digits.test.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Digits } from "../digits"; +import { renderTui } from "./render-tui"; + +describe("Digits", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders sm size as plain text", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("42")).toBe(true); + }); + + it("renders md size with box drawing characters", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("╭")).toBe(true); + expect(tui.screen.contains("╰")).toBe(true); + }); + + it("renders lg size with wider segments", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("╭───╮")).toBe(true); + }); + + it("renders numeric value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("123")).toBe(true); + }); + + it("renders colon character", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("●")).toBe(true); + }); + + it("renders multiple digits in a row", () => { + const tui = renderTui(); + ({ unmount } = tui); + const lines = tui.screen.lines(); + expect(lines.length).toBeGreaterThanOrEqual(5); + }); +}); diff --git a/registry/ui/__tests__/gauge.test.tsx b/registry/ui/__tests__/gauge.test.tsx new file mode 100644 index 0000000..36aef62 --- /dev/null +++ b/registry/ui/__tests__/gauge.test.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Gauge } from "../gauge"; +import { renderTui } from "./render-tui"; + +describe("Gauge", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders bar fill characters for sm", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + expect(tui.screen.contains("░")).toBe(true); + }); + + it("shows percentage", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("75%")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("CPU")).toBe(true); + }); + + it("clamps value to min/max range", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("100%")).toBe(true); + }); + + it("renders md size with border characters", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("╭")).toBe(true); + expect(tui.screen.contains("╯")).toBe(true); + }); + + it("renders lg size", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("╱")).toBe(true); + expect(tui.screen.contains("╲")).toBe(true); + }); + + it("supports custom min/max", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("50%")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/heat-map.test.tsx b/registry/ui/__tests__/heat-map.test.tsx new file mode 100644 index 0000000..59b281a --- /dev/null +++ b/registry/ui/__tests__/heat-map.test.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { HeatMap } from "../heat-map"; +import { renderTui } from "./render-tui"; + +describe("HeatMap", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const sampleData = [ + [1, 5, 9], + [3, 7, 2], + ]; + + it("renders shade characters", () => { + const tui = renderTui(); + ({ unmount } = tui); + const text = tui.screen.text(); + const hasShades = /[░▒▓█]/.test(text); + expect(hasShades).toBe(true); + }); + + it("renders row labels", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Mon")).toBe(true); + expect(tui.screen.contains("Tue")).toBe(true); + }); + + it("renders column labels", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("AM")).toBe(true); + expect(tui.screen.contains("PM")).toBe(true); + }); + + it("shows values when showValues is true", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("5")).toBe(true); + expect(tui.screen.contains("9")).toBe(true); + }); + + it("renders color scale legend", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Low")).toBe(true); + expect(tui.screen.contains("High")).toBe(true); + }); + + it("renders empty state", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("No data")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/line-chart.test.tsx b/registry/ui/__tests__/line-chart.test.tsx new file mode 100644 index 0000000..32e8097 --- /dev/null +++ b/registry/ui/__tests__/line-chart.test.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { LineChart } from "../line-chart"; +import { renderTui } from "./render-tui"; + +describe("LineChart", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders plot characters", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("●")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("CPU Usage")).toBe(true); + }); + + it("renders axes by default", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("│")).toBe(true); + expect(tui.screen.contains("└")).toBe(true); + }); + + it("hides axes when showAxes is false", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("└")).toBe(false); + }); + + it("renders empty state", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("No data")).toBe(true); + }); + + it("handles single data point", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("●")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/multi-progress.test.tsx b/registry/ui/__tests__/multi-progress.test.tsx new file mode 100644 index 0000000..67dfd87 --- /dev/null +++ b/registry/ui/__tests__/multi-progress.test.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { MultiProgress } from "../multi-progress"; +import { renderTui } from "./render-tui"; + +describe("MultiProgress", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders item labels", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Download")).toBe(true); + expect(tui.screen.contains("Install")).toBe(true); + }); + + it('renders progress bars with "█" and "░"', () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + expect(tui.screen.contains("░")).toBe(true); + }); + + it("renders percentage", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("50%")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/pie-chart.test.tsx b/registry/ui/__tests__/pie-chart.test.tsx new file mode 100644 index 0000000..4fff1d6 --- /dev/null +++ b/registry/ui/__tests__/pie-chart.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { PieChart } from "../pie-chart"; +import { renderTui } from "./render-tui"; + +describe("PieChart", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const sampleData = [ + { label: "React", value: 60 }, + { label: "Vue", value: 25 }, + { label: "Svelte", value: 15 }, + ]; + + it("renders pie chart block characters", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + }); + + it("renders legend labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("React")).toBe(true); + expect(tui.screen.contains("Vue")).toBe(true); + expect(tui.screen.contains("Svelte")).toBe(true); + }); + + it("shows percentages", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("60.0%")).toBe(true); + }); + + it("hides legend", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("■")).toBe(false); + }); + + it("renders empty state", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("No data")).toBe(true); + }); + + it("renders legend square markers", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("■")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/progress-bar.test.tsx b/registry/ui/__tests__/progress-bar.test.tsx new file mode 100644 index 0000000..2594231 --- /dev/null +++ b/registry/ui/__tests__/progress-bar.test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { ProgressBar } from "../progress-bar"; +import { renderTui } from "./render-tui"; + +describe("ProgressBar", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders bar characters", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + expect(tui.screen.contains("░")).toBe(true); + }); + + it("renders percentage", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("25%")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Uploading")).toBe(true); + }); + + it("renders value/total", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("3/10")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/progress-circle.test.tsx b/registry/ui/__tests__/progress-circle.test.tsx new file mode 100644 index 0000000..ac1b4ea --- /dev/null +++ b/registry/ui/__tests__/progress-circle.test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { ProgressCircle } from "../progress-circle"; +import { renderTui } from "./render-tui"; + +describe("ProgressCircle", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders small braille character", () => { + const tui = renderTui(); + ({ unmount } = tui); + const text = tui.screen.text(); + const hasBraille = /[○◔◑◕●◉⬤]/.test(text); + expect(hasBraille).toBe(true); + }); + + it("shows percent when showPercent is true", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("75%")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Upload")).toBe(true); + }); + + it("clamps value to 0-100", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("100%")).toBe(true); + }); + + it("renders md size with angle brackets", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("⟨")).toBe(true); + expect(tui.screen.contains("⟩")).toBe(true); + }); + + it("renders lg size with block art", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("▄")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/skeleton.test.tsx b/registry/ui/__tests__/skeleton.test.tsx new file mode 100644 index 0000000..0d68648 --- /dev/null +++ b/registry/ui/__tests__/skeleton.test.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { Skeleton } from "../skeleton"; +import { renderTui } from "./render-tui"; + +describe("Skeleton", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders block characters", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("░")).toBe(true); + }); + + it("renders multiple rows", () => { + const tui = renderTui(); + ({ unmount } = tui); + const lines = tui.screen.lines(); + expect(lines.length).toBeGreaterThanOrEqual(3); + }); + + it("animates shimmer over time", async () => { + const tui = renderTui(); + ({ unmount } = tui); + const before = tui.screen.text(); + await vi.advanceTimersByTimeAsync(500); + const after = tui.screen.text(); + expect(after).not.toBe(before); + }); + + it("renders static when animated is false", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("░")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/sparkline.test.tsx b/registry/ui/__tests__/sparkline.test.tsx new file mode 100644 index 0000000..b270303 --- /dev/null +++ b/registry/ui/__tests__/sparkline.test.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Sparkline } from "../sparkline"; +import { renderTui } from "./render-tui"; + +describe("Sparkline", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders sparkline characters (braille)", () => { + const tui = renderTui(); + ({ unmount } = tui); + const text = tui.screen.text(); + expect(/[⣀-⣿]/.test(text)).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("cpu")).toBe(true); + }); + + it('renders empty state with "─"', () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("────────")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/spinner.test.tsx b/registry/ui/__tests__/spinner.test.tsx new file mode 100644 index 0000000..84ca2fa --- /dev/null +++ b/registry/ui/__tests__/spinner.test.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { Spinner } from "../spinner"; +import { renderTui } from "./render-tui"; + +describe("Spinner", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders the first dots frame by default", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("⠋")).toBe(true); + }); + + it("renders label alongside spinner", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Loading...")).toBe(true); + }); + + it("cycles through frames when time advances", async () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("⠋")).toBe(true); + await vi.advanceTimersByTimeAsync(200); + const text = tui.screen.text(); + expect(text.includes("⠋")).toBe(false); + }); + + it("renders custom frames", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("A")).toBe(true); + }); + + it("renders line style", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("—")).toBe(true); + }); + + it("renders star style", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("✶")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/status-message.test.tsx b/registry/ui/__tests__/status-message.test.tsx new file mode 100644 index 0000000..a31d691 --- /dev/null +++ b/registry/ui/__tests__/status-message.test.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { StatusMessage } from "../status-message"; +import { renderTui } from "./render-tui"; + +describe("StatusMessage", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders children text", () => { + const tui = renderTui(Installing); + ({ unmount } = tui); + expect(tui.screen.contains("Installing")).toBe(true); + }); + + it("renders info icon by default", () => { + const tui = renderTui(Info); + ({ unmount } = tui); + expect(tui.screen.contains("ℹ")).toBe(true); + }); + + it("renders success icon", () => { + const tui = renderTui( + Done + ); + ({ unmount } = tui); + expect(tui.screen.contains("✓")).toBe(true); + }); + + it("renders error icon", () => { + const tui = renderTui( + Failed + ); + ({ unmount } = tui); + expect(tui.screen.contains("✗")).toBe(true); + }); + + it("renders warning icon", () => { + const tui = renderTui( + Caution + ); + ({ unmount } = tui); + expect(tui.screen.contains("⚠")).toBe(true); + }); + + it("renders pending icon", () => { + const tui = renderTui( + Waiting + ); + ({ unmount } = tui); + expect(tui.screen.contains("○")).toBe(true); + }); + + it("renders spinner for loading variant", () => { + const tui = renderTui( + Loading data + ); + ({ unmount } = tui); + expect(tui.screen.contains("Loading data")).toBe(true); + expect(tui.screen.contains("⠋")).toBe(true); + }); + + it("renders custom icon", () => { + const tui = renderTui(Deploying); + ({ unmount } = tui); + expect(tui.screen.contains("🚀")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/stopwatch.test.tsx b/registry/ui/__tests__/stopwatch.test.tsx new file mode 100644 index 0000000..e540ebf --- /dev/null +++ b/registry/ui/__tests__/stopwatch.test.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { Stopwatch } from "../stopwatch"; +import { renderTui } from "./render-tui"; + +describe("Stopwatch", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders initial state as Ready", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[Ready]")).toBe(true); + expect(tui.screen.contains("00:00:00.00")).toBe(true); + }); + + it("starts running with autoStart", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[Running]")).toBe(true); + }); + + it("toggles running with space key", async () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[Ready]")).toBe(true); + tui.keys.space(); + await vi.advanceTimersByTimeAsync(100); + expect(tui.screen.contains("[Running]")).toBe(true); + }); + + it("resets with r key", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.space(); + await vi.advanceTimersByTimeAsync(1000); + tui.keys.press("r"); + await vi.advanceTimersByTimeAsync(0); + expect(tui.screen.contains("[Ready]")).toBe(true); + expect(tui.screen.contains("00:00:00.00")).toBe(true); + }); + + it("shows help text", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("space start/stop")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/streaming-text.test.tsx b/registry/ui/__tests__/streaming-text.test.tsx new file mode 100644 index 0000000..1d2e167 --- /dev/null +++ b/registry/ui/__tests__/streaming-text.test.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { StreamingText } from "../streaming-text"; +import { renderTui } from "./render-tui"; + +describe("StreamingText", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders full text when animate is false", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Hello World")).toBe(true); + }); + + it("animates text character by character", async () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("ABCDE")).toBe(false); + await vi.advanceTimersByTimeAsync(50); + expect(tui.screen.contains("A")).toBe(true); + await vi.advanceTimersByTimeAsync(200); + expect(tui.screen.contains("ABCDE")).toBe(true); + }); + + it("calls onComplete when animation finishes", async () => { + const onComplete = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await vi.advanceTimersByTimeAsync(150); + expect(onComplete).toHaveBeenCalledWith("AB"); + }); + + it("renders cursor when animating", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await vi.advanceTimersByTimeAsync(50); + expect(tui.screen.contains("▌")).toBe(true); + }); + + it("renders without cursor when cursor is false", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("▌")).toBe(false); + }); +}); diff --git a/registry/ui/__tests__/timer.test.tsx b/registry/ui/__tests__/timer.test.tsx new file mode 100644 index 0000000..5c698dd --- /dev/null +++ b/registry/ui/__tests__/timer.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { Timer } from "../timer"; +import { renderTui } from "./render-tui"; + +describe("Timer", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders initial duration in hms format", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("00:01:30")).toBe(true); + }); + + it("shows Paused status when not autoStarted", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[Paused]")).toBe(true); + }); + + it("counts down when autoStart is true", async () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[Running]")).toBe(true); + await vi.advanceTimersByTimeAsync(3000); + expect(tui.screen.contains("00:00:07")).toBe(true); + }); + + it("calls onComplete when timer finishes", async () => { + const onComplete = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await vi.advanceTimersByTimeAsync(2000); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(tui.screen.contains("[Done!]")).toBe(true); + }); + + it("toggles pause/resume with space", async () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[Paused]")).toBe(true); + tui.keys.space(); + await vi.advanceTimersByTimeAsync(0); + expect(tui.screen.contains("[Running]")).toBe(true); + tui.keys.space(); + await vi.advanceTimersByTimeAsync(0); + expect(tui.screen.contains("[Paused]")).toBe(true); + }); + + it("resets with r key", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await vi.advanceTimersByTimeAsync(5000); + expect(tui.screen.contains("00:00:05")).toBe(true); + tui.keys.press("r"); + await vi.advanceTimersByTimeAsync(0); + expect(tui.screen.contains("00:00:10")).toBe(true); + expect(tui.screen.contains("[Paused]")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Pomodoro")).toBe(true); + }); + + it("renders ms format", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("01:30")).toBe(true); + }); + + it("renders s format", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("45s")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/toast.test.tsx b/registry/ui/__tests__/toast.test.tsx new file mode 100644 index 0000000..1db9df5 --- /dev/null +++ b/registry/ui/__tests__/toast.test.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { Toast } from "../toast"; +import { renderTui } from "./render-tui"; + +describe("Toast", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders message text", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("File saved")).toBe(true); + }); + + it("renders info variant icon by default", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("ℹ")).toBe(true); + }); + + it("renders success variant icon", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("✓")).toBe(true); + }); + + it("renders error variant icon", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("✗")).toBe(true); + }); + + it("renders warning variant icon", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("⚠")).toBe(true); + }); + + it("renders custom icon", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("🔥")).toBe(true); + }); + + it("renders countdown bar", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + expect(tui.screen.contains("3.0s")).toBe(true); + }); + + it("counts down over time", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await vi.advanceTimersByTimeAsync(1000); + expect(tui.screen.contains("2.0s")).toBe(true); + }); + + it("calls onDismiss and disappears after duration", async () => { + const onDismiss = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await vi.advanceTimersByTimeAsync(2000); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(tui.screen.contains("Bye")).toBe(false); + }); +}); diff --git a/registry/ui/__tests__/token-usage.test.tsx b/registry/ui/__tests__/token-usage.test.tsx new file mode 100644 index 0000000..b5023ec --- /dev/null +++ b/registry/ui/__tests__/token-usage.test.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { ContextMeter, TokenUsage } from "../token-usage"; +import { renderTui } from "./render-tui"; + +describe("TokenUsage", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it('renders prompt/completion tokens with "in" and "out"', () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("in")).toBe(true); + expect(tui.screen.contains("out")).toBe(true); + expect(tui.screen.contains("800")).toBe(true); + expect(tui.screen.contains("200")).toBe(true); + }); + + it("renders model name", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("gpt-4o-mini")).toBe(true); + }); +}); + +describe("ContextMeter", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders ContextMeter with bar", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("ctx")).toBe(true); + expect(tui.screen.contains("█")).toBe(true); + expect(tui.screen.contains("░")).toBe(true); + expect(tui.screen.contains("50%")).toBe(true); + }); +}); From 79d0ef2646a2919b04db33396f75e7bf16ca252c Mon Sep 17 00:00:00 2001 From: Aniket-508 Date: Mon, 6 Apr 2026 13:03:36 +0530 Subject: [PATCH 4/6] test(ui): add Ink tests for forms, menus, and inputs - Cover text, password, email, number, masked, path, search, tag inputs - Cover select, multi-select, checkbox group, radio, toggle, pickers - Cover confirm, form, form-field, command palette, and menu Made-with: Cursor --- registry/ui/__tests__/checkbox-group.test.tsx | 102 ++++++++++++++ registry/ui/__tests__/checkbox.test.tsx | 74 ++++++++++ registry/ui/__tests__/color-picker.test.tsx | 115 +++++++++++++++ .../ui/__tests__/command-palette.test.tsx | 115 +++++++++++++++ registry/ui/__tests__/confirm.test.tsx | 101 ++++++++++++++ registry/ui/__tests__/date-picker.test.tsx | 122 ++++++++++++++++ registry/ui/__tests__/email-input.test.tsx | 91 ++++++++++++ registry/ui/__tests__/form-field.test.tsx | 51 +++++++ registry/ui/__tests__/form.test.tsx | 107 ++++++++++++++ registry/ui/__tests__/masked-input.test.tsx | 105 ++++++++++++++ registry/ui/__tests__/menu.test.tsx | 120 ++++++++++++++++ registry/ui/__tests__/multi-select.test.tsx | 115 +++++++++++++++ registry/ui/__tests__/number-input.test.tsx | 103 ++++++++++++++ registry/ui/__tests__/password-input.test.tsx | 67 +++++++++ registry/ui/__tests__/path-input.test.tsx | 50 +++++++ registry/ui/__tests__/radio-group.test.tsx | 113 +++++++++++++++ registry/ui/__tests__/search-input.test.tsx | 110 +++++++++++++++ registry/ui/__tests__/select.test.tsx | 132 ++++++++++++++++++ registry/ui/__tests__/tag-input.test.tsx | 94 +++++++++++++ registry/ui/__tests__/text-area.test.tsx | 98 +++++++++++++ registry/ui/__tests__/text-input.test.tsx | 106 ++++++++++++++ registry/ui/__tests__/toggle.test.tsx | 70 ++++++++++ 22 files changed, 2161 insertions(+) create mode 100644 registry/ui/__tests__/checkbox-group.test.tsx create mode 100644 registry/ui/__tests__/checkbox.test.tsx create mode 100644 registry/ui/__tests__/color-picker.test.tsx create mode 100644 registry/ui/__tests__/command-palette.test.tsx create mode 100644 registry/ui/__tests__/confirm.test.tsx create mode 100644 registry/ui/__tests__/date-picker.test.tsx create mode 100644 registry/ui/__tests__/email-input.test.tsx create mode 100644 registry/ui/__tests__/form-field.test.tsx create mode 100644 registry/ui/__tests__/form.test.tsx create mode 100644 registry/ui/__tests__/masked-input.test.tsx create mode 100644 registry/ui/__tests__/menu.test.tsx create mode 100644 registry/ui/__tests__/multi-select.test.tsx create mode 100644 registry/ui/__tests__/number-input.test.tsx create mode 100644 registry/ui/__tests__/password-input.test.tsx create mode 100644 registry/ui/__tests__/path-input.test.tsx create mode 100644 registry/ui/__tests__/radio-group.test.tsx create mode 100644 registry/ui/__tests__/search-input.test.tsx create mode 100644 registry/ui/__tests__/select.test.tsx create mode 100644 registry/ui/__tests__/tag-input.test.tsx create mode 100644 registry/ui/__tests__/text-area.test.tsx create mode 100644 registry/ui/__tests__/text-input.test.tsx create mode 100644 registry/ui/__tests__/toggle.test.tsx diff --git a/registry/ui/__tests__/checkbox-group.test.tsx b/registry/ui/__tests__/checkbox-group.test.tsx new file mode 100644 index 0000000..a7946c4 --- /dev/null +++ b/registry/ui/__tests__/checkbox-group.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { CheckboxGroup } from "../checkbox-group"; +import { renderTui } from "./render-tui"; + +describe("CheckboxGroup", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const options = [ + { label: "Alpha", value: "a" }, + { label: "Beta", value: "b" }, + { label: "Gamma", value: "c" }, + ]; + + it("renders all option labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Alpha")).toBe(true); + expect(tui.screen.contains("Beta")).toBe(true); + expect(tui.screen.contains("Gamma")).toBe(true); + }); + + it("renders label when provided", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Pick items")).toBe(true); + }); + + it("shows cursor indicator on first item", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("toggles selection with space", () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith(["a"]); + }); + + it("navigates down and selects second option", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith(["b"]); + }); + + it("skips disabled options when navigating", async () => { + const opts = [ + { label: "Alpha", value: "a" }, + { disabled: true, label: "Beta", value: "b" }, + { label: "Gamma", value: "c" }, + ]; + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith(["c"]); + }); + + it("shows error when selecting fewer than min", async () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.space(); + await tui.flush(); + expect(tui.screen.contains("Select at least 2 options.")).toBe(true); + }); + + it("prevents exceeding max selections", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.space(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/registry/ui/__tests__/checkbox.test.tsx b/registry/ui/__tests__/checkbox.test.tsx new file mode 100644 index 0000000..c0c8d48 --- /dev/null +++ b/registry/ui/__tests__/checkbox.test.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Checkbox } from "../checkbox"; +import { renderTui } from "./render-tui"; + +describe("Checkbox", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders unchecked icon by default", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("□")).toBe(true); + }); + + it("renders checked icon when checked", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("■")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Accept terms")).toBe(true); + }); + + it("renders indeterminate icon", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("▪")).toBe(true); + }); + + it("toggles on space when focused", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("unchecks on space when already checked", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it("does not toggle when disabled", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.space(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("renders custom icons", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("✔")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/color-picker.test.tsx b/registry/ui/__tests__/color-picker.test.tsx new file mode 100644 index 0000000..d933cb6 --- /dev/null +++ b/registry/ui/__tests__/color-picker.test.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { ColorPicker } from "../color-picker"; +import type { TuiInstance } from "./render-tui"; +import { renderTui } from "./render-tui"; + +const typeChars = async (tui: TuiInstance, text: string) => { + for (const char of text) { + tui.keys.press(char); + await tui.flush(); + } +}; + +describe("ColorPicker", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Color")).toBe(true); + }); + + it("renders selected color value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Selected:")).toBe(true); + expect(tui.screen.contains("#000000")).toBe(true); + }); + + it("renders hex input area", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("#")).toBe(true); + }); + + it("navigates palette with arrow keys", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + await tui.flush(); + tui.keys.right(); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onChange).toHaveBeenCalledWith("#800000"); + }); + + it("selects color on enter", async () => { + const onSubmit = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onSubmit).toHaveBeenCalledWith("#000000"); + }); + + it("switches to hex mode on tab", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await tui.flush(); + expect(tui.screen.contains("navigate")).toBe(true); + tui.keys.tab(); + await tui.flush(); + expect(tui.screen.contains("Type hex")).toBe(true); + }); + + it("accepts hex input in hex mode", async () => { + const onSubmit = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + await tui.flush(); + tui.keys.tab(); + await tui.flush(); + await typeChars(tui, "ff0000"); + tui.keys.enter(); + await tui.flush(); + expect(onSubmit).toHaveBeenCalledWith("#ff0000"); + }); + + it("handles custom palette", () => { + const palette = ["#111111", "#222222"]; + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("#111111")).toBe(true); + }); + + it("navigates palette down", async () => { + const onChange = vi.fn(); + const palette = [ + "#000000", + "#111111", + "#222222", + "#333333", + "#444444", + "#555555", + "#666666", + "#777777", + "#888888", + "#999999", + ]; + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onChange).toHaveBeenCalledWith("#888888"); + }); +}); diff --git a/registry/ui/__tests__/command-palette.test.tsx b/registry/ui/__tests__/command-palette.test.tsx new file mode 100644 index 0000000..40497c2 --- /dev/null +++ b/registry/ui/__tests__/command-palette.test.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { CommandPalette } from "../command-palette"; +import type { TuiInstance } from "./render-tui"; +import { renderTui } from "./render-tui"; + +const typeChars = async (tui: TuiInstance, text: string) => { + for (const char of text) { + tui.keys.press(char); + await tui.flush(); + } +}; + +describe("CommandPalette", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const commands = [ + { id: "save", label: "Save File", shortcut: "Ctrl+S" }, + { id: "open", label: "Open File", shortcut: "Ctrl+O" }, + { id: "close", label: "Close Tab" }, + { description: "Find in files", id: "search", label: "Search" }, + ]; + + it("renders nothing when closed", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Save File")).toBe(false); + }); + + it("renders commands when open", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Save File")).toBe(true); + expect(tui.screen.contains("Open File")).toBe(true); + }); + + it("renders placeholder", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Run...")).toBe(true); + }); + + it("renders shortcut badges", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Ctrl+S")).toBe(true); + }); + + it("renders command description", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Find in files")).toBe(true); + }); + + it("filters commands by query", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await typeChars(tui, "save"); + expect(tui.screen.contains("Save File")).toBe(true); + expect(tui.screen.contains("Close Tab")).toBe(false); + }); + + it("shows no commands found when filter yields nothing", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await typeChars(tui, "zzzzz"); + expect(tui.screen.contains("No commands found")).toBe(true); + }); + + it("selects command on enter", async () => { + const onSelect = vi.fn(); + const cmds = [ + { id: "a", label: "Alpha", onSelect }, + { id: "b", label: "Beta" }, + ]; + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.enter(); + await tui.flush(); + expect(onSelect).toHaveBeenCalled(); + }); + + it("calls onClose on escape", async () => { + const onClose = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.escape(); + await tui.flush(); + expect(onClose).toHaveBeenCalled(); + }); + + it("navigates with up/down arrows", async () => { + const onSelectA = vi.fn(); + const onSelectB = vi.fn(); + const cmds = [ + { id: "a", label: "Alpha", onSelect: onSelectA }, + { id: "b", label: "Beta", onSelect: onSelectB }, + ]; + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onSelectB).toHaveBeenCalled(); + }); +}); diff --git a/registry/ui/__tests__/confirm.test.tsx b/registry/ui/__tests__/confirm.test.tsx new file mode 100644 index 0000000..fffdce1 --- /dev/null +++ b/registry/ui/__tests__/confirm.test.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Confirm } from "../confirm"; +import { renderTui } from "./render-tui"; + +describe("Confirm", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders message and default labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Delete file?")).toBe(true); + expect(tui.screen.contains("Yes")).toBe(true); + expect(tui.screen.contains("No")).toBe(true); + }); + + it("renders custom labels", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Yep")).toBe(true); + expect(tui.screen.contains("Nah")).toBe(true); + }); + + it("calls onConfirm when Enter pressed with defaultValue=true", () => { + const onConfirm = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.enter(); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("calls onCancel when Enter pressed with defaultValue=false", () => { + const onCancel = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.enter(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("toggles selection with arrow keys", async () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.right(); + await tui.flush(); + tui.keys.enter(); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("toggles back with left arrow", async () => { + const onCancel = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.left(); + await tui.flush(); + tui.keys.enter(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("'y' shortcut calls onConfirm", () => { + const onConfirm = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.press("y"); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("'n' shortcut calls onCancel", () => { + const onCancel = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.press("n"); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("'Y' shortcut also works", () => { + const onConfirm = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.press("Y"); + expect(onConfirm).toHaveBeenCalledOnce(); + }); +}); diff --git a/registry/ui/__tests__/date-picker.test.tsx b/registry/ui/__tests__/date-picker.test.tsx new file mode 100644 index 0000000..7fb81a2 --- /dev/null +++ b/registry/ui/__tests__/date-picker.test.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { DatePicker } from "../date-picker"; +import { renderTui } from "./render-tui"; + +describe("DatePicker", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Start Date")).toBe(true); + }); + + it("renders month/day/year fields", () => { + const date = new Date(2025, 0, 15); + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Jan")).toBe(true); + expect(tui.screen.contains("15")).toBe(true); + expect(tui.screen.contains("2025")).toBe(true); + }); + + it("renders navigation hint when focused", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await tui.flush(); + expect(tui.screen.contains("Tab: next field")).toBe(true); + }); + + it("increments month with up arrow", async () => { + const onChange = vi.fn(); + const date = new Date(2025, 0, 15); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.up(); + await tui.flush(); + expect(onChange).toHaveBeenCalled(); + const calledDate = onChange.mock.calls[0][0] as Date; + expect(calledDate.getMonth()).toBe(1); + }); + + it("decrements month with down arrow", async () => { + const onChange = vi.fn(); + const date = new Date(2025, 3, 10); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + const calledDate = onChange.mock.calls[0][0] as Date; + expect(calledDate.getMonth()).toBe(2); + }); + + it("switches field with tab", async () => { + const onChange = vi.fn(); + const date = new Date(2025, 0, 15); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.tab(); + await tui.flush(); + tui.keys.up(); + await tui.flush(); + const calledDate = onChange.mock.calls[0][0] as Date; + expect(calledDate.getDate()).toBe(16); + }); + + it("wraps month from January down to December", async () => { + const onChange = vi.fn(); + const date = new Date(2025, 0, 15); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + const calledDate = onChange.mock.calls[0][0] as Date; + expect(calledDate.getMonth()).toBe(11); + }); + + it("calls onSubmit on enter", async () => { + const onSubmit = vi.fn(); + const date = new Date(2025, 5, 20); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onSubmit).toHaveBeenCalled(); + }); + + it("changes year when year field is active", async () => { + const onChange = vi.fn(); + const date = new Date(2025, 0, 1); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.tab(); + await tui.flush(); + tui.keys.tab(); + await tui.flush(); + tui.keys.up(); + await tui.flush(); + const calledDate = onChange.mock.calls[0][0] as Date; + expect(calledDate.getFullYear()).toBe(2026); + }); +}); diff --git a/registry/ui/__tests__/email-input.test.tsx b/registry/ui/__tests__/email-input.test.tsx new file mode 100644 index 0000000..69ffcec --- /dev/null +++ b/registry/ui/__tests__/email-input.test.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { EmailInput } from "../email-input"; +import { renderTui } from "./render-tui"; + +describe("EmailInput", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders placeholder when empty", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("you@example.com")).toBe(true); + }); + + it("renders custom placeholder", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("email...")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Email")).toBe(true); + }); + + it("renders controlled value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("user@test.com")).toBe(true); + }); + + it("calls onChange when typing", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.type("a"); + expect(onChange).toHaveBeenCalledWith("a"); + }); + + it("calls onSubmit with valid email", async () => { + const onSubmit = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.enter(); + expect(onSubmit).toHaveBeenCalledWith("user@test.com"); + }); + + it("shows error for invalid email on submit", async () => { + const onSubmit = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(tui.screen.contains("valid email")).toBe(true); + }); + + it("completes domain on Tab", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.tab(); + expect(onChange).toHaveBeenCalledWith("user@gmail.com"); + }); + + it("handles backspace", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.backspace(); + expect(onChange).toHaveBeenCalledWith("ab"); + }); +}); diff --git a/registry/ui/__tests__/form-field.test.tsx b/registry/ui/__tests__/form-field.test.tsx new file mode 100644 index 0000000..a713a43 --- /dev/null +++ b/registry/ui/__tests__/form-field.test.tsx @@ -0,0 +1,51 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { FormField } from "../form-field"; +import { renderTui } from "./render-tui"; + +describe("FormField", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders label", () => { + const tui = renderTui( + + field + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Username")).toBe(true); + }); + + it("shows error", () => { + const tui = renderTui( + + input + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Invalid email")).toBe(true); + }); + + it("shows hint when there is no error", () => { + const tui = renderTui( + + input + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Use your legal name")).toBe(true); + }); + + it("shows asterisk when required", () => { + const tui = renderTui( + + secret + + ); + ({ unmount } = tui); + expect(tui.screen.contains("*")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/form.test.tsx b/registry/ui/__tests__/form.test.tsx new file mode 100644 index 0000000..446ff2e --- /dev/null +++ b/registry/ui/__tests__/form.test.tsx @@ -0,0 +1,107 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Form, useFormContext } from "../form"; +import { renderTui } from "./render-tui"; + +const FieldDisplay = ({ name }: { name: string }) => { + const { values, errors } = useFormContext(); + return ( + + {name}:{String(values[name] ?? "")} + {errors[name] ? ` error:${errors[name]}` : ""} + + ); +}; + +describe("Form", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children and submit hint", () => { + const tui = renderTui( +
+ child content +
+ ); + ({ unmount } = tui); + expect(tui.screen.contains("child content")).toBe(true); + expect(tui.screen.contains("Ctrl+S")).toBe(true); + }); + + it("provides initial values via context", () => { + const tui = renderTui( +
+ + + ); + ({ unmount } = tui); + expect(tui.screen.contains("name:Alice")).toBe(true); + }); + + it("calls onSubmit with values on Ctrl+S", async () => { + const onSubmit = vi.fn(); + const tui = renderTui( +
+ form +
+ ); + ({ unmount } = tui); + tui.keys.raw("\u0013"); + await tui.flush(); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ name: "Bob" }) + ); + }); + + it("validates fields before submit", async () => { + const onSubmit = vi.fn(); + const fields = [ + { + name: "email", + validate: (v: unknown) => + typeof v === "string" && v.includes("@") ? null : "Invalid email", + }, + ]; + const tui = renderTui( +
+ + + ); + ({ unmount } = tui); + tui.keys.raw("\u0013"); + await tui.flush(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("submits when validation passes", async () => { + const onSubmit = vi.fn(); + const fields = [ + { + name: "email", + validate: (v: unknown) => + typeof v === "string" && v.includes("@") ? null : "Invalid", + }, + ]; + const tui = renderTui( +
+ ok +
+ ); + ({ unmount } = tui); + tui.keys.raw("\u0013"); + await tui.flush(); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ email: "a@b.com" }) + ); + }); +}); diff --git a/registry/ui/__tests__/masked-input.test.tsx b/registry/ui/__tests__/masked-input.test.tsx new file mode 100644 index 0000000..2ef0400 --- /dev/null +++ b/registry/ui/__tests__/masked-input.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { MaskedInput } from "../masked-input"; +import type { TuiInstance } from "./render-tui"; +import { renderTui } from "./render-tui"; + +const typeChars = async (tui: TuiInstance, text: string) => { + for (const char of text) { + tui.keys.press(char); + await tui.flush(); + } +}; + +describe("MaskedInput", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Phone")).toBe(true); + }); + + it("renders placeholder when empty", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Enter digits")).toBe(true); + }); + + it("formats digits through mask", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await tui.flush(); + await typeChars(tui, "123"); + expect(tui.screen.contains("(123")).toBe(true); + }); + + it("calls onChange with raw digits", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.press("5"); + await tui.flush(); + expect(onChange).toHaveBeenCalledWith("5"); + }); + + it("handles backspace", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.backspace(); + await tui.flush(); + expect(onChange).toHaveBeenCalledWith("1"); + }); + + it("calls onSubmit on enter", async () => { + const onSubmit = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onSubmit).toHaveBeenCalledWith("1234567"); + }); + + it("ignores non-digit characters", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.press("a"); + await tui.flush(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("does not exceed max digits", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.press("4"); + await tui.flush(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/registry/ui/__tests__/menu.test.tsx b/registry/ui/__tests__/menu.test.tsx new file mode 100644 index 0000000..00dd748 --- /dev/null +++ b/registry/ui/__tests__/menu.test.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Menu } from "../menu"; +import { renderTui } from "./render-tui"; + +describe("Menu", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const items = [ + { key: "copy", label: "Copy" }, + { key: "paste", label: "Paste" }, + { key: "cut", label: "Cut" }, + ]; + + it("renders all items", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Copy")).toBe(true); + expect(tui.screen.contains("Paste")).toBe(true); + expect(tui.screen.contains("Cut")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Edit")).toBe(true); + }); + + it("renders shortcuts", () => { + const itemsWithShortcuts = [ + { key: "copy", label: "Copy", shortcut: "Ctrl+C" }, + ]; + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Ctrl+C")).toBe(true); + }); + + it("renders separator", () => { + const itemsWithSep = [ + { key: "copy", label: "Copy" }, + { key: "sep", label: "", separator: true }, + { key: "paste", label: "Paste" }, + ]; + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("─")).toBe(true); + }); + + it("navigates with arrow keys and selects on enter", async () => { + const onSelect = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ key: "paste" }) + ); + }); + + it("skips disabled items during navigation", async () => { + const onSelect = vi.fn(); + const itemsWithDisabled = [ + { key: "a", label: "A" }, + { disabled: true, key: "b", label: "B" }, + { key: "c", label: "C" }, + ]; + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ key: "c" }) + ); + }); + + it("opens submenu with right arrow", async () => { + const itemsWithSub = [ + { + children: [ + { key: "new", label: "New" }, + { key: "open", label: "Open" }, + ], + key: "file", + label: "File", + }, + ]; + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.right(); + await tui.flush(); + expect(tui.screen.contains("New")).toBe(true); + expect(tui.screen.contains("Open")).toBe(true); + }); + + it("closes submenu with escape", async () => { + const itemsWithSub = [ + { + children: [{ key: "new", label: "New File" }], + key: "file", + label: "File", + }, + ]; + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.right(); + await tui.flush(); + expect(tui.screen.contains("New File")).toBe(true); + tui.keys.escape(); + await tui.flush(); + expect(tui.screen.contains("File")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/multi-select.test.tsx b/registry/ui/__tests__/multi-select.test.tsx new file mode 100644 index 0000000..4d5cd94 --- /dev/null +++ b/registry/ui/__tests__/multi-select.test.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { MultiSelect } from "../multi-select"; +import { renderTui } from "./render-tui"; + +const options = [ + { label: "Alpha", value: "a" }, + { label: "Beta", value: "b" }, + { label: "Charlie", value: "c" }, +]; + +describe("MultiSelect", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders all option labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Alpha")).toBe(true); + expect(tui.screen.contains("Beta")).toBe(true); + expect(tui.screen.contains("Charlie")).toBe(true); + }); + + it("shows cursor on first option", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("renders ○ for unselected options", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("○")).toBe(true); + }); + + it("renders checkmark for selected options", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("◉")).toBe(true); + }); + + it("toggles selection with space", () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith(["a"]); + }); + + it("deselects on second space press", () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it("navigates down and toggles", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith(["b"]); + }); + + it("submits selection on Enter", () => { + const onSubmit = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.enter(); + expect(onSubmit).toHaveBeenCalledWith(["a", "c"]); + }); + + it("skips disabled options when navigating", async () => { + const opts = [ + { label: "Alpha", value: "a" }, + { disabled: true, label: "Beta", value: "b" }, + { label: "Charlie", value: "c" }, + ]; + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith(["c"]); + }); + + it("does not toggle disabled option", () => { + const opts = [{ disabled: true, label: "Alpha", value: "a" }]; + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.space(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("renders hint text", () => { + const opts = [{ hint: "popular", label: "Alpha", value: "a" }]; + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("popular")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/number-input.test.tsx b/registry/ui/__tests__/number-input.test.tsx new file mode 100644 index 0000000..5087bb1 --- /dev/null +++ b/registry/ui/__tests__/number-input.test.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { NumberInput } from "../number-input"; +import { renderTui } from "./render-tui"; + +describe("NumberInput", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders placeholder when empty", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Enter number")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Quantity")).toBe(true); + }); + + it("renders controlled value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("42")).toBe(true); + }); + + it("increments on up arrow", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.up(); + expect(onChange).toHaveBeenCalledWith(11); + }); + + it("decrements on down arrow", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.down(); + expect(onChange).toHaveBeenCalledWith(9); + }); + + it("respects step value", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.up(); + expect(onChange).toHaveBeenCalledWith(15); + }); + + it("clamps to max", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.up(); + expect(onChange).toHaveBeenCalledWith(100); + }); + + it("clamps to min", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.down(); + expect(onChange).toHaveBeenCalledWith(0); + }); + + it("calls onSubmit on Enter", async () => { + const onSubmit = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.enter(); + expect(onSubmit).toHaveBeenCalledWith(42); + }); + + it("shows step hint when focused", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + expect(tui.screen.contains("↑ +5")).toBe(true); + expect(tui.screen.contains("↓ -5")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/password-input.test.tsx b/registry/ui/__tests__/password-input.test.tsx new file mode 100644 index 0000000..17b8e28 --- /dev/null +++ b/registry/ui/__tests__/password-input.test.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { PasswordInput } from "../password-input"; +import { renderTui } from "./render-tui"; + +describe("PasswordInput", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders placeholder when empty", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Enter password")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Password")).toBe(true); + }); + + it("masks input with default mask character", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("●●●●●●")).toBe(true); + expect(tui.screen.contains("secret")).toBe(false); + }); + + it("masks input with custom mask character", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("***")).toBe(true); + }); + + it("calls onChange when typing", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.type("a"); + expect(onChange).toHaveBeenCalledWith("a"); + }); + + it("calls onChange on backspace", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.backspace(); + expect(onChange).toHaveBeenCalledWith("ab"); + }); + + it("calls onSubmit on Enter", async () => { + const onSubmit = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.tab(); + await tui.flush(); + tui.keys.enter(); + expect(onSubmit).toHaveBeenCalledWith("pass123"); + }); +}); diff --git a/registry/ui/__tests__/path-input.test.tsx b/registry/ui/__tests__/path-input.test.tsx new file mode 100644 index 0000000..4921390 --- /dev/null +++ b/registry/ui/__tests__/path-input.test.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { PathInput } from "../path-input"; +import { renderTui } from "./render-tui"; + +vi.mock("node:fs", () => ({ + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ isDirectory: () => false })), +})); + +describe("PathInput", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Path")).toBe(true); + }); + + it("renders placeholder", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("/home")).toBe(true); + }); + + it("renders default placeholder", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("/")).toBe(true); + }); + + it("renders cursor when focused", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await tui.flush(); + expect(tui.screen.contains("█")).toBe(true); + }); + + it("calls onChange on typing", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + await tui.flush(); + tui.keys.type("/"); + await tui.flush(); + expect(onChange).toHaveBeenCalled(); + }); +}); diff --git a/registry/ui/__tests__/radio-group.test.tsx b/registry/ui/__tests__/radio-group.test.tsx new file mode 100644 index 0000000..34e3679 --- /dev/null +++ b/registry/ui/__tests__/radio-group.test.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { RadioGroup } from "../radio-group"; +import { renderTui } from "./render-tui"; + +const options = [ + { label: "Alpha", value: "a" }, + { label: "Beta", value: "b" }, + { label: "Charlie", value: "c" }, +]; + +describe("RadioGroup", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders all option labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Alpha")).toBe(true); + expect(tui.screen.contains("Beta")).toBe(true); + expect(tui.screen.contains("Charlie")).toBe(true); + }); + + it("shows cursor on first option", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("renders ○ for unselected options", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("○")).toBe(true); + }); + + it("renders ◉ for selected option", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("◉")).toBe(true); + }); + + it("selects option with space", () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith("a"); + }); + + it("selects option with Enter", () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.enter(); + expect(onChange).toHaveBeenCalledWith("a"); + }); + + it("navigates down and selects", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith("b"); + }); + + it("navigates up and selects", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + tui.keys.up(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith("b"); + }); + + it("skips disabled options when navigating", async () => { + const opts = [ + { label: "Alpha", value: "a" }, + { disabled: true, label: "Beta", value: "b" }, + { label: "Charlie", value: "c" }, + ]; + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.space(); + expect(onChange).toHaveBeenCalledWith("c"); + }); + + it("does not select disabled option", () => { + const opts = [{ disabled: true, label: "Alpha", value: "a" }]; + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.space(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("renders hint text", () => { + const opts = [{ hint: "recommended", label: "Alpha", value: "a" }]; + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("recommended")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/search-input.test.tsx b/registry/ui/__tests__/search-input.test.tsx new file mode 100644 index 0000000..a33d1dc --- /dev/null +++ b/registry/ui/__tests__/search-input.test.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { SearchInput } from "../search-input"; +import type { TuiInstance } from "./render-tui"; +import { renderTui } from "./render-tui"; + +const typeChars = async (tui: TuiInstance, text: string) => { + for (const char of text) { + tui.keys.press(char); + await tui.flush(); + } +}; + +const focusAndFlush = async (tui: TuiInstance) => { + await tui.flush(); + tui.keys.tab(); + await tui.flush(); +}; + +describe("SearchInput", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]; + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Find")).toBe(true); + }); + + it("renders placeholder", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Type to search")).toBe(true); + }); + + it("renders search icon", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("🔍")).toBe(true); + }); + + it("filters options as user types", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await focusAndFlush(tui); + await typeChars(tui, "ban"); + expect(tui.screen.contains("Banana")).toBe(true); + expect(tui.screen.contains("Cherry")).toBe(false); + }); + + it("shows results on down arrow", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await focusAndFlush(tui); + tui.keys.down(); + await tui.flush(); + expect(tui.screen.contains("Apple")).toBe(true); + }); + + it("calls onSelect when selecting a result", async () => { + const onSelect = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + await focusAndFlush(tui); + await typeChars(tui, "ch"); + tui.keys.enter(); + await tui.flush(); + expect(onSelect).toHaveBeenCalledWith("Cherry"); + }); + + it("clears query on escape", async () => { + const tui = renderTui(); + ({ unmount } = tui); + await focusAndFlush(tui); + await typeChars(tui, "app"); + expect(tui.screen.contains("app")).toBe(true); + tui.keys.escape(); + await tui.flush(); + expect(tui.screen.contains("Search...")).toBe(true); + }); + + it("calls onChange for controlled value", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + await focusAndFlush(tui); + tui.keys.press("x"); + await tui.flush(); + expect(onChange).toHaveBeenCalledWith("x"); + }); + + it("limits results to maxResults", async () => { + const many = ["A1", "A2", "A3", "A4", "A5", "A6", "A7"]; + const tui = renderTui( + + ); + ({ unmount } = tui); + await focusAndFlush(tui); + tui.keys.down(); + await tui.flush(); + const text = tui.screen.text(); + const matches = many.filter((item) => text.includes(item)); + expect(matches.length).toBeLessThanOrEqual(3); + }); +}); diff --git a/registry/ui/__tests__/select.test.tsx b/registry/ui/__tests__/select.test.tsx new file mode 100644 index 0000000..48cca31 --- /dev/null +++ b/registry/ui/__tests__/select.test.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Select } from "../select"; +import { renderTui } from "./render-tui"; + +const options = [ + { label: "Alpha", value: "a" }, + { label: "Beta", value: "b" }, + { label: "Charlie", value: "c" }, +]; + +describe("Select", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders all option labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Pick one")).toBe(true); + }); + + it("shows cursor on first option", () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + expect(onChange).toHaveBeenCalledWith("b"); + }); + + it("navigates up with arrow key", async () => { + const onChange = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.enter(); + expect(onSubmit).toHaveBeenCalledWith("a"); + }); + + it("skips disabled options when navigating down", async () => { + const opts = [ + { label: "Alpha", value: "a" }, + { disabled: true, label: "Beta", value: "b" }, + { label: "Charlie", value: "c" }, + ]; + const onSubmit = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.up(); + await tui.flush(); + tui.keys.enter(); + expect(onSubmit).toHaveBeenCalledWith("a"); + }); + + it("does not go past first option", () => { + const onSubmit = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + expect(onSubmit).toHaveBeenCalledWith("c"); + }); + + it("renders hint text", () => { + const opts = [{ hint: "first letter", label: "Alpha", value: "a" }]; + const tui = renderTui(