test: initial frontend testing setup

This commit is contained in:
Christian Benincasa
2026-02-10 15:15:34 -05:00
parent 64b359810b
commit 696eb9eca1
15 changed files with 1817 additions and 18 deletions

472
pnpm-lock.yaml generated
View File

@@ -80,7 +80,7 @@ importers:
version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2))
esbuild:
specifier: ^0.21.5
version: 0.21.5
@@ -146,7 +146,7 @@ importers:
version: 8.46.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2)
server:
dependencies:
@@ -375,7 +375,7 @@ importers:
version: 17.0.33
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2))
'@yao-pkg/pkg':
specifier: ^6.9.0
version: 6.9.0
@@ -456,7 +456,7 @@ importers:
version: 8.46.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2)
shared:
dependencies:
@@ -496,7 +496,7 @@ importers:
version: 22.10.7
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2))
rimraf:
specifier: ^5.0.5
version: 5.0.5
@@ -514,7 +514,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2)
types:
dependencies:
@@ -706,6 +706,15 @@ importers:
'@tanstack/router-vite-plugin':
specifier: ^1.133.13
version: 1.133.13(@tanstack/react-router@1.133.13(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(vite@7.1.10(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2))
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/lodash-es':
specifier: 4.17.9
version: 4.17.9
@@ -745,6 +754,9 @@ importers:
eslint-plugin-react-refresh:
specifier: ^0.4.16
version: 0.4.16(eslint@9.39.2(jiti@2.6.1))
jsdom:
specifier: ^28.0.0
version: 28.0.0(@noble/hashes@1.8.0)
make-vfs:
specifier: ^1.0.15
version: 1.0.15
@@ -768,7 +780,7 @@ importers:
version: 4.5.0(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.10(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2))
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2)
packages:
@@ -776,6 +788,9 @@ packages:
resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==}
engines: {node: '>=0.10.0'}
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
'@actions/core@2.0.1':
resolution: {integrity: sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg==}
@@ -788,6 +803,9 @@ packages:
'@actions/io@2.0.0':
resolution: {integrity: sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg==}
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
@@ -807,6 +825,15 @@ packages:
peerDependencies:
openapi-types: '>=7'
'@asamuzakjp/css-color@4.1.2':
resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==}
'@asamuzakjp/dom-selector@6.7.8':
resolution: {integrity: sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/code-frame@7.24.7':
resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==}
engines: {node: '>=6.9.0'}
@@ -1154,6 +1181,37 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@csstools/color-helpers@6.0.1':
resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.0.0':
resolution: {integrity: sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.0.1':
resolution: {integrity: sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.0.26':
resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==}
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dotenvx/dotenvx@1.45.1':
resolution: {integrity: sha512-wKHPD+/NMMJVBPg3i98uD9jsURDy+Ck6RQRiWf39TlOAzC+Ge1FkmDk3sgeljYZxA3qF6E7SJmvRqC70XQuuVA==}
hasBin: true
@@ -1862,6 +1920,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@exodus/bytes@1.11.0':
resolution: {integrity: sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@faker-js/faker@9.9.0':
resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==}
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
@@ -3360,6 +3427,35 @@ packages:
'@tanstack/react-router': '>=1.43.2'
zod: ^3.23.8
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/jest-dom@6.9.1':
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
@@ -3394,6 +3490,9 @@ packages:
'@types/argparse@1.0.38':
resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/async-retry@1.4.9':
resolution: {integrity: sha512-s1ciZQJzRh3708X/m3vPExr5KJlzlZJvXsKpbtE2luqNcbROr64qU+3KpJsYHqWMeaxI839OvXf9PrUSw1Xtyg==}
@@ -3937,6 +4036,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
ansi-styles@6.2.1:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
engines: {node: '>=12'}
@@ -3975,6 +4078,13 @@ packages:
argv-formatter@1.0.0:
resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
arktype@1.0.18-alpha:
resolution: {integrity: sha512-7yUOaaeEws1GkDFGvB7IPkurVL5FFcZBbWLNVrqNXWccKbmMz9gozqXfR3pnconHeSbXLEcYUmtGAXTnhRgJrw==}
@@ -4156,6 +4266,9 @@ packages:
better-sqlite3@11.8.1:
resolution: {integrity: sha512-9BxNaBkblMjhJW8sMRZxnxVTRgbRmssZW0Oxc1MPBTfiR+WW21e2Mk4qu8CzrcZb1LwPCnFsfDEzq+SNcBU8eg==}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
@@ -4704,10 +4817,21 @@ packages:
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
cssstyle@5.3.7:
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
engines: {node: '>=20'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -4719,6 +4843,10 @@ packages:
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
engines: {node: '>= 14'}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
data-view-buffer@1.0.1:
resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==}
engines: {node: '>= 0.4'}
@@ -4796,6 +4924,9 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.0.2:
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
@@ -4899,6 +5030,12 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -5954,6 +6091,10 @@ packages:
resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==}
engines: {node: ^20.17.0 || >=22.9.0}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -6279,6 +6420,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-regex@1.1.4:
resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
engines: {node: '>= 0.4'}
@@ -6458,6 +6602,15 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsdom@28.0.0:
resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
jsep@1.4.0:
resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==}
engines: {node: '>= 10.16.0'}
@@ -6725,6 +6878,10 @@ packages:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
lru-cache@11.2.5:
resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -6740,6 +6897,10 @@ packages:
resolution: {integrity: sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
macos-release@3.4.0:
resolution: {integrity: sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -6820,6 +6981,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@@ -7532,6 +7696,9 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
pastable@2.2.1:
resolution: {integrity: sha512-K4ClMxRKpgN4sXj6VIPPrvor/TMp2yPNCGtfhvV106C73SwefQ3FuegURsH7AQHpqu0WwbvKXRl1HQxF6qax9w==}
engines: {node: '>=14.x'}
@@ -7719,6 +7886,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
pretty-ms@9.2.0:
resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==}
engines: {node: '>=18'}
@@ -7836,6 +8007,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-is@19.1.0:
resolution: {integrity: sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==}
@@ -8117,6 +8291,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.23.0:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
@@ -8543,6 +8721,9 @@ packages:
svg-parser@2.0.4:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
table@6.8.2:
resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==}
engines: {node: '>=10.0.0'}
@@ -8656,6 +8837,13 @@ packages:
resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.22:
resolution: {integrity: sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==}
tldts@7.0.22:
resolution: {integrity: sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==}
hasBin: true
tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
@@ -8687,12 +8875,20 @@ packages:
resolution: {integrity: sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==}
hasBin: true
tough-cookie@6.0.0:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tr46@1.0.1:
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
traverse@0.6.8:
resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==}
engines: {node: '>= 0.4'}
@@ -8997,6 +9193,10 @@ packages:
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
engines: {node: '>=20.18.1'}
undici@7.21.0:
resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==}
engines: {node: '>=20.18.1'}
unicode-emoji-modifier-base@1.0.0:
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
engines: {node: '>=4'}
@@ -9224,6 +9424,10 @@ packages:
jsdom:
optional: true
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
walk-up-path@4.0.0:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
@@ -9234,6 +9438,10 @@ packages:
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -9246,6 +9454,14 @@ packages:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.0:
resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -9343,6 +9559,13 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -9481,6 +9704,8 @@ snapshots:
'@aashutoshrathi/word-wrap@1.2.6': {}
'@acemir/cssom@0.9.31': {}
'@actions/core@2.0.1':
dependencies:
'@actions/exec': 2.0.0
@@ -9497,6 +9722,8 @@ snapshots:
'@actions/io@2.0.0': {}
'@adobe/css-tools@4.4.4': {}
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.5
@@ -9523,6 +9750,24 @@ snapshots:
call-me-maybe: 1.0.2
openapi-types: 12.1.3
'@asamuzakjp/css-color@4.1.2':
dependencies:
'@csstools/css-calc': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
lru-cache: 11.2.5
'@asamuzakjp/dom-selector@6.7.8':
dependencies:
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.1.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.5
'@asamuzakjp/nwsapi@2.3.9': {}
'@babel/code-frame@7.24.7':
dependencies:
'@babel/highlight': 7.24.7
@@ -9804,8 +10049,7 @@ snapshots:
'@babel/runtime@7.27.1': {}
'@babel/runtime@7.28.4':
optional: true
'@babel/runtime@7.28.4': {}
'@babel/template@7.24.7':
dependencies:
@@ -10007,6 +10251,28 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.9
optional: true
'@csstools/color-helpers@6.0.1': {}
'@csstools/css-calc@3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.0.1
'@csstools/css-calc': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.0.26': {}
'@csstools/css-tokenizer@4.0.0': {}
'@dotenvx/dotenvx@1.45.1':
dependencies:
commander: 11.1.0
@@ -10497,6 +10763,10 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@exodus/bytes@1.11.0(@noble/hashes@1.8.0)':
optionalDependencies:
'@noble/hashes': 1.8.0
'@faker-js/faker@9.9.0': {}
'@fastify/accept-negotiator@2.0.0': {}
@@ -10948,7 +11218,7 @@ snapshots:
'@mui/private-theming@7.0.2(@types/react@18.2.15)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.28.4
'@mui/utils': 7.0.2(@types/react@18.2.15)(react@18.2.0)
prop-types: 15.8.1
react: 18.2.0
@@ -10957,7 +11227,7 @@ snapshots:
'@mui/styled-engine@7.0.2(@emotion/react@11.14.0(@types/react@18.2.15)(react@18.2.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.2.15)(react@18.2.0))(@types/react@18.2.15)(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.28.4
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
'@emotion/sheet': 1.4.0
@@ -12018,6 +12288,40 @@ snapshots:
'@tanstack/react-router': 1.133.13(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
zod: 4.3.6
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/runtime': 7.28.4
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
lz-string: 1.5.0
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/jest-dom@6.9.1':
dependencies:
'@adobe/css-tools': 4.4.4
aria-query: 5.3.2
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.4
'@testing-library/dom': 10.4.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.15
'@types/react-dom': 18.2.7
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
'@testing-library/dom': 10.4.1
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
@@ -12054,6 +12358,8 @@ snapshots:
'@types/argparse@1.0.38': {}
'@types/aria-query@5.0.4': {}
'@types/async-retry@1.4.9':
dependencies:
'@types/retry': 0.12.5
@@ -12458,7 +12764,7 @@ snapshots:
transitivePeerDependencies:
- '@swc/helpers'
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -12473,7 +12779,7 @@ snapshots:
std-env: 3.9.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -12673,6 +12979,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansi-styles@6.2.1: {}
ansis@4.2.0: {}
@@ -12717,6 +13025,12 @@ snapshots:
argv-formatter@1.0.0: {}
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
aria-query@5.3.2: {}
arktype@1.0.18-alpha: {}
array-buffer-byte-length@1.0.1:
@@ -12871,7 +13185,7 @@ snapshots:
babel-plugin-macros@3.1.0:
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.28.4
cosmiconfig: 7.1.0
resolve: 1.22.10
@@ -12935,6 +13249,10 @@ snapshots:
bindings: 1.5.0
prebuild-install: 7.1.3
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
binary-extensions@2.2.0: {}
bindings@1.5.0:
@@ -13573,14 +13891,35 @@ snapshots:
domutils: 3.2.2
nth-check: 2.1.1
css-tree@3.1.0:
dependencies:
mdn-data: 2.12.2
source-map-js: 1.2.1
css-what@6.2.2: {}
css.escape@1.5.1: {}
cssstyle@5.3.7:
dependencies:
'@asamuzakjp/css-color': 4.1.2
'@csstools/css-syntax-patches-for-csstree': 1.0.26
css-tree: 3.1.0
lru-cache: 11.2.4
csstype@3.1.3: {}
dargs@8.1.0: {}
data-uri-to-buffer@6.0.2: {}
data-urls@7.0.0(@noble/hashes@1.8.0):
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.0(@noble/hashes@1.8.0)
transitivePeerDependencies:
- '@noble/hashes'
data-view-buffer@1.0.1:
dependencies:
call-bind: 1.0.8
@@ -13651,6 +13990,8 @@ snapshots:
decamelize@1.2.0: {}
decimal.js@10.6.0: {}
decode-named-character-reference@1.0.2:
dependencies:
character-entities: 2.0.2
@@ -13747,6 +14088,10 @@ snapshots:
dependencies:
esutils: 2.0.3
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.27.1
@@ -15067,6 +15412,12 @@ snapshots:
dependencies:
lru-cache: 11.2.4
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
dependencies:
'@exodus/bytes': 1.11.0(@noble/hashes@1.8.0)
transitivePeerDependencies:
- '@noble/hashes'
html-escaper@2.0.2: {}
html-url-attributes@3.0.1: {}
@@ -15362,6 +15713,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
is-regex@1.1.4:
dependencies:
call-bind: 1.0.8
@@ -15539,6 +15892,32 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@28.0.0(@noble/hashes@1.8.0):
dependencies:
'@acemir/cssom': 0.9.31
'@asamuzakjp/dom-selector': 6.7.8
'@exodus/bytes': 1.11.0(@noble/hashes@1.8.0)
cssstyle: 5.3.7
data-urls: 7.0.0(@noble/hashes@1.8.0)
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
parse5: 8.0.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.0
undici: 7.21.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.0(@noble/hashes@1.8.0)
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
- supports-color
jsep@1.4.0: {}
jsesc@2.5.2: {}
@@ -15783,6 +16162,8 @@ snapshots:
lru-cache@11.2.4: {}
lru-cache@11.2.5: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -15795,6 +16176,8 @@ snapshots:
luxon@3.4.3: {}
lz-string@1.5.0: {}
macos-release@3.4.0: {}
magic-string@0.30.17:
@@ -15938,6 +16321,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.12.2: {}
media-typer@1.1.0: {}
meilisearch@0.50.0: {}
@@ -16714,6 +17099,10 @@ snapshots:
dependencies:
entities: 6.0.1
parse5@8.0.0:
dependencies:
entities: 6.0.1
pastable@2.2.1(react@18.2.0):
dependencies:
'@babel/core': 7.24.7
@@ -16888,6 +17277,12 @@ snapshots:
prettier@3.6.2: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
pretty-ms@9.2.0:
dependencies:
parse-ms: 4.0.0
@@ -17001,6 +17396,8 @@ snapshots:
react-is@16.13.1: {}
react-is@17.0.2: {}
react-is@19.1.0: {}
react-markdown@9.0.3(@types/react@18.2.15)(react@18.2.0):
@@ -17139,7 +17536,7 @@ snapshots:
redux@4.2.1:
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.28.4
reflect-metadata@0.2.2: {}
@@ -17434,6 +17831,10 @@ snapshots:
safer-buffer@2.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.23.0:
dependencies:
loose-envify: 1.4.0
@@ -17920,6 +18321,8 @@ snapshots:
svg-parser@2.0.4: {}
symbol-tree@3.2.4: {}
table@6.8.2:
dependencies:
ajv: 8.12.0
@@ -18059,6 +18462,12 @@ snapshots:
tinyspy@4.0.3: {}
tldts-core@7.0.22: {}
tldts@7.0.22:
dependencies:
tldts-core: 7.0.22
tmp-promise@3.0.3:
dependencies:
tmp: 0.2.5
@@ -18088,12 +18497,20 @@ snapshots:
dependencies:
nopt: 1.0.10
tough-cookie@6.0.0:
dependencies:
tldts: 7.0.22
tr46@0.0.3: {}
tr46@1.0.1:
dependencies:
punycode: 2.3.1
tr46@6.0.0:
dependencies:
punycode: 2.3.1
traverse@0.6.8: {}
tree-dump@1.1.0(tslib@2.6.2):
@@ -18405,6 +18822,8 @@ snapshots:
undici@7.16.0: {}
undici@7.21.0: {}
unicode-emoji-modifier-base@1.0.0: {}
unicorn-magic@0.1.0: {}
@@ -18608,7 +19027,7 @@ snapshots:
tsx: 4.20.6
yaml: 2.8.2
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.10.7)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(tsx@4.20.6)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
@@ -18636,6 +19055,7 @@ snapshots:
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 22.10.7
jsdom: 28.0.0(@noble/hashes@1.8.0)
transitivePeerDependencies:
- jiti
- less
@@ -18650,12 +19070,18 @@ snapshots:
- tsx
- yaml
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
walk-up-path@4.0.0: {}
webidl-conversions@3.0.1: {}
webidl-conversions@4.0.2: {}
webidl-conversions@8.0.1: {}
webpack-virtual-modules@0.6.2: {}
whatwg-encoding@3.1.1:
@@ -18664,6 +19090,16 @@ snapshots:
whatwg-mimetype@4.0.0: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.0(@noble/hashes@1.8.0):
dependencies:
'@exodus/bytes': 1.11.0(@noble/hashes@1.8.0)
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
@@ -18804,6 +19240,10 @@ snapshots:
dependencies:
is-wsl: 3.1.0
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
xtend@4.0.2: {}
y18n@5.0.8: {}

View File

@@ -70,6 +70,9 @@
"@tanstack/react-table": "8.19.3",
"@tanstack/router-cli": "^1.35.4",
"@tanstack/router-vite-plugin": "^1.133.13",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/lodash-es": "4.17.9",
"@types/pluralize": "^0.0.33",
"@types/react": "^18.2.15",
@@ -83,6 +86,7 @@
"eslint": "catalog:",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"jsdom": "^28.0.0",
"make-vfs": "^1.0.15",
"nodemon": "^3.0.3",
"openapi-zod-client": "^1.14.0",

View File

@@ -0,0 +1,129 @@
import { describe, expect, test, vi } from 'vitest';
import { renderWithProviders, screen } from '@/test/utils';
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog';
describe('DeleteConfirmationDialog', () => {
test('renders title and body', () => {
renderWithProviders(
<DeleteConfirmationDialog
open={true}
title="Delete Item"
body="Are you sure you want to delete this item?"
onConfirm={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getByText('Delete Item')).toBeInTheDocument();
expect(
screen.getByText('Are you sure you want to delete this item?'),
).toBeInTheDocument();
});
test('delete button calls onConfirm then onClose', async () => {
const onConfirm = vi.fn();
const onClose = vi.fn();
const { user } = renderWithProviders(
<DeleteConfirmationDialog
open={true}
title="Confirm Delete"
body="This action cannot be undone."
onConfirm={onConfirm}
onClose={onClose}
/>,
);
const deleteButton = screen.getByRole('button', { name: 'Delete' });
await user.click(deleteButton);
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
// onConfirm should be called before onClose
expect(onConfirm.mock.invocationCallOrder[0]).toBeLessThan(
onClose.mock.invocationCallOrder[0],
);
});
test('cancel button calls onCancel (if provided) then onClose', async () => {
const onCancel = vi.fn();
const onClose = vi.fn();
const { user } = renderWithProviders(
<DeleteConfirmationDialog
open={true}
title="Confirm Delete"
onConfirm={() => {}}
onCancel={onCancel}
onClose={onClose}
/>,
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
test('cancel button only calls onClose when onCancel not provided', async () => {
const onClose = vi.fn();
const { user } = renderWithProviders(
<DeleteConfirmationDialog
open={true}
title="Confirm Delete"
onConfirm={() => {}}
onClose={onClose}
/>,
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
test('body is optional - does not render DialogContent if missing', () => {
renderWithProviders(
<DeleteConfirmationDialog
open={true}
title="Delete Without Body"
onConfirm={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getByText('Delete Without Body')).toBeInTheDocument();
// Should have title and buttons but no body content
expect(screen.queryByRole('paragraph')).not.toBeInTheDocument();
});
test('does not render when open is false', () => {
renderWithProviders(
<DeleteConfirmationDialog
open={false}
title="Hidden Dialog"
body="Should not be visible"
onConfirm={() => {}}
onClose={() => {}}
/>,
);
expect(screen.queryByText('Hidden Dialog')).not.toBeInTheDocument();
});
test('passes dialogProps to Dialog component', () => {
renderWithProviders(
<DeleteConfirmationDialog
open={true}
title="With Props"
onConfirm={() => {}}
onClose={() => {}}
dialogProps={{ maxWidth: 'sm', fullWidth: true }}
/>,
);
expect(screen.getByText('With Props')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,154 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderWithProviders, screen } from '@/test/utils';
import AddFlexModal from './AddFlexModal';
import type { UIFlexProgram } from '@/types/index';
// Mock the store actions
const mockAddProgramsToCurrentChannel = vi.fn();
const mockSetProgramAtIndex = vi.fn();
vi.mock('../../store/channelEditor/actions.ts', () => ({
addProgramsToCurrentChannel: (programs: unknown) =>
mockAddProgramsToCurrentChannel(programs),
setProgramAtIndex: (program: unknown, index: number) =>
mockSetProgramAtIndex(program, index),
}));
describe('AddFlexModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('shows "Add Flex Time" title when no initialProgram', () => {
renderWithProviders(<AddFlexModal open={true} onClose={() => {}} />);
expect(screen.getByText('Add Flex Time')).toBeInTheDocument();
});
test('shows "Edit Flex Time" title when initialProgram provided', () => {
const initialProgram: UIFlexProgram & { index: number } = {
type: 'flex',
duration: 300000, // 5 minutes in ms
persisted: false,
uiIndex: 0,
originalIndex: 0,
index: 0,
};
renderWithProviders(
<AddFlexModal open={true} onClose={() => {}} initialProgram={initialProgram} />,
);
expect(screen.getByText('Edit Flex Time')).toBeInTheDocument();
});
test('displays validation error for empty input', async () => {
const { user } = renderWithProviders(
<AddFlexModal open={true} onClose={() => {}} />,
);
const input = screen.getByLabelText('Duration (seconds)');
// Clear the input - component rejects non-numeric input, so clearing shows numeric error
await user.clear(input);
// When input is empty, it shows "must be numeric" error
expect(screen.getByText('Duration must be numeric')).toBeInTheDocument();
});
test('displays validation error for zero value', async () => {
const { user } = renderWithProviders(
<AddFlexModal open={true} onClose={() => {}} />,
);
const input = screen.getByLabelText('Duration (seconds)');
await user.clear(input);
await user.type(input, '0');
expect(screen.getByText('Duration must be greater than 0.')).toBeInTheDocument();
});
test('shows humanized duration in helper text for valid input', () => {
renderWithProviders(<AddFlexModal open={true} onClose={() => {}} />);
// Default is 5 minutes (300 seconds)
// dayjs humanizes this as "5 minutes"
expect(screen.getByText('5 minutes')).toBeInTheDocument();
});
test('calls addProgramsToCurrentChannel on save (add mode)', async () => {
const onClose = vi.fn();
const { user } = renderWithProviders(
<AddFlexModal open={true} onClose={onClose} />,
);
const saveButton = screen.getByRole('button', { name: 'Save' });
await user.click(saveButton);
expect(mockAddProgramsToCurrentChannel).toHaveBeenCalledWith([
{ type: 'flex', duration: 300000, persisted: false }, // 300 seconds in ms
]);
expect(onClose).toHaveBeenCalled();
});
test('calls setProgramAtIndex on save (edit mode)', async () => {
const onClose = vi.fn();
const initialProgram: UIFlexProgram & { index: number } = {
type: 'flex',
duration: 600000, // 10 minutes
persisted: false,
uiIndex: 0,
originalIndex: 0,
index: 5,
};
const { user } = renderWithProviders(
<AddFlexModal open={true} onClose={onClose} initialProgram={initialProgram} />,
);
const saveButton = screen.getByRole('button', { name: 'Save' });
await user.click(saveButton);
expect(mockSetProgramAtIndex).toHaveBeenCalledWith(
expect.objectContaining({
type: 'flex',
duration: 600000,
persisted: false,
}),
5,
);
expect(onClose).toHaveBeenCalled();
});
test('calls onClose when cancel clicked', async () => {
const onClose = vi.fn();
const { user } = renderWithProviders(
<AddFlexModal open={true} onClose={onClose} />,
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(onClose).toHaveBeenCalled();
expect(mockAddProgramsToCurrentChannel).not.toHaveBeenCalled();
});
test('does not render when open is false', () => {
renderWithProviders(<AddFlexModal open={false} onClose={() => {}} />);
expect(screen.queryByText('Add Flex Time')).not.toBeInTheDocument();
});
test('updates duration when user types new value', async () => {
const { user } = renderWithProviders(
<AddFlexModal open={true} onClose={() => {}} />,
);
const input = screen.getByLabelText('Duration (seconds)');
await user.clear(input);
await user.type(input, '3600');
// 3600 seconds = 1 hour, humanized as "an hour"
expect(screen.getByText('an hour')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,146 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderWithProviders, screen, within } from '@/test/utils';
import AddPaddingModal from './AddPaddingModal';
import { StartTimePaddingOptions } from '@/hooks/programming_controls/usePadStartTimes';
// Mock the usePadStartTimes hook
const mockPadStartTimes = vi.fn();
vi.mock('@/hooks/programming_controls/usePadStartTimes', async () => {
const actual = await vi.importActual<
typeof import('@/hooks/programming_controls/usePadStartTimes')
>('@/hooks/programming_controls/usePadStartTimes');
return {
...actual,
usePadStartTimes: () => mockPadStartTimes,
};
});
describe('AddPaddingModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('renders with dialog title', () => {
renderWithProviders(<AddPaddingModal open={true} onClose={() => {}} />);
// Use getByRole to specifically get the dialog title
expect(screen.getByRole('heading', { name: 'Pad Start Times' })).toBeInTheDocument();
});
test('renders description text', () => {
renderWithProviders(<AddPaddingModal open={true} onClose={() => {}} />);
expect(
screen.getByText(/Adds Flex breaks after each TV episode or movie/),
).toBeInTheDocument();
});
test('renders with select dropdown', () => {
renderWithProviders(<AddPaddingModal open={true} onClose={() => {}} />);
// MUI Select uses a combobox role
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
test('shows all padding options when dropdown is opened', async () => {
const { user } = renderWithProviders(
<AddPaddingModal open={true} onClose={() => {}} />,
);
// Click to open the select dropdown
const select = screen.getByRole('combobox');
await user.click(select);
// Check that all options are present
const listbox = screen.getByRole('listbox');
for (const option of StartTimePaddingOptions) {
expect(within(listbox).getByText(option.description)).toBeInTheDocument();
}
});
test('calls padStartTimes with selected option on save', async () => {
const onClose = vi.fn();
const { user } = renderWithProviders(
<AddPaddingModal open={true} onClose={onClose} />,
);
// Open the select dropdown
const select = screen.getByRole('combobox');
await user.click(select);
// Select the 15 minute option (":00, :15, :30, :45")
const listbox = screen.getByRole('listbox');
const option15min = within(listbox).getByText(':00, :15, :30, :45');
await user.click(option15min);
// Click Add Padding button
const addButton = screen.getByRole('button', { name: /Add Padding/i });
await user.click(addButton);
expect(mockPadStartTimes).toHaveBeenCalledWith(
expect.objectContaining({
key: 15,
mod: 15,
description: ':00, :15, :30, :45',
}),
);
expect(onClose).toHaveBeenCalled();
});
test('calls padStartTimes with null when no option selected', async () => {
const onClose = vi.fn();
const { user } = renderWithProviders(
<AddPaddingModal open={true} onClose={onClose} />,
);
// Click Add Padding without selecting anything
const addButton = screen.getByRole('button', { name: /Add Padding/i });
await user.click(addButton);
expect(mockPadStartTimes).toHaveBeenCalledWith(null);
expect(onClose).toHaveBeenCalled();
});
test('calls onClose on cancel without calling padStartTimes', async () => {
const onClose = vi.fn();
const { user } = renderWithProviders(
<AddPaddingModal open={true} onClose={onClose} />,
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(onClose).toHaveBeenCalled();
expect(mockPadStartTimes).not.toHaveBeenCalled();
});
test('does not render when open is false', () => {
renderWithProviders(<AddPaddingModal open={false} onClose={() => {}} />);
expect(screen.queryByRole('heading', { name: 'Pad Start Times' })).not.toBeInTheDocument();
});
test('allows selecting None option', async () => {
const onClose = vi.fn();
const { user } = renderWithProviders(
<AddPaddingModal open={true} onClose={onClose} />,
);
// Open the select dropdown
const select = screen.getByRole('combobox');
await user.click(select);
// Select "None"
const listbox = screen.getByRole('listbox');
const noneOption = within(listbox).getByText('None');
await user.click(noneOption);
// Click Add Padding
const addButton = screen.getByRole('button', { name: /Add Padding/i });
await user.click(addButton);
// When "None" is selected (key: -1), the handler sets padding to null
expect(mockPadStartTimes).toHaveBeenCalledWith(null);
});
});

View File

@@ -0,0 +1,175 @@
import type { ChannelProgram, ContentProgram, FlexProgram } from '@tunarr/types';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { addBreaks, type AddBreaksConfig } from './useAddBreaks';
dayjs.extend(duration);
// Mock the random helper
vi.mock('../../helpers/random.ts', () => ({
random: {
integer: vi.fn((min: number, max: number) => min), // Always return min for deterministic tests
},
}));
const createContentProgram = (
durationMs: number,
title = 'Test',
): ContentProgram => ({
type: 'content',
id: `id-${title}-${durationMs}`,
persisted: true,
subtype: 'movie',
title,
duration: durationMs,
externalIds: [],
});
const createFlexProgram = (durationMs: number): FlexProgram => ({
type: 'flex',
duration: durationMs,
persisted: false,
});
const createConfig = (
afterMinutes: number,
minMinutes: number,
maxMinutes: number,
): AddBreaksConfig => ({
afterDuration: dayjs.duration(afterMinutes, 'minutes'),
minDuration: dayjs.duration(minMinutes, 'minutes'),
maxDuration: dayjs.duration(maxMinutes, 'minutes'),
});
describe('addBreaks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('inserts flex break when cumulative duration exceeds threshold', () => {
const programs: ChannelProgram[] = [
createContentProgram(30 * 60 * 1000), // 30 min
createContentProgram(20 * 60 * 1000), // 20 min - total 50 min
createContentProgram(15 * 60 * 1000), // 15 min - total 65 min, exceeds 60
];
// Break after 60 min, insert 5 min breaks
const config = createConfig(60, 5, 10);
const result = addBreaks(programs, config);
// Should have: program1, program2, flex, program3
expect(result).toHaveLength(4);
expect(result[0].type).toBe('content');
expect(result[1].type).toBe('content');
expect(result[2].type).toBe('flex');
expect(result[3].type).toBe('content');
});
test('resets duration counter when flex is encountered', () => {
const programs: ChannelProgram[] = [
createContentProgram(40 * 60 * 1000), // 40 min
createFlexProgram(5 * 60 * 1000), // Existing flex - resets counter
createContentProgram(40 * 60 * 1000), // 40 min - counter is 40, not 80
createContentProgram(25 * 60 * 1000), // 25 min - total 65, exceeds 60
];
const config = createConfig(60, 5, 10);
const result = addBreaks(programs, config);
// Should have: program1, existing-flex, program2, new-flex, program3
expect(result).toHaveLength(5);
expect(result[0].type).toBe('content');
expect(result[1].type).toBe('flex'); // Original flex
expect(result[2].type).toBe('content');
expect(result[3].type).toBe('flex'); // New flex inserted
expect(result[4].type).toBe('content');
});
test('uses random duration between min and max (mocked to min)', async () => {
const { random } = await import('../../helpers/random.ts');
const programs: ChannelProgram[] = [
createContentProgram(70 * 60 * 1000), // 70 min, exceeds 60 min threshold
];
// min 5 min, max 10 min
const config = createConfig(60, 5, 10);
const result = addBreaks(programs, config);
expect(random.integer).toHaveBeenCalledWith(
5 * 60 * 1000, // min in ms
10 * 60 * 1000, // max in ms
);
// Flex should be inserted with min duration (mocked)
const flexProgram = result.find((p) => p.type === 'flex') as FlexProgram;
expect(flexProgram.duration).toBe(5 * 60 * 1000);
});
test('no breaks inserted when programs are short', () => {
const programs: ChannelProgram[] = [
createContentProgram(10 * 60 * 1000), // 10 min
createContentProgram(15 * 60 * 1000), // 15 min - total 25 min
createContentProgram(20 * 60 * 1000), // 20 min - total 45 min
];
// Break after 60 min
const config = createConfig(60, 5, 10);
const result = addBreaks(programs, config);
// No flex should be inserted since total is 45 min < 60 min
expect(result).toHaveLength(3);
expect(result.every((p) => p.type === 'content')).toBe(true);
});
test('handles empty program list', () => {
const programs: ChannelProgram[] = [];
const config = createConfig(60, 5, 10);
const result = addBreaks(programs, config);
expect(result).toEqual([]);
});
test('inserts multiple breaks for long lists', () => {
const programs: ChannelProgram[] = [
createContentProgram(40 * 60 * 1000), // 40 min
createContentProgram(30 * 60 * 1000), // 30 min - total 70, break
createContentProgram(40 * 60 * 1000), // 40 min - counter reset after break
createContentProgram(30 * 60 * 1000), // 30 min - total 70, break again
];
const config = createConfig(60, 5, 10);
const result = addBreaks(programs, config);
// Should have: p1, flex, p2, p3, flex, p4
expect(result).toHaveLength(6);
expect(result[0].type).toBe('content');
expect(result[1].type).toBe('flex');
expect(result[2].type).toBe('content');
expect(result[3].type).toBe('content');
expect(result[4].type).toBe('flex');
expect(result[5].type).toBe('content');
});
test('break is inserted before the program that exceeds threshold', () => {
const programs: ChannelProgram[] = [
createContentProgram(50 * 60 * 1000, 'First'), // 50 min
createContentProgram(20 * 60 * 1000, 'Second'), // Would make 70 min
];
const config = createConfig(60, 5, 10);
const result = addBreaks(programs, config);
// Flex should be inserted before 'Second'
expect(result).toHaveLength(3);
expect((result[0] as ContentProgram).title).toBe('First');
expect(result[1].type).toBe('flex');
expect((result[2] as ContentProgram).title).toBe('Second');
});
});

View File

@@ -14,7 +14,7 @@ export function useAddBreaks() {
};
}
function addBreaks(
export function addBreaks(
programs: ChannelProgram[],
{ afterDuration, minDuration, maxDuration }: AddBreaksConfig,
) {

View File

@@ -0,0 +1,114 @@
import type { ChannelProgram, ContentProgram } from '@tunarr/types';
import { describe, expect, test } from 'vitest';
import { sortPrograms } from './useAlphaSort';
const createContentProgram = (
title: string,
overrides: Partial<ContentProgram> = {},
): ContentProgram => ({
type: 'content',
id: `id-${title}`,
persisted: true,
subtype: 'movie',
title,
duration: 3600000,
externalIds: [],
...overrides,
});
const createFlexProgram = (): ChannelProgram => ({
type: 'flex',
duration: 60000,
persisted: false,
});
const createRedirectProgram = (channel: string): ChannelProgram => ({
type: 'redirect',
channel,
duration: 3600000,
persisted: false,
});
describe('sortPrograms', () => {
test('sorts content programs alphabetically in ascending order', () => {
const programs: ChannelProgram[] = [
createContentProgram('Charlie'),
createContentProgram('Alpha'),
createContentProgram('Bravo'),
];
const { newProgramSort } = sortPrograms(programs, 'asc');
expect(newProgramSort.map((p) => (p as ContentProgram).title)).toEqual([
'Alpha',
'Bravo',
'Charlie',
]);
});
test('sorts content programs alphabetically in descending order', () => {
const programs: ChannelProgram[] = [
createContentProgram('Alpha'),
createContentProgram('Charlie'),
createContentProgram('Bravo'),
];
const { newProgramSort } = sortPrograms(programs, 'desc');
expect(newProgramSort.map((p) => (p as ContentProgram).title)).toEqual([
'Charlie',
'Bravo',
'Alpha',
]);
});
test('content programs appear before non-content programs (flex, redirect)', () => {
const programs: ChannelProgram[] = [
createFlexProgram(),
createContentProgram('Zebra'),
createRedirectProgram('channel-1'),
createContentProgram('Apple'),
];
const { newProgramSort } = sortPrograms(programs, 'asc');
// Content programs should come first, sorted alphabetically
expect(newProgramSort[0].type).toBe('content');
expect((newProgramSort[0] as ContentProgram).title).toBe('Apple');
expect(newProgramSort[1].type).toBe('content');
expect((newProgramSort[1] as ContentProgram).title).toBe('Zebra');
// Non-content programs come after
expect(newProgramSort[2].type).not.toBe('content');
expect(newProgramSort[3].type).not.toBe('content');
});
test('handles empty array', () => {
const programs: ChannelProgram[] = [];
const { newProgramSort } = sortPrograms(programs, 'asc');
expect(newProgramSort).toEqual([]);
});
test('handles array with single program', () => {
const programs: ChannelProgram[] = [createContentProgram('Only One')];
const { newProgramSort } = sortPrograms(programs, 'asc');
expect(newProgramSort).toHaveLength(1);
expect((newProgramSort[0] as ContentProgram).title).toBe('Only One');
});
test('handles programs with identical titles', () => {
const programs: ChannelProgram[] = [
createContentProgram('Same', { id: 'id-1' }),
createContentProgram('Same', { id: 'id-2' }),
createContentProgram('Same', { id: 'id-3' }),
];
const { newProgramSort } = sortPrograms(programs, 'asc');
expect(newProgramSort).toHaveLength(3);
expect(newProgramSort.every((p) => (p as ContentProgram).title === 'Same')).toBe(true);
});
});

View File

@@ -0,0 +1,181 @@
import type { ContentProgram, CustomProgram, FlexProgram, RedirectProgram } from '@tunarr/types';
import { describe, expect, test } from 'vitest';
import type { UIChannelProgram } from '../../types/index';
import { removeDuplicatePrograms } from './useRemoveDuplicates';
const createContentProgram = (
id: string,
overrides: Partial<ContentProgram> = {},
): UIChannelProgram<ContentProgram> => ({
type: 'content',
id,
persisted: true,
subtype: 'movie',
title: `Movie ${id}`,
duration: 3600000,
externalIds: [],
uiIndex: 0,
originalIndex: 0,
...overrides,
});
const createFlexProgram = (): UIChannelProgram<FlexProgram> => ({
type: 'flex',
duration: 60000,
persisted: false,
uiIndex: 0,
originalIndex: 0,
});
const createRedirectProgram = (channel: string): UIChannelProgram<RedirectProgram> => ({
type: 'redirect',
channel,
duration: 3600000,
persisted: false,
uiIndex: 0,
originalIndex: 0,
});
const createCustomProgram = (
customShowId: string,
id: string,
): UIChannelProgram<CustomProgram> => ({
type: 'custom',
customShowId,
id,
duration: 3600000,
persisted: false,
uiIndex: 0,
originalIndex: 0,
});
describe('removeDuplicatePrograms', () => {
test('removes all flex programs', () => {
const programs: UIChannelProgram[] = [
createContentProgram('1'),
createFlexProgram(),
createContentProgram('2'),
createFlexProgram(),
];
const result = removeDuplicatePrograms(programs);
expect(result).toHaveLength(2);
expect(result.every((p) => p.type !== 'flex')).toBe(true);
});
test('deduplicates by persisted id (database ID)', () => {
const programs: UIChannelProgram[] = [
createContentProgram('db-id-1'),
createContentProgram('db-id-2'),
createContentProgram('db-id-1'), // Duplicate
createContentProgram('db-id-3'),
createContentProgram('db-id-2'), // Duplicate
];
const result = removeDuplicatePrograms(programs);
expect(result).toHaveLength(3);
expect(result.map((p) => (p as ContentProgram).id)).toEqual([
'db-id-1',
'db-id-2',
'db-id-3',
]);
});
test('deduplicates by external ID (Plex/Jellyfin)', () => {
const programWithExternalId = (
internalId: string,
externalId: string,
): UIChannelProgram<ContentProgram> =>
createContentProgram(internalId, {
persisted: false,
externalIds: [
{
type: 'multi',
source: 'plex',
sourceId: 'server-1',
id: externalId,
},
],
});
const programs: UIChannelProgram[] = [
programWithExternalId('a', 'plex-123'),
programWithExternalId('b', 'plex-456'),
programWithExternalId('c', 'plex-123'), // Duplicate external ID
];
const result = removeDuplicatePrograms(programs);
expect(result).toHaveLength(2);
});
test('keeps first occurrence of redirect programs (by channel)', () => {
const programs: UIChannelProgram[] = [
createRedirectProgram('channel-a'),
createRedirectProgram('channel-b'),
createRedirectProgram('channel-a'), // Duplicate channel
createRedirectProgram('channel-c'),
];
const result = removeDuplicatePrograms(programs);
expect(result).toHaveLength(3);
expect(result.map((p) => (p as RedirectProgram).channel)).toEqual([
'channel-a',
'channel-b',
'channel-c',
]);
});
test('keeps first occurrence of custom programs (by customShowId + id)', () => {
const programs: UIChannelProgram[] = [
createCustomProgram('show-1', 'prog-a'),
createCustomProgram('show-1', 'prog-b'),
createCustomProgram('show-1', 'prog-a'), // Duplicate
createCustomProgram('show-2', 'prog-a'), // Different show, same prog id - not duplicate
];
const result = removeDuplicatePrograms(programs);
expect(result).toHaveLength(3);
});
test('handles mixed program types correctly', () => {
const programs: UIChannelProgram[] = [
createContentProgram('content-1'),
createFlexProgram(),
createRedirectProgram('channel-1'),
createContentProgram('content-1'), // Duplicate content
createCustomProgram('show-1', 'custom-1'),
createFlexProgram(), // Flex always removed
createRedirectProgram('channel-1'), // Duplicate redirect
createCustomProgram('show-1', 'custom-1'), // Duplicate custom
];
const result = removeDuplicatePrograms(programs);
// content-1 (1), channel-1 redirect (1), custom show-1/custom-1 (1)
expect(result).toHaveLength(3);
expect(result.map((p) => p.type)).toEqual(['content', 'redirect', 'custom']);
});
test('handles empty array', () => {
const result = removeDuplicatePrograms([]);
expect(result).toEqual([]);
});
test('preserves order of first occurrences', () => {
const programs: UIChannelProgram[] = [
createContentProgram('3'),
createContentProgram('1'),
createContentProgram('2'),
createContentProgram('1'), // Duplicate
];
const result = removeDuplicatePrograms(programs);
expect(result.map((p) => (p as ContentProgram).id)).toEqual(['3', '1', '2']);
});
});

View File

@@ -0,0 +1,140 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { useNumberString } from './useNumberString';
describe('useNumberString', () => {
test('initializes with numeric value as string', () => {
const { result } = renderHook(() => useNumberString(42));
expect(result.current.numValue).toBe(42);
expect(result.current.strValue).toBe('42');
expect(result.current.isValid).toBe(true);
});
test('initializes with float value as string', () => {
const { result } = renderHook(() => useNumberString(3.14, true));
expect(result.current.numValue).toBe(3.14);
expect(result.current.strValue).toBe('3.14');
expect(result.current.isValid).toBe(true);
});
test('parses valid integer input', async () => {
const { result } = renderHook(() => useNumberString(0));
act(() => {
result.current.setValue('123');
});
expect(result.current.strValue).toBe('123');
// Wait for debounce
await waitFor(() => {
expect(result.current.numValue).toBe(123);
expect(result.current.isValid).toBe(true);
});
});
test('parses valid float input when isFloat=true', async () => {
const { result } = renderHook(() => useNumberString(0, true));
act(() => {
result.current.setValue('3.14159');
});
await waitFor(() => {
expect(result.current.numValue).toBe(3.14159);
expect(result.current.isValid).toBe(true);
});
});
test('sets valid=false for non-numeric input', async () => {
const { result } = renderHook(() => useNumberString(0));
act(() => {
result.current.setValue('not a number');
});
await waitFor(() => {
expect(result.current.isValid).toBe(false);
});
});
test('sets valid=false for empty string', async () => {
const { result } = renderHook(() => useNumberString(42));
act(() => {
result.current.setValue('');
});
await waitFor(() => {
expect(result.current.isValid).toBe(false);
});
});
test('preserves last valid numValue when input becomes invalid', async () => {
const { result } = renderHook(() => useNumberString(100));
// First set a valid value
act(() => {
result.current.setValue('200');
});
await waitFor(() => {
expect(result.current.numValue).toBe(200);
});
// Then set an invalid value
act(() => {
result.current.setValue('invalid');
});
await waitFor(() => {
expect(result.current.isValid).toBe(false);
// numValue should still be the last valid value
expect(result.current.numValue).toBe(200);
});
});
test('strValue updates immediately while numValue debounces', async () => {
const { result } = renderHook(() => useNumberString(0));
act(() => {
result.current.setValue('5');
});
// strValue updates immediately
expect(result.current.strValue).toBe('5');
// numValue updates after debounce
await waitFor(() => {
expect(result.current.numValue).toBe(5);
});
});
test('handles negative numbers', async () => {
const { result } = renderHook(() => useNumberString(0));
act(() => {
result.current.setValue('-50');
});
await waitFor(() => {
expect(result.current.numValue).toBe(-50);
expect(result.current.isValid).toBe(true);
});
});
test('handles zero', async () => {
const { result } = renderHook(() => useNumberString(100));
act(() => {
result.current.setValue('0');
});
await waitFor(() => {
expect(result.current.numValue).toBe(0);
expect(result.current.isValid).toBe(true);
});
});
});

View File

@@ -0,0 +1,188 @@
import type { ProgramOrFolder } from '@tunarr/types';
import { describe, expect, test } from 'vitest';
import { getChildSearchFilter } from './useProgramSearch';
const createProgram = (
type: ProgramOrFolder['type'],
uuid = 'test-uuid',
): ProgramOrFolder =>
({
type,
uuid,
title: 'Test Program',
libraryId: 'lib-1',
}) as ProgramOrFolder;
describe('getChildSearchFilter', () => {
describe('leaf types return null', () => {
test('returns null for episode', () => {
const result = getChildSearchFilter(createProgram('episode'));
expect(result).toBeNull();
});
test('returns null for movie', () => {
const result = getChildSearchFilter(createProgram('movie'));
expect(result).toBeNull();
});
test('returns null for track', () => {
const result = getChildSearchFilter(createProgram('track'));
expect(result).toBeNull();
});
test('returns null for music_video', () => {
const result = getChildSearchFilter(createProgram('music_video'));
expect(result).toBeNull();
});
test('returns null for other_video', () => {
const result = getChildSearchFilter(createProgram('other_video'));
expect(result).toBeNull();
});
test('returns null for collection', () => {
const result = getChildSearchFilter(createProgram('collection'));
expect(result).toBeNull();
});
test('returns null for folder', () => {
const result = getChildSearchFilter(createProgram('folder'));
expect(result).toBeNull();
});
test('returns null for playlist', () => {
const result = getChildSearchFilter(createProgram('playlist'));
expect(result).toBeNull();
});
});
describe('parent types return filters for children', () => {
test('returns season filter for show', () => {
const show = createProgram('show', 'show-uuid-123');
const result = getChildSearchFilter(show);
expect(result).toEqual({
type: 'op',
op: 'and',
children: [
{
type: 'value',
fieldSpec: {
key: 'type',
name: 'Type',
op: '=',
type: 'string',
value: ['season'],
},
},
{
type: 'value',
fieldSpec: {
key: 'parent.id',
name: '',
op: '=',
type: 'string',
value: ['show-uuid-123'],
},
},
],
});
});
test('returns episode filter for season', () => {
const season = createProgram('season', 'season-uuid-456');
const result = getChildSearchFilter(season);
expect(result).toEqual({
type: 'op',
op: 'and',
children: [
{
type: 'value',
fieldSpec: {
key: 'type',
name: 'Type',
op: '=',
type: 'string',
value: ['episode'],
},
},
{
type: 'value',
fieldSpec: {
key: 'parent.id',
name: '',
op: '=',
type: 'string',
value: ['season-uuid-456'],
},
},
],
});
});
test('returns album filter for artist', () => {
const artist = createProgram('artist', 'artist-uuid-789');
const result = getChildSearchFilter(artist);
expect(result).toEqual({
type: 'op',
op: 'and',
children: [
{
type: 'value',
fieldSpec: {
key: 'type',
name: 'Type',
op: '=',
type: 'string',
value: ['album'],
},
},
{
type: 'value',
fieldSpec: {
key: 'parent.id',
name: '',
op: '=',
type: 'string',
value: ['artist-uuid-789'],
},
},
],
});
});
test('returns track filter for album', () => {
const album = createProgram('album', 'album-uuid-012');
const result = getChildSearchFilter(album);
expect(result).toEqual({
type: 'op',
op: 'and',
children: [
{
type: 'value',
fieldSpec: {
key: 'type',
name: 'Type',
op: '=',
type: 'string',
value: ['track'],
},
},
{
type: 'value',
fieldSpec: {
key: 'parent.id',
name: '',
op: '=',
type: 'string',
value: ['album-uuid-012'],
},
},
],
});
});
});
});

15
web/src/test/setup.ts Normal file
View File

@@ -0,0 +1,15 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { afterEach } from 'vitest';
// Load dayjs plugins needed by components
dayjs.extend(duration);
dayjs.extend(relativeTime);
// Automatically cleanup after each test
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { renderWithProviders, screen } from './utils';
function TestComponent({ message }: { message: string }) {
return <div data-testid="test-component">{message}</div>;
}
describe('Test utilities', () => {
test('renderWithProviders renders component with providers', () => {
renderWithProviders(<TestComponent message="Hello, World!" />);
expect(screen.getByTestId('test-component')).toBeInTheDocument();
expect(screen.getByText('Hello, World!')).toBeInTheDocument();
});
test('renderWithProviders returns user for interactions', async () => {
const { user } = renderWithProviders(
<button onClick={() => console.log('clicked')}>Click me</button>,
);
const button = screen.getByRole('button', { name: 'Click me' });
expect(button).toBeInTheDocument();
// Verify user is available for interactions
await user.click(button);
});
});

84
web/src/test/utils.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement, ReactNode } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ThemeProvider } from '@mui/material';
import { Theme } from '@/theme.ts';
/**
* Creates a fresh QueryClient configured for testing.
* Uses short retry settings and suppresses error logging.
*/
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
}
interface TestProvidersProps {
children: ReactNode;
queryClient?: QueryClient;
}
/**
* Wraps children with all necessary providers for testing.
*/
function TestProviders({ children, queryClient }: TestProvidersProps) {
const client = queryClient ?? createTestQueryClient();
return (
<QueryClientProvider client={client}>
<DndProvider backend={HTML5Backend}>
<ThemeProvider theme={Theme}>{children}</ThemeProvider>
</DndProvider>
</QueryClientProvider>
);
}
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
}
/**
* Custom render function that wraps the component with all necessary providers.
* Use this instead of @testing-library/react's render for component tests.
*
* @example
* ```tsx
* import { renderWithProviders, screen } from '@/test/utils';
*
* test('renders component', () => {
* renderWithProviders(<MyComponent />);
* expect(screen.getByText('Hello')).toBeInTheDocument();
* });
* ```
*/
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {},
) {
const { queryClient, ...renderOptions } = options;
const Wrapper = ({ children }: { children: ReactNode }) => (
<TestProviders queryClient={queryClient}>{children}</TestProviders>
);
return {
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}
// Re-export everything from @testing-library/react for convenience
export * from '@testing-library/react';
export { userEvent };

View File

@@ -9,7 +9,9 @@ export default defineConfig({
},
test: {
globals: true,
includeSource: ['src/**/*.test.ts'],
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
includeSource: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
define: {
'import.meta.vitest': false,