committed by
GitHub
87 changed files with 2474 additions and 1503 deletions
@ -27,6 +27,10 @@ |
|||||
"npm:@radix-ui/react-toggle-group@^1.1.9": "1.1.10_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
"npm:@radix-ui/react-toggle-group@^1.1.9": "1.1.10_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
||||
"npm:@radix-ui/react-tooltip@^1.2.4": "1.2.4_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
"npm:@radix-ui/react-tooltip@^1.2.4": "1.2.4_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
||||
"npm:@tailwindcss/postcss@^4.1.5": "4.1.5", |
"npm:@tailwindcss/postcss@^4.1.5": "4.1.5", |
||||
|
"npm:@tanstack/react-router-devtools@^1.120.16": "1.120.16_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]", |
||||
|
"npm:@tanstack/react-router@^1.120.15": "[email protected][email protected][email protected]", |
||||
|
"npm:@tanstack/router-devtools@^1.120.15": "1.120.15_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]", |
||||
|
"npm:@tanstack/router-plugin@^1.120.15": "1.120.15_@[email protected][email protected][email protected][email protected][email protected]__@[email protected][email protected]_@[email protected][email protected][email protected][email protected]_@[email protected]", |
||||
"npm:@testing-library/jest-dom@^6.6.3": "6.6.3", |
"npm:@testing-library/jest-dom@^6.6.3": "6.6.3", |
||||
"npm:@testing-library/react@^16.3.0": "16.3.0_@[email protected]_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
"npm:@testing-library/react@^16.3.0": "16.3.0_@[email protected]_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
||||
"npm:@testing-library/user-event@^14.6.1": "14.6.1_@[email protected]", |
"npm:@testing-library/user-event@^14.6.1": "14.6.1_@[email protected]", |
||||
@ -366,6 +370,20 @@ |
|||||
"@babel/helper-plugin-utils" |
"@babel/helper-plugin-utils" |
||||
] |
] |
||||
}, |
}, |
||||
|
"@babel/[email protected]_@[email protected]": { |
||||
|
"integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", |
||||
|
"dependencies": [ |
||||
|
"@babel/core", |
||||
|
"@babel/helper-plugin-utils" |
||||
|
] |
||||
|
}, |
||||
|
"@babel/[email protected]_@[email protected]": { |
||||
|
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", |
||||
|
"dependencies": [ |
||||
|
"@babel/core", |
||||
|
"@babel/helper-plugin-utils" |
||||
|
] |
||||
|
}, |
||||
"@babel/[email protected]_@[email protected]": { |
"@babel/[email protected]_@[email protected]": { |
||||
"integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", |
"integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -2341,6 +2359,136 @@ |
|||||
"tailwindcss" |
"tailwindcss" |
||||
] |
] |
||||
}, |
}, |
||||
|
"@tanstack/[email protected]": { |
||||
|
"integrity": "sha512-K7JJNrRVvyjAVnbXOH2XLRhFXDkeP54Kt2P4FR1Kl2KDGlIbkua5VqZQD2rot3qaDrpufyUa63nuLai1kOLTsQ==" |
||||
|
}, |
||||
|
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]": { |
||||
|
"integrity": "sha512-5KcUXc3fkiLo/6Y56gOM3JqmYXG1ElIH2iyUWuG5IlcegLrpXhu4OBQ+8Q4+62CD0OKy0ifUDyemrCOAEOfCvw==", |
||||
|
"dependencies": [ |
||||
|
"@tanstack/react-router", |
||||
|
"@tanstack/router-devtools-core", |
||||
|
"react", |
||||
|
"react-dom", |
||||
|
"solid-js" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]": { |
||||
|
"integrity": "sha512-DWXmMLknVJJMGP2k5yeUWBDhJOHbV2jVfnZKxtGzA64xXhwDFgU9qpodcmYSq3+kHWsKrd7iX0wc7d27rGwGDA==", |
||||
|
"dependencies": [ |
||||
|
"@tanstack/react-router", |
||||
|
"@tanstack/router-devtools-core", |
||||
|
"react", |
||||
|
"react-dom", |
||||
|
"solid-js" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected][email protected][email protected][email protected]": { |
||||
|
"integrity": "sha512-apzBmXh4pHwqUGU3kD8y2FJMi7rVoUbRxh5oV7v8kEb6Aq5Xpdo+OcpThw8h/M2zv7v4Ef8IoY6WFCKKu3HBjQ==", |
||||
|
"dependencies": [ |
||||
|
"@tanstack/history", |
||||
|
"@tanstack/react-store", |
||||
|
"@tanstack/router-core", |
||||
|
"[email protected]", |
||||
|
"react", |
||||
|
"react-dom", |
||||
|
"tiny-invariant", |
||||
|
"tiny-warning" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected][email protected][email protected][email protected]": { |
||||
|
"integrity": "sha512-qUTEKdId6QPWGiWyKAPf/gkN29scEsz6EUSJ0C3HgLMgaqTAyBsQ2sMCfGVcqb+kkhEXAdjleCgH6LAPD6f2sA==", |
||||
|
"dependencies": [ |
||||
|
"@tanstack/store", |
||||
|
"react", |
||||
|
"react-dom", |
||||
|
"use-sync-external-store" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected]": { |
||||
|
"integrity": "sha512-soLj+mEuvSxAVFK/3b85IowkkvmSuQL6J0RSIyKJFGFgy0CmUzpcBGEO99+JNWvvvzHgIoY4F4KtLIN+rvFSFA==", |
||||
|
"dependencies": [ |
||||
|
"@tanstack/history", |
||||
|
"@tanstack/store", |
||||
|
"tiny-invariant" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected]": { |
||||
|
"integrity": "sha512-AT9obPHKpJqnHMbwshozSy6sApg5LchiAll3blpS3MMDybUCidYHrdhe9MZJLmlC99IQiEGmuZERP3VRcuPNHg==", |
||||
|
"dependencies": [ |
||||
|
"@tanstack/router-core", |
||||
|
"clsx", |
||||
|
"goober", |
||||
|
"solid-js", |
||||
|
"tiny-invariant" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]": { |
||||
|
"integrity": "sha512-u7KbvupWSppoEUYuhCBzmWkd1hcODzHhvGIuWZKoQO9q/qeNY5XptbzGqBSUooXyoF4T/pAdCRILF5zFIqexJw==", |
||||
|
"dependencies": [ |
||||
|
"@tanstack/react-router", |
||||
|
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]", |
||||
|
"clsx", |
||||
|
"goober", |
||||
|
"react", |
||||
|
"react-dom" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]": { |
||||
|
"integrity": "sha512-QwZ0rNXxzgOEUDRRAEWVjofKxuxSMIYEdYC3z20k6a7jkLC6pnlCORFx41Vf4xVCO6eElqlrUKXWLTleYSsvQw==", |
||||
|
"dependencies": [ |
||||
|
"@tanstack/react-router", |
||||
|
"@tanstack/virtual-file-routes", |
||||
|
"prettier", |
||||
|
"tsx", |
||||
|
"zod" |
||||
|
], |
||||
|
"optionalPeers": [ |
||||
|
"@tanstack/react-router" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected]__@[email protected][email protected]_@[email protected][email protected][email protected][email protected]_@[email protected]": { |
||||
|
"integrity": "sha512-ARuuPRKO5HzN3V0LzmkIGm0e447t5VCy2ijbUnzd08KjcJm3lG221ViC2qI+vTom1zp6yeNZHfJW1LBh1yLrTw==", |
||||
|
"dependencies": [ |
||||
|
"@babel/core", |
||||
|
"@babel/plugin-syntax-jsx", |
||||
|
"@babel/plugin-syntax-typescript", |
||||
|
"@babel/template", |
||||
|
"@babel/traverse", |
||||
|
"@babel/types", |
||||
|
"@tanstack/react-router", |
||||
|
"@tanstack/router-core", |
||||
|
"@tanstack/router-generator", |
||||
|
"@tanstack/router-utils", |
||||
|
"@tanstack/virtual-file-routes", |
||||
|
"@types/babel__core", |
||||
|
"@types/babel__template", |
||||
|
"@types/babel__traverse", |
||||
|
"babel-dead-code-elimination", |
||||
|
"chokidar", |
||||
|
"unplugin", |
||||
|
"vite", |
||||
|
"zod" |
||||
|
], |
||||
|
"optionalPeers": [ |
||||
|
"@tanstack/react-router", |
||||
|
"vite" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected]": { |
||||
|
"integrity": "sha512-Dng4y+uLR9b5zPGg7dHReHOTHQa6x+G6nCoZshsDtWrYsrdCcJEtLyhwZ5wG8OyYS6dVr/Cn+E5Bd2b6BhJ89w==", |
||||
|
"dependencies": [ |
||||
|
"@babel/generator", |
||||
|
"@babel/parser", |
||||
|
"ansis", |
||||
|
"diff" |
||||
|
] |
||||
|
}, |
||||
|
"@tanstack/[email protected]": { |
||||
|
"integrity": "sha512-PjUQKXEXhLYj2X5/6c1Xn/0/qKY0IVFxTJweopRfF26xfjVyb14yALydJrHupDh3/d+1WKmfEgZPBVCmDkzzwg==" |
||||
|
}, |
||||
|
"@tanstack/[email protected]": { |
||||
|
"integrity": "sha512-XLUh1Py3AftcERrxkxC5Y5m5mfllRH3YR6YVlyjFgI2Tc2Ssy2NKmQFQIafoxfW459UJ8Dn81nWKETEIJifE4g==" |
||||
|
}, |
||||
"@testing-library/[email protected]": { |
"@testing-library/[email protected]": { |
||||
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", |
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -3972,6 +4120,9 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" |
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" |
||||
}, |
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==" |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", |
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -4071,6 +4222,15 @@ |
|||||
"possible-typed-array-names" |
"possible-typed-array-names" |
||||
] |
] |
||||
}, |
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==", |
||||
|
"dependencies": [ |
||||
|
"@babel/core", |
||||
|
"@babel/parser", |
||||
|
"@babel/traverse", |
||||
|
"@babel/types" |
||||
|
] |
||||
|
}, |
||||
"[email protected]_@[email protected]": { |
"[email protected]_@[email protected]": { |
||||
"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", |
"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -4559,6 +4719,9 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" |
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" |
||||
}, |
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==" |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", |
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -5018,6 +5181,12 @@ |
|||||
"gopd" |
"gopd" |
||||
] |
] |
||||
}, |
}, |
||||
|
"[email protected][email protected]": { |
||||
|
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", |
||||
|
"dependencies": [ |
||||
|
"csstype" |
||||
|
] |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" |
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" |
||||
}, |
}, |
||||
@ -5937,6 +6106,10 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" |
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" |
||||
}, |
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", |
||||
|
"bin": true |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==" |
"integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==" |
||||
}, |
}, |
||||
@ -6358,6 +6531,15 @@ |
|||||
"randombytes" |
"randombytes" |
||||
] |
] |
||||
}, |
}, |
||||
|
"[email protected][email protected]": { |
||||
|
"integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==", |
||||
|
"dependencies": [ |
||||
|
"seroval" |
||||
|
] |
||||
|
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==" |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", |
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -6465,6 +6647,14 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==" |
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==" |
||||
}, |
}, |
||||
|
"[email protected][email protected]": { |
||||
|
"integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==", |
||||
|
"dependencies": [ |
||||
|
"csstype", |
||||
|
"seroval", |
||||
|
"seroval-plugins" |
||||
|
] |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==" |
"integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==" |
||||
}, |
}, |
||||
@ -6720,6 +6910,12 @@ |
|||||
"setimmediate" |
"setimmediate" |
||||
] |
] |
||||
}, |
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" |
||||
|
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" |
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" |
||||
}, |
}, |
||||
@ -6909,6 +7105,14 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==" |
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==" |
||||
}, |
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==", |
||||
|
"dependencies": [ |
||||
|
"acorn", |
||||
|
"[email protected]", |
||||
|
"webpack-virtual-modules" |
||||
|
] |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==" |
"integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==" |
||||
}, |
}, |
||||
@ -6951,6 +7155,12 @@ |
|||||
"@types/react" |
"@types/react" |
||||
] |
] |
||||
}, |
}, |
||||
|
"[email protected][email protected]": { |
||||
|
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", |
||||
|
"dependencies": [ |
||||
|
"react" |
||||
|
] |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" |
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" |
||||
}, |
}, |
||||
@ -7100,6 +7310,9 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" |
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" |
||||
}, |
}, |
||||
|
"[email protected]": { |
||||
|
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==" |
||||
|
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" |
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" |
||||
}, |
}, |
||||
@ -7390,6 +7603,10 @@ |
|||||
"npm:@radix-ui/react-toggle-group@^1.1.9", |
"npm:@radix-ui/react-toggle-group@^1.1.9", |
||||
"npm:@radix-ui/react-tooltip@^1.2.4", |
"npm:@radix-ui/react-tooltip@^1.2.4", |
||||
"npm:@tailwindcss/postcss@^4.1.5", |
"npm:@tailwindcss/postcss@^4.1.5", |
||||
|
"npm:@tanstack/react-router-devtools@^1.120.16", |
||||
|
"npm:@tanstack/react-router@^1.120.15", |
||||
|
"npm:@tanstack/router-devtools@^1.120.15", |
||||
|
"npm:@tanstack/router-plugin@^1.120.15", |
||||
"npm:@testing-library/jest-dom@^6.6.3", |
"npm:@testing-library/jest-dom@^6.6.3", |
||||
"npm:@testing-library/react@^16.3.0", |
"npm:@testing-library/react@^16.3.0", |
||||
"npm:@testing-library/user-event@^14.6.1", |
"npm:@testing-library/user-event@^14.6.1", |
||||
|
|||||
@ -1,27 +0,0 @@ |
|||||
import MapPage from "@app/pages/Map/index.tsx"; |
|
||||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|
||||
import ChannelsPage from "@pages/Channels.tsx"; |
|
||||
import ConfigPage from "@pages/Config/index.tsx"; |
|
||||
import MessagesPage from "@pages/Messages.tsx"; |
|
||||
import NodesPage from "@pages/Nodes.tsx"; |
|
||||
import { ErrorBoundary } from "react-error-boundary"; |
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx"; |
|
||||
|
|
||||
export const ErrorBoundaryWrapper = ({ |
|
||||
children, |
|
||||
}: { children: React.ReactNode }) => ( |
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary> |
|
||||
); |
|
||||
|
|
||||
export const PageRouter = () => { |
|
||||
const { activePage } = useDevice(); |
|
||||
return ( |
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}> |
|
||||
{activePage === "messages" && <MessagesPage />} |
|
||||
{activePage === "map" && <MapPage />} |
|
||||
{activePage === "config" && <ConfigPage />} |
|
||||
{activePage === "channels" && <ChannelsPage />} |
|
||||
{activePage === "nodes" && <NodesPage />} |
|
||||
</ErrorBoundary> |
|
||||
); |
|
||||
}; |
|
||||
@ -0,0 +1,304 @@ |
|||||
|
import { describe, expect, it, vi } from "vitest"; |
||||
|
import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx"; |
||||
|
import { DynamicForm } from "./DynamicForm.tsx"; |
||||
|
import { z } from "zod/v4"; |
||||
|
import { useAppStore } from "@core/stores/appStore.ts"; |
||||
|
|
||||
|
vi.mock("react-i18next", () => ({ |
||||
|
useTranslation: () => ({ |
||||
|
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key), |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
const addErrorMock = vi.fn(); |
||||
|
const removeErrorMock = vi.fn(); |
||||
|
|
||||
|
vi.mock("@core/stores/appStore.ts", () => ({ |
||||
|
useAppStore: () => ({ |
||||
|
addError: addErrorMock, |
||||
|
removeError: removeErrorMock, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
describe("DynamicForm", () => { |
||||
|
const schema = z.object({ |
||||
|
name: z.string().min(3, { message: "Too short" }), |
||||
|
}); |
||||
|
|
||||
|
const fieldGroups = [ |
||||
|
{ |
||||
|
label: "Test Group", |
||||
|
description: "Testing validation", |
||||
|
fields: [ |
||||
|
{ |
||||
|
type: "text", |
||||
|
id: "name", |
||||
|
name: "name", |
||||
|
label: "Name", |
||||
|
description: "Enter your name", |
||||
|
properties: {}, |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
it("shows validation error when input is too short", async () => { |
||||
|
render( |
||||
|
<DynamicForm<z.infer<typeof schema>> |
||||
|
onSubmit={vi.fn()} |
||||
|
validationSchema={schema} |
||||
|
defaultValues={{ name: "" }} |
||||
|
fieldGroups={fieldGroups} |
||||
|
/>, |
||||
|
); |
||||
|
const input = screen.getByLabelText("Name") as HTMLInputElement; |
||||
|
|
||||
|
fireEvent.input(input, { target: { value: "ab" } }); |
||||
|
|
||||
|
const error = await screen.findByText( |
||||
|
"formValidation.tooSmall.string", |
||||
|
); |
||||
|
expect(error).toBeVisible(); |
||||
|
}); |
||||
|
|
||||
|
it("clears validation error when input becomes valid", async () => { |
||||
|
render( |
||||
|
<DynamicForm<z.infer<typeof schema>> |
||||
|
onSubmit={vi.fn()} |
||||
|
validationSchema={schema} |
||||
|
defaultValues={{ name: "" }} |
||||
|
fieldGroups={fieldGroups} |
||||
|
/>, |
||||
|
); |
||||
|
const input = screen.getByLabelText("Name") as HTMLInputElement; |
||||
|
|
||||
|
fireEvent.input(input, { target: { value: "ab" } }); |
||||
|
expect( |
||||
|
await screen.findByText("formValidation.tooSmall.string"), |
||||
|
).toBeVisible(); |
||||
|
|
||||
|
fireEvent.input(input, { target: { value: "abcd" } }); |
||||
|
await waitFor(() => |
||||
|
expect( |
||||
|
screen.queryByText("formValidation.tooSmall.string"), |
||||
|
).toBeNull() |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("calls onSubmit when form is valid onChange", async () => { |
||||
|
const onSubmit = vi.fn(); |
||||
|
render( |
||||
|
<DynamicForm<z.infer<typeof schema>> |
||||
|
onSubmit={onSubmit} |
||||
|
validationSchema={schema} |
||||
|
defaultValues={{ name: "" }} |
||||
|
fieldGroups={fieldGroups} |
||||
|
/>, |
||||
|
); |
||||
|
|
||||
|
const input = screen.getByLabelText("Name") as HTMLInputElement; |
||||
|
|
||||
|
fireEvent.input(input, { target: { value: "ab" } }); |
||||
|
expect( |
||||
|
await screen.findByText("formValidation.tooSmall.string"), |
||||
|
).toBeVisible(); |
||||
|
|
||||
|
fireEvent.input(input, { target: { value: "abcd" } }); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(onSubmit).toHaveBeenCalledTimes(1); |
||||
|
}); |
||||
|
|
||||
|
expect(onSubmit).toHaveBeenCalledWith( |
||||
|
{ name: "abcd" }, |
||||
|
expect.any(Object), |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => { |
||||
|
const onSubmit = vi.fn(); |
||||
|
render( |
||||
|
<DynamicForm<z.infer<typeof schema>> |
||||
|
onSubmit={onSubmit} |
||||
|
submitType="onSubmit" |
||||
|
hasSubmitButton |
||||
|
validationSchema={schema} |
||||
|
defaultValues={{ name: "" }} |
||||
|
fieldGroups={fieldGroups} |
||||
|
/>, |
||||
|
); |
||||
|
|
||||
|
const btn = screen.getByRole("button", { name: /submit/i }); |
||||
|
expect(btn).toBeInTheDocument(); |
||||
|
|
||||
|
fireEvent.input(screen.getByLabelText("Name"), { target: { value: "ab" } }); |
||||
|
await screen.findByText("formValidation.tooSmall.string"); |
||||
|
fireEvent.click(btn); |
||||
|
expect(onSubmit).not.toHaveBeenCalled(); |
||||
|
|
||||
|
fireEvent.input(screen.getByLabelText("Name"), { |
||||
|
target: { value: "abcd" }, |
||||
|
}); |
||||
|
await waitFor(() => |
||||
|
expect(screen.queryByText("formValidation.tooSmall.string")).toBeNull() |
||||
|
); |
||||
|
fireEvent.click(btn); |
||||
|
|
||||
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); |
||||
|
expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object)); |
||||
|
}); |
||||
|
|
||||
|
it("renders defaultValues correctly", () => { |
||||
|
render( |
||||
|
<DynamicForm<{ name: string }> |
||||
|
onSubmit={vi.fn()} |
||||
|
// no validationSchema
|
||||
|
defaultValues={{ name: "Alice" }} |
||||
|
fieldGroups={[ |
||||
|
{ |
||||
|
label: "Group", |
||||
|
description: "", |
||||
|
fields: [ |
||||
|
{ |
||||
|
type: "text", |
||||
|
name: "name", |
||||
|
label: "Name", |
||||
|
description: "", |
||||
|
properties: {}, |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
]} |
||||
|
/>, |
||||
|
); |
||||
|
const input = screen.getByLabelText("Name") as HTMLInputElement; |
||||
|
expect(input.value).toBe("Alice"); |
||||
|
}); |
||||
|
|
||||
|
it("toggles disabled state based on disabledBy rules", async () => { |
||||
|
const schema = z.object({ |
||||
|
enable: z.boolean(), |
||||
|
follow: z.string(), |
||||
|
}); |
||||
|
render( |
||||
|
<DynamicForm<z.infer<typeof schema>> |
||||
|
onSubmit={vi.fn()} |
||||
|
validationSchema={schema} |
||||
|
defaultValues={{ enable: false, follow: "" }} |
||||
|
fieldGroups={[ |
||||
|
{ |
||||
|
label: "Group", |
||||
|
description: "", |
||||
|
fields: [ |
||||
|
{ |
||||
|
type: "toggle", |
||||
|
name: "enable", |
||||
|
label: "enable", |
||||
|
description: "", |
||||
|
}, |
||||
|
{ |
||||
|
type: "text", |
||||
|
name: "follow", |
||||
|
label: "follow", |
||||
|
description: "", |
||||
|
disabledBy: [{ fieldName: "enable" }], |
||||
|
properties: {}, |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
]} |
||||
|
/>, |
||||
|
); |
||||
|
const enable = screen.getByRole("switch", { |
||||
|
name: "enable", |
||||
|
}) as HTMLInputElement; |
||||
|
|
||||
|
const follow = screen.getByLabelText("follow") as HTMLInputElement; |
||||
|
await waitFor(() => { |
||||
|
expect(enable.getAttribute("aria-checked")).toBe("false"); |
||||
|
expect(follow).toBeDisabled(); |
||||
|
}); |
||||
|
|
||||
|
fireEvent.click(enable); |
||||
|
await waitFor(() => { |
||||
|
expect(enable.getAttribute("aria-checked")).toBe("true"); |
||||
|
expect(follow).not.toBeDisabled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("always calls onSubmit onChange when no validationSchema is provided", async () => { |
||||
|
const onSubmit = vi.fn(); |
||||
|
render( |
||||
|
<DynamicForm<{ foo: string }> |
||||
|
onSubmit={onSubmit} |
||||
|
// no validationSchema
|
||||
|
defaultValues={{ foo: "" }} |
||||
|
fieldGroups={[ |
||||
|
{ |
||||
|
label: "G", |
||||
|
description: "", |
||||
|
fields: [ |
||||
|
{ |
||||
|
type: "text", |
||||
|
name: "foo", |
||||
|
label: "Foo", |
||||
|
description: "", |
||||
|
properties: {}, |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
]} |
||||
|
/>, |
||||
|
); |
||||
|
const input = screen.getByLabelText("Foo") as HTMLInputElement; |
||||
|
fireEvent.input(input, { target: { value: "bar" } }); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(onSubmit).toHaveBeenCalledTimes(1); |
||||
|
expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object)); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("syncs errors to appStore when formId is set", async () => { |
||||
|
const { addError, removeError } = useAppStore(); |
||||
|
const schema = z.object({ foo: z.string().min(2) }); |
||||
|
const groups = [ |
||||
|
{ |
||||
|
label: "G", |
||||
|
description: "", |
||||
|
fields: [ |
||||
|
{ |
||||
|
type: "text", |
||||
|
name: "foo", |
||||
|
label: "Foo", |
||||
|
description: "", |
||||
|
properties: {}, |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
render( |
||||
|
<DynamicForm<z.infer<typeof schema>> |
||||
|
onSubmit={vi.fn()} |
||||
|
formId="myForm" |
||||
|
validationSchema={schema} |
||||
|
defaultValues={{ foo: "" }} |
||||
|
fieldGroups={groups} |
||||
|
/>, |
||||
|
); |
||||
|
const input = screen.getByLabelText("Foo") as HTMLInputElement; |
||||
|
|
||||
|
fireEvent.input(input, { target: { value: "a" } }); |
||||
|
await screen.findByText(/tooSmall/i); |
||||
|
|
||||
|
expect(addError).toHaveBeenCalledWith("foo", ""); |
||||
|
expect(addError).toHaveBeenCalledWith("myForm", ""); |
||||
|
|
||||
|
fireEvent.input(input, { target: { value: "abc" } }); |
||||
|
await waitFor(() => { |
||||
|
expect(removeError).toHaveBeenCalledWith("foo"); |
||||
|
expect(removeError).toHaveBeenCalledWith("myForm"); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,64 @@ |
|||||
|
import type { ZodType } from "zod/v4"; |
||||
|
import type { |
||||
|
FieldError, |
||||
|
FieldValues, |
||||
|
Resolver, |
||||
|
ResolverOptions, |
||||
|
ResolverResult, |
||||
|
} from "react-hook-form"; |
||||
|
|
||||
|
export function createZodResolver<T extends FieldValues>( |
||||
|
schema: ZodType<T, unknown>, |
||||
|
): Resolver<T, unknown> { |
||||
|
return ( |
||||
|
values: T, |
||||
|
_context: unknown, |
||||
|
_options?: ResolverOptions<T>, |
||||
|
): ResolverResult<T> => { |
||||
|
const result = schema.safeParse(values); |
||||
|
if (result.success) { |
||||
|
return { |
||||
|
values: result.data, |
||||
|
errors: {}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const errors: Record< |
||||
|
string, |
||||
|
FieldError & { params?: Record<string, unknown> } |
||||
|
> = {}; |
||||
|
|
||||
|
for (const issue of result.error.issues) { |
||||
|
const { path, code, message, ...params } = issue; |
||||
|
const key = path.join("."); |
||||
|
|
||||
|
const suffix = "format" in params |
||||
|
? params.format |
||||
|
: "origin" in params |
||||
|
? params.origin |
||||
|
: "expected" in params |
||||
|
? params.expected |
||||
|
: ""; |
||||
|
|
||||
|
const newCode = code.replace( |
||||
|
/_([a-z])/g, |
||||
|
(_, char) => char.toUpperCase(), |
||||
|
) + (suffix ? `.${suffix}` : ""); |
||||
|
|
||||
|
const fieldError: FieldError & { params?: Record<string, unknown> } = { |
||||
|
type: newCode, |
||||
|
message: message, |
||||
|
...(Object.keys(params).length ? { params } : {}), |
||||
|
}; |
||||
|
|
||||
|
if (!errors[key]) { |
||||
|
errors[key] = fieldError; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
values: {} as T, |
||||
|
errors, |
||||
|
}; |
||||
|
}; |
||||
|
} |
||||
@ -1,30 +0,0 @@ |
|||||
import type { SecurityAction, SecurityState } from "./types.ts"; |
|
||||
|
|
||||
export function securityReducer( |
|
||||
state: SecurityState, |
|
||||
action: SecurityAction, |
|
||||
): SecurityState { |
|
||||
switch (action.type) { |
|
||||
case "SET_PRIVATE_KEY": |
|
||||
return { ...state, privateKey: action.payload }; |
|
||||
case "SET_PRIVATE_KEY_BIT_COUNT": |
|
||||
return { ...state, privateKeyBitCount: action.payload }; |
|
||||
case "SET_PUBLIC_KEY": |
|
||||
return { ...state, publicKey: action.payload }; |
|
||||
case "SET_ADMIN_KEY": |
|
||||
return { ...state, adminKey: action.payload }; |
|
||||
case "SHOW_PRIVATE_KEY_DIALOG": |
|
||||
return { ...state, privateKeyDialogOpen: action.payload }; |
|
||||
case "REGENERATE_PRIV_PUB_KEY": |
|
||||
return { |
|
||||
...state, |
|
||||
privateKey: action.payload.privateKey, |
|
||||
publicKey: action.payload.publicKey, |
|
||||
privateKeyDialogOpen: false, |
|
||||
}; |
|
||||
case "SET_TOGGLE": |
|
||||
return { ...state, [action.field]: action.payload }; |
|
||||
default: |
|
||||
return state; |
|
||||
} |
|
||||
} |
|
||||
@ -1,35 +0,0 @@ |
|||||
import { type MessageInitShape } from "@bufbuild/protobuf"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
export interface SecurityState { |
|
||||
privateKey: string; |
|
||||
privateKeyVisible: boolean; |
|
||||
adminKeyVisible: [boolean, boolean, boolean]; |
|
||||
privateKeyBitCount: number; |
|
||||
publicKey: string; |
|
||||
adminKey: [string, string, string]; |
|
||||
privateKeyDialogOpen: boolean; |
|
||||
isManaged: boolean; |
|
||||
adminChannelEnabled: boolean; |
|
||||
debugLogApiEnabled: boolean; |
|
||||
serialEnabled: boolean; |
|
||||
} |
|
||||
|
|
||||
export type SecurityAction = |
|
||||
| { type: "SET_PRIVATE_KEY"; payload: string } |
|
||||
| { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number } |
|
||||
| { type: "SET_PUBLIC_KEY"; payload: string } |
|
||||
| { type: "SET_ADMIN_KEY"; payload: [string, string, string] } |
|
||||
| { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean } |
|
||||
| { type: "SET_TOGGLE"; payload: boolean; field: string } |
|
||||
| { |
|
||||
type: "REGENERATE_PRIV_PUB_KEY"; |
|
||||
payload: { privateKey: string; publicKey: string }; |
|
||||
}; |
|
||||
|
|
||||
export type SecurityConfigInit = Extract< |
|
||||
MessageInitShape< |
|
||||
typeof Protobuf.Config.ConfigSchema |
|
||||
>["payloadVariant"], |
|
||||
{ case: "security" } |
|
||||
>["value"]; |
|
||||
@ -0,0 +1,66 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { dotPaths } from "./dotPath.ts"; |
||||
|
|
||||
|
describe("dotPaths", () => { |
||||
|
it("returns flat keys for a simple object", () => { |
||||
|
const obj = { a: 1, b: 2, c: 3 }; |
||||
|
expect(dotPaths(obj)).toEqual(["a", "b", "c"]); |
||||
|
}); |
||||
|
|
||||
|
it("returns dot notation keys for nested objects", () => { |
||||
|
const obj = { a: { b: { c: 1 } }, d: 2 }; |
||||
|
expect(dotPaths(obj)).toEqual(["a.b.c", "d"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles arrays at the root", () => { |
||||
|
const arr = [{ x: 1 }, { y: 2 }]; |
||||
|
expect(dotPaths(arr)).toEqual(["0.x", "1.y"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles arrays nested in objects", () => { |
||||
|
const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 }; |
||||
|
expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles objects nested in arrays", () => { |
||||
|
const arr = [{ a: { b: 1 } }, { c: 2 }]; |
||||
|
expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles primitive values in arrays", () => { |
||||
|
const arr = [1, { a: 2 }, 3]; |
||||
|
expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles empty objects and arrays", () => { |
||||
|
expect(dotPaths({})).toEqual([]); |
||||
|
expect(dotPaths([])).toEqual([]); |
||||
|
}); |
||||
|
|
||||
|
it("handles mixed nested structures", () => { |
||||
|
const obj = { |
||||
|
a: [ |
||||
|
{ b: 1, c: [2, 3] }, |
||||
|
{ d: { e: 4 } }, |
||||
|
], |
||||
|
f: 5, |
||||
|
}; |
||||
|
expect(dotPaths(obj)).toEqual([ |
||||
|
"a.0.b", |
||||
|
"a.0.c.0", |
||||
|
"a.0.c.1", |
||||
|
"a.1.d.e", |
||||
|
"f", |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it("handles prefix argument", () => { |
||||
|
const obj = { a: { b: 1 } }; |
||||
|
expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]); |
||||
|
}); |
||||
|
|
||||
|
it("skips null and undefined values", () => { |
||||
|
const obj = { a: null, b: undefined, c: { d: 1 } }; |
||||
|
expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,19 @@ |
|||||
|
export type DotPath = { [key: string]: unknown } | unknown[]; |
||||
|
|
||||
|
export const dotPaths = <T extends DotPath>( |
||||
|
obj: T, |
||||
|
prefix = "", |
||||
|
): string[] => { |
||||
|
if (Array.isArray(obj)) { |
||||
|
return obj.flatMap((v, i) => |
||||
|
v && typeof v === "object" |
||||
|
? dotPaths(v as DotPath, `${prefix}${i}.`) |
||||
|
: [`${prefix}${i}`] |
||||
|
); |
||||
|
} |
||||
|
return Object.entries(obj).flatMap(([k, v]) => |
||||
|
v && typeof v === "object" |
||||
|
? dotPaths(v as DotPath, `${prefix}${k}.`) |
||||
|
: [`${prefix}${k}`] |
||||
|
); |
||||
|
}; |
||||
@ -1,12 +1,80 @@ |
|||||
import { render } from "@testing-library/react"; |
import { |
||||
import type { ReactElement } from "react"; |
createMemoryHistory, |
||||
|
createRouter, |
||||
|
Outlet, |
||||
|
RootRoute, |
||||
|
Route, |
||||
|
RouterProvider, |
||||
|
} from "@tanstack/react-router"; |
||||
|
import { render as rtlRender, RenderOptions } from "@testing-library/react"; |
||||
|
import type { FunctionComponent, ReactElement, ReactNode } from "react"; |
||||
|
|
||||
function customRender(ui: ReactElement, options = {}) { |
// a root route for the test router.
|
||||
return render(ui, { |
const rootRoute = new RootRoute({ |
||||
// wrapper: ({ children }) => <MapProvider>{children}</MapProvider>,
|
component: () => ( |
||||
...options, |
<> |
||||
}); |
<Outlet /> |
||||
|
</> |
||||
|
), |
||||
|
}); |
||||
|
|
||||
|
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> { |
||||
|
initialEntries?: string[]; |
||||
|
ui?: ReactElement; |
||||
} |
} |
||||
|
|
||||
|
let currentRouter: ReturnType<typeof createRouter> | null = null; |
||||
|
|
||||
|
/** |
||||
|
* Custom render function for testing components that need TanStack Router context. |
||||
|
* @param ui The main ReactElement to render (your component under test). |
||||
|
* @param options Custom render options including initialEntries for the router. |
||||
|
* @returns An object containing the testing-library render result and the router instance. |
||||
|
*/ |
||||
|
const customRender = ( |
||||
|
ui: ReactElement, |
||||
|
options: CustomRenderOptions = {}, |
||||
|
) => { |
||||
|
const { initialEntries = ["/"], ...renderOptions } = options; |
||||
|
|
||||
|
// A specific route that renders the component under test (ui).
|
||||
|
// It defaults to the first path in initialEntries or '/'.
|
||||
|
const testComponentRoute = new Route({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: initialEntries[0] || "/", |
||||
|
component: () => ui, // The component passed to render will be the element for this route
|
||||
|
}); |
||||
|
|
||||
|
const routeTree = rootRoute.addChildren([testComponentRoute]); |
||||
|
|
||||
|
const router = createRouter({ |
||||
|
history: createMemoryHistory({ initialEntries }), |
||||
|
routeTree, |
||||
|
// You can add default error components or other router options if needed for tests.
|
||||
|
// defaultErrorComponent: ({ error }) => <div>Test Error: {error.message}</div>,
|
||||
|
}); |
||||
|
|
||||
|
currentRouter = router; // Store the router instance for access in tests
|
||||
|
|
||||
|
const Wrapper: FunctionComponent<{ children?: ReactNode }> = ( |
||||
|
{ children }, |
||||
|
) => { |
||||
|
return ( |
||||
|
<> |
||||
|
<RouterProvider router={router} /> |
||||
|
{children} |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); |
||||
|
|
||||
|
return { |
||||
|
...renderResult, |
||||
|
router, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
export * from "@testing-library/react"; |
export * from "@testing-library/react"; |
||||
export { customRender as render }; |
export { customRender as render }; |
||||
|
export const getTestRouter = () => currentRouter; |
||||
|
|||||
@ -0,0 +1,59 @@ |
|||||
|
/* eslint-disable */ |
||||
|
|
||||
|
// @ts-nocheck
|
||||
|
|
||||
|
// noinspection JSUnusedGlobalSymbols
|
||||
|
|
||||
|
// This file was automatically generated by TanStack Router.
|
||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
|
||||
|
// Import Routes
|
||||
|
|
||||
|
import { Route as rootRoute } from './routes/__root' |
||||
|
|
||||
|
// Create/Update Routes
|
||||
|
|
||||
|
// Populate the FileRoutesByPath interface
|
||||
|
|
||||
|
declare module '@tanstack/react-router' { |
||||
|
interface FileRoutesByPath {} |
||||
|
} |
||||
|
|
||||
|
// Create and export the route tree
|
||||
|
|
||||
|
export interface FileRoutesByFullPath {} |
||||
|
|
||||
|
export interface FileRoutesByTo {} |
||||
|
|
||||
|
export interface FileRoutesById { |
||||
|
__root__: typeof rootRoute |
||||
|
} |
||||
|
|
||||
|
export interface FileRouteTypes { |
||||
|
fileRoutesByFullPath: FileRoutesByFullPath |
||||
|
fullPaths: never |
||||
|
fileRoutesByTo: FileRoutesByTo |
||||
|
to: never |
||||
|
id: '__root__' |
||||
|
fileRoutesById: FileRoutesById |
||||
|
} |
||||
|
|
||||
|
export interface RootRouteChildren {} |
||||
|
|
||||
|
const rootRouteChildren: RootRouteChildren = {} |
||||
|
|
||||
|
export const routeTree = rootRoute |
||||
|
._addFileChildren(rootRouteChildren) |
||||
|
._addFileTypes<FileRouteTypes>() |
||||
|
|
||||
|
/* ROUTE_MANIFEST_START |
||||
|
{ |
||||
|
"routes": { |
||||
|
"__root__": { |
||||
|
"filePath": "__root.tsx", |
||||
|
"children": [] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
ROUTE_MANIFEST_END */ |
||||
@ -0,0 +1,79 @@ |
|||||
|
import { createRoute, redirect } from "@tanstack/react-router"; |
||||
|
import { Dashboard } from "@pages/Dashboard/index.tsx"; |
||||
|
import MessagesPage from "@pages/Messages.tsx"; |
||||
|
import MapPage from "@pages/Map/index.tsx"; |
||||
|
import ConfigPage from "@pages/Config/index.tsx"; |
||||
|
import ChannelsPage from "@pages/Channels.tsx"; |
||||
|
import NodesPage from "@pages/Nodes.tsx"; |
||||
|
import { createRootRoute } from "@tanstack/react-router"; |
||||
|
import { App } from "./App.tsx"; |
||||
|
import { DialogManager } from "@components/Dialog/DialogManager.tsx"; |
||||
|
|
||||
|
const rootRoute = createRootRoute({ |
||||
|
component: App, |
||||
|
}); |
||||
|
|
||||
|
const indexRoute = createRoute({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: "/", |
||||
|
component: Dashboard, |
||||
|
loader: () => { |
||||
|
// Redirect to the broadcast messages page on initial load
|
||||
|
return redirect({ to: `/messages/broadcast/0`, replace: true }); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const messagesRoute = createRoute({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: "/messages", |
||||
|
component: MessagesPage, |
||||
|
}); |
||||
|
|
||||
|
const messagesWithParamsRoute = createRoute({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: "/messages/$type/$chatId", |
||||
|
component: MessagesPage, |
||||
|
}); |
||||
|
|
||||
|
const mapRoute = createRoute({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: "/map", |
||||
|
component: MapPage, |
||||
|
}); |
||||
|
|
||||
|
const configRoute = createRoute({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: "/config", |
||||
|
component: ConfigPage, |
||||
|
}); |
||||
|
|
||||
|
const channelsRoute = createRoute({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: "/channels", |
||||
|
component: ChannelsPage, |
||||
|
}); |
||||
|
|
||||
|
const nodesRoute = createRoute({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: "/nodes", |
||||
|
component: NodesPage, |
||||
|
}); |
||||
|
|
||||
|
const dialogWithParamsRoute = createRoute({ |
||||
|
getParentRoute: () => rootRoute, |
||||
|
path: "/dialog/$dialogId", |
||||
|
component: DialogManager, |
||||
|
}); |
||||
|
|
||||
|
export const routeTree = rootRoute.addChildren([ |
||||
|
indexRoute, |
||||
|
messagesRoute, |
||||
|
messagesWithParamsRoute, |
||||
|
mapRoute, |
||||
|
configRoute, |
||||
|
channelsRoute, |
||||
|
nodesRoute, |
||||
|
dialogWithParamsRoute, |
||||
|
]); |
||||
|
|
||||
|
export { rootRoute }; |
||||
@ -0,0 +1,141 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { makeChannelSchema } from "./channel.ts"; |
||||
|
import { fromByteArray } from "base64-js"; |
||||
|
|
||||
|
const mockRole = 0; |
||||
|
|
||||
|
function makeBase64OfLength(len: number): string { |
||||
|
return fromByteArray(new Uint8Array(len)); |
||||
|
} |
||||
|
|
||||
|
describe("makeChannelSchema", () => { |
||||
|
const allowedBytes = 16; |
||||
|
const schema = makeChannelSchema(allowedBytes); |
||||
|
|
||||
|
const validBase64 = makeBase64OfLength(allowedBytes); |
||||
|
|
||||
|
const validSettings = { |
||||
|
channelNum: 3, |
||||
|
psk: validBase64, |
||||
|
name: "TestName", |
||||
|
id: 3, |
||||
|
uplinkEnabled: true, |
||||
|
downlinkEnabled: false, |
||||
|
moduleSettings: { positionPrecision: 10 }, |
||||
|
}; |
||||
|
|
||||
|
it("accepts valid channel object", () => { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: validSettings, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects invalid base64 psk", () => { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { ...validSettings, psk: "not_base64!" }, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.path.includes("settings") && i.path.includes("psk") |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects psk of wrong length", () => { |
||||
|
const wrongLength = makeBase64OfLength(8); |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { ...validSettings, psk: wrongLength }, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.path.includes("settings") && i.path.includes("psk") |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects name longer than 12 bytes", () => { |
||||
|
const longName = "a".repeat(13); |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { ...validSettings, name: longName }, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.path.includes("settings") && i.path.includes("name") |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects channelNum out of range", () => { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { ...validSettings, channelNum: 10 }, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.path.includes("settings") && i.path.includes("channelNum") |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects missing required fields", () => { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: {}, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues.length).toBeGreaterThan(0); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => { |
||||
|
for (const val of [0, 10, 15, 19, 32]) { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { |
||||
|
...validSettings, |
||||
|
moduleSettings: { positionPrecision: val }, |
||||
|
}, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects moduleSettings.positionPrecision out of range", () => { |
||||
|
for (const val of [9, 20, 31, 33]) { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { |
||||
|
...validSettings, |
||||
|
moduleSettings: { positionPrecision: val }, |
||||
|
}, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
@ -1,51 +1,38 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { |
import { makePskHelpers } from "./pskSchema.ts"; |
||||
IsBoolean, |
import { validateMaxByteLength } from "@core/utils/string.ts"; |
||||
IsEnum, |
|
||||
IsInt, |
const RoleEnum = z.enum(Protobuf.Channel.Channel_Role); |
||||
IsNumber, |
|
||||
IsString, |
const moduleSettingsSchema = z.object({ |
||||
Length, |
positionPrecision: z.union([ |
||||
} from "class-validator"; |
z.literal(0), |
||||
|
z.coerce.number().int().min(10).max(19), |
||||
export class ChannelValidation |
z.literal(32), |
||||
implements Omit<Protobuf.Channel.Channel, keyof Message | "settings"> { |
]), |
||||
@IsNumber() |
}); |
||||
index: number; |
|
||||
|
export function makeChannelSchema(allowedBytes: number) { |
||||
settings: Channel_SettingsValidation; |
const { stringSchema } = makePskHelpers([allowedBytes]); |
||||
|
|
||||
@IsEnum(Protobuf.Channel.Channel_Role) |
const ChannelSettingsSchema = z.object({ |
||||
role: Protobuf.Channel.Channel_Role; |
channelNum: z.coerce.number().int().min(0).max(7), |
||||
} |
psk: stringSchema(false), |
||||
|
name: z.string() |
||||
export class Channel_SettingsValidation |
.refine( |
||||
implements Omit<Protobuf.Channel.ChannelSettings, keyof Message | "psk"> { |
(s) => validateMaxByteLength(s, 12).isValid, |
||||
@IsNumber() |
{ message: "formValidation.tooBig.bytes", params: { maximum: 12 } }, |
||||
channelNum: number; |
), |
||||
|
id: z.coerce.number().int(), |
||||
@IsString() |
uplinkEnabled: z.boolean(), |
||||
psk: string; |
downlinkEnabled: z.boolean(), |
||||
|
moduleSettings: moduleSettingsSchema, |
||||
@Length(0, 11) |
}); |
||||
name: string; |
|
||||
|
return z.object({ |
||||
@IsInt() |
index: z.coerce.number(), |
||||
id: number; |
settings: ChannelSettingsSchema, |
||||
|
role: RoleEnum, |
||||
@IsBoolean() |
}); |
||||
uplinkEnabled: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
downlinkEnabled: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
positionEnabled: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
preciseLocation: boolean; |
|
||||
|
|
||||
@IsInt() |
|
||||
positionPrecision: number; |
|
||||
} |
} |
||||
|
|||||
@ -1,18 +1,14 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|
||||
|
|
||||
export class BluetoothValidation implements |
const PairingModeEnum = z.enum( |
||||
Omit< |
Protobuf.Config.Config_BluetoothConfig_PairingMode, |
||||
Protobuf.Config.Config_BluetoothConfig, |
); |
||||
keyof Message | "deviceLoggingEnabled" |
|
||||
> { |
|
||||
@IsBoolean() |
|
||||
enabled: boolean; |
|
||||
|
|
||||
@IsEnum(Protobuf.Config.Config_BluetoothConfig_PairingMode) |
export const BluetoothValidationSchema = z.object({ |
||||
mode: Protobuf.Config.Config_BluetoothConfig_PairingMode; |
enabled: z.boolean(), |
||||
|
mode: PairingModeEnum, |
||||
|
fixedPin: z.coerce.number().int().min(100000).max(999999), |
||||
|
}); |
||||
|
|
||||
@IsInt() |
export type BluetoothValidation = z.infer<typeof BluetoothValidationSchema>; |
||||
fixedPin: number; |
|
||||
} |
|
||||
|
|||||
@ -1,42 +1,25 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator"; |
|
||||
|
|
||||
export class DeviceValidation |
const RoleEnum = z.enum( |
||||
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message> { |
Protobuf.Config.Config_DeviceConfig_Role, |
||||
@IsEnum(Protobuf.Config.Config_DeviceConfig_Role) |
); |
||||
role: Protobuf.Config.Config_DeviceConfig_Role; |
const RebroadcastModeEnum = z.enum( |
||||
|
Protobuf.Config.Config_DeviceConfig_RebroadcastMode, |
||||
@IsBoolean() |
); |
||||
serialEnabled: boolean; |
|
||||
|
export const DeviceValidationSchema = z.object({ |
||||
@IsBoolean() |
role: RoleEnum, |
||||
debugLogEnabled: boolean; |
serialEnabled: z.boolean(), |
||||
|
buttonGpio: z.coerce.number().int().min(0), |
||||
@IsInt() |
buzzerGpio: z.coerce.number().int().min(0), |
||||
buttonGpio: number; |
rebroadcastMode: RebroadcastModeEnum, |
||||
|
nodeInfoBroadcastSecs: z.coerce.number().int().min(0), |
||||
@IsInt() |
doubleTapAsButtonPress: z.boolean(), |
||||
buzzerGpio: number; |
isManaged: z.boolean(), |
||||
|
disableTripleClick: z.boolean(), |
||||
@IsEnum(Protobuf.Config.Config_DeviceConfig_RebroadcastMode) |
ledHeartbeatDisabled: z.boolean(), |
||||
rebroadcastMode: Protobuf.Config.Config_DeviceConfig_RebroadcastMode; |
tzdef: z.string().max(65), |
||||
|
}); |
||||
@IsInt() |
|
||||
nodeInfoBroadcastSecs: number; |
export type DeviceValidation = z.infer<typeof DeviceValidationSchema>; |
||||
|
|
||||
@IsBoolean() |
|
||||
doubleTapAsButtonPress: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
isManaged: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
disableTripleClick: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
ledHeartbeatDisabled: boolean; |
|
||||
|
|
||||
@IsString() |
|
||||
tzdef: string; |
|
||||
} |
|
||||
|
|||||
@ -1,39 +1,35 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|
||||
|
|
||||
export class DisplayValidation |
const GpsCoordinateEnum = z.enum( |
||||
implements Omit<Protobuf.Config.Config_DisplayConfig, keyof Message> { |
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat, |
||||
@IsInt() |
); |
||||
screenOnSecs: number; |
const DisplayUnitsEnum = z.enum( |
||||
|
Protobuf.Config.Config_DisplayConfig_DisplayUnits, |
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat) |
); |
||||
gpsFormat: Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat; |
const OledTypeEnum = z.enum( |
||||
|
Protobuf.Config.Config_DisplayConfig_OledType, |
||||
@IsInt() |
); |
||||
autoScreenCarouselSecs: number; |
const DisplayModeEnum = z.enum( |
||||
|
Protobuf.Config.Config_DisplayConfig_DisplayMode, |
||||
@IsBoolean() |
); |
||||
compassNorthTop: boolean; |
const CompassOrientationEnum = z.enum( |
||||
|
Protobuf.Config.Config_DisplayConfig_CompassOrientation, |
||||
@IsBoolean() |
); |
||||
flipScreen: boolean; |
|
||||
|
export const DisplayValidationSchema = z.object({ |
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayUnits) |
screenOnSecs: z.coerce.number().int().min(0), |
||||
units: Protobuf.Config.Config_DisplayConfig_DisplayUnits; |
gpsFormat: GpsCoordinateEnum, |
||||
|
autoScreenCarouselSecs: z.coerce.number().int().min(0), |
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_OledType) |
compassNorthTop: z.boolean(), |
||||
oled: Protobuf.Config.Config_DisplayConfig_OledType; |
flipScreen: z.boolean(), |
||||
|
units: DisplayUnitsEnum, |
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayMode) |
oled: OledTypeEnum, |
||||
displaymode: Protobuf.Config.Config_DisplayConfig_DisplayMode; |
displaymode: DisplayModeEnum, |
||||
|
headingBold: z.boolean(), |
||||
@IsBoolean() |
wakeOnTapOrMotion: z.boolean(), |
||||
headingBold: boolean; |
compassOrientation: CompassOrientationEnum, |
||||
|
use12hClock: z.boolean(), |
||||
@IsBoolean() |
}); |
||||
wakeOnTapOrMotion: boolean; |
|
||||
|
export type DisplayValidation = z.infer<typeof DisplayValidationSchema>; |
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation) |
|
||||
compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation; |
|
||||
} |
|
||||
|
|||||
@ -1,65 +1,31 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator"; |
|
||||
|
|
||||
export class LoRaValidation |
const ModemPresetEnum = z.enum( |
||||
implements |
Protobuf.Config.Config_LoRaConfig_ModemPreset, |
||||
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled"> { |
); |
||||
@IsBoolean() |
const RegionCodeEnum = z.enum( |
||||
usePreset: boolean; |
Protobuf.Config.Config_LoRaConfig_RegionCode, |
||||
|
); |
||||
@IsEnum(Protobuf.Config.Config_LoRaConfig_ModemPreset) |
|
||||
modemPreset: Protobuf.Config.Config_LoRaConfig_ModemPreset; |
export const LoRaValidationSchema = z.object({ |
||||
|
usePreset: z.boolean(), |
||||
@IsInt() |
modemPreset: ModemPresetEnum, |
||||
bandwidth: number; |
bandwidth: z.coerce.number().int(), |
||||
|
spreadFactor: z.coerce.number().int().max(12), |
||||
@IsInt() |
codingRate: z.coerce.number().int().min(0).max(10), |
||||
// @Min(7)
|
frequencyOffset: z.coerce.number().int(), |
||||
@Max(12) |
region: RegionCodeEnum, |
||||
spreadFactor: number; |
hopLimit: z.coerce.number().int().min(0).max(7), |
||||
|
txEnabled: z.boolean(), |
||||
@IsInt() |
txPower: z.coerce.number().int().min(0), |
||||
@Min(0) |
channelNum: z.coerce.number().int(), |
||||
@Max(10) |
overrideDutyCycle: z.boolean(), |
||||
codingRate: number; |
sx126xRxBoostedGain: z.boolean(), |
||||
|
overrideFrequency: z.coerce.number().int(), |
||||
@IsInt() |
ignoreIncoming: z.coerce.number().array(), |
||||
frequencyOffset: number; |
ignoreMqtt: z.boolean(), |
||||
|
configOkToMqtt: z.boolean(), |
||||
@IsEnum(Protobuf.Config.Config_LoRaConfig_RegionCode) |
}); |
||||
region: Protobuf.Config.Config_LoRaConfig_RegionCode; |
|
||||
|
export type LoRaValidation = z.infer<typeof LoRaValidationSchema>; |
||||
@IsInt() |
|
||||
@Min(1) |
|
||||
@Max(7) |
|
||||
hopLimit: number; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
txEnabled: boolean; |
|
||||
|
|
||||
@IsInt() |
|
||||
@Min(0) |
|
||||
txPower: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
channelNum: number; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
overrideDutyCycle: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
sx126xRxBoostedGain: boolean; |
|
||||
|
|
||||
@IsInt() |
|
||||
overrideFrequency: number; |
|
||||
|
|
||||
@IsArray() |
|
||||
ignoreIncoming: number[]; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
ignoreMqtt: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
configOkToMqtt: boolean; |
|
||||
} |
|
||||
|
|||||
@ -1,30 +1,30 @@ |
|||||
import { z } from "zod"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
|
|
||||
const AddressModeEnum = z.nativeEnum( |
const AddressModeEnum = z.enum( |
||||
Protobuf.Config.Config_NetworkConfig_AddressMode, |
Protobuf.Config.Config_NetworkConfig_AddressMode, |
||||
); |
); |
||||
const ProtocolFlagsEnum = z.nativeEnum( |
const ProtocolFlagsEnum = z.enum( |
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags, |
Protobuf.Config.Config_NetworkConfig_ProtocolFlags, |
||||
); |
); |
||||
|
|
||||
export const NetworkValidationIpV4ConfigSchema = z.object({ |
export const NetworkValidationIpV4ConfigSchema = z.object({ |
||||
ip: z.string().ip(), |
ip: z.ipv4(), |
||||
gateway: z.string().ip(), |
gateway: z.ipv4(), |
||||
subnet: z.string().ip(), |
subnet: z.ipv4(), |
||||
dns: z.string().ip(), |
dns: z.ipv4(), |
||||
}); |
}); |
||||
|
|
||||
export const NetworkValidationSchema = z.object({ |
export const NetworkValidationSchema = z.object({ |
||||
wifiEnabled: z.boolean(), |
wifiEnabled: z.boolean(), |
||||
wifiSsid: z.string().min(0).max(33).optional(), |
wifiSsid: z.string().max(33), |
||||
wifiPsk: z.string().min(0).max(64).optional(), |
wifiPsk: z.string().max(64), |
||||
ntpServer: z.string().min(2).max(30), |
ntpServer: z.string().min(2).max(33), |
||||
ethEnabled: z.boolean(), |
ethEnabled: z.boolean(), |
||||
addressMode: AddressModeEnum, |
addressMode: AddressModeEnum, |
||||
ipv4Config: NetworkValidationIpV4ConfigSchema.optional(), |
ipv4Config: NetworkValidationIpV4ConfigSchema, |
||||
enabledProtocols: ProtocolFlagsEnum, |
enabledProtocols: ProtocolFlagsEnum, |
||||
rsyslogServer: z.string(), |
rsyslogServer: z.string().max(33), |
||||
}); |
}); |
||||
|
|
||||
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>; |
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>; |
||||
|
|||||
@ -1,44 +1,22 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|
||||
|
|
||||
const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"]; |
const GpsModeEnum = z.enum( |
||||
|
Protobuf.Config.Config_PositionConfig_GpsMode, |
||||
export class PositionValidation implements |
); |
||||
Omit< |
|
||||
Protobuf.Config.Config_PositionConfig, |
export const PositionValidationSchema = z.object({ |
||||
keyof Message | (typeof DeprecatedPositionValidationFields)[number] |
positionBroadcastSecs: z.coerce.number().int().min(0), |
||||
> { |
positionBroadcastSmartEnabled: z.boolean(), |
||||
@IsInt() |
fixedPosition: z.boolean(), |
||||
positionBroadcastSecs: number; |
gpsUpdateInterval: z.coerce.number().int().min(0), |
||||
|
positionFlags: z.coerce.number().int().min(0), |
||||
@IsBoolean() |
rxGpio: z.coerce.number().int().min(0), |
||||
positionBroadcastSmartEnabled: boolean; |
txGpio: z.coerce.number().int().min(0), |
||||
|
broadcastSmartMinimumDistance: z.coerce.number().int().min(0), |
||||
@IsBoolean() |
broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0), |
||||
fixedPosition: boolean; |
gpsEnGpio: z.coerce.number().int().min(0), |
||||
|
gpsMode: GpsModeEnum, |
||||
@IsInt() |
}); |
||||
gpsUpdateInterval: number; |
|
||||
|
export type PositionValidation = z.infer<typeof PositionValidationSchema>; |
||||
@IsInt() |
|
||||
positionFlags: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
rxGpio: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
txGpio: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
broadcastSmartMinimumDistance: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
broadcastSmartMinimumIntervalSecs: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
gpsEnGpio: number; |
|
||||
|
|
||||
@IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode) |
|
||||
gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode; |
|
||||
} |
|
||||
|
|||||
@ -1,35 +1,14 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator"; |
export const PowerValidationSchema = z.object({ |
||||
|
isPowerSaving: z.boolean(), |
||||
export class PowerValidation implements |
onBatteryShutdownAfterSecs: z.coerce.number().int().min(0), |
||||
Omit< |
adcMultiplierOverride: z.coerce.number().min(0).max(4), |
||||
Protobuf.Config.Config_PowerConfig, |
waitBluetoothSecs: z.coerce.number().int().min(0), |
||||
keyof Message | "powermonEnables" |
sdsSecs: z.coerce.number().int().min(0), |
||||
> { |
lsSecs: z.coerce.number().int().min(0), |
||||
@IsBoolean() |
minWakeSecs: z.coerce.number().int().min(0), |
||||
isPowerSaving: boolean; |
deviceBatteryInaAddress: z.coerce.number().int().min(0), |
||||
|
}); |
||||
@IsInt() |
|
||||
onBatteryShutdownAfterSecs: number; |
export type PowerValidation = z.infer<typeof PowerValidationSchema>; |
||||
|
|
||||
@IsNumber() |
|
||||
@Min(2) |
|
||||
@Max(4) |
|
||||
adcMultiplierOverride: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
waitBluetoothSecs: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
sdsSecs: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
lsSecs: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
minWakeSecs: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
deviceBatteryInaAddress: number; |
|
||||
} |
|
||||
|
|||||
@ -0,0 +1,113 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { fromByteArray } from "base64-js"; |
||||
|
import { ParsedSecuritySchema, RawSecuritySchema } from "./security.ts"; |
||||
|
|
||||
|
function makeBase64OfLength(len: number): string { |
||||
|
return fromByteArray(new Uint8Array(len)); |
||||
|
} |
||||
|
|
||||
|
describe("RawSecuritySchema", () => { |
||||
|
const validKey = makeBase64OfLength(32); |
||||
|
|
||||
|
it("accepts valid security config", () => { |
||||
|
const result = RawSecuritySchema.safeParse({ |
||||
|
isManaged: false, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: [validKey, "", ""], |
||||
|
}); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects if privateKey is invalid", () => { |
||||
|
const result = RawSecuritySchema.safeParse({ |
||||
|
isManaged: false, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: "badkey", |
||||
|
publicKey: validKey, |
||||
|
adminKey: [validKey, "", ""], |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues.some((i) => i.path.includes("privateKey"))) |
||||
|
.toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("requires at least one adminKey if isManaged", () => { |
||||
|
const result = RawSecuritySchema.safeParse({ |
||||
|
isManaged: true, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: ["", "", ""], |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.message === "formValidation.adminKeyRequiredWhenManaged" |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts if at least one adminKey is valid when isManaged", () => { |
||||
|
const result = RawSecuritySchema.safeParse({ |
||||
|
isManaged: true, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: [validKey, "", ""], |
||||
|
}); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("ParsedSecuritySchema", () => { |
||||
|
const validKey = new Uint8Array(32); |
||||
|
|
||||
|
it("accepts valid parsed security config", () => { |
||||
|
const result = ParsedSecuritySchema.safeParse({ |
||||
|
isManaged: false, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: [validKey, new Uint8Array(), new Uint8Array()], |
||||
|
}); |
||||
|
console.log(result); |
||||
|
|
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("requires at least one adminKey if isManaged", () => { |
||||
|
const result = ParsedSecuritySchema.safeParse({ |
||||
|
isManaged: true, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: [new Uint8Array(), new Uint8Array(), new Uint8Array()], |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.message === "formValidation.adminKeyRequiredWhenManaged" |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
@ -1,36 +1,47 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z, ZodType } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
import { makePskHelpers } from "./../pskSchema.ts"; |
||||
import { IsBoolean, IsString } from "class-validator"; |
|
||||
|
const { |
||||
export class SecurityValidation implements |
stringSchema, |
||||
Omit< |
bytesSchema, |
||||
Protobuf.Config.Config_SecurityConfig, |
isValidKey, |
||||
| keyof Message |
} = makePskHelpers([32]); // 256-bit
|
||||
| "adminKey" |
|
||||
| "privateKey" |
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged"; |
||||
| "publicKey" |
|
||||
> { |
function makeSecuritySchema<KeyT>( |
||||
@IsBoolean() |
keyMaker: (optional: boolean) => ZodType<KeyT>, |
||||
adminChannelEnabled: boolean; |
) { |
||||
|
return z |
||||
@IsString() |
.object({ |
||||
adminKey: [string, string, string]; |
isManaged: z.boolean(), |
||||
|
adminChannelEnabled: z.boolean(), |
||||
@IsBoolean() |
debugLogApiEnabled: z.boolean(), |
||||
bluetoothLoggingEnabled: boolean; |
serialEnabled: z.boolean(), |
||||
|
|
||||
@IsBoolean() |
privateKey: keyMaker(false), |
||||
debugLogApiEnabled: boolean; |
publicKey: keyMaker(false), |
||||
|
adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]), |
||||
@IsBoolean() |
}) |
||||
isManaged: boolean; |
.check((ctx) => { |
||||
|
if (ctx.value.isManaged) { |
||||
@IsString() |
const hasAdmin = ctx.value.adminKey.some(isValidKey); |
||||
privateKey: string; |
if (!hasAdmin) { |
||||
|
for (const path of [["isManaged"], ["adminKey", 0]] as const) { |
||||
|
ctx.issues.push({ |
||||
|
code: "custom", |
||||
|
message: isManagedRequiredMsg, |
||||
|
path: [...path], |
||||
|
input: ctx.value, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
@IsString() |
export const RawSecuritySchema = makeSecuritySchema(stringSchema); |
||||
publicKey: string; |
export type RawSecurity = z.infer<typeof RawSecuritySchema>; |
||||
|
|
||||
@IsBoolean() |
export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema); |
||||
serialEnabled: boolean; |
export type ParsedSecurity = z.infer<typeof ParsedSecuritySchema>; |
||||
} |
|
||||
|
|||||
@ -1,24 +1,13 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { IsBoolean, IsInt } from "class-validator"; |
export const AmbientLightingValidationSchema = z.object({ |
||||
|
ledState: z.boolean(), |
||||
export class AmbientLightingValidation implements |
current: z.coerce.number().int().min(0), |
||||
Omit< |
red: z.coerce.number().int().min(0).max(255), |
||||
Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig, |
green: z.coerce.number().int().min(0).max(255), |
||||
keyof Message |
blue: z.coerce.number().int().min(0).max(255), |
||||
> { |
}); |
||||
@IsBoolean() |
|
||||
ledState: boolean; |
export type AmbientLightingValidation = z.infer< |
||||
|
typeof AmbientLightingValidationSchema |
||||
@IsInt() |
>; |
||||
current: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
red: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
green: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
blue: number; |
|
||||
} |
|
||||
|
|||||
@ -1,28 +1,18 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|
||||
|
|
||||
export class AudioValidation |
const Audio_BaudEnum = z.enum( |
||||
implements |
Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud, |
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_AudioConfig, keyof Message> { |
); |
||||
@IsBoolean() |
|
||||
codec2Enabled: boolean; |
|
||||
|
|
||||
@IsInt() |
export const AudioValidationSchema = z.object({ |
||||
pttPin: number; |
codec2Enabled: z.boolean(), |
||||
|
pttPin: z.coerce.number().int().min(0), |
||||
|
bitrate: Audio_BaudEnum, |
||||
|
i2sWs: z.coerce.number().int().min(0), |
||||
|
i2sSd: z.coerce.number().int().min(0), |
||||
|
i2sDin: z.coerce.number().int().min(0), |
||||
|
i2sSck: z.coerce.number().int().min(0), |
||||
|
}); |
||||
|
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud) |
export type AudioValidation = z.infer<typeof AudioValidationSchema>; |
||||
bitrate: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud; |
|
||||
|
|
||||
@IsInt() |
|
||||
i2sWs: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
i2sSd: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
i2sDin: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
i2sSck: number; |
|
||||
} |
|
||||
|
|||||
@ -1,45 +1,24 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsBoolean, IsEnum, IsInt, Length } from "class-validator"; |
|
||||
|
|
||||
export class CannedMessageValidation implements |
const InputEventCharEnum = z.enum( |
||||
Omit< |
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar, |
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig, |
); |
||||
keyof Message |
|
||||
> { |
export const CannedMessageValidationSchema = z.object({ |
||||
@IsBoolean() |
rotary1Enabled: z.boolean(), |
||||
rotary1Enabled: boolean; |
inputbrokerPinA: z.coerce.number().int().min(0), |
||||
|
inputbrokerPinB: z.coerce.number().int().min(0), |
||||
@IsInt() |
inputbrokerPinPress: z.coerce.number().int().min(0), |
||||
inputbrokerPinA: number; |
inputbrokerEventCw: InputEventCharEnum, |
||||
|
inputbrokerEventCcw: InputEventCharEnum, |
||||
@IsInt() |
inputbrokerEventPress: InputEventCharEnum, |
||||
inputbrokerPinB: number; |
updown1Enabled: z.boolean(), |
||||
|
enabled: z.boolean(), |
||||
@IsInt() |
allowInputSource: z.string().max(30), |
||||
inputbrokerPinPress: number; |
sendBell: z.boolean(), |
||||
|
}); |
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) |
|
||||
inputbrokerEventCw: |
export type CannedMessageValidation = z.infer< |
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; |
typeof CannedMessageValidationSchema |
||||
|
>; |
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) |
|
||||
inputbrokerEventCcw: |
|
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; |
|
||||
|
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) |
|
||||
inputbrokerEventPress: |
|
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
updown1Enabled: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
enabled: boolean; |
|
||||
|
|
||||
@Length(2, 30) |
|
||||
allowInputSource: string; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
sendBell: boolean; |
|
||||
} |
|
||||
|
|||||
@ -1,33 +1,21 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsBoolean, IsInt, Length } from "class-validator"; |
|
||||
|
const detectionTriggerTypeEnum = z.enum( |
||||
export class DetectionSensorValidation implements |
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType, |
||||
Omit< |
); |
||||
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig, |
|
||||
keyof Message |
export const DetectionSensorValidationSchema = z.object({ |
||||
> { |
enabled: z.boolean(), |
||||
@IsBoolean() |
minimumBroadcastSecs: z.coerce.number().int().min(0), |
||||
enabled: boolean; |
stateBroadcastSecs: z.coerce.number().int().min(0), |
||||
|
sendBell: z.boolean(), |
||||
@IsInt() |
name: z.string().min(0).max(20), |
||||
minimumBroadcastSecs: number; |
monitorPin: z.coerce.number().int().min(0), |
||||
|
detectionTriggerType: detectionTriggerTypeEnum, |
||||
@IsInt() |
usePullup: z.boolean(), |
||||
stateBroadcastSecs: number; |
}); |
||||
|
|
||||
@IsBoolean() |
export type DetectionSensorValidation = z.infer< |
||||
sendBell: boolean; |
typeof DetectionSensorValidationSchema |
||||
|
>; |
||||
@Length(0, 20) |
|
||||
name: string; |
|
||||
|
|
||||
@IsInt() |
|
||||
monitorPin: number; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
detectionTriggeredHigh: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
usePullup: boolean; |
|
||||
} |
|
||||
|
|||||
@ -1,54 +1,23 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { IsBoolean, IsInt } from "class-validator"; |
export const ExternalNotificationValidationSchema = z.object({ |
||||
|
enabled: z.boolean(), |
||||
export class ExternalNotificationValidation implements |
outputMs: z.coerce.number().int().min(0), |
||||
Omit< |
output: z.coerce.number().int().min(0), |
||||
Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig, |
outputVibra: z.coerce.number().int().min(0), |
||||
keyof Message |
outputBuzzer: z.coerce.number().int().min(0), |
||||
> { |
active: z.boolean(), |
||||
@IsBoolean() |
alertMessage: z.boolean(), |
||||
enabled: boolean; |
alertMessageVibra: z.boolean(), |
||||
|
alertMessageBuzzer: z.boolean(), |
||||
@IsInt() |
alertBell: z.boolean(), |
||||
outputMs: number; |
alertBellVibra: z.boolean(), |
||||
|
alertBellBuzzer: z.boolean(), |
||||
@IsInt() |
usePwm: z.boolean(), |
||||
output: number; |
nagTimeout: z.coerce.number().int().min(0), |
||||
|
useI2sAsBuzzer: z.boolean(), |
||||
@IsInt() |
}); |
||||
outputVibra: number; |
|
||||
|
export type ExternalNotificationValidation = z.infer< |
||||
@IsInt() |
typeof ExternalNotificationValidationSchema |
||||
outputBuzzer: number; |
>; |
||||
|
|
||||
@IsBoolean() |
|
||||
active: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
alertMessage: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
alertMessageVibra: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
alertMessageBuzzer: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
alertBell: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
alertBellVibra: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
alertBellBuzzer: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
usePwm: boolean; |
|
||||
|
|
||||
@IsInt() |
|
||||
nagTimeout: number; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
useI2sAsBuzzer: boolean; |
|
||||
} |
|
||||
|
|||||
@ -1,59 +1,22 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { |
export const MqttValidationMapReportSettingsSchema = z.object({ |
||||
IsBoolean, |
publishIntervalSecs: z.number().optional(), |
||||
IsNumber, |
positionPrecision: z.number().optional(), |
||||
IsOptional, |
}); |
||||
IsString, |
|
||||
Length, |
export const MqttValidationSchema = z.object({ |
||||
} from "class-validator"; |
enabled: z.boolean(), |
||||
|
address: z.string().min(0).max(30), |
||||
export class MqttValidation implements |
username: z.string().min(0).max(30), |
||||
Omit< |
password: z.string().min(0).max(30), |
||||
Protobuf.ModuleConfig.ModuleConfig_MQTTConfig, |
encryptionEnabled: z.boolean(), |
||||
keyof Message | "mapReportSettings" |
jsonEnabled: z.boolean(), |
||||
> { |
tlsEnabled: z.boolean(), |
||||
@IsBoolean() |
root: z.string(), |
||||
enabled: boolean; |
proxyToClientEnabled: z.boolean(), |
||||
|
mapReportingEnabled: z.boolean(), |
||||
@Length(0, 30) |
mapReportSettings: MqttValidationMapReportSettingsSchema, |
||||
address: string; |
}); |
||||
|
|
||||
@Length(0, 30) |
export type MqttValidation = z.infer<typeof MqttValidationSchema>; |
||||
username: string; |
|
||||
|
|
||||
@Length(0, 30) |
|
||||
password: string; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
encryptionEnabled: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
jsonEnabled: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
tlsEnabled: boolean; |
|
||||
|
|
||||
@IsString() |
|
||||
root: string; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
proxyToClientEnabled: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
mapReportingEnabled: boolean; |
|
||||
|
|
||||
mapReportSettings: MqttValidationMapReportSettings; |
|
||||
} |
|
||||
|
|
||||
export class MqttValidationMapReportSettings |
|
||||
implements |
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message> { |
|
||||
@IsNumber() |
|
||||
@IsOptional() |
|
||||
publishIntervalSecs: number; |
|
||||
|
|
||||
@IsNumber() |
|
||||
@IsOptional() |
|
||||
positionPrecision: number; |
|
||||
} |
|
||||
|
|||||
@ -1,13 +1,10 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { IsBoolean, IsInt } from "class-validator"; |
|
||||
|
|
||||
export class NeighborInfoValidation |
export const NeighborInfoValidationSchema = z.object({ |
||||
implements |
enabled: z.boolean(), |
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig, keyof Message> { |
updateInterval: z.coerce.number().int().min(0), |
||||
@IsBoolean() |
}); |
||||
enabled: boolean; |
|
||||
|
|
||||
@IsInt() |
export type NeighborInfoValidation = z.infer< |
||||
updateInterval: number; |
typeof NeighborInfoValidationSchema |
||||
} |
>; |
||||
|
|||||
@ -1,19 +1,10 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { IsBoolean, IsInt } from "class-validator"; |
|
||||
|
|
||||
export class PaxcounterValidation |
export const PaxcounterValidationSchema = z.object({ |
||||
implements |
enabled: z.boolean(), |
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig, keyof Message> { |
paxcounterUpdateInterval: z.coerce.number().int().min(0), |
||||
@IsBoolean() |
bleThreshold: z.coerce.number().int(), |
||||
enabled: boolean; |
wifiThreshold: z.coerce.number().int(), |
||||
|
}); |
||||
|
|
||||
@IsInt() |
export type PaxcounterValidation = z.infer<typeof PaxcounterValidationSchema>; |
||||
paxcounterUpdateInterval: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
bleThreshold: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
wifiThreshold: number; |
|
||||
} |
|
||||
|
|||||
@ -1,16 +1,9 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { IsBoolean, IsInt } from "class-validator"; |
|
||||
|
|
||||
export class RangeTestValidation |
export const RangeTestValidationSchema = z.object({ |
||||
implements |
enabled: z.boolean(), |
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig, keyof Message> { |
sender: z.coerce.number().int().min(0), |
||||
@IsBoolean() |
save: z.boolean(), |
||||
enabled: boolean; |
}); |
||||
|
|
||||
@IsInt() |
export type RangeTestValidation = z.infer<typeof RangeTestValidationSchema>; |
||||
sender: number; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
save: boolean; |
|
||||
} |
|
||||
|
|||||
@ -1,31 +1,22 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|
||||
|
|
||||
export class SerialValidation |
const Serial_BaudEnum = z.enum( |
||||
implements |
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud, |
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_SerialConfig, keyof Message> { |
); |
||||
@IsBoolean() |
const Serial_ModeEnum = z.enum( |
||||
enabled: boolean; |
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode, |
||||
|
); |
||||
@IsBoolean() |
|
||||
echo: boolean; |
export const SerialValidationSchema = z.object({ |
||||
|
enabled: z.boolean(), |
||||
@IsInt() |
echo: z.boolean(), |
||||
rxd: number; |
rxd: z.coerce.number().int().min(0), |
||||
|
txd: z.coerce.number().int().min(0), |
||||
@IsInt() |
baud: Serial_BaudEnum, |
||||
txd: number; |
timeout: z.coerce.number().int().min(0), |
||||
|
mode: Serial_ModeEnum, |
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud) |
overrideConsoleSerialPort: z.boolean(), |
||||
baud: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud; |
}); |
||||
|
|
||||
@IsInt() |
export type SerialValidation = z.infer<typeof SerialValidationSchema>; |
||||
timeout: number; |
|
||||
|
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode) |
|
||||
mode: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
overrideConsoleSerialPort: boolean; |
|
||||
} |
|
||||
|
|||||
@ -1,24 +1,13 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { IsBoolean, IsInt } from "class-validator"; |
export const StoreForwardValidationSchema = z.object({ |
||||
|
enabled: z.boolean(), |
||||
export class StoreForwardValidation implements |
heartbeat: z.boolean(), |
||||
Omit< |
records: z.coerce.number().int().min(0), |
||||
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, |
historyReturnMax: z.coerce.number().int().min(0), |
||||
keyof Message | "isServer" |
historyReturnWindow: z.coerce.number().int().min(0), |
||||
> { |
}); |
||||
@IsBoolean() |
|
||||
enabled: boolean; |
export type StoreForwardValidation = z.infer< |
||||
|
typeof StoreForwardValidationSchema |
||||
@IsBoolean() |
>; |
||||
heartbeat: boolean; |
|
||||
|
|
||||
@IsInt() |
|
||||
records: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
historyReturnMax: number; |
|
||||
|
|
||||
@IsInt() |
|
||||
historyReturnWindow: number; |
|
||||
} |
|
||||
|
|||||
@ -1,37 +1,18 @@ |
|||||
import type { Message } from "@bufbuild/protobuf"; |
import { z } from "zod/v4"; |
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
import { IsBoolean, IsInt } from "class-validator"; |
export const TelemetryValidationSchema = z.object({ |
||||
|
deviceUpdateInterval: z.coerce.number().int().min(0), |
||||
export class TelemetryValidation |
environmentUpdateInterval: z.coerce.number().int().min(0), |
||||
implements |
environmentMeasurementEnabled: z.boolean(), |
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig, keyof Message> { |
environmentScreenEnabled: z.boolean(), |
||||
@IsInt() |
environmentDisplayFahrenheit: z.boolean(), |
||||
deviceUpdateInterval: number; |
airQualityEnabled: z.boolean(), |
||||
|
airQualityInterval: z.coerce.number().int().min(0), |
||||
@IsInt() |
powerMeasurementEnabled: z.boolean(), |
||||
environmentUpdateInterval: number; |
powerUpdateInterval: z.coerce.number().int().min(0), |
||||
|
powerScreenEnabled: z.boolean(), |
||||
@IsBoolean() |
}); |
||||
environmentMeasurementEnabled: boolean; |
|
||||
|
export type TelemetryValidation = z.infer< |
||||
@IsBoolean() |
typeof TelemetryValidationSchema |
||||
environmentScreenEnabled: boolean; |
>; |
||||
|
|
||||
@IsBoolean() |
|
||||
environmentDisplayFahrenheit: boolean; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
airQualityEnabled: boolean; |
|
||||
|
|
||||
@IsInt() |
|
||||
airQualityInterval: number; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
powerMeasurementEnabled: boolean; |
|
||||
|
|
||||
@IsInt() |
|
||||
powerUpdateInterval: number; |
|
||||
|
|
||||
@IsBoolean() |
|
||||
powerScreenEnabled: boolean; |
|
||||
} |
|
||||
|
|||||
@ -0,0 +1,174 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { makePskHelpers } from "./pskSchema.ts"; |
||||
|
import { fromByteArray } from "base64-js"; |
||||
|
|
||||
|
function makeBase64OfLength(len: number): string { |
||||
|
return fromByteArray(new Uint8Array(len)); |
||||
|
} |
||||
|
|
||||
|
describe("stringSchema", () => { |
||||
|
it("accepts valid base64 string of allowed length", () => { |
||||
|
const { stringSchema } = makePskHelpers([16]); |
||||
|
const valid = makeBase64OfLength(16); |
||||
|
expect(() => stringSchema().parse(valid)).not.toThrow(); |
||||
|
}); |
||||
|
|
||||
|
it("rejects base64 string of disallowed length", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([16]); |
||||
|
const invalid = makeBase64OfLength(8); |
||||
|
const result = stringSchema().safeParse(invalid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.length); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects invalid base64 string", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([16]); |
||||
|
const result = stringSchema().safeParse("not_base64!"); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.format); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects empty string if not optional and 0 not allowed", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([16]); |
||||
|
const result = stringSchema().safeParse(""); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.required); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty string if 0 is allowed", () => { |
||||
|
const { stringSchema } = makePskHelpers([0]); |
||||
|
const result = stringSchema().safeParse(""); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty string if optional=true", () => { |
||||
|
const { stringSchema } = makePskHelpers([16]); |
||||
|
const result = stringSchema(true).safeParse(""); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts all allowed lengths", () => { |
||||
|
const { stringSchema } = makePskHelpers([8, 16, 32]); |
||||
|
for (const len of [8, 16, 32]) { |
||||
|
const valid = makeBase64OfLength(len); |
||||
|
const result = stringSchema().safeParse(valid); |
||||
|
expect(result.success).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts valid base64 string as optional when optional=true", () => { |
||||
|
const { stringSchema } = makePskHelpers([16]); |
||||
|
const valid = makeBase64OfLength(16); |
||||
|
const result = stringSchema(true).safeParse(valid); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects base64 string with correct length but extra padding", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([16]); |
||||
|
const valid = makeBase64OfLength(16) + "=="; |
||||
|
const result = stringSchema().safeParse(valid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.format); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty string if allowedByteLengths includes 0 and optional=false", () => { |
||||
|
const { stringSchema } = makePskHelpers([0, 16]); |
||||
|
const result = stringSchema(false).safeParse(""); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects base64 string with valid format but not in allowedByteLengths", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([8, 32]); |
||||
|
const invalid = makeBase64OfLength(16); |
||||
|
const result = stringSchema().safeParse(invalid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.length); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
describe("bytesSchema", () => { |
||||
|
it("accepts valid byte array of allowed length", () => { |
||||
|
const { bytesSchema } = makePskHelpers([16]); |
||||
|
const valid = new Uint8Array(16); |
||||
|
expect(() => bytesSchema().parse(valid)).not.toThrow(); |
||||
|
}); |
||||
|
|
||||
|
it("rejects byte array of disallowed length", () => { |
||||
|
const { bytesSchema, msgs } = makePskHelpers([16]); |
||||
|
const invalid = new Uint8Array(8); |
||||
|
const result = bytesSchema().safeParse(invalid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.length); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects non-Uint8Array input", () => { |
||||
|
const { bytesSchema } = makePskHelpers([16]); |
||||
|
const result = bytesSchema().safeParse([1, 2, 3]); |
||||
|
expect(result.success).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("rejects empty array if not optional and 0 not allowed", () => { |
||||
|
const { bytesSchema, msgs } = makePskHelpers([16]); |
||||
|
const result = bytesSchema().safeParse(new Uint8Array(0)); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.required); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty array if 0 is allowed", () => { |
||||
|
const { bytesSchema } = makePskHelpers([0]); |
||||
|
const result = bytesSchema().safeParse(new Uint8Array(0)); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty array if optional=true", () => { |
||||
|
const { bytesSchema } = makePskHelpers([16]); |
||||
|
const result = bytesSchema(true).safeParse(new Uint8Array(0)); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts all allowed lengths", () => { |
||||
|
const { bytesSchema } = makePskHelpers([8, 16, 32]); |
||||
|
for (const len of [8, 16, 32]) { |
||||
|
const valid = new Uint8Array(len); |
||||
|
const result = bytesSchema().safeParse(valid); |
||||
|
expect(result.success).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts valid byte array as optional when optional=true", () => { |
||||
|
const { bytesSchema } = makePskHelpers([16]); |
||||
|
const valid = new Uint8Array(16); |
||||
|
const result = bytesSchema(true).safeParse(valid); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty array if allowedByteLengths includes 0 and optional=false", () => { |
||||
|
const { bytesSchema } = makePskHelpers([0, 16]); |
||||
|
const result = bytesSchema(false).safeParse(new Uint8Array(0)); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects byte array with valid format but not in allowedByteLengths", () => { |
||||
|
const { bytesSchema, msgs } = makePskHelpers([8, 32]); |
||||
|
const invalid = new Uint8Array(16); |
||||
|
const result = bytesSchema().safeParse(invalid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.length); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,70 @@ |
|||||
|
import { z, ZodType } from "zod/v4"; |
||||
|
import { toByteArray } from "base64-js"; |
||||
|
|
||||
|
export function makePskHelpers( |
||||
|
allowedByteLengths: readonly number[], |
||||
|
) { |
||||
|
const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | "); |
||||
|
const msgs = { |
||||
|
format: "formValidation.invalidFormat.key", |
||||
|
required: "formValidation.required.key", |
||||
|
length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`, |
||||
|
} as const; |
||||
|
|
||||
|
function tryParse(str: string): Uint8Array | null { |
||||
|
try { |
||||
|
return toByteArray(str); |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function isValidString(str: string): boolean { |
||||
|
const arr = tryParse(str); |
||||
|
return arr !== null && |
||||
|
allowedByteLengths.includes(arr.byteLength); |
||||
|
} |
||||
|
|
||||
|
function isValidKey(v: unknown): boolean { |
||||
|
if (typeof v === "string") return isValidString(v); |
||||
|
if (v instanceof Uint8Array) { |
||||
|
return allowedByteLengths.includes(v.byteLength); |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const stringSchema = (optional = false) => |
||||
|
z.string() |
||||
|
.refine((s) => |
||||
|
optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), { |
||||
|
message: msgs.required, |
||||
|
}) |
||||
|
.refine((s) => |
||||
|
s === "" || tryParse(s) !== null, { message: msgs.format }) |
||||
|
.refine((s) => |
||||
|
s === "" || isValidString(s), { |
||||
|
message: msgs.length, |
||||
|
params: { bits: bitsLabel }, |
||||
|
}); |
||||
|
|
||||
|
const bytesSchema = (optional = false): ZodType<Uint8Array> => |
||||
|
z.instanceof(Uint8Array) |
||||
|
.refine( |
||||
|
(arr) => |
||||
|
optional || arr.byteLength !== 0 || allowedByteLengths.includes(0), |
||||
|
{ message: msgs.required }, |
||||
|
) |
||||
|
.refine( |
||||
|
(arr) => optional || allowedByteLengths.includes(arr.byteLength), |
||||
|
{ message: msgs.length, params: { bits: bitsLabel } }, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
allowedByteLengths, |
||||
|
msgs, |
||||
|
tryParseStringKey: tryParse, |
||||
|
isValidKey, |
||||
|
stringSchema, |
||||
|
bytesSchema, |
||||
|
}; |
||||
|
} |
||||
@ -1,22 +0,0 @@ |
|||||
import { IsArray, IsBoolean, IsNumber, IsString, IsUrl } from "class-validator"; |
|
||||
|
|
||||
import type { RasterSource } from "@core/stores/appStore.ts"; |
|
||||
|
|
||||
export class MapValidation { |
|
||||
@IsArray() |
|
||||
rasterSources: MapValidation_RasterSources[]; |
|
||||
} |
|
||||
|
|
||||
export class MapValidation_RasterSources implements RasterSource { |
|
||||
@IsBoolean() |
|
||||
enabled: boolean; |
|
||||
|
|
||||
@IsString() |
|
||||
title: string; |
|
||||
|
|
||||
@IsUrl() |
|
||||
tiles: string; |
|
||||
|
|
||||
@IsNumber() |
|
||||
tileSize: number; |
|
||||
} |
|
||||
Loading…
Reference in new issue