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/registry/ui/__tests__/alert.test.tsx b/registry/ui/__tests__/alert.test.tsx new file mode 100644 index 0000000..09fe454 --- /dev/null +++ b/registry/ui/__tests__/alert.test.tsx @@ -0,0 +1,53 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Alert } from "../alert"; +import { renderTui } from "./render-tui"; + +describe("Alert", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + body text + + ); + ({ unmount } = tui); + expect(tui.screen.contains("body text")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui( + + msg + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Notice")).toBe(true); + }); + + it.each([ + ["success", "✓"], + ["error", "✗"], + ["warning", "⚠"], + ["info", "ℹ"], + ] as const)("renders variant icon for %s", (variant, icon) => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains(icon)).toBe(true); + }); + + it("renders without border", () => { + const tui = renderTui( + + inside + + ); + ({ unmount } = tui); + expect(tui.screen.contains("inside")).toBe(true); + expect(tui.screen.contains("T")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/app-shell.test.tsx b/registry/ui/__tests__/app-shell.test.tsx new file mode 100644 index 0000000..974eba5 --- /dev/null +++ b/registry/ui/__tests__/app-shell.test.tsx @@ -0,0 +1,180 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { AppShell } from "../app-shell"; +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("AppShell", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + App Content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("App Content")).toBe(true); + }); + + describe("AppShell.Header", () => { + it("renders header children", () => { + const tui = renderTui( + + + My App Header + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("My App Header")).toBe(true); + }); + }); + + describe("AppShell.Content", () => { + it("renders content children", () => { + const tui = renderTui( + + + Main content area + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Main content area")).toBe(true); + }); + }); + + describe("AppShell.Input", () => { + it("renders placeholder", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Enter command...")).toBe(true); + }); + + it("renders prefix", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("$")).toBe(true); + }); + + it("accepts typed input", async () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + await typeChars(tui, "hello"); + expect(tui.screen.contains("hello")).toBe(true); + }); + + it("calls onChange", async () => { + const onChange = vi.fn(); + const tui = renderTui( + + + + ); + ({ unmount } = tui); + tui.keys.press("x"); + await tui.flush(); + expect(onChange).toHaveBeenCalledWith("x"); + }); + + it("calls onSubmit on enter", async () => { + const onSubmit = vi.fn(); + const tui = renderTui( + + + + ); + ({ unmount } = tui); + await typeChars(tui, "cmd"); + tui.keys.enter(); + await tui.flush(); + expect(onSubmit).toHaveBeenCalledWith("cmd"); + }); + + it("clears input after uncontrolled submit", async () => { + const onSubmit = vi.fn(); + const tui = renderTui( + + + + ); + ({ unmount } = tui); + await typeChars(tui, "test"); + tui.keys.enter(); + await tui.flush(); + expect(tui.screen.contains("Type something...")).toBe(true); + }); + + it("handles backspace", async () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + await typeChars(tui, "ab"); + tui.keys.backspace(); + await tui.flush(); + expect(tui.screen.contains("a")).toBe(true); + }); + }); + + describe("AppShell.Hints", () => { + it("renders hint items", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Ctrl+C: quit")).toBe(true); + expect(tui.screen.contains("?: help")).toBe(true); + }); + + it("renders children as content", () => { + const tui = renderTui( + + Press q to quit + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Press q to quit")).toBe(true); + }); + }); + + describe("AppShell.Tip", () => { + it("renders tip text", () => { + const tui = renderTui( + + Use tab to navigate + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Tip:")).toBe(true); + expect(tui.screen.contains("Use tab to navigate")).toBe(true); + }); + }); +}); diff --git a/registry/ui/__tests__/aspect-ratio.test.tsx b/registry/ui/__tests__/aspect-ratio.test.tsx new file mode 100644 index 0000000..1191e08 --- /dev/null +++ b/registry/ui/__tests__/aspect-ratio.test.tsx @@ -0,0 +1,31 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { AspectRatio } from "../aspect-ratio"; +import { renderTui } from "./render-tui"; + +describe("AspectRatio", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + child + + ); + ({ unmount } = tui); + expect(tui.screen.contains("child")).toBe(true); + }); + + it("renders without crashing", () => { + const tui = renderTui( + + ok + + ); + ({ unmount } = tui); + expect(tui.screen.text().length).toBeGreaterThan(0); + }); +}); diff --git a/registry/ui/__tests__/badge.test.tsx b/registry/ui/__tests__/badge.test.tsx new file mode 100644 index 0000000..dec7cbe --- /dev/null +++ b/registry/ui/__tests__/badge.test.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Badge } from "../badge"; +import { renderTui } from "./render-tui"; + +describe("Badge", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children text", () => { + const tui = renderTui(hello); + ({ unmount } = tui); + expect(tui.screen.contains("hello")).toBe(true); + }); + + it("renders without border when bordered=false", () => { + const tui = renderTui(plain); + ({ unmount } = tui); + expect(tui.screen.contains("plain")).toBe(true); + }); + + it("renders all variants", () => { + for (const variant of [ + "default", + "success", + "warning", + "error", + "info", + "secondary", + ] as const) { + const tui = renderTui(v); + expect(tui.screen.text().length).toBeGreaterThan(0); + tui.unmount(); + } + }); +}); diff --git a/registry/ui/__tests__/banner.test.tsx b/registry/ui/__tests__/banner.test.tsx new file mode 100644 index 0000000..f944a9d --- /dev/null +++ b/registry/ui/__tests__/banner.test.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Banner } from "../banner"; +import { renderTui } from "./render-tui"; + +describe("Banner", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui(Important message); + ({ unmount } = tui); + expect(tui.screen.contains("Important message")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui(body); + ({ unmount } = tui); + expect(tui.screen.contains("Alert")).toBe(true); + }); + + it("renders accent character", () => { + const tui = renderTui(text); + ({ unmount } = tui); + expect(tui.screen.contains("┃")).toBe(true); + }); + + it("renders custom accent character", () => { + const tui = renderTui(text); + ({ unmount } = tui); + expect(tui.screen.contains("|")).toBe(true); + }); + + it.each([ + ["error", "✗"], + ["info", "ℹ"], + ["neutral", "·"], + ["success", "✓"], + ["warning", "⚠"], + ] as const)("renders %s variant icon: %s", (variant, icon) => { + const tui = renderTui(text); + ({ unmount } = tui); + expect(tui.screen.contains(icon)).toBe(true); + }); + + it("renders custom icon", () => { + const tui = renderTui(text); + ({ unmount } = tui); + expect(tui.screen.contains("🔔")).toBe(true); + }); + + it("shows dismiss hint when dismissible", () => { + const tui = renderTui(text); + ({ unmount } = tui); + expect(tui.screen.contains("press Esc to dismiss")).toBe(true); + }); + + it("does not show dismiss hint by default", () => { + const tui = renderTui(text); + ({ unmount } = tui); + expect(tui.screen.contains("press Esc to dismiss")).toBe(false); + }); + + it("dismisses on Escape when dismissible", async () => { + const onDismiss = vi.fn(); + const tui = renderTui( + + Dismissable + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Dismissable")).toBe(true); + tui.keys.escape(); + await tui.flush(); + expect(onDismiss).toHaveBeenCalledOnce(); + expect(tui.screen.contains("Dismissable")).toBe(false); + }); + + it("does not dismiss when not dismissible", async () => { + const tui = renderTui(Persistent); + ({ unmount } = tui); + tui.keys.escape(); + await tui.flush(); + expect(tui.screen.contains("Persistent")).toBe(true); + }); +}); 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__/box.test.tsx b/registry/ui/__tests__/box.test.tsx new file mode 100644 index 0000000..661933c --- /dev/null +++ b/registry/ui/__tests__/box.test.tsx @@ -0,0 +1,42 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Box } from "../box"; +import { renderTui } from "./render-tui"; + +describe("Box", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + boxed + + ); + ({ unmount } = tui); + expect(tui.screen.contains("boxed")).toBe(true); + }); + + it("renders with border=true", () => { + const tui = renderTui( + + with border + + ); + ({ unmount } = tui); + expect(tui.screen.contains("with border")).toBe(true); + expect(tui.screen.text().length).toBeGreaterThan("with border".length); + }); + + it("renders without border", () => { + const tui = renderTui( + + no border + + ); + ({ unmount } = tui); + expect(tui.screen.contains("no border")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/breadcrumb.test.tsx b/registry/ui/__tests__/breadcrumb.test.tsx new file mode 100644 index 0000000..6110151 --- /dev/null +++ b/registry/ui/__tests__/breadcrumb.test.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Breadcrumb } from "../breadcrumb"; +import { renderTui } from "./render-tui"; + +describe("Breadcrumb", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const items = [ + { key: "home", label: "Home" }, + { key: "docs", label: "Docs" }, + { key: "api", label: "API" }, + ]; + + it("renders all breadcrumb labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Home")).toBe(true); + expect(tui.screen.contains("Docs")).toBe(true); + expect(tui.screen.contains("API")).toBe(true); + }); + + it("renders default separator", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("renders custom separator", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("/")).toBe(true); + }); + + it("defaults active to last item", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("API")).toBe(true); + }); + + it("supports activeKey prop", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Docs")).toBe(true); + }); + + it("calls onSelect on left arrow navigating to previous item", () => { + const onSelect = vi.fn(); + const itemsWithSelect = [ + { key: "home", label: "Home", onSelect }, + { key: "docs", label: "Docs" }, + ]; + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.left(); + expect(onSelect).toHaveBeenCalled(); + }); +}); diff --git a/registry/ui/__tests__/bullet-list.test.tsx b/registry/ui/__tests__/bullet-list.test.tsx new file mode 100644 index 0000000..3d691f0 --- /dev/null +++ b/registry/ui/__tests__/bullet-list.test.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { BulletList } from "../bullet-list"; +import { renderTui } from "./render-tui"; + +describe("BulletList", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders items with bullet ●", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("●")).toBe(true); + expect(tui.screen.contains("First")).toBe(true); + }); + + it("renders check items with ■ when done and □ when not", () => { + const tui = renderTui( + + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("■")).toBe(true); + expect(tui.screen.contains("□")).toBe(true); + expect(tui.screen.contains("Done task")).toBe(true); + expect(tui.screen.contains("Todo")).toBe(true); + }); + + it("renders tree items with └", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("└")).toBe(true); + expect(tui.screen.contains("leaf")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/card.test.tsx b/registry/ui/__tests__/card.test.tsx new file mode 100644 index 0000000..605e906 --- /dev/null +++ b/registry/ui/__tests__/card.test.tsx @@ -0,0 +1,51 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Card } from "../card"; +import { renderTui } from "./render-tui"; + +describe("Card", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + main + + ); + ({ unmount } = tui); + expect(tui.screen.contains("main")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui( + + x + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Card title")).toBe(true); + }); + + it("renders subtitle", () => { + const tui = renderTui( + + x + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Sub here")).toBe(true); + }); + + it("renders footer", () => { + const tui = renderTui( + foot}> + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("foot")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/center.test.tsx b/registry/ui/__tests__/center.test.tsx new file mode 100644 index 0000000..26b991e --- /dev/null +++ b/registry/ui/__tests__/center.test.tsx @@ -0,0 +1,21 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Center } from "../center"; +import { renderTui } from "./render-tui"; + +describe("Center", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( +
+ centered-ish +
+ ); + ({ unmount } = tui); + expect(tui.screen.contains("centered-ish")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/chat-message.test.tsx b/registry/ui/__tests__/chat-message.test.tsx new file mode 100644 index 0000000..23fcb81 --- /dev/null +++ b/registry/ui/__tests__/chat-message.test.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { ChatMessage } from "../chat-message"; +import { renderTui } from "./render-tui"; + +describe("ChatMessage", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders sender role label", () => { + const tui = renderTui(Hello); + ({ unmount } = tui); + expect(tui.screen.contains("user")).toBe(true); + expect(tui.screen.contains("Hello")).toBe(true); + }); + + it("renders custom name instead of role", () => { + const tui = renderTui( + + Hi + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Claude")).toBe(true); + }); + + it("shows streaming indicator when streaming with no children", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("assistant")).toBe(true); + }); + + it("shows children when streaming with content", () => { + const tui = renderTui( + + partial response + + ); + ({ unmount } = tui); + expect(tui.screen.contains("partial response")).toBe(true); + }); + + it("renders collapsed state with expand hint", () => { + const tui = renderTui( + + Some long message + + ); + ({ unmount } = tui); + expect(tui.screen.contains("[expand]")).toBe(true); + }); + + it("toggles collapsed state on enter", async () => { + const tui = renderTui( + + Some long message + + ); + ({ unmount } = tui); + expect(tui.screen.contains("[expand]")).toBe(true); + tui.keys.enter(); + await tui.flush(); + expect(tui.screen.contains("[expand]")).toBe(false); + }); + + it("renders all sender roles", () => { + for (const role of ["user", "assistant", "system", "error"] as const) { + const tui = renderTui(content); + expect(tui.screen.contains(role)).toBe(true); + tui.unmount(); + } + }); +}); diff --git a/registry/ui/__tests__/chat-thread.test.tsx b/registry/ui/__tests__/chat-thread.test.tsx new file mode 100644 index 0000000..c79f427 --- /dev/null +++ b/registry/ui/__tests__/chat-thread.test.tsx @@ -0,0 +1,21 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { ChatThread } from "../chat-thread"; +import { renderTui } from "./render-tui"; + +describe("ChatThread", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + hello thread + + ); + ({ unmount } = tui); + expect(tui.screen.contains("hello thread")).toBe(true); + }); +}); 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__/clipboard.test.tsx b/registry/ui/__tests__/clipboard.test.tsx new file mode 100644 index 0000000..45fa22d --- /dev/null +++ b/registry/ui/__tests__/clipboard.test.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Clipboard } from "../clipboard"; +import { renderTui } from "./render-tui"; + +vi.mock("@/hooks/use-clipboard", () => ({ + useClipboard: () => ({ write: vi.fn() }), +})); + +describe("Clipboard", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("abc123")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Token")).toBe(true); + }); + + it("renders Copy button", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Copy")).toBe(true); + }); + + it("renders copy hint", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("press c or space to copy")).toBe(true); + }); + + it("shows success message after pressing c", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.press("c"); + await tui.flush(); + expect(tui.screen.contains("Done!")).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__/code.test.tsx b/registry/ui/__tests__/code.test.tsx new file mode 100644 index 0000000..894aad7 --- /dev/null +++ b/registry/ui/__tests__/code.test.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Code } from "../code"; +import { renderTui } from "./render-tui"; + +describe("Code", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders code text", () => { + const tui = renderTui(const x = 1); + ({ unmount } = tui); + expect(tui.screen.contains("const")).toBe(true); + expect(tui.screen.contains("x")).toBe(true); + }); + + it("renders inline code", () => { + const tui = renderTui(inline snippet); + ({ unmount } = tui); + expect(tui.screen.contains("inline snippet")).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__/columns.test.tsx b/registry/ui/__tests__/columns.test.tsx new file mode 100644 index 0000000..997f974 --- /dev/null +++ b/registry/ui/__tests__/columns.test.tsx @@ -0,0 +1,23 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Columns } from "../columns"; +import { renderTui } from "./render-tui"; + +describe("Columns", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + left col + right col + + ); + ({ unmount } = tui); + expect(tui.screen.contains("left col")).toBe(true); + expect(tui.screen.contains("right col")).toBe(true); + }); +}); 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__/data-grid.test.tsx b/registry/ui/__tests__/data-grid.test.tsx new file mode 100644 index 0000000..a35a2af --- /dev/null +++ b/registry/ui/__tests__/data-grid.test.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { DataGrid } from "../data-grid"; +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("DataGrid", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const columns = [ + { header: "Name", key: "name" as const }, + { header: "Age", key: "age" as const }, + ]; + + const data = [ + { age: 30, name: "Alice" }, + { age: 25, name: "Bob" }, + { age: 35, name: "Carol" }, + ]; + + it("renders column headers", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Name")).toBe(true); + expect(tui.screen.contains("Age")).toBe(true); + }); + + it("renders data rows", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Alice")).toBe(true); + expect(tui.screen.contains("Bob")).toBe(true); + expect(tui.screen.contains("Carol")).toBe(true); + }); + + it("shows no data message when empty", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("No data")).toBe(true); + }); + + it("navigates rows with arrow keys", async () => { + const onRowSelect = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onRowSelect).toHaveBeenCalledWith( + expect.objectContaining({ name: "Bob" }) + ); + }); + + it("selects first row on enter by default", async () => { + const onRowSelect = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.enter(); + await tui.flush(); + expect(onRowSelect).toHaveBeenCalledWith( + expect.objectContaining({ name: "Alice" }) + ); + }); + + it("paginates with n/p keys", async () => { + const largeData = Array.from({ length: 15 }, (_, i) => ({ + age: 20 + i, + name: `Person ${i}`, + })); + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Page 1/")).toBe(true); + tui.keys.press("n"); + await tui.flush(); + expect(tui.screen.contains("Page 2/")).toBe(true); + tui.keys.press("p"); + await tui.flush(); + expect(tui.screen.contains("Page 1/")).toBe(true); + }); + + it("enters filter mode with / key", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.press("/"); + await tui.flush(); + expect(tui.screen.contains("Filter:")).toBe(true); + }); + + it("filters data in filter mode", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.press("/"); + await tui.flush(); + await typeChars(tui, "Alice"); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(tui.screen.contains("Alice")).toBe(true); + expect(tui.screen.contains("1 rows")).toBe(true); + }); + + it("shows row numbers when enabled", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("1")).toBe(true); + }); + + it("shows page info", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("3 rows")).toBe(true); + }); +}); 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__/definition.test.tsx b/registry/ui/__tests__/definition.test.tsx new file mode 100644 index 0000000..967b753 --- /dev/null +++ b/registry/ui/__tests__/definition.test.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Definition } from "../definition"; +import { renderTui } from "./render-tui"; + +describe("Definition", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders terms and descriptions", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("API")).toBe(true); + expect(tui.screen.contains("Application Programming Interface")).toBe(true); + expect(tui.screen.contains("CLI")).toBe(true); + expect(tui.screen.contains("Command Line Interface")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/dialog.test.tsx b/registry/ui/__tests__/dialog.test.tsx new file mode 100644 index 0000000..abd02cf --- /dev/null +++ b/registry/ui/__tests__/dialog.test.tsx @@ -0,0 +1,136 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Dialog } from "../dialog"; +import { renderTui } from "./render-tui"; + +describe("Dialog", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders nothing when closed", () => { + const tui = renderTui( + + Hidden + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Hidden")).toBe(false); + }); + + it("renders children when open", () => { + const tui = renderTui( + + Dialog body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Dialog body")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Confirm Action")).toBe(true); + }); + + it("renders default button labels", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("OK")).toBe(true); + expect(tui.screen.contains("Cancel")).toBe(true); + }); + + it("renders custom button labels", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Delete")).toBe(true); + expect(tui.screen.contains("Keep")).toBe(true); + }); + + it("calls onCancel on Enter when cancel button focused (default)", async () => { + const onCancel = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.enter(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("calls onConfirm on Enter after Tab to confirm button", async () => { + const onConfirm = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.tab(); + await tui.flush(); + tui.keys.enter(); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("toggles focus with arrow keys", async () => { + const onConfirm = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.right(); + await tui.flush(); + tui.keys.enter(); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("calls onCancel on Escape", async () => { + const onCancel = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + await tui.flush(); + tui.keys.escape(); + await tui.flush(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("does not fire callbacks when closed", async () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + tui.keys.enter(); + tui.keys.escape(); + await tui.flush(); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/registry/ui/__tests__/diff-view.test.tsx b/registry/ui/__tests__/diff-view.test.tsx new file mode 100644 index 0000000..33b4e54 --- /dev/null +++ b/registry/ui/__tests__/diff-view.test.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { DiffView } from "../diff-view"; +import { renderTui } from "./render-tui"; + +describe("DiffView", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders filename", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("test.txt")).toBe(true); + }); + + it("shows added lines with + prefix", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("+added line")).toBe(true); + }); + + it("shows removed lines with - prefix", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("-removed line")).toBe(true); + }); + + it("shows no differences when texts are equal", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("No differences")).toBe(true); + }); + + it("renders unified mode by default", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("@@")).toBe(true); + }); + + it("renders split mode", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("│")).toBe(true); + }); + + it("renders inline mode", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("-old")).toBe(true); + expect(tui.screen.contains("+new")).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__/directory-tree.test.tsx b/registry/ui/__tests__/directory-tree.test.tsx new file mode 100644 index 0000000..68092c6 --- /dev/null +++ b/registry/ui/__tests__/directory-tree.test.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { DirectoryTree } from "../directory-tree"; +import { renderTui } from "./render-tui"; + +vi.mock("node:fs", () => ({ + readdirSync: vi.fn((dir: string) => { + if (dir === "/mock") { + return ["docs", "src", "readme.md"]; + } + if (dir === "/mock/src") { + return ["index.ts", "utils.ts"]; + } + return []; + }), + statSync: vi.fn((p: string) => ({ + isDirectory: () => p === "/mock/docs" || p === "/mock/src", + })), +})); + +describe("DirectoryTree", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders root path", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("/mock")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Files")).toBe(true); + }); + + it("renders directory entries", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("docs")).toBe(true); + expect(tui.screen.contains("src")).toBe(true); + expect(tui.screen.contains("readme.md")).toBe(true); + }); + + it("shows directory icons", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("▶")).toBe(true); + }); + + it("shows file icons", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("·")).toBe(true); + }); + + it("renders navigation hint", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("navigate")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/divider.test.tsx b/registry/ui/__tests__/divider.test.tsx new file mode 100644 index 0000000..d7c7616 --- /dev/null +++ b/registry/ui/__tests__/divider.test.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Divider } from "../divider"; +import { renderTui } from "./render-tui"; + +describe("Divider", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders horizontal divider", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.lines().length).toBeGreaterThan(0); + }); + + it("renders with label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Section")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/drawer.test.tsx b/registry/ui/__tests__/drawer.test.tsx new file mode 100644 index 0000000..15775fe --- /dev/null +++ b/registry/ui/__tests__/drawer.test.tsx @@ -0,0 +1,77 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Drawer } from "../drawer"; +import { renderTui } from "./render-tui"; + +describe("Drawer", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders nothing when closed", () => { + const tui = renderTui( + + Hidden + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Hidden")).toBe(false); + }); + + it("renders children when open", () => { + const tui = renderTui( + + Drawer content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Drawer content")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Settings")).toBe(true); + }); + + it("shows Esc to close hint", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Esc to close")).toBe(true); + }); + + it("calls onClose on Escape", async () => { + const onClose = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + tui.keys.escape(); + await tui.flush(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("does not call onClose when closed", async () => { + const onClose = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + tui.keys.escape(); + await tui.flush(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); 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__/error-boundary.test.tsx b/registry/ui/__tests__/error-boundary.test.tsx new file mode 100644 index 0000000..da731f3 --- /dev/null +++ b/registry/ui/__tests__/error-boundary.test.tsx @@ -0,0 +1,78 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { ErrorBoundary } from "../error-boundary"; +import { renderTui } from "./render-tui"; + +const ThrowingComponent = () => { + throw new Error("test explosion"); +}; + +describe("ErrorBoundary", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children when no error", () => { + const tui = renderTui( + + safe content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("safe content")).toBe(true); + }); + + it("renders default error UI on error", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("test explosion")).toBe(true); + expect(tui.screen.contains("Error")).toBe(true); + spy.mockRestore(); + }); + + it("renders custom fallback on error", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const tui = renderTui( + oops}> + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("oops")).toBe(true); + spy.mockRestore(); + }); + + it("calls onError callback", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const onError = vi.fn(); + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ componentStack: expect.any(String) }) + ); + spy.mockRestore(); + }); + + it("renders custom title", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Crash")).toBe(true); + spy.mockRestore(); + }); +}); diff --git a/registry/ui/__tests__/file-change.test.tsx b/registry/ui/__tests__/file-change.test.tsx new file mode 100644 index 0000000..00d9a01 --- /dev/null +++ b/registry/ui/__tests__/file-change.test.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { FileChange } from "../file-change"; +import { renderTui } from "./render-tui"; + +describe("FileChange", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const changes = [ + { diff: "-old\n+new", path: "src/app.tsx", type: "modify" as const }, + { + content: "export const x = 1;", + path: "src/utils.ts", + type: "create" as const, + }, + { path: "src/legacy.ts", type: "delete" as const }, + ]; + + it("renders file change count", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("File Changes (3)")).toBe(true); + }); + + it("renders file paths", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("src/app.tsx")).toBe(true); + expect(tui.screen.contains("src/utils.ts")).toBe(true); + expect(tui.screen.contains("src/legacy.ts")).toBe(true); + }); + + it("renders type indicators", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("M")).toBe(true); + expect(tui.screen.contains("A")).toBe(true); + expect(tui.screen.contains("D")).toBe(true); + }); + + it("renders cursor on active item", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("navigates with arrow keys", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + const lines = tui.screen.lines(); + const legacyLine = lines.find((l: string) => l.includes("src/legacy.ts")); + expect(legacyLine).toContain("›"); + }); + + it("expands diff on enter", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.enter(); + await tui.flush(); + expect(tui.screen.contains("▼")).toBe(true); + }); + + it("collapses on second enter", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.enter(); + await tui.flush(); + expect(tui.screen.contains("▼")).toBe(true); + tui.keys.enter(); + await tui.flush(); + expect(tui.screen.contains("▶")).toBe(true); + }); + + it("accepts a change with y key", async () => { + const onAccept = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.press("y"); + await tui.flush(); + expect(onAccept).toHaveBeenCalledWith("src/app.tsx"); + expect(tui.screen.contains("accepted")).toBe(true); + }); + + it("rejects a change with n key", async () => { + const onReject = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.press("n"); + await tui.flush(); + expect(onReject).toHaveBeenCalledWith("src/app.tsx"); + expect(tui.screen.contains("rejected")).toBe(true); + }); + + it("calls onAcceptAll with a key", async () => { + const onAcceptAll = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.press("a"); + await tui.flush(); + expect(onAcceptAll).toHaveBeenCalled(); + }); + + it("renders help instructions", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("navigate")).toBe(true); + expect(tui.screen.contains("accept")).toBe(true); + expect(tui.screen.contains("reject")).toBe(true); + }); + + it("expands file with content (create type)", async () => { + const createOnly = [ + { content: "hello world", path: "new.ts", type: "create" as const }, + ]; + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.enter(); + await tui.flush(); + expect(tui.screen.contains("hello world")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/file-picker.test.tsx b/registry/ui/__tests__/file-picker.test.tsx new file mode 100644 index 0000000..bc898b3 --- /dev/null +++ b/registry/ui/__tests__/file-picker.test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { FilePicker } from "../file-picker"; +import { renderTui } from "./render-tui"; + +vi.mock("node:fs", () => ({ + readdirSync: vi.fn(() => ["folder", "file.txt", "data.json"]), + statSync: vi.fn((p: string) => ({ + isDirectory: () => p.endsWith("folder"), + })), +})); + +describe("FilePicker", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders label", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Choose file")).toBe(true); + }); + + it("renders current directory", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("/test")).toBe(true); + }); + + it("renders file entries", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("file.txt")).toBe(true); + }); + + it("renders parent directory entry", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("..")).toBe(true); + }); + + it("renders directory icon", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("▶")).toBe(true); + }); +}); 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__/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__/git-status.test.tsx b/registry/ui/__tests__/git-status.test.tsx new file mode 100644 index 0000000..1f3685e --- /dev/null +++ b/registry/ui/__tests__/git-status.test.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { GitStatus } from "../git-status"; +import { renderTui } from "./render-tui"; + +describe("GitStatus", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders branch name", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("feature/foo")).toBe(true); + }); + + it("renders counts", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("1↑")).toBe(true); + expect(tui.screen.contains("4↓")).toBe(true); + expect(tui.screen.contains("staged 2")).toBe(true); + expect(tui.screen.contains("modified 3")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/gradient.test.tsx b/registry/ui/__tests__/gradient.test.tsx new file mode 100644 index 0000000..8ff9b0d --- /dev/null +++ b/registry/ui/__tests__/gradient.test.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Gradient } from "../gradient"; +import { renderTui } from "./render-tui"; + +describe("Gradient", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders text characters", () => { + const tui = renderTui( + Hi + ); + ({ unmount } = tui); + expect(tui.screen.contains("H")).toBe(true); + expect(tui.screen.contains("i")).toBe(true); + }); + + it("renders with preset name", () => { + const tui = renderTui(preset); + ({ unmount } = tui); + expect(tui.screen.contains("preset")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/grid.test.tsx b/registry/ui/__tests__/grid.test.tsx new file mode 100644 index 0000000..0302ebc --- /dev/null +++ b/registry/ui/__tests__/grid.test.tsx @@ -0,0 +1,25 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Grid } from "../grid"; +import { renderTui } from "./render-tui"; + +describe("Grid", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + a + b + c + + ); + ({ unmount } = tui); + expect(tui.screen.contains("a")).toBe(true); + expect(tui.screen.contains("b")).toBe(true); + expect(tui.screen.contains("c")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/heading.test.tsx b/registry/ui/__tests__/heading.test.tsx new file mode 100644 index 0000000..7959c2e --- /dev/null +++ b/registry/ui/__tests__/heading.test.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Heading } from "../heading"; +import { renderTui } from "./render-tui"; + +describe("Heading", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders level 1 with uppercase", () => { + const tui = renderTui(section); + ({ unmount } = tui); + expect(tui.screen.contains("██")).toBe(true); + expect(tui.screen.contains("SECTION")).toBe(true); + }); + + it("renders level 2", () => { + const tui = renderTui(Two); + ({ unmount } = tui); + expect(tui.screen.contains("▌")).toBe(true); + expect(tui.screen.contains("Two")).toBe(true); + }); + + it("renders level 3", () => { + const tui = renderTui(Three); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + expect(tui.screen.contains("Three")).toBe(true); + }); + + it("renders level 4", () => { + const tui = renderTui(Small); + ({ unmount } = tui); + expect(tui.screen.contains("Small")).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__/help-screen.test.tsx b/registry/ui/__tests__/help-screen.test.tsx new file mode 100644 index 0000000..7d178dd --- /dev/null +++ b/registry/ui/__tests__/help-screen.test.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { HelpScreen } from "../help-screen"; +import { renderTui } from "./render-tui"; + +describe("HelpScreen", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders section label", () => { + const tui = renderTui( + + + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Options")).toBe(true); + }); + + it("renders row flag and description", () => { + const tui = renderTui( + + + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("--verbose")).toBe(true); + expect(tui.screen.contains("Enable verbose")).toBe(true); + }); + + it("renders tagline", () => { + const tui = renderTui( + + + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("A handy tool")).toBe(true); + }); + + it("renders usage", () => { + const tui = renderTui( + + + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Usage:")).toBe(true); + expect(tui.screen.contains("cli [opts]")).toBe(true); + }); + + it("renders description", () => { + const tui = renderTui( + + + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Does things")).toBe(true); + }); + + it("renders multiple sections", () => { + const tui = renderTui( + + + + + + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Commands")).toBe(true); + expect(tui.screen.contains("Options")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/help.test.tsx b/registry/ui/__tests__/help.test.tsx new file mode 100644 index 0000000..041be74 --- /dev/null +++ b/registry/ui/__tests__/help.test.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Help } from "../help"; +import { renderTui } from "./render-tui"; + +describe("Help", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders keymap entries", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("q")).toBe(true); + expect(tui.screen.contains("quit")).toBe(true); + expect(tui.screen.contains("j")).toBe(true); + expect(tui.screen.contains("down")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Shortcuts")).toBe(true); + }); + + it("renders compact mode with · separator", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("·")).toBe(true); + expect(tui.screen.contains("x: exit")).toBe(true); + expect(tui.screen.contains("y: yank")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/image.test.tsx b/registry/ui/__tests__/image.test.tsx new file mode 100644 index 0000000..75386c4 --- /dev/null +++ b/registry/ui/__tests__/image.test.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Image } from "../image"; +import { renderTui } from "./render-tui"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(() => Buffer.from("fakeimage")), +})); + +describe("Image", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders ascii fallback box", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("┌")).toBe(true); + expect(tui.screen.contains("┘")).toBe(true); + }); + + it("renders alt text", () => { + const tui = renderTui( + My Photo + ); + ({ unmount } = tui); + expect(tui.screen.contains("My Photo")).toBe(true); + }); + + it("renders filename in ascii fallback", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("photo.png")).toBe(true); + }); + + it("shows ascii fallback label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("ascii fallback")).toBe(true); + }); + + it("renders extension", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains(".jpg")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/info-box.test.tsx b/registry/ui/__tests__/info-box.test.tsx new file mode 100644 index 0000000..016146e --- /dev/null +++ b/registry/ui/__tests__/info-box.test.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { InfoBox } from "../info-box"; +import { renderTui } from "./render-tui"; + +describe("InfoBox", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders with header label", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("My service")).toBe(true); + }); + + it("renders rows", () => { + const tui = renderTui( + + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Host")).toBe(true); + expect(tui.screen.contains("localhost")).toBe(true); + expect(tui.screen.contains("Port")).toBe(true); + expect(tui.screen.contains("3000")).toBe(true); + }); + + it("renders tree rows with └", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("└")).toBe(true); + expect(tui.screen.contains("nested")).toBe(true); + expect(tui.screen.contains("leaf")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/json.test.tsx b/registry/ui/__tests__/json.test.tsx new file mode 100644 index 0000000..87e191d --- /dev/null +++ b/registry/ui/__tests__/json.test.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { JSONView } from "../json"; +import { renderTui } from "./render-tui"; + +describe("JSONView", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders primitive string value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("hello")).toBe(true); + }); + + it("renders number value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("42")).toBe(true); + }); + + it("renders null value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("null")).toBe(true); + }); + + it("renders boolean value", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("true")).toBe(true); + }); + + it("renders object keys", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("name")).toBe(true); + expect(tui.screen.contains("Alice")).toBe(true); + expect(tui.screen.contains("age")).toBe(true); + expect(tui.screen.contains("30")).toBe(true); + }); + + it("renders object with braces", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("{")).toBe(true); + expect(tui.screen.contains("}")).toBe(true); + }); + + it("renders array with brackets", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[")).toBe(true); + expect(tui.screen.contains("]")).toBe(true); + }); + + it("renders label when provided", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Config")).toBe(true); + }); + + it("collapses object when collapsed is true", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("...")).toBe(true); + }); + + it("toggles collapse on space for nested object", async () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Alice")).toBe(true); + tui.keys.down(); + await tui.flush(); + tui.keys.space(); + await tui.flush(); + expect(tui.screen.contains("...")).toBe(true); + }); + + it("navigates with arrow keys", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + expect(tui.screen.contains("b")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/key-value.test.tsx b/registry/ui/__tests__/key-value.test.tsx new file mode 100644 index 0000000..e22b60b --- /dev/null +++ b/registry/ui/__tests__/key-value.test.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { KeyValue } from "../key-value"; +import { renderTui } from "./render-tui"; + +describe("KeyValue", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders keys and values", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("name")).toBe(true); + expect(tui.screen.contains("Ada")).toBe(true); + expect(tui.screen.contains("role")).toBe(true); + expect(tui.screen.contains("dev")).toBe(true); + }); + + it("renders separator", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains(" => ")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/keyboard-shortcuts.test.tsx b/registry/ui/__tests__/keyboard-shortcuts.test.tsx new file mode 100644 index 0000000..4401c2a --- /dev/null +++ b/registry/ui/__tests__/keyboard-shortcuts.test.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { KeyboardShortcuts } from "../keyboard-shortcuts"; +import { renderTui } from "./render-tui"; + +describe("KeyboardShortcuts", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders shortcut keys and descriptions", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("q")).toBe(true); + expect(tui.screen.contains("Quit")).toBe(true); + expect(tui.screen.contains("?")).toBe(true); + expect(tui.screen.contains("Help")).toBe(true); + }); + + it('renders title with "⌨"', () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("⌨")).toBe(true); + expect(tui.screen.contains("Shortcuts")).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__/link.test.tsx b/registry/ui/__tests__/link.test.tsx new file mode 100644 index 0000000..2ba2354 --- /dev/null +++ b/registry/ui/__tests__/link.test.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { describe, it, expect, afterEach, vi } from "vitest"; + +import { Link } from "../link"; +import { renderTui } from "./render-tui"; + +describe("Link", () => { + let unmount: () => void; + afterEach(() => { + unmount?.(); + vi.unstubAllEnvs(); + }); + + it("renders children text", () => { + vi.stubEnv("TERM", "dumb"); + vi.stubEnv("TERM_PROGRAM", ""); + const tui = renderTui( + Documentation + ); + ({ unmount } = tui); + expect(tui.screen.contains("Documentation")).toBe(true); + }); + + it("renders href in fallback mode", () => { + vi.stubEnv("TERM", "dumb"); + vi.stubEnv("TERM_PROGRAM", ""); + const tui = renderTui(Open); + ({ unmount } = tui); + expect(tui.screen.contains("https://example.com/page")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/list.test.tsx b/registry/ui/__tests__/list.test.tsx new file mode 100644 index 0000000..4fa03bf --- /dev/null +++ b/registry/ui/__tests__/list.test.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { List } from "../list"; +import { renderTui } from "./render-tui"; + +const items = [ + { key: "1", label: "Apple" }, + { key: "2", label: "Banana" }, + { key: "3", label: "Cherry" }, +]; + +describe("List", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders all item labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Apple")).toBe(true); + expect(tui.screen.contains("Banana")).toBe(true); + expect(tui.screen.contains("Cherry")).toBe(true); + }); + + it("shows cursor on first item", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("selects item on Enter", () => { + const onSelect = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.enter(); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ key: "1", label: "Apple" }) + ); + }); + + it("navigates down and selects", async () => { + const onSelect = vi.fn(); + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ key: "2", label: "Banana" }) + ); + }); + + it("navigates up", async () => { + const onSelect = 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.enter(); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ key: "2", label: "Banana" }) + ); + }); + + it("renders description", () => { + const withDesc = [{ description: "A fruit", key: "1", label: "Apple" }]; + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("A fruit")).toBe(true); + }); + + it("filters items when filterable", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.type("ban"); + await tui.flush(); + expect(tui.screen.contains("Banana")).toBe(true); + expect(tui.screen.contains("Apple")).toBe(false); + expect(tui.screen.contains("Cherry")).toBe(false); + }); + + it("shows filter input when filterable", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Type to filter…")).toBe(true); + }); + + it("clears filter with backspace", async () => { + const tui = renderTui(); + ({ unmount } = tui); + tui.keys.type("ch"); + await tui.flush(); + expect(tui.screen.contains("Cherry")).toBe(true); + expect(tui.screen.contains("Apple")).toBe(false); + tui.keys.raw("\u0008"); + await tui.flush(); + tui.keys.raw("\u0008"); + await tui.flush(); + expect(tui.screen.contains("Apple")).toBe(true); + expect(tui.screen.contains("Cherry")).toBe(true); + }); + + it("shows overflow indicator", () => { + const manyItems = Array.from({ length: 15 }, (_, i) => ({ + key: String(i), + label: `Item ${i}`, + })); + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("more…")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/log.test.tsx b/registry/ui/__tests__/log.test.tsx new file mode 100644 index 0000000..f2136e6 --- /dev/null +++ b/registry/ui/__tests__/log.test.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Log } from "../log"; +import { renderTui } from "./render-tui"; + +describe("Log", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const entries = [ + { level: "info" as const, message: "Server started" }, + { level: "warn" as const, message: "Deprecated API used" }, + { level: "error" as const, message: "Connection failed" }, + { level: "debug" as const, message: "Parsing config" }, + ]; + + it("renders log messages", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Server started")).toBe(true); + expect(tui.screen.contains("Deprecated API used")).toBe(true); + expect(tui.screen.contains("Connection failed")).toBe(true); + }); + + it("renders level labels", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("INF")).toBe(true); + expect(tui.screen.contains("WRN")).toBe(true); + expect(tui.screen.contains("ERR")).toBe(true); + expect(tui.screen.contains("DBG")).toBe(true); + }); + + it("shows scroll position indicator", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("1–4/4")).toBe(true); + }); + + it("shows follow indicator", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("↓ follow")).toBe(true); + }); + + it("shows scroll hint", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("j/k scroll")).toBe(true); + }); + + it("filters entries by filter string", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Connection failed")).toBe(true); + expect(tui.screen.contains("Server started")).toBe(false); + }); + + it("scrolls down with down arrow", async () => { + const manyEntries = Array.from({ length: 20 }, (_, i) => ({ + level: "info" as const, + message: `Line ${i}`, + })); + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Line 0")).toBe(true); + tui.keys.down(); + await tui.flush(); + expect(tui.screen.contains("2–6/20")).toBe(true); + }); + + it("shows filter text in status bar", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("filter: warn")).toBe(true); + }); + + it("renders timestamps when provided", () => { + const entriesWithTime = [ + { + level: "info" as const, + message: "test", + timestamp: new Date(2025, 0, 1, 14, 30, 0), + }, + ]; + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("14:30:00")).toBe(true); + }); +}); diff --git a/registry/ui/__tests__/login-flow.test.tsx b/registry/ui/__tests__/login-flow.test.tsx new file mode 100644 index 0000000..c79ba86 --- /dev/null +++ b/registry/ui/__tests__/login-flow.test.tsx @@ -0,0 +1,152 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { LoginFlow } from "../login-flow"; +import { renderTui } from "./render-tui"; + +describe("LoginFlow", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + describe("LoginFlow (root)", () => { + it("renders children", () => { + const tui = renderTui( + + Welcome back + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Welcome back")).toBe(true); + }); + + it("renders title via BigText", () => { + const tui = renderTui( + + content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("content")).toBe(true); + const text = tui.screen.text(); + expect(text.length).toBeGreaterThan("content".length); + }); + }); + + describe("LoginFlow.Announcement", () => { + it("renders announcement with icon", () => { + const tui = renderTui( + + + New version available! + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("!")).toBe(true); + expect(tui.screen.contains("New version available!")).toBe(true); + }); + + it("renders with default icon", () => { + const tui = renderTui( + + Notice + + ); + ({ unmount } = tui); + expect(tui.screen.contains("*")).toBe(true); + expect(tui.screen.contains("Notice")).toBe(true); + }); + }); + + describe("LoginFlow.Description", () => { + it("renders description text", () => { + const tui = renderTui( + + Please sign in + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Please sign in")).toBe(true); + }); + }); + + describe("LoginFlow.Select", () => { + const options = ["GitHub", "Google", "Email"]; + + it("renders options", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("GitHub")).toBe(true); + expect(tui.screen.contains("Google")).toBe(true); + expect(tui.screen.contains("Email")).toBe(true); + }); + + it("renders label", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Sign in with:")).toBe(true); + }); + + it("renders cursor on active item", () => { + const tui = renderTui( + + + + ); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("navigates with arrow keys", 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(1); + }); + + it("selects by number key", async () => { + const onSelect = vi.fn(); + const tui = renderTui( + + + + ); + ({ unmount } = tui); + tui.keys.press("3"); + await tui.flush(); + expect(onSelect).toHaveBeenCalledWith(2); + }); + + it("does not go above first option", async () => { + const onSelect = vi.fn(); + const tui = renderTui( + + + + ); + ({ unmount } = tui); + tui.keys.up(); + await tui.flush(); + tui.keys.enter(); + await tui.flush(); + expect(onSelect).toHaveBeenCalledWith(0); + }); + }); +}); diff --git a/registry/ui/__tests__/markdown.test.tsx b/registry/ui/__tests__/markdown.test.tsx new file mode 100644 index 0000000..f60220d --- /dev/null +++ b/registry/ui/__tests__/markdown.test.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Markdown } from "../markdown"; +import { renderTui } from "./render-tui"; + +describe("Markdown", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders heading text", () => { + const tui = renderTui(# Hello World); + ({ unmount } = tui); + expect(tui.screen.contains("Hello World")).toBe(true); + }); + + it("renders h2 heading", () => { + const tui = renderTui(## Subtitle); + ({ unmount } = tui); + expect(tui.screen.contains("Subtitle")).toBe(true); + }); + + it("renders list items with bullet", () => { + const tui = renderTui({"- item one\n- item two"}); + ({ unmount } = tui); + expect(tui.screen.contains("•")).toBe(true); + expect(tui.screen.contains("item one")).toBe(true); + expect(tui.screen.contains("item two")).toBe(true); + }); + + it("renders bold text", () => { + const tui = renderTui(**bold text**); + ({ unmount } = tui); + expect(tui.screen.contains("bold text")).toBe(true); + }); + + it("renders inline code", () => { + const tui = renderTui(`code here`); + ({ unmount } = tui); + expect(tui.screen.contains("code here")).toBe(true); + }); + + it("renders blockquote with pipe", () => { + const tui = renderTui({"> quoted text"}); + ({ unmount } = tui); + expect(tui.screen.contains("│")).toBe(true); + expect(tui.screen.contains("quoted text")).toBe(true); + }); + + it("renders horizontal rule", () => { + const tui = renderTui(---); + ({ unmount } = tui); + expect(tui.screen.contains("─")).toBe(true); + }); + + it("renders plain paragraph text", () => { + const tui = renderTui(Just a paragraph.); + ({ unmount } = tui); + expect(tui.screen.contains("Just a paragraph.")).toBe(true); + }); +}); 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__/modal.test.tsx b/registry/ui/__tests__/modal.test.tsx new file mode 100644 index 0000000..a73a2c7 --- /dev/null +++ b/registry/ui/__tests__/modal.test.tsx @@ -0,0 +1,97 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Modal } from "../modal"; +import { renderTui } from "./render-tui"; + +describe("Modal", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders nothing when closed", () => { + const tui = renderTui( + + Hidden content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Hidden content")).toBe(false); + }); + + it("renders children when open", () => { + const tui = renderTui( + + Visible content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Visible content")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("My Modal")).toBe(true); + }); + + it("shows close hint by default", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Press Esc to close")).toBe(true); + }); + + it("hides close hint when closeHint=false", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Press Esc to close")).toBe(false); + }); + + it("renders custom close hint", () => { + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Hit Esc")).toBe(true); + }); + + it("calls onClose on Escape", async () => { + const onClose = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + tui.keys.escape(); + await tui.flush(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("does not call onClose when closed", async () => { + const onClose = vi.fn(); + const tui = renderTui( + + body + + ); + ({ unmount } = tui); + tui.keys.escape(); + await tui.flush(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/registry/ui/__tests__/model-selector.test.tsx b/registry/ui/__tests__/model-selector.test.tsx new file mode 100644 index 0000000..cb5579d --- /dev/null +++ b/registry/ui/__tests__/model-selector.test.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { ModelSelector } from "../model-selector"; +import { renderTui } from "./render-tui"; + +describe("ModelSelector", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + const models = [ + { context: 128_000, id: "gpt4", name: "GPT-4", provider: "OpenAI" }, + { context: 200_000, id: "claude", name: "Claude", provider: "Anthropic" }, + { context: 1_000_000, id: "gemini", name: "Gemini", provider: "Google" }, + ]; + + it("renders model names", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("GPT-4")).toBe(true); + expect(tui.screen.contains("Claude")).toBe(true); + expect(tui.screen.contains("Gemini")).toBe(true); + }); + + it("shows checkmark on selected model", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("✓")).toBe(true); + }); + + it("shows provider when showProvider is true", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("OpenAI")).toBe(true); + expect(tui.screen.contains("Anthropic")).toBe(true); + }); + + it("shows context size", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("128k ctx")).toBe(true); + expect(tui.screen.contains("1M ctx")).toBe(true); + }); + + it("hides context when showContext is false", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("128k ctx")).toBe(false); + }); + + it("calls onSelect on enter", async () => { + const onSelect = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + expect(onSelect).toHaveBeenCalledWith("claude"); + }); + + it("navigates with arrow keys", async () => { + const onSelect = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.down(); + await tui.flush(); + tui.keys.down(); + await tui.flush(); + tui.keys.enter(); + expect(onSelect).toHaveBeenCalledWith("gemini"); + }); + + it("shows cursor on active item", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("renders grouped by provider", () => { + const tui = renderTui( + + ); + ({ unmount } = tui); + expect(tui.screen.contains("OpenAI")).toBe(true); + expect(tui.screen.contains("Anthropic")).toBe(true); + expect(tui.screen.contains("Google")).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__/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__/notification-badge.test.tsx b/registry/ui/__tests__/notification-badge.test.tsx new file mode 100644 index 0000000..2844647 --- /dev/null +++ b/registry/ui/__tests__/notification-badge.test.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { NotificationBadge } from "../notification-badge"; +import { renderTui } from "./render-tui"; + +describe("NotificationBadge", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders count in brackets like [3]", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[3]")).toBe(true); + }); + + it("renders nothing when count=0", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.text().trim()).toBe(""); + }); +}); diff --git a/registry/ui/__tests__/notification-center.test.tsx b/registry/ui/__tests__/notification-center.test.tsx new file mode 100644 index 0000000..c29f037 --- /dev/null +++ b/registry/ui/__tests__/notification-center.test.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { NotificationCenter } from "../notification-center"; +import { renderTui } from "./render-tui"; + +describe("NotificationCenter", () => { + let unmount: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + unmount?.(); + vi.useRealTimers(); + }); + + it("renders nothing when no notifications", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.text().trim()).toBe(""); + }); +}); 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__/pagination.test.tsx b/registry/ui/__tests__/pagination.test.tsx new file mode 100644 index 0000000..1296b80 --- /dev/null +++ b/registry/ui/__tests__/pagination.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Pagination } from "../pagination"; +import { renderTui } from "./render-tui"; + +describe("Pagination", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders current page highlighted", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[3]")).toBe(true); + }); + + it("renders navigation arrows", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("‹")).toBe(true); + expect(tui.screen.contains("›")).toBe(true); + }); + + it("renders all pages when total <= 7", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("[1]")).toBe(true); + expect(tui.screen.contains("5")).toBe(true); + }); + + it("shows ellipsis for large page counts", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("…")).toBe(true); + }); + + it("calls onChange on right arrow", () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.right(); + expect(onChange).toHaveBeenCalledWith(3); + }); + + it("calls onChange on left arrow", () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.left(); + expect(onChange).toHaveBeenCalledWith(2); + }); + + it("does not go below page 1", () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.left(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("does not go above total", () => { + const onChange = vi.fn(); + const tui = renderTui( + + ); + ({ unmount } = tui); + tui.keys.right(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/registry/ui/__tests__/panel.test.tsx b/registry/ui/__tests__/panel.test.tsx new file mode 100644 index 0000000..9f90650 --- /dev/null +++ b/registry/ui/__tests__/panel.test.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { Panel } from "../panel"; +import { Text } from "../text"; +import { renderTui } from "./render-tui"; + +describe("Panel", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + Panel body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Panel body")).toBe(true); + }); + + it("renders title", () => { + const tui = renderTui( + + content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Settings")).toBe(true); + }); + + it("renders without border", () => { + const tui = renderTui( + + inside + + ); + ({ unmount } = tui); + expect(tui.screen.contains("inside")).toBe(true); + expect(tui.screen.contains("No outer")).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__/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__/popover.test.tsx b/registry/ui/__tests__/popover.test.tsx new file mode 100644 index 0000000..88fa298 --- /dev/null +++ b/registry/ui/__tests__/popover.test.tsx @@ -0,0 +1,87 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { Popover } from "../popover"; +import { renderTui } from "./render-tui"; + +describe("Popover", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("always renders trigger", () => { + const tui = renderTui( + Open}> + Content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Open")).toBe(true); + }); + + it("hides children when closed", () => { + const tui = renderTui( + Open}> + Hidden content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Hidden content")).toBe(false); + }); + + it("shows children when open", () => { + const tui = renderTui( + Open} isOpen> + Visible content + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Visible content")).toBe(true); + }); + + it("renders title when open", () => { + const tui = renderTui( + Open} isOpen title="Pop Title"> + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Pop Title")).toBe(true); + }); + + it("shows close hint when open", () => { + const tui = renderTui( + Open} isOpen> + body + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Press Esc to close")).toBe(true); + }); + + it("calls onClose on Escape", async () => { + const onClose = vi.fn(); + const tui = renderTui( + Open} isOpen onClose={onClose}> + body + + ); + ({ unmount } = tui); + tui.keys.escape(); + await tui.flush(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("does not call onClose when closed", async () => { + const onClose = vi.fn(); + const tui = renderTui( + Open} onClose={onClose}> + body + + ); + ({ unmount } = tui); + tui.keys.escape(); + await tui.flush(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); 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__/qr-code.test.tsx b/registry/ui/__tests__/qr-code.test.tsx new file mode 100644 index 0000000..f33a0aa --- /dev/null +++ b/registry/ui/__tests__/qr-code.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { QRCode } from "../qr-code"; +import { renderTui } from "./render-tui"; + +describe("QRCode", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders non-empty output", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.text().length).toBeGreaterThan(0); + }); + + it("renders label", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("Scan me")).toBe(true); + }); + + it("renders in small size", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.text().length).toBeGreaterThan(0); + }); + + it("renders in large size", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.text().length).toBeGreaterThan(0); + }); + + it("contains block characters", () => { + const tui = renderTui(); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + }); +}); 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__/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}); diff --git a/registry/ui/__tests__/scroll-view.test.tsx b/registry/ui/__tests__/scroll-view.test.tsx new file mode 100644 index 0000000..96b59b1 --- /dev/null +++ b/registry/ui/__tests__/scroll-view.test.tsx @@ -0,0 +1,67 @@ +import { Text } from "ink"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; + +import { ScrollView } from "../scroll-view"; +import { renderTui } from "./render-tui"; + +describe("ScrollView", () => { + let unmount: () => void; + afterEach(() => unmount?.()); + + it("renders children", () => { + const tui = renderTui( + + Hello scroll + + ); + ({ unmount } = tui); + expect(tui.screen.contains("Hello scroll")).toBe(true); + }); + + it("shows scrollbar by default", () => { + const tui = renderTui( + + line 1 + line 2 + line 3 + + ); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(true); + }); + + it("hides scrollbar when showScrollbar is false", () => { + const tui = renderTui( + + line 1 + + ); + ({ unmount } = tui); + expect(tui.screen.contains("█")).toBe(false); + }); + + it("uses custom track and thumb characters", () => { + const tui = renderTui( + + line 1 + + ); + ({ unmount } = tui); + expect(tui.screen.contains("#")).toBe(true); + }); + + it("renders content without scrollbar when content fits", () => { + const tui = renderTui( + + line A + line B + line C + + ); + ({ unmount } = tui); + expect(tui.screen.contains("line A")).toBe(true); + expect(tui.screen.contains("line B")).toBe(true); + expect(tui.screen.contains("line C")).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(