diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99e72cc..f7934dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: run: npm audit continue-on-error: true + - name: Run unit tests + run: npm test -- --run + - name: Run TypeScript type check and build run: npm run build env: diff --git a/package-lock.json b/package-lock.json index 4caad70..d8ba897 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^4.1.7", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -253,9 +254,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -348,6 +349,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1783,32 +1794,63 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1817,7 +1859,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1829,26 +1871,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -1856,13 +1898,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1871,9 +1914,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -1881,14 +1924,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2015,6 +2059,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.24", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", @@ -2389,9 +2452,9 @@ "license": "ISC" }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -3000,6 +3063,13 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3146,6 +3216,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3342,6 +3451,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4155,9 +4292,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -4379,9 +4516,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4647,31 +4784,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4687,12 +4824,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -4713,6 +4853,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -4721,6 +4867,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index 2c1cab3..2c690df 100644 --- a/package.json +++ b/package.json @@ -1 +1 @@ -{"name":"neonplug","version":"0.1.0","type":"module","description":"Cyberpunk-themed Radio CPS for Baofeng DM-32UV","scripts":{"dev":"vite","build":"tsc && vite build","build:prod":"tsc && vite build --mode production","build:single":"tsc && vite build --mode singlefile","preview":"vite preview","lint":"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0","test":"vitest","audit:prod":"npm audit --omit=dev"},"dependencies":{"jszip":"^3.10.1","react":"^18.2.0","react-dom":"^18.2.0","react-easy-crop":"^5.5.6","zustand":"^4.4.7"},"devDependencies":{"@types/pako":"^2.0.4","@types/react":"^18.2.43","@types/react-dom":"^18.2.17","@typescript-eslint/eslint-plugin":"^6.14.0","@typescript-eslint/parser":"^6.14.0","@vitejs/plugin-react":"^4.2.1","autoprefixer":"^10.4.16","eslint":"^8.55.0","eslint-plugin-react-hooks":"^4.6.0","eslint-plugin-react-refresh":"^0.4.5","postcss":"^8.4.32","tailwindcss":"^3.4.0","typescript":"^5.2.2","vite":"^7.3.0","vite-plugin-singlefile":"^2.3.0","vitest":"^4.0.16"}} \ No newline at end of file +{"name":"neonplug","version":"0.1.0","type":"module","description":"Cyberpunk-themed Radio CPS for Baofeng DM-32UV","scripts":{"dev":"vite","build":"tsc && vite build","build:prod":"tsc && vite build --mode production","build:single":"tsc && vite build --mode singlefile","preview":"vite preview","lint":"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0","test":"vitest","audit:prod":"npm audit --omit=dev"},"dependencies":{"jszip":"^3.10.1","react":"^18.2.0","react-dom":"^18.2.0","react-easy-crop":"^5.5.6","zustand":"^4.4.7"},"devDependencies":{"@types/pako":"^2.0.4","@types/react":"^18.2.43","@types/react-dom":"^18.2.17","@typescript-eslint/eslint-plugin":"^6.14.0","@typescript-eslint/parser":"^6.14.0","@vitejs/plugin-react":"^4.2.1","@vitest/coverage-v8":"^4.1.7","autoprefixer":"^10.4.16","eslint":"^8.55.0","eslint-plugin-react-hooks":"^4.6.0","eslint-plugin-react-refresh":"^0.4.5","postcss":"^8.4.32","tailwindcss":"^3.4.0","typescript":"^5.2.2","vite":"^7.3.0","vite-plugin-singlefile":"^2.3.0","vitest":"^4.0.16"}} \ No newline at end of file diff --git a/src/components/channels/ChannelsTab.tsx b/src/components/channels/ChannelsTab.tsx index 2c22905..926823b 100644 --- a/src/components/channels/ChannelsTab.tsx +++ b/src/components/channels/ChannelsTab.tsx @@ -1,8 +1,7 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; import { useChannelsStore } from '../../store/channelsStore'; import { useRadioSettingsStore } from '../../store/radioSettingsStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { ChannelsTable } from './ChannelsTable'; import { createDefaultChannel } from '../../utils/channelHelpers'; import { ConfirmModal } from '../ui/ConfirmModal'; @@ -13,8 +12,7 @@ const isVFOChannel = (n: number) => n === 4001 || n === 4002; export const ChannelsTab: React.FC = () => { const { channels, addChannel, deleteChannels } = useChannelsStore(); const { settings: radioSettings } = useRadioSettingsStore(); - const effectiveModel = useEffectiveRadioModel(); - const caps = getCapabilitiesForModel(effectiveModel); + const { caps } = useRadioCapabilities(); const supportsVfoChannels = caps?.supportsVfoChannels === true; const [searchQuery, setSearchQuery] = useState(''); const [scrollToChannel, setScrollToChannel] = useState(null); diff --git a/src/components/channels/ChannelsTable.tsx b/src/components/channels/ChannelsTable.tsx index 52903cb..5ca7db1 100644 --- a/src/components/channels/ChannelsTable.tsx +++ b/src/components/channels/ChannelsTable.tsx @@ -1,9 +1,8 @@ import React, { useState, useEffect, useRef } from 'react'; import { useChannelsStore } from '../../store/channelsStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { useRadioSettingsStore } from '../../store/radioSettingsStore'; import { useScanListsStore } from '../../store/scanListsStore'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; import { useRXGroupsStore } from '../../store/rxGroupsStore'; import { useEncryptionKeysStore } from '../../store/encryptionKeysStore'; import { useQuickContactsStore } from '../../store/quickContactsStore'; @@ -70,9 +69,8 @@ export const ChannelsTable: React.FC = ({ onSelectionChange, }) => { const { channels: channelsFromStore, updateChannel, deleteChannel, addChannel } = useChannelsStore(); - const effectiveModel = useEffectiveRadioModel(); + const { caps } = useRadioCapabilities(); const { settings: radioSettings, updateSettings } = useRadioSettingsStore(); - const caps = getCapabilitiesForModel(effectiveModel); const bandLimits = caps?.bandLimits ?? null; const maxChannels = caps?.maxChannels ?? 4000; const analogOnly = caps?.analogOnly === true; diff --git a/src/components/diagnostics/DiagnosticsTab.tsx b/src/components/diagnostics/DiagnosticsTab.tsx index 29f91b8..ceb651d 100644 --- a/src/components/diagnostics/DiagnosticsTab.tsx +++ b/src/components/diagnostics/DiagnosticsTab.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo, useRef, useEffect } from 'react'; import { useRadioStore } from '../../store/radioStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { useRadioSettingsStore } from '../../store/radioSettingsStore'; import { useChannelsStore } from '../../store/channelsStore'; import { useZonesStore } from '../../store/zonesStore'; @@ -14,7 +14,6 @@ import { useQuickContactsStore } from '../../store/quickContactsStore'; import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore'; import { useEncryptionKeysStore } from '../../store/encryptionKeysStore'; import { useLogStore } from '../../store/logStore'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; import { POWER_ON_INTERFACE_OPTIONS, COLOR_OPTIONS, @@ -49,8 +48,7 @@ export const DiagnosticsTab: React.FC = () => { const { contacts: quickContacts } = useQuickContactsStore(); const { radioIds: dmrRadioIds } = useDMRRadioIDsStore(); const { keys: encryptionKeys } = useEncryptionKeysStore(); - const effectiveModel = useEffectiveRadioModel(); - const caps = useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); + const { caps } = useRadioCapabilities(); const [showMetadataBlock, setShowMetadataBlock] = useState(false); const [showMetadataBlock41, setShowMetadataBlock41] = useState(false); const [showContactBlock, setShowContactBlock] = useState(true); diff --git a/src/components/digital/DigitalTab.tsx b/src/components/digital/DigitalTab.tsx index a2f38eb..d75bd3d 100644 --- a/src/components/digital/DigitalTab.tsx +++ b/src/components/digital/DigitalTab.tsx @@ -1,13 +1,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useRadioStore } from '../../store/radioStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { useEncryptionKeysStore } from '../../store/encryptionKeysStore'; import { useDigitalEmergencyStore } from '../../store/digitalEmergencyStore'; import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore'; import { useQuickContactsStore } from '../../store/quickContactsStore'; import { useRXGroupsStore } from '../../store/rxGroupsStore'; import { useQuickMessagesStore } from '../../store/quickMessagesStore'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; import { isValidDMRId } from '../../services/validation/dmrValidator'; import { RXGroupsList } from '../rxgroups/RXGroupsList'; import { Card } from '../ui/Card'; @@ -20,8 +19,7 @@ const DEFAULT_DMR_RADIO_IDS_MAX = 250; export const DigitalTab: React.FC = () => { const { blockMetadata, blockData } = useRadioStore(); - const effectiveModel = useEffectiveRadioModel(); - const caps = useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); + const { caps } = useRadioCapabilities(); const limits = caps?.digital?.limits; const talkGroupsMax = limits?.TALK_GROUPS_MAX ?? DEFAULT_TALK_GROUPS_MAX; const dmrRadioIdsMax = limits?.DMR_RADIO_IDS_MAX ?? DEFAULT_DMR_RADIO_IDS_MAX; diff --git a/src/components/import/SelectAllButtons.tsx b/src/components/import/SelectAllButtons.tsx new file mode 100644 index 0000000..d59c694 --- /dev/null +++ b/src/components/import/SelectAllButtons.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +interface SelectAllButtonsProps { + onSelectAll: () => void; + onDeselectAll: () => void; + selectAllLabel?: string; +} + +export const SelectAllButtons: React.FC = ({ + onSelectAll, + onDeselectAll, + selectAllLabel = 'Select All', +}) => ( +
+ + +
+); diff --git a/src/components/import/SmartImportTab.tsx b/src/components/import/SmartImportTab.tsx index f96eb20..70de890 100644 --- a/src/components/import/SmartImportTab.tsx +++ b/src/components/import/SmartImportTab.tsx @@ -1,121 +1,64 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { useChannelsStore } from '../../store/channelsStore'; -import { useZonesStore } from '../../store/zonesStore'; -import { getCurrentLocation, geocodeLocation } from '../../services/repeaterFinder'; -import { getAvailableFixedChannelSets, getChannelsForSet } from '../../services/fixedChannels'; -import { mergeOverlappingChannels, getChannelFullKey } from '../../services/channelMerger'; -import { generateAirportChannels } from '../../services/airportChannels'; -import { generateZoneId } from '../../utils/zoneHelpers'; -import { findNearbyAirports, getAirportFrequenciesWithTypes, type AirportData } from '../../data/airportsData'; -import { generateTaflChannels } from '../../services/taflChannels'; -import { findNearbyTaflEntries, groupTaflEntriesByName, type TaflData } from '../../data/taflData'; -import { generateRptrsChannels } from '../../services/rptrsChannels'; -import { findNearbyRptrs, convertRptrFrequency, type RptrData } from '../../data/rptrsData'; -import { importChannelsFromChirpCSV, exportChannelsToChirpCSV, downloadCSV } from '../../services/csv'; -import { - generateMMDVMChannels, - isValidMMDVMFrequency, - MMDVM_FREQ_MIN_MHZ, - MMDVM_FREQ_MAX_MHZ, - type MMDVMChannelEntry, -} from '../../services/mmdvmChannels'; -import type { Channel } from '../../models'; -import type { Zone } from '../../models'; +import React, { useState } from 'react'; +import { useLocationState } from '../../hooks/useLocationState'; +import { findNearbyAirports, type AirportData } from '../../data/airportsData'; +import { findNearbyTaflEntries, type TaflData } from '../../data/taflData'; +import { findNearbyRptrs, type RptrData } from '../../data/rptrsData'; import { Button } from '../ui/Button'; import { Card } from '../ui/Card'; import { SectionTitle } from '../ui/SectionTitle'; -import { useContactsStore } from '../../store/contactsStore'; -import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; +import { ChirpSource } from './sources/ChirpSource'; +import { AirportSource } from './sources/AirportSource'; +import { TaflSource } from './sources/TaflSource'; +import { RptrsSource } from './sources/RptrsSource'; +import { MmdvmSource } from './sources/MmdvmSource'; +import { FixedChannelsSource } from './sources/FixedChannelsSource'; export const SmartImportTab: React.FC = () => { - const { channels, setChannels } = useChannelsStore(); - const { zones, setZones } = useZonesStore(); - const { contacts, setContacts } = useContactsStore(); - const { radioIds } = useDMRRadioIDsStore(); - const effectiveModel = useEffectiveRadioModel(); - const caps = React.useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); + const { caps } = useRadioCapabilities(); const supportsDigital = caps?.analogOnly !== true; - - const [locationType, setLocationType] = useState<'coordinates' | 'city' | 'current'>('current'); - const [latitude, setLatitude] = useState(''); - const [longitude, setLongitude] = useState(''); - const [city, setCity] = useState(''); - const [state, setState] = useState(''); + + const { + locationType, setLocationType, + latitude, setLatitude, + longitude, setLongitude, + city, setCity, + state, setState, + searchRadius, setSearchRadius, + resolveCoordinates, + } = useLocationState(); + const [error, setError] = useState(null); - - // Unified location search state - const [searchRadius, setSearchRadius] = useState('50'); const [searchAirports, setSearchAirports] = useState(true); const [searchTafl, setSearchTafl] = useState(true); const [searchDmrRepeaters, setSearchDmrRepeaters] = useState(true); const [isSearchingAll, setIsSearchingAll] = useState(false); - + // Generation result const [generationResult, setGenerationResult] = useState<{ channels: number; zones: number } | null>(null); - - // Fixed channels state - const [selectedFixedSets, setSelectedFixedSets] = useState>(new Set()); - const [isAddingFixed, setIsAddingFixed] = useState(false); - const [expandedChannelSet, setExpandedChannelSet] = useState(null); - - // Airport channels state - const [airportRadius] = useState('50'); - const [isAddingAirports, setIsAddingAirports] = useState(false); + + // Airport search results + const [airports, setAirports] = useState<(AirportData & { distance?: number })[]>([]); const [isSearchingAirports, setIsSearchingAirports] = useState(false); - const [airports, setAirports] = useState([]); - const [selectedAirports, setSelectedAirports] = useState>(new Set()); - const [airportZoneGrouping, setAirportZoneGrouping] = useState<'individual' | 'single'>('individual'); - - // TAFL channels state - const [taflRadius] = useState('10'); // Reduced default from 50 to 10 - const [taflSearchFilter, setTaflSearchFilter] = useState(''); - const [isAddingTafl, setIsAddingTafl] = useState(false); - const [isSearchingTafl, setIsSearchingTafl] = useState(false); + + // TAFL search results const [taflEntries, setTaflEntries] = useState([]); - const [selectedTaflEntries, setSelectedTaflEntries] = useState>(new Set()); - const [expandedTaflGroups, setExpandedTaflGroups] = useState>(new Set()); const [taflLoadProgress, setTaflLoadProgress] = useState<{ percent: number; loaded: number; total: number } | null>(null); - - // DMR Repeater (rptrs) channels state - const [rptrsRadius] = useState('50'); - const [rptrsSearchFilter, setRptrsSearchFilter] = useState(''); - const [isAddingRptrs, setIsAddingRptrs] = useState(false); - const [isSearchingRptrs, setIsSearchingRptrs] = useState(false); + const [isSearchingTafl, setIsSearchingTafl] = useState(false); + + // Rptrs search results const [rptrs, setRptrs] = useState<(RptrData & { distance?: number })[]>([]); - const [selectedRptrs, setSelectedRptrs] = useState>(new Set()); - const [rptrsZoneGrouping, setRptrsZoneGrouping] = useState<'location' | 'single'>('location'); - const [rptrsSeparateTimeslots, setRptrsSeparateTimeslots] = useState(true); const [rptrsLoadProgress, setRptrsLoadProgress] = useState<{ percent: number; loaded: number; total: number } | null>(null); - - // Chirp CSV import/export state - const [isImportingChirp, setIsImportingChirp] = useState(false); - const [chirpImportResult, setChirpImportResult] = useState<{ - operation: 'import' | 'export'; - channels: number; - errors?: string[] - } | null>(null); - const fileInputRef = useRef(null); - - // MMDVM simplex state - const [mmdvmFrequency, setMmdvmFrequency] = useState('431.150'); - const [mmdvmEntries, setMmdvmEntries] = useState([ - { channelName: '', talkGroupName: 'Local', talkGroupId: 9 }, - ]); - const [mmdvmZoneName, setMmdvmZoneName] = useState('MMDVM'); - const [mmdvmDmrRadioIdIndex, setMmdvmDmrRadioIdIndex] = useState(''); // '' = None, or String(index) - const [isAddingMmdvm, setIsAddingMmdvm] = useState(false); - const mmdvmDmrIdDefaultSetRef = useRef(false); + const [isSearchingRptrs, setIsSearchingRptrs] = useState(false); - // Preset MMDVM DMR Radio ID to first ID (slot 1) when list becomes available, once - useEffect(() => { - if (radioIds.length > 0 && !mmdvmDmrIdDefaultSetRef.current) { - setMmdvmDmrRadioIdIndex(String(radioIds[0].index)); - mmdvmDmrIdDefaultSetRef.current = true; - } - }, [radioIds]); + // These are kept here for the search handler to use (not passed to children) + const [airportRadius] = useState('50'); + const [taflRadius] = useState('10'); + const [rptrsRadius] = useState('50'); + const handleSetError = (msg: string) => { + setError(msg || null); + }; // Unified search handler that searches all selected types const handleSearchAll = async () => { @@ -134,68 +77,20 @@ export const SmartImportTab: React.FC = () => { setIsSearchingTafl(searchTafl); setIsSearchingRptrs(supportsDigital && searchDmrRepeaters); setError(null); - + // Clear previous results if (searchAirports) { setAirports([]); - setSelectedAirports(new Set()); } if (searchTafl) { setTaflEntries([]); - setSelectedTaflEntries(new Set()); } if (searchDmrRepeaters) { setRptrs([]); - setSelectedRptrs(new Set()); } try { - let lat: number; - let lon: number; - - // Get location - if (locationType === 'current') { - const currentLoc = await getCurrentLocation(); - lat = currentLoc.latitude; - lon = currentLoc.longitude; - } else if (locationType === 'coordinates') { - const parsedLat = parseFloat(latitude); - const parsedLon = parseFloat(longitude); - - if (isNaN(parsedLat) || isNaN(parsedLon) || !latitude.trim() || !longitude.trim()) { - throw new Error('Invalid coordinates. Please enter valid latitude and longitude.'); - } - - if (parsedLat < -90 || parsedLat > 90) { - throw new Error('Latitude must be between -90 and 90'); - } - - if (parsedLon < -180 || parsedLon > 180) { - throw new Error('Longitude must be between -180 and 180'); - } - - lat = parsedLat; - lon = parsedLon; - } else { - // City/State - need to geocode - if (!city.trim()) { - throw new Error('Please enter a city name.'); - } - const geocoded = await geocodeLocation(city, state); - if (!geocoded) { - throw new Error('Could not find location. Please check the city and state names, or use coordinates instead.'); - } - lat = geocoded.latitude; - lon = geocoded.longitude; - // Optionally update the coordinates fields so user can see them - setLatitude(lat.toFixed(6)); - setLongitude(lon.toFixed(6)); - } - - const radius = parseFloat(searchRadius) || 50; - if (isNaN(radius) || radius <= 0) { - throw new Error('Please enter a valid search radius (greater than 0).'); - } + const { lat, lon, radius } = await resolveCoordinates(); // Search all selected types in parallel const searchPromises: Promise[] = []; @@ -206,7 +101,6 @@ export const SmartImportTab: React.FC = () => { const airportRadiusValue = parseFloat(airportRadius) || radius; const nearbyAirports = await findNearbyAirports(lat, lon, airportRadiusValue); setAirports(nearbyAirports); - setSelectedAirports(new Set(nearbyAirports.map((_, i) => i))); setIsSearchingAirports(false); })() ); @@ -251,7 +145,6 @@ export const SmartImportTab: React.FC = () => { } ); setRptrs(nearbyRptrs); - setSelectedRptrs(new Set(nearbyRptrs.map((_, i) => i))); setIsSearchingRptrs(false); })() ); @@ -270,638 +163,12 @@ export const SmartImportTab: React.FC = () => { } }; - const handleAddFixedChannels = () => { - if (selectedFixedSets.size === 0) { - setError('Please select at least one channel set'); - return; - } - - setIsAddingFixed(true); - setError(null); - - try { - // Find next available channel number - const existingNumbers = new Set(channels.map(ch => ch.number)); - let nextChannelNumber = 1; - while (existingNumbers.has(nextChannelNumber)) { - nextChannelNumber++; - } - - // Generate channels for each selected set (with temporary numbers) - const channelSets: Channel[][] = []; - const setNames: string[] = []; - - for (const setName of selectedFixedSets) { - // Use generic function to get channels for any set - const setChannels = getChannelsForSet(setName, 1); - - if (setChannels.length > 0) { - channelSets.push(setChannels); - setNames.push(setName); - } - } - - // FIRST: Check against existing channels and build mapping for duplicates - // Match on ALL settings: frequency, name, mode, bandwidth, power, CTCSS/DCS - const existingChannelMap = new Map(); // full key -> channel number - for (const ch of channels) { - const fullKey = getChannelFullKey(ch); - existingChannelMap.set(fullKey, ch.number); - } - - // Merge overlapping channels (within new sets only) - const { mergedChannels, channelMapping } = mergeOverlappingChannels(channelSets, nextChannelNumber); - - // Update mapping to use existing channels where ALL settings match - const finalChannelMapping = new Map(); - const channelsToAdd: Channel[] = []; - - for (const newChannel of mergedChannels) { - const fullKey = getChannelFullKey(newChannel); - - if (existingChannelMap.has(fullKey)) { - // This exact channel already exists - use existing channel - const existingChannelNum = existingChannelMap.get(fullKey)!; - console.log(`[SmartImport] Channel ${newChannel.number} "${newChannel.name}" (${newChannel.rxFrequency} MHz) is EXACT DUPLICATE of existing channel ${existingChannelNum}`); - - // Update all mappings that point to this merged channel - for (const [origNum, mergedNum] of channelMapping.entries()) { - if (mergedNum === newChannel.number) { - finalChannelMapping.set(origNum, existingChannelNum); - } - } - } else { - // New unique channel (or different settings) - add it - channelsToAdd.push(newChannel); - - // Copy mapping as-is for this channel - for (const [origNum, mergedNum] of channelMapping.entries()) { - if (mergedNum === newChannel.number) { - finalChannelMapping.set(origNum, newChannel.number); - } - } - } - } - - console.log(`[SmartImport] Adding ${channelsToAdd.length} new channels (${mergedChannels.length - channelsToAdd.length} were duplicates of existing)`); - - // Create zones with final channel numbers - const newZones: Zone[] = []; - for (let i = 0; i < channelSets.length; i++) { - const setChannels = channelSets[i]; - const setName = setNames[i]; - - // Map original channel numbers to final channel numbers - const zoneChannelNumbers = setChannels - .map(ch => finalChannelMapping.get(ch.number)) - .filter((num): num is number => num !== undefined) - .sort((a, b) => a - b); - - if (zoneChannelNumbers.length > 0) { - newZones.push({ - id: generateZoneId(), - name: setName, - channels: zoneChannelNumbers, - }); - } - } - - // Add only new channels (not duplicates) - const updatedChannels = [...channels, ...channelsToAdd]; - setChannels(updatedChannels); - - const updatedZones = [...zones, ...newZones]; - setZones(updatedZones); - - setGenerationResult({ - channels: channelsToAdd.length, - zones: newZones.length, - }); - - // Clear selection - setSelectedFixedSets(new Set()); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add fixed channels'); - } finally { - setIsAddingFixed(false); - } - }; - - const handleToggleFixedSet = (setName: string) => { - const newSelected = new Set(selectedFixedSets); - if (newSelected.has(setName)) { - newSelected.delete(setName); - } else { - newSelected.add(setName); - } - setSelectedFixedSets(newSelected); - }; - - const fixedChannelSets = getAvailableFixedChannelSets(); - - - const handleToggleAirport = (index: number) => { - const newSelected = new Set(selectedAirports); - if (newSelected.has(index)) { - newSelected.delete(index); - } else { - newSelected.add(index); - } - setSelectedAirports(newSelected); - }; - - const handleSelectAllAirports = () => { - setSelectedAirports(new Set(airports.map((_, i) => i))); - }; - - const handleDeselectAllAirports = () => { - setSelectedAirports(new Set()); - }; - - const handleAddAirportChannels = async () => { - if (selectedAirports.size === 0) { - setError('Please select at least one airport'); - return; - } - - setIsAddingAirports(true); - setError(null); - - try { - // Get selected airports - const selectedAirportList = Array.from(selectedAirports) - .map(i => airports[i]) - .filter(Boolean); - - if (selectedAirportList.length === 0) { - throw new Error('No airports selected'); - } - - // Find next available channel number - const existingNumbers = new Set(channels.map(ch => ch.number)); - let nextChannelNumber = 1; - while (existingNumbers.has(nextChannelNumber)) { - nextChannelNumber++; - } - - // Generate channels and zones for selected airports - const result = generateAirportChannels( - nextChannelNumber, - selectedAirportList, // Pass selected airports - airportZoneGrouping === 'single' // Group all in one zone if selected - ); - - if (result.channels.length === 0) { - setError('No channels to add from selected airports'); - return; - } - - // Add channels - const updatedChannels = [...channels, ...result.channels]; - setChannels(updatedChannels); - - // Add zones (one per airport) - const updatedZones = [...zones, ...result.zones]; - setZones(updatedZones); - - setGenerationResult({ - channels: result.channels.length, - zones: result.zones.length, - }); - - // Clear selection - setSelectedAirports(new Set()); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add airport channels'); - } finally { - setIsAddingAirports(false); - } - }; - - - // Note: handleToggleTafl is now handled inline in the render for deduplicated entries - - // Compute filtered TAFL entries for display - const filteredTaflEntries = taflSearchFilter.trim() - ? taflEntries.filter(entry => - entry.c.toLowerCase().includes(taflSearchFilter.toLowerCase()) - ) - : taflEntries; - - // Deduplicate entries: if same name AND frequency, only keep one - const uniqueFilteredEntries = new Map(); - const entryIndexMap = new Map(); // Map unique key to original index - - for (let i = 0; i < filteredTaflEntries.length; i++) { - const entry = filteredTaflEntries[i]; - const key = `${entry.c}|${entry.f}`; // Use name + frequency (in kHz) as unique key - if (!uniqueFilteredEntries.has(key)) { - uniqueFilteredEntries.set(key, entry); - entryIndexMap.set(key, i); - } - } - - const deduplicatedEntries = Array.from(uniqueFilteredEntries.values()); - - // Map deduplicated entries to their original indices in filteredTaflEntries - const filteredTaflIndices = deduplicatedEntries.map(entry => { - const key = `${entry.c}|${entry.f}`; - return entryIndexMap.get(key) ?? filteredTaflEntries.findIndex(e => e === entry); - }); - - // Group deduplicated entries by name prefix for display - const taflGroups = groupTaflEntriesByName(deduplicatedEntries, 2); - const taflGroupArray = Array.from(taflGroups.entries()).sort((a, b) => a[0].localeCompare(b[0])); - - const handleSelectAllFilteredTafl = () => { - const newSelected = new Set(selectedTaflEntries); - filteredTaflIndices.forEach(idx => newSelected.add(idx)); - setSelectedTaflEntries(newSelected); - }; - - const handleDeselectAllTafl = () => { - setSelectedTaflEntries(new Set()); - }; - - const handleAddTaflChannels = async () => { - if (selectedTaflEntries.size === 0) { - setError('Please select at least one TAFL entry'); - return; - } - - setIsAddingTafl(true); - setError(null); - - try { - // Get selected entries - const selectedTaflList = Array.from(selectedTaflEntries) - .map(i => taflEntries[i]) - .filter(Boolean); - - if (selectedTaflList.length === 0) { - throw new Error('No TAFL entries selected'); - } - - // Find next available channel number - const existingNumbers = new Set(channels.map(ch => ch.number)); - let nextChannelNumber = 1; - while (existingNumbers.has(nextChannelNumber)) { - nextChannelNumber++; - } - - // Generate channels and zones for selected entries - // TAFL always uses individual zones grouped by name - const result = generateTaflChannels( - nextChannelNumber, - selectedTaflList, // Pass selected entries - false, // Always use individual zones (not single zone) - true // Always group by name - ); - - if (result.channels.length === 0) { - setError('No channels to add from selected TAFL entries'); - return; - } - - // Add channels - const updatedChannels = [...channels, ...result.channels]; - setChannels(updatedChannels); - - // Add zones - const updatedZones = [...zones, ...result.zones]; - setZones(updatedZones); - - setGenerationResult({ - channels: result.channels.length, - zones: result.zones.length, - }); - - // Clear selection - setSelectedTaflEntries(new Set()); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add TAFL channels'); - } finally { - setIsAddingTafl(false); - } - }; - - - - const handleToggleRptr = (index: number) => { - const newSelected = new Set(selectedRptrs); - if (newSelected.has(index)) { - newSelected.delete(index); - } else { - newSelected.add(index); - } - setSelectedRptrs(newSelected); - }; - - const handleSelectAllRptrs = () => { - setSelectedRptrs(new Set(rptrs.map((_, i) => i))); - }; - - const handleDeselectAllRptrs = () => { - setSelectedRptrs(new Set()); - }; - - const handleAddRptrsChannels = async () => { - if (selectedRptrs.size === 0) { - setError('Please select at least one DMR repeater'); - return; - } - - setIsAddingRptrs(true); - setError(null); - - try { - // Get selected repeaters - const selectedRptrsList = Array.from(selectedRptrs) - .map(i => rptrs[i]) - .filter(Boolean); - - if (selectedRptrsList.length === 0) { - throw new Error('No DMR repeaters selected'); - } - - // Find next available channel number - const existingNumbers = new Set(channels.map(ch => ch.number)); - let nextChannelNumber = 1; - while (existingNumbers.has(nextChannelNumber)) { - nextChannelNumber++; - } - - // Generate channels and zones for selected repeaters - const result = generateRptrsChannels( - nextChannelNumber, - selectedRptrsList, - rptrsZoneGrouping === 'single', - rptrsZoneGrouping === 'location', - rptrsSeparateTimeslots - ); - - if (result.channels.length === 0) { - setError('No channels to add from selected DMR repeaters'); - return; - } - - // Merge with existing channels to avoid duplicates - const mergedResult = mergeOverlappingChannels([channels, result.channels]); - setChannels(mergedResult.mergedChannels); - - // Add zones - const updatedZones = [...zones, ...result.zones]; - setZones(updatedZones); - - setGenerationResult({ - channels: result.channels.length, - zones: result.zones.length, - }); - - // Clear selection - setSelectedRptrs(new Set()); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add DMR repeater channels'); - } finally { - setIsAddingRptrs(false); - } - }; - - const handleChirpCSVImport = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - setIsImportingChirp(true); - setError(null); - setChirpImportResult(null); - - try { - const content = await file.text(); - - // Find next available channel number - const existingNumbers = new Set(channels.map(ch => ch.number)); - let nextChannelNumber = 1; - while (existingNumbers.has(nextChannelNumber)) { - nextChannelNumber++; - } - - const result = importChannelsFromChirpCSV(content, nextChannelNumber); - - if (result.success && result.channels) { - // Add imported channels - const newChannels = [...channels, ...result.channels]; - setChannels(newChannels); - - setChirpImportResult({ - operation: 'import', - channels: result.channels.length, - errors: result.errors, - }); - } else { - setError(result.errors?.join('\n') || 'Failed to import CHIRP CSV'); - setChirpImportResult({ - operation: 'import', - channels: 0, - errors: result.errors, - }); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to import CHIRP CSV file'); - } finally { - setIsImportingChirp(false); - // Reset file input - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } - }; - - const handleAddMmdvmChannels = () => { - const freq = parseFloat(mmdvmFrequency); - if (!isValidMMDVMFrequency(freq)) { - setError(`Frequency must be between ${MMDVM_FREQ_MIN_MHZ} and ${MMDVM_FREQ_MAX_MHZ} MHz`); - return; - } - const validEntries = mmdvmEntries.filter( - (e) => (e.talkGroupName?.trim() || e.channelName?.trim()) && !isNaN(e.talkGroupId) && e.talkGroupId >= 0 - ); - if (validEntries.length === 0) { - setError('Add at least one channel with a Talk Group name and Talk Group ID.'); - return; - } - - setIsAddingMmdvm(true); - setError(null); - - try { - const existingChannelNumbers = new Set(channels.map((ch) => ch.number)); - let nextChannelNumber = 1; - while (existingChannelNumbers.has(nextChannelNumber)) { - nextChannelNumber++; - } - - const maxContactId = contacts.length > 0 ? Math.max(...contacts.map((c) => c.id)) : 0; - const firstContactId = maxContactId + 1; - - const firstDmrRadioIdIndex = - mmdvmDmrRadioIdIndex === '' || mmdvmDmrRadioIdIndex === 'none' - ? undefined - : parseInt(mmdvmDmrRadioIdIndex, 10); - const validDmrIndex = - firstDmrRadioIdIndex !== undefined && - !isNaN(firstDmrRadioIdIndex) && - radioIds.some((r) => r.index === firstDmrRadioIdIndex) - ? firstDmrRadioIdIndex - : undefined; - - const result = generateMMDVMChannels({ - frequencyMhz: freq, - entries: validEntries, - firstChannelNumber: nextChannelNumber, - firstContactId, - dmrRadioIdIndex: validDmrIndex, - zoneName: mmdvmZoneName.trim() || undefined, - }); - - setContacts([...contacts, ...result.contacts]); - setChannels([...channels, ...result.channels]); - setZones([...zones, result.zone]); - - setGenerationResult({ - channels: result.channels.length, - zones: 1, - }); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add MMDVM channels'); - } finally { - setIsAddingMmdvm(false); - } - }; - - const handleChirpCSVExport = () => { - try { - // Filter out digital channels - Chirp doesn't support them - const analogChannels = channels.filter(ch => - ch.mode === 'Analog' || ch.mode === 'Fixed Analog' - ); - - if (analogChannels.length === 0) { - setError('No analog channels to export. CHIRP only supports analog channels.'); - return; - } - - const digitalCount = channels.length - analogChannels.length; - const csvContent = exportChannelsToChirpCSV(analogChannels); - downloadCSV(csvContent, 'chirp_channels.csv'); - - if (digitalCount > 0) { - setChirpImportResult({ - operation: 'export', - channels: analogChannels.length, - errors: [`Exported ${analogChannels.length} analog channel${analogChannels.length !== 1 ? 's' : ''}. ${digitalCount} digital channel${digitalCount !== 1 ? 's' : ''} excluded (CHIRP doesn't support digital).`], - }); - } else { - setChirpImportResult({ - operation: 'export', - channels: analogChannels.length, - errors: undefined, - }); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to export CHIRP CSV'); - } - }; - return (
-
- Smart Import/Export -

- Import channels from CHIRP CSV format or export your channels to CHIRP CSV format -

-
- - {/* Chirp CSV Import/Export Section */} - - Analog CHIRP CSV Import/Export -

- Import or export analog channels in CHIRP CSV format for use with other radio programming software. Digital channels are not supported by CHIRP and will be excluded from exports. -

- -
-
- -

- Any digital channels in the CSV will be imported as analog. -

- - -
-
- -

- Only analog channels will be exported. Digital channels are excluded. -

- -
-
- - {chirpImportResult && ( -
0 - ? 'bg-yellow-900 border border-yellow-500 text-yellow-200' - : 'bg-deep-gray border border-neon-cyan text-neon-cyan' - }`}> -
- {chirpImportResult.operation === 'import' - ? (chirpImportResult.errors && chirpImportResult.errors.length > 0 - ? 'Import completed with warnings' - : 'Import successful') - : (chirpImportResult.errors && chirpImportResult.errors.length > 0 - ? 'Export completed with warnings' - : 'Export successful')} -
-
- {chirpImportResult.operation === 'import' - ? `Imported ${chirpImportResult.channels} channel${chirpImportResult.channels !== 1 ? 's' : ''}` - : `Exported ${chirpImportResult.channels} channel${chirpImportResult.channels !== 1 ? 's' : ''}`} -
- {chirpImportResult.errors && chirpImportResult.errors.length > 0 && ( -
-
Warnings:
-
    - {chirpImportResult.errors.slice(0, 5).map((err, idx) => ( -
  • {err}
  • - ))} - {chirpImportResult.errors.length > 5 && ( -
  • ... and {chirpImportResult.errors.length - 5} more
  • - )} -
-
- )} -
- )} -
+ {/* 1. ChirpSource */} + + {/* 2. Channel Wizard heading */}
Channel Wizard

@@ -909,7 +176,7 @@ export const SmartImportTab: React.FC = () => {

- {/* Location-Based Search Section */} + {/* 3. Location controls card */} Location-Based Search

@@ -917,7 +184,7 @@ export const SmartImportTab: React.FC = () => { ? 'Search for nearby airports, TAFL entries, and DMR repeaters based on your location' : 'Search for nearby airports and TAFL entries based on your location'}

- +
@@ -1070,9 +337,9 @@ export const SmartImportTab: React.FC = () => { disabled={isSearchingAll || (supportsDigital ? (!searchAirports && !searchTafl && !searchDmrRepeaters) : (!searchAirports && !searchTafl))} className="bg-neon-cyan text-dark-charcoal hover:bg-neon-cyan-bright w-full" > - {isSearchingAll - ? (locationType === 'current' - ? 'Getting location and searching...' + {isSearchingAll + ? (locationType === 'current' + ? 'Getting location and searching...' : 'Searching...') : (locationType === 'current' ? 'Use Current Location & Search' @@ -1119,759 +386,60 @@ export const SmartImportTab: React.FC = () => { )} - {/* Airport Results */} - {airports.length > 0 && ( - - Airports - <> -
- - Found {airports.length} Airport{airports.length !== 1 ? 's' : ''} - -
- - -
-
- -
- {airports.map((airport, index) => ( -
handleToggleAirport(index)} - > -
-
-
- handleToggleAirport(index)} - onClick={(e) => e.stopPropagation()} - className="mr-2" - /> - {airport.c} -
-
-
- {'distance' in airport && typeof airport.distance === 'number' - ? `${airport.distance.toFixed(1)} miles away` - : 'Distance unknown'} -
-
- Frequencies: - {getAirportFrequenciesWithTypes(airport).map((freqInfo, idx) => ( -
- - {(freqInfo.frequency / 1000).toFixed(3)} MHz - - - {freqInfo.type} - - - {freqInfo.description} - -
- ))} -
-
-
-
-
- ))} -
- - {selectedAirports.size > 0 && ( - <> -
- -
- - -
-
- - - )} - -
- )} - - {/* TAFL Results */} - {taflEntries.length > 0 && ( - - TAFL Entries - -
- - setTaflSearchFilter(e.target.value)} - placeholder="Search entries..." - className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white" - /> -
- <> -
- - {filteredTaflEntries.length} of {taflEntries.length} TAFL Entr{filteredTaflEntries.length !== 1 ? 'ies' : 'y'} - {taflSearchFilter.trim() && ` (filtered)`} - -
- - -
-
- -
- {taflGroupArray.map(([groupName, groupEntries]) => { - // Get original indices for this group (using deduplicated entry mapping) - const groupIndices = groupEntries.map(entry => { - const key = `${entry.c}|${entry.f}`; - return entryIndexMap.get(key) ?? filteredTaflEntries.findIndex(e => e === entry); - }).filter(idx => idx !== -1); - - const allSelected = groupIndices.every(idx => selectedTaflEntries.has(idx)); - const someSelected = groupIndices.some(idx => selectedTaflEntries.has(idx)); - const isGroup = groupEntries.length > 1; - const isExpanded = expandedTaflGroups.has(groupName); - - const handleToggleGroup = () => { - const newSelected = new Set(selectedTaflEntries); - if (allSelected) { - // Deselect all in group - groupIndices.forEach(idx => newSelected.delete(idx)); - } else { - // Select all in group - groupIndices.forEach(idx => newSelected.add(idx)); - } - setSelectedTaflEntries(newSelected); - }; - - const handleToggleExpand = (e: React.MouseEvent) => { - e.stopPropagation(); - const newExpanded = new Set(expandedTaflGroups); - if (isExpanded) { - newExpanded.delete(groupName); - } else { - newExpanded.add(groupName); - } - setExpandedTaflGroups(newExpanded); - }; - - return ( -
- {isGroup && ( -
-
- { - e.stopPropagation(); - const input = e.target as HTMLInputElement; - input.indeterminate = someSelected && !allSelected; - }} - className="mr-2" - /> - - - {groupName} ({groupEntries.length} entries) - -
-
- )} - {(isGroup ? isExpanded : true) && ( -
- {groupEntries.map((entry) => { - // Find all indices in filteredTaflEntries that match this entry (name + frequency) - const matchingIndices = filteredTaflEntries - .map((e, idx) => e.c === entry.c && e.f === entry.f ? idx : -1) - .filter(idx => idx !== -1); - - // Use first matching index as the key for display - const displayIndex = matchingIndices[0] ?? -1; - if (displayIndex === -1) return null; - - // Check if any of the matching entries are selected - const isSelected = matchingIndices.some(idx => selectedTaflEntries.has(idx)); - - const handleToggleEntry = () => { - const newSelected = new Set(selectedTaflEntries); - if (isSelected) { - // Deselect all matching entries - matchingIndices.forEach(idx => newSelected.delete(idx)); - } else { - // Select all matching entries (they're duplicates, so select all) - matchingIndices.forEach(idx => newSelected.add(idx)); - } - setSelectedTaflEntries(newSelected); - }; - - return ( -
-
-
-
- e.stopPropagation()} - className="mr-2" - /> - {entry.c} - {matchingIndices.length > 1 && ( - - ({matchingIndices.length} duplicates) - - )} -
-
-
- {'distance' in entry && typeof entry.distance === 'number' - ? `${entry.distance.toFixed(1)} miles away` - : 'Distance unknown'} -
-
- Frequency: -
- - {(entry.f / 1000.0).toFixed(3)} MHz - -
-
-
-
-
-
- ); - })} -
- )} -
- ); - })} -
- - {selectedTaflEntries.size > 0 && ( - - )} - -
- )} - - {/* Error Display */} + {/* 4. Error display */} {error && (
{error}
)} - {/* Success Message */} + {/* 5. AirportSource */} + + + {/* 6. TaflSource */} + + + {/* 7. RptrsSource (only if supportsDigital) */} + + + {/* 8. Generation result success banner */} {generationResult && (
Successfully generated {generationResult.channels} channels and {generationResult.zones} zones!
)} - - {/* DMR Repeater Results (digital radios only) */} - {supportsDigital && rptrs.length > 0 && ( - - DMR Repeaters - <> -
- setRptrsSearchFilter(e.target.value)} - className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white" - /> -
- -
- - {rptrs.filter(r => { - if (!rptrsSearchFilter.trim()) return true; - const filter = rptrsSearchFilter.toLowerCase(); - return r.callsign.toLowerCase().includes(filter) || - r.city.toLowerCase().includes(filter) || - r.state.toLowerCase().includes(filter) || - r.ipsc_network.toLowerCase().includes(filter); - }).length} of {rptrs.length} DMR Repeater{rptrs.length !== 1 ? 's' : ''} - {rptrsSearchFilter.trim() && ` (filtered)`} - -
- - -
-
- -
- {rptrs - .filter(r => { - if (!rptrsSearchFilter.trim()) return true; - const filter = rptrsSearchFilter.toLowerCase(); - return r.callsign.toLowerCase().includes(filter) || - r.city.toLowerCase().includes(filter) || - r.state.toLowerCase().includes(filter) || - r.ipsc_network.toLowerCase().includes(filter); - }) - .map((rptr) => { - const originalIndex = rptrs.findIndex(r => r === rptr); - return ( -
handleToggleRptr(originalIndex)} - > -
-
-
- handleToggleRptr(originalIndex)} - onClick={(e) => e.stopPropagation()} - className="mr-2" - /> - {rptr.callsign} - CC{rptr.color_code} - {rptr.ts_linked} -
-
-
- {convertRptrFrequency(rptr.frequency).toFixed(5)} MHz - {rptr.offset && ` (Offset: ${rptr.offset} MHz)`} -
-
- {rptr.city} - {rptr.state && `, ${rptr.state}`} - {rptr.distance && ` (${rptr.distance.toFixed(1)} mi)`} -
-
- Network: {rptr.ipsc_network || 'Unknown'} -
-
-
-
-
- ); - })} -
- - {selectedRptrs.size > 0 && ( -
-
- - - -
- - -
- )} - -
- )} - - {/* MMDVM Simplex Section (digital radios only) */} + {/* 9. MmdvmSource (only if supportsDigital) */} {supportsDigital && ( - - MMDVM -

- Add simplex MMDVM hotspot channels (one frequency, Slot 2, Color Code 1). You can create multiple channels on the same frequency with different talk groups—for example, one for local (TG 9) and one for a brandmeister talk group. -

- -
-
-
- - setMmdvmZoneName(e.target.value)} - placeholder="Default: MMDVM" - maxLength={16} - className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white" - /> -
-
- - setMmdvmFrequency(e.target.value)} - min={MMDVM_FREQ_MIN_MHZ} - max={MMDVM_FREQ_MAX_MHZ} - step="0.001" - placeholder="431.150" - className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white" - /> -

- {MMDVM_FREQ_MIN_MHZ}–{MMDVM_FREQ_MAX_MHZ} MHz -

-
-
- - -

- For TX on all channels -

-
-
- -
- -

- Each row is one channel. Set the talk group name and ID (e.g. Local = 9, Brandmeister Canada = 3100). -

-
- {mmdvmEntries.map((entry, index) => ( -
-
- - { - const next = [...mmdvmEntries]; - next[index] = { ...next[index], channelName: e.target.value }; - setMmdvmEntries(next); - }} - placeholder="Optional" - maxLength={16} - className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm" - /> -
-
- - { - const next = [...mmdvmEntries]; - next[index] = { ...next[index], talkGroupName: e.target.value }; - setMmdvmEntries(next); - }} - placeholder="e.g. Local" - maxLength={16} - className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm" - /> -
-
- - { - const v = e.target.value === '' ? 0 : parseInt(e.target.value, 10); - const next = [...mmdvmEntries]; - next[index] = { ...next[index], talkGroupId: isNaN(v) ? 0 : v }; - setMmdvmEntries(next); - }} - min={0} - max={16776415} - placeholder="9" - className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm" - /> -
-
- {mmdvmEntries.length > 1 ? ( - - ) : null} - {index === mmdvmEntries.length - 1 ? ( - - ) : null} -
-
- ))} -
-
- - {radioIds.length === 0 && ( -
- No DMR Radio ID set. Add one in the Digital tab so your radio can transmit on these channels. -
- )} - -

- Settings: Digital, Slot 2, Color Code 1. Selected DMR Radio ID is used for TX on all channels. -

-
- - -
+ )} - {/* Fixed Channels Section */} - - Fixed Channels -

- Add standard channel sets that are location-independent (FRS, GMRS, MURS, etc.) -

- -
- {fixedChannelSets.map((set) => { - const isExpanded = expandedChannelSet === set.name; - - return ( -
-
setExpandedChannelSet(isExpanded ? null : set.name)} - > -
-
-
- handleToggleFixedSet(set.name)} - onClick={(e) => e.stopPropagation()} - className="mr-2" - /> - {set.displayName || set.name} - - ({set.channels.length} channels) - - -
-
- {set.description} -
-
-
-
- - {isExpanded && ( -
-
Channels:
-
- {set.channels.map((channel, index) => ( -
-
{channel.name}
-
- RX: {channel.rxFrequency.toFixed(4)} MHz -
-
- TX: {channel.txFrequency.toFixed(4)} MHz -
-
- Power: {channel.power} -
-
- ))} -
-
- )} -
- ); - })} -
- - {selectedFixedSets.size > 0 && ( - - )} -
- + {/* 10. FixedChannelsSource */} +
); }; diff --git a/src/components/import/sources/AirportSource.tsx b/src/components/import/sources/AirportSource.tsx new file mode 100644 index 0000000..61c343b --- /dev/null +++ b/src/components/import/sources/AirportSource.tsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react'; +import { useImportStores } from '../../../hooks/useImportStores'; +import { getNextChannelNumber, selectionCardClass } from '../../../utils/importHelpers'; +import { generateAirportChannels } from '../../../services/airportChannels'; +import { getAirportFrequenciesWithTypes, type AirportData } from '../../../data/airportsData'; +import { SelectAllButtons } from '../SelectAllButtons'; +import { Button } from '../../ui/Button'; +import { Card } from '../../ui/Card'; +import { SectionTitle } from '../../ui/SectionTitle'; + +interface AirportSourceProps { + airports: (AirportData & { distance?: number })[]; + isSearching: boolean; + onError: (msg: string) => void; + onGenerationResult: (r: { channels: number; zones: number }) => void; +} + +export const AirportSource: React.FC = ({ + airports, + isSearching: _isSearching, + onError, + onGenerationResult, +}) => { + const { channels, setChannels, zones, setZones } = useImportStores(); + + const [selectedAirports, setSelectedAirports] = useState>(new Set()); + const [airportZoneGrouping, setAirportZoneGrouping] = useState<'individual' | 'single'>('individual'); + const [isAddingAirports, setIsAddingAirports] = useState(false); + + const handleToggleAirport = (index: number) => { + const newSelected = new Set(selectedAirports); + if (newSelected.has(index)) { + newSelected.delete(index); + } else { + newSelected.add(index); + } + setSelectedAirports(newSelected); + }; + + const handleSelectAllAirports = () => { + setSelectedAirports(new Set(airports.map((_, i) => i))); + }; + + const handleDeselectAllAirports = () => { + setSelectedAirports(new Set()); + }; + + const handleAddAirportChannels = async () => { + if (selectedAirports.size === 0) { + onError('Please select at least one airport'); + return; + } + + setIsAddingAirports(true); + onError(''); + + try { + // Get selected airports + const selectedAirportList = Array.from(selectedAirports) + .map(i => airports[i]) + .filter(Boolean); + + if (selectedAirportList.length === 0) { + throw new Error('No airports selected'); + } + + const nextChannelNumber = getNextChannelNumber(channels); + + // Generate channels and zones for selected airports + const result = generateAirportChannels( + nextChannelNumber, + selectedAirportList, // Pass selected airports + airportZoneGrouping === 'single' // Group all in one zone if selected + ); + + if (result.channels.length === 0) { + onError('No channels to add from selected airports'); + return; + } + + // Add channels + const updatedChannels = [...channels, ...result.channels]; + setChannels(updatedChannels); + + // Add zones (one per airport) + const updatedZones = [...zones, ...result.zones]; + setZones(updatedZones); + + onGenerationResult({ + channels: result.channels.length, + zones: result.zones.length, + }); + + // Clear selection + setSelectedAirports(new Set()); + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to add airport channels'); + } finally { + setIsAddingAirports(false); + } + }; + + if (airports.length === 0) return null; + + return ( + + Airports + <> +
+ + Found {airports.length} Airport{airports.length !== 1 ? 's' : ''} + + +
+ +
+ {airports.map((airport, index) => ( +
handleToggleAirport(index)} + > +
+
+
+ handleToggleAirport(index)} + onClick={(e) => e.stopPropagation()} + className="mr-2" + /> + {airport.c} +
+
+
+ {'distance' in airport && typeof airport.distance === 'number' + ? `${airport.distance.toFixed(1)} miles away` + : 'Distance unknown'} +
+
+ Frequencies: + {getAirportFrequenciesWithTypes(airport).map((freqInfo, idx) => ( +
+ + {(freqInfo.frequency / 1000).toFixed(3)} MHz + + + {freqInfo.type} + + + {freqInfo.description} + +
+ ))} +
+
+
+
+
+ ))} +
+ + {selectedAirports.size > 0 && ( + <> +
+ +
+ + +
+
+ + + )} + +
+ ); +}; diff --git a/src/components/import/sources/ChirpSource.tsx b/src/components/import/sources/ChirpSource.tsx new file mode 100644 index 0000000..278247a --- /dev/null +++ b/src/components/import/sources/ChirpSource.tsx @@ -0,0 +1,193 @@ +import React, { useState, useRef } from 'react'; +import { useChannelsStore } from '../../../store/channelsStore'; +import { getNextChannelNumber } from '../../../utils/importHelpers'; +import { importChannelsFromChirpCSV, exportChannelsToChirpCSV, downloadCSV } from '../../../services/csv'; +import { Button } from '../../ui/Button'; +import { Card } from '../../ui/Card'; +import { SectionTitle } from '../../ui/SectionTitle'; + +interface ChirpSourceProps { + onError: (msg: string) => void; +} + +export const ChirpSource: React.FC = ({ onError }) => { + const { channels, setChannels } = useChannelsStore(); + + const [isImportingChirp, setIsImportingChirp] = useState(false); + const [chirpImportResult, setChirpImportResult] = useState<{ + operation: 'import' | 'export'; + channels: number; + errors?: string[]; + } | null>(null); + const fileInputRef = useRef(null); + + const handleChirpCSVImport = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setIsImportingChirp(true); + onError(''); + setChirpImportResult(null); + + try { + const content = await file.text(); + + const nextChannelNumber = getNextChannelNumber(channels); + + const result = importChannelsFromChirpCSV(content, nextChannelNumber); + + if (result.success && result.channels) { + // Add imported channels + const newChannels = [...channels, ...result.channels]; + setChannels(newChannels); + + setChirpImportResult({ + operation: 'import', + channels: result.channels.length, + errors: result.errors, + }); + } else { + onError(result.errors?.join('\n') || 'Failed to import CHIRP CSV'); + setChirpImportResult({ + operation: 'import', + channels: 0, + errors: result.errors, + }); + } + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to import CHIRP CSV file'); + } finally { + setIsImportingChirp(false); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleChirpCSVExport = () => { + try { + // Filter out digital channels - Chirp doesn't support them + const analogChannels = channels.filter(ch => + ch.mode === 'Analog' || ch.mode === 'Fixed Analog' + ); + + if (analogChannels.length === 0) { + onError('No analog channels to export. CHIRP only supports analog channels.'); + return; + } + + const digitalCount = channels.length - analogChannels.length; + const csvContent = exportChannelsToChirpCSV(analogChannels); + downloadCSV(csvContent, 'chirp_channels.csv'); + + if (digitalCount > 0) { + setChirpImportResult({ + operation: 'export', + channels: analogChannels.length, + errors: [`Exported ${analogChannels.length} analog channel${analogChannels.length !== 1 ? 's' : ''}. ${digitalCount} digital channel${digitalCount !== 1 ? 's' : ''} excluded (CHIRP doesn't support digital).`], + }); + } else { + setChirpImportResult({ + operation: 'export', + channels: analogChannels.length, + errors: undefined, + }); + } + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to export CHIRP CSV'); + } + }; + + return ( + <> +
+ Smart Import/Export +

+ Import channels from CHIRP CSV format or export your channels to CHIRP CSV format +

+
+ + {/* Chirp CSV Import/Export Section */} + + Analog CHIRP CSV Import/Export +

+ Import or export analog channels in CHIRP CSV format for use with other radio programming software. Digital channels are not supported by CHIRP and will be excluded from exports. +

+ +
+
+ +

+ Any digital channels in the CSV will be imported as analog. +

+ + +
+
+ +

+ Only analog channels will be exported. Digital channels are excluded. +

+ +
+
+ + {chirpImportResult && ( +
0 + ? 'bg-yellow-900 border border-yellow-500 text-yellow-200' + : 'bg-deep-gray border border-neon-cyan text-neon-cyan' + }`}> +
+ {chirpImportResult.operation === 'import' + ? (chirpImportResult.errors && chirpImportResult.errors.length > 0 + ? 'Import completed with warnings' + : 'Import successful') + : (chirpImportResult.errors && chirpImportResult.errors.length > 0 + ? 'Export completed with warnings' + : 'Export successful')} +
+
+ {chirpImportResult.operation === 'import' + ? `Imported ${chirpImportResult.channels} channel${chirpImportResult.channels !== 1 ? 's' : ''}` + : `Exported ${chirpImportResult.channels} channel${chirpImportResult.channels !== 1 ? 's' : ''}`} +
+ {chirpImportResult.errors && chirpImportResult.errors.length > 0 && ( +
+
Warnings:
+
    + {chirpImportResult.errors.slice(0, 5).map((err, idx) => ( +
  • {err}
  • + ))} + {chirpImportResult.errors.length > 5 && ( +
  • ... and {chirpImportResult.errors.length - 5} more
  • + )} +
+
+ )} +
+ )} +
+ + ); +}; diff --git a/src/components/import/sources/FixedChannelsSource.tsx b/src/components/import/sources/FixedChannelsSource.tsx new file mode 100644 index 0000000..90df13f --- /dev/null +++ b/src/components/import/sources/FixedChannelsSource.tsx @@ -0,0 +1,246 @@ +import React, { useState } from 'react'; +import { useImportStores } from '../../../hooks/useImportStores'; +import { getNextChannelNumber } from '../../../utils/importHelpers'; +import { getAvailableFixedChannelSets, getChannelsForSet } from '../../../services/fixedChannels'; +import { mergeOverlappingChannels, getChannelFullKey } from '../../../services/channelMerger'; +import { generateZoneId } from '../../../utils/zoneHelpers'; +import type { Channel } from '../../../models'; +import type { Zone } from '../../../models'; +import { Button } from '../../ui/Button'; +import { Card } from '../../ui/Card'; +import { SectionTitle } from '../../ui/SectionTitle'; + +interface FixedChannelsSourceProps { + onError: (msg: string) => void; + onGenerationResult: (r: { channels: number; zones: number }) => void; +} + +export const FixedChannelsSource: React.FC = ({ + onError, + onGenerationResult, +}) => { + const { channels, setChannels, zones, setZones } = useImportStores(); + + const [selectedFixedSets, setSelectedFixedSets] = useState>(new Set()); + const [isAddingFixed, setIsAddingFixed] = useState(false); + const [expandedChannelSet, setExpandedChannelSet] = useState(null); + + const fixedChannelSets = getAvailableFixedChannelSets(); + + const handleAddFixedChannels = () => { + if (selectedFixedSets.size === 0) { + onError('Please select at least one channel set'); + return; + } + + setIsAddingFixed(true); + onError(''); + + try { + const nextChannelNumber = getNextChannelNumber(channels); + + // Generate channels for each selected set (with temporary numbers) + const channelSets: Channel[][] = []; + const setNames: string[] = []; + + for (const setName of selectedFixedSets) { + // Use generic function to get channels for any set + const setChannels = getChannelsForSet(setName, 1); + + if (setChannels.length > 0) { + channelSets.push(setChannels); + setNames.push(setName); + } + } + + // FIRST: Check against existing channels and build mapping for duplicates + // Match on ALL settings: frequency, name, mode, bandwidth, power, CTCSS/DCS + const existingChannelMap = new Map(); // full key -> channel number + for (const ch of channels) { + const fullKey = getChannelFullKey(ch); + existingChannelMap.set(fullKey, ch.number); + } + + // Merge overlapping channels (within new sets only) + const { mergedChannels, channelMapping } = mergeOverlappingChannels(channelSets, nextChannelNumber); + + // Update mapping to use existing channels where ALL settings match + const finalChannelMapping = new Map(); + const channelsToAdd: Channel[] = []; + + for (const newChannel of mergedChannels) { + const fullKey = getChannelFullKey(newChannel); + + if (existingChannelMap.has(fullKey)) { + // This exact channel already exists - use existing channel + const existingChannelNum = existingChannelMap.get(fullKey)!; + + // Update all mappings that point to this merged channel + for (const [origNum, mergedNum] of channelMapping.entries()) { + if (mergedNum === newChannel.number) { + finalChannelMapping.set(origNum, existingChannelNum); + } + } + } else { + // New unique channel (or different settings) - add it + channelsToAdd.push(newChannel); + + // Copy mapping as-is for this channel + for (const [origNum, mergedNum] of channelMapping.entries()) { + if (mergedNum === newChannel.number) { + finalChannelMapping.set(origNum, newChannel.number); + } + } + } + } + + // Create zones with final channel numbers + const newZones: Zone[] = []; + for (let i = 0; i < channelSets.length; i++) { + const setChannels = channelSets[i]; + const setName = setNames[i]; + + // Map original channel numbers to final channel numbers + const zoneChannelNumbers = setChannels + .map(ch => finalChannelMapping.get(ch.number)) + .filter((num): num is number => num !== undefined) + .sort((a, b) => a - b); + + if (zoneChannelNumbers.length > 0) { + newZones.push({ + id: generateZoneId(), + name: setName, + channels: zoneChannelNumbers, + }); + } + } + + // Add only new channels (not duplicates) + const updatedChannels = [...channels, ...channelsToAdd]; + setChannels(updatedChannels); + + const updatedZones = [...zones, ...newZones]; + setZones(updatedZones); + + onGenerationResult({ + channels: channelsToAdd.length, + zones: newZones.length, + }); + + // Clear selection + setSelectedFixedSets(new Set()); + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to add fixed channels'); + } finally { + setIsAddingFixed(false); + } + }; + + const handleToggleFixedSet = (setName: string) => { + const newSelected = new Set(selectedFixedSets); + if (newSelected.has(setName)) { + newSelected.delete(setName); + } else { + newSelected.add(setName); + } + setSelectedFixedSets(newSelected); + }; + + return ( + + Fixed Channels +

+ Add standard channel sets that are location-independent (FRS, GMRS, MURS, etc.) +

+ +
+ {fixedChannelSets.map((set) => { + const isExpanded = expandedChannelSet === set.name; + + return ( +
+
setExpandedChannelSet(isExpanded ? null : set.name)} + > +
+
+
+ handleToggleFixedSet(set.name)} + onClick={(e) => e.stopPropagation()} + className="mr-2" + /> + {set.displayName || set.name} + + ({set.channels.length} channels) + + +
+
+ {set.description} +
+
+
+
+ + {isExpanded && ( +
+
Channels:
+
+ {set.channels.map((channel, index) => ( +
+
{channel.name}
+
+ RX: {channel.rxFrequency.toFixed(4)} MHz +
+
+ TX: {channel.txFrequency.toFixed(4)} MHz +
+
+ Power: {channel.power} +
+
+ ))} +
+
+ )} +
+ ); + })} +
+ + {selectedFixedSets.size > 0 && ( + + )} +
+ ); +}; diff --git a/src/components/import/sources/MmdvmSource.tsx b/src/components/import/sources/MmdvmSource.tsx new file mode 100644 index 0000000..9187c0a --- /dev/null +++ b/src/components/import/sources/MmdvmSource.tsx @@ -0,0 +1,266 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useImportStores } from '../../../hooks/useImportStores'; +import { useContactsStore } from '../../../store/contactsStore'; +import { useDMRRadioIDsStore } from '../../../store/dmrRadioIdsStore'; +import { getNextChannelNumber } from '../../../utils/importHelpers'; +import { + generateMMDVMChannels, + isValidMMDVMFrequency, + MMDVM_FREQ_MIN_MHZ, + MMDVM_FREQ_MAX_MHZ, + type MMDVMChannelEntry, +} from '../../../services/mmdvmChannels'; +import { Button } from '../../ui/Button'; +import { Card } from '../../ui/Card'; +import { SectionTitle } from '../../ui/SectionTitle'; + +interface MmdvmSourceProps { + onError: (msg: string) => void; + onGenerationResult: (r: { channels: number; zones: number }) => void; +} + +export const MmdvmSource: React.FC = ({ onError, onGenerationResult }) => { + const { channels, setChannels, zones, setZones } = useImportStores(); + const { contacts, setContacts } = useContactsStore(); + const { radioIds } = useDMRRadioIDsStore(); + + const [mmdvmFrequency, setMmdvmFrequency] = useState('431.150'); + const [mmdvmEntries, setMmdvmEntries] = useState([ + { channelName: '', talkGroupName: 'Local', talkGroupId: 9 }, + ]); + const [mmdvmZoneName, setMmdvmZoneName] = useState('MMDVM'); + const [mmdvmDmrRadioIdIndex, setMmdvmDmrRadioIdIndex] = useState(''); // '' = None, or String(index) + const [isAddingMmdvm, setIsAddingMmdvm] = useState(false); + const mmdvmDmrIdDefaultSetRef = useRef(false); + + // Preset MMDVM DMR Radio ID to first ID (slot 1) when list becomes available, once + useEffect(() => { + if (radioIds.length > 0 && !mmdvmDmrIdDefaultSetRef.current) { + setMmdvmDmrRadioIdIndex(String(radioIds[0].index)); + mmdvmDmrIdDefaultSetRef.current = true; + } + }, [radioIds]); + + const handleAddMmdvmChannels = () => { + const freq = parseFloat(mmdvmFrequency); + if (!isValidMMDVMFrequency(freq)) { + onError(`Frequency must be between ${MMDVM_FREQ_MIN_MHZ} and ${MMDVM_FREQ_MAX_MHZ} MHz`); + return; + } + const validEntries = mmdvmEntries.filter( + (e) => (e.talkGroupName?.trim() || e.channelName?.trim()) && !isNaN(e.talkGroupId) && e.talkGroupId >= 0 + ); + if (validEntries.length === 0) { + onError('Add at least one channel with a Talk Group name and Talk Group ID.'); + return; + } + + setIsAddingMmdvm(true); + onError(''); + + try { + const nextChannelNumber = getNextChannelNumber(channels); + + const maxContactId = contacts.length > 0 ? Math.max(...contacts.map((c) => c.id)) : 0; + const firstContactId = maxContactId + 1; + + const firstDmrRadioIdIndex = + mmdvmDmrRadioIdIndex === '' || mmdvmDmrRadioIdIndex === 'none' + ? undefined + : parseInt(mmdvmDmrRadioIdIndex, 10); + const validDmrIndex = + firstDmrRadioIdIndex !== undefined && + !isNaN(firstDmrRadioIdIndex) && + radioIds.some((r) => r.index === firstDmrRadioIdIndex) + ? firstDmrRadioIdIndex + : undefined; + + const result = generateMMDVMChannels({ + frequencyMhz: freq, + entries: validEntries, + firstChannelNumber: nextChannelNumber, + firstContactId, + dmrRadioIdIndex: validDmrIndex, + zoneName: mmdvmZoneName.trim() || undefined, + }); + + setContacts([...contacts, ...result.contacts]); + setChannels([...channels, ...result.channels]); + setZones([...zones, result.zone]); + + onGenerationResult({ + channels: result.channels.length, + zones: 1, + }); + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to add MMDVM channels'); + } finally { + setIsAddingMmdvm(false); + } + }; + + return ( + + MMDVM +

+ Add simplex MMDVM hotspot channels (one frequency, Slot 2, Color Code 1). You can create multiple channels on the same frequency with different talk groups—for example, one for local (TG 9) and one for a brandmeister talk group. +

+ +
+
+
+ + setMmdvmZoneName(e.target.value)} + placeholder="Default: MMDVM" + maxLength={16} + className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white" + /> +
+
+ + setMmdvmFrequency(e.target.value)} + min={MMDVM_FREQ_MIN_MHZ} + max={MMDVM_FREQ_MAX_MHZ} + step="0.001" + placeholder="431.150" + className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white" + /> +

+ {MMDVM_FREQ_MIN_MHZ}–{MMDVM_FREQ_MAX_MHZ} MHz +

+
+
+ + +

+ For TX on all channels +

+
+
+ +
+ +

+ Each row is one channel. Set the talk group name and ID (e.g. Local = 9, Brandmeister Canada = 3100). +

+
+ {mmdvmEntries.map((entry, index) => ( +
+
+ + { + const next = [...mmdvmEntries]; + next[index] = { ...next[index], channelName: e.target.value }; + setMmdvmEntries(next); + }} + placeholder="Optional" + maxLength={16} + className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm" + /> +
+
+ + { + const next = [...mmdvmEntries]; + next[index] = { ...next[index], talkGroupName: e.target.value }; + setMmdvmEntries(next); + }} + placeholder="e.g. Local" + maxLength={16} + className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm" + /> +
+
+ + { + const v = e.target.value === '' ? 0 : parseInt(e.target.value, 10); + const next = [...mmdvmEntries]; + next[index] = { ...next[index], talkGroupId: isNaN(v) ? 0 : v }; + setMmdvmEntries(next); + }} + min={0} + max={16776415} + placeholder="9" + className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm" + /> +
+
+ {mmdvmEntries.length > 1 ? ( + + ) : null} + {index === mmdvmEntries.length - 1 ? ( + + ) : null} +
+
+ ))} +
+
+ + {radioIds.length === 0 && ( +
+ No DMR Radio ID set. Add one in the Digital tab so your radio can transmit on these channels. +
+ )} + +

+ Settings: Digital, Slot 2, Color Code 1. Selected DMR Radio ID is used for TX on all channels. +

+
+ + +
+ ); +}; diff --git a/src/components/import/sources/RptrsSource.tsx b/src/components/import/sources/RptrsSource.tsx new file mode 100644 index 0000000..e376429 --- /dev/null +++ b/src/components/import/sources/RptrsSource.tsx @@ -0,0 +1,243 @@ +import React, { useState } from 'react'; +import { useImportStores } from '../../../hooks/useImportStores'; +import { getNextChannelNumber, selectionCardClass } from '../../../utils/importHelpers'; +import { generateRptrsChannels } from '../../../services/rptrsChannels'; +import { mergeOverlappingChannels } from '../../../services/channelMerger'; +import { convertRptrFrequency, type RptrData } from '../../../data/rptrsData'; +import { SelectAllButtons } from '../SelectAllButtons'; +import { Button } from '../../ui/Button'; +import { Card } from '../../ui/Card'; +import { SectionTitle } from '../../ui/SectionTitle'; + +interface RptrsSourceProps { + rptrs: (RptrData & { distance?: number })[]; + isSearching: boolean; + loadProgress: { percent: number; loaded: number; total: number } | null; + supportsDigital: boolean; + onError: (msg: string) => void; + onGenerationResult: (r: { channels: number; zones: number }) => void; +} + +export const RptrsSource: React.FC = ({ + rptrs, + isSearching: _isSearching, + loadProgress: _loadProgress, + supportsDigital, + onError, + onGenerationResult, +}) => { + const { channels, setChannels, zones, setZones } = useImportStores(); + + const [rptrsSearchFilter, setRptrsSearchFilter] = useState(''); + const [selectedRptrs, setSelectedRptrs] = useState>(new Set()); + const [rptrsZoneGrouping, setRptrsZoneGrouping] = useState<'location' | 'single'>('location'); + const [rptrsSeparateTimeslots, setRptrsSeparateTimeslots] = useState(true); + const [isAddingRptrs, setIsAddingRptrs] = useState(false); + + const handleToggleRptr = (index: number) => { + const newSelected = new Set(selectedRptrs); + if (newSelected.has(index)) { + newSelected.delete(index); + } else { + newSelected.add(index); + } + setSelectedRptrs(newSelected); + }; + + const handleSelectAllRptrs = () => { + setSelectedRptrs(new Set(rptrs.map((_, i) => i))); + }; + + const handleDeselectAllRptrs = () => { + setSelectedRptrs(new Set()); + }; + + const handleAddRptrsChannels = async () => { + if (selectedRptrs.size === 0) { + onError('Please select at least one DMR repeater'); + return; + } + + setIsAddingRptrs(true); + onError(''); + + try { + // Get selected repeaters + const selectedRptrsList = Array.from(selectedRptrs) + .map(i => rptrs[i]) + .filter(Boolean); + + if (selectedRptrsList.length === 0) { + throw new Error('No DMR repeaters selected'); + } + + const nextChannelNumber = getNextChannelNumber(channels); + + // Generate channels and zones for selected repeaters + const result = generateRptrsChannels( + nextChannelNumber, + selectedRptrsList, + rptrsZoneGrouping === 'single', + rptrsZoneGrouping === 'location', + rptrsSeparateTimeslots + ); + + if (result.channels.length === 0) { + onError('No channels to add from selected DMR repeaters'); + return; + } + + // Merge with existing channels to avoid duplicates + const mergedResult = mergeOverlappingChannels([channels, result.channels]); + setChannels(mergedResult.mergedChannels); + + // Add zones + const updatedZones = [...zones, ...result.zones]; + setZones(updatedZones); + + onGenerationResult({ + channels: result.channels.length, + zones: result.zones.length, + }); + + // Clear selection + setSelectedRptrs(new Set()); + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to add DMR repeater channels'); + } finally { + setIsAddingRptrs(false); + } + }; + + if (!supportsDigital || rptrs.length === 0) return null; + + return ( + + DMR Repeaters + <> +
+ setRptrsSearchFilter(e.target.value)} + className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white" + /> +
+ +
+ + {rptrs.filter(r => { + if (!rptrsSearchFilter.trim()) return true; + const filter = rptrsSearchFilter.toLowerCase(); + return r.callsign.toLowerCase().includes(filter) || + r.city.toLowerCase().includes(filter) || + r.state.toLowerCase().includes(filter) || + r.ipsc_network.toLowerCase().includes(filter); + }).length} of {rptrs.length} DMR Repeater{rptrs.length !== 1 ? 's' : ''} + {rptrsSearchFilter.trim() && ` (filtered)`} + + +
+ +
+ {rptrs + .filter(r => { + if (!rptrsSearchFilter.trim()) return true; + const filter = rptrsSearchFilter.toLowerCase(); + return r.callsign.toLowerCase().includes(filter) || + r.city.toLowerCase().includes(filter) || + r.state.toLowerCase().includes(filter) || + r.ipsc_network.toLowerCase().includes(filter); + }) + .map((rptr) => { + const originalIndex = rptrs.findIndex(r => r === rptr); + return ( +
handleToggleRptr(originalIndex)} + > +
+
+
+ handleToggleRptr(originalIndex)} + onClick={(e) => e.stopPropagation()} + className="mr-2" + /> + {rptr.callsign} + CC{rptr.color_code} + {rptr.ts_linked} +
+
+
+ {convertRptrFrequency(rptr.frequency).toFixed(5)} MHz + {rptr.offset && ` (Offset: ${rptr.offset} MHz)`} +
+
+ {rptr.city} + {rptr.state && `, ${rptr.state}`} + {rptr.distance && ` (${rptr.distance.toFixed(1)} mi)`} +
+
+ Network: {rptr.ipsc_network || 'Unknown'} +
+
+
+
+
+ ); + })} +
+ + {selectedRptrs.size > 0 && ( +
+
+ + + +
+ + +
+ )} + +
+ ); +}; diff --git a/src/components/import/sources/TaflSource.tsx b/src/components/import/sources/TaflSource.tsx new file mode 100644 index 0000000..be90fe7 --- /dev/null +++ b/src/components/import/sources/TaflSource.tsx @@ -0,0 +1,327 @@ +import React, { useState } from 'react'; +import { useImportStores } from '../../../hooks/useImportStores'; +import { getNextChannelNumber } from '../../../utils/importHelpers'; +import { generateTaflChannels } from '../../../services/taflChannels'; +import { groupTaflEntriesByName, type TaflData } from '../../../data/taflData'; +import { SelectAllButtons } from '../SelectAllButtons'; +import { Button } from '../../ui/Button'; +import { Card } from '../../ui/Card'; +import { SectionTitle } from '../../ui/SectionTitle'; + +interface TaflSourceProps { + entries: TaflData[]; + isSearching: boolean; + loadProgress: { percent: number; loaded: number; total: number } | null; + onError: (msg: string) => void; + onGenerationResult: (r: { channels: number; zones: number }) => void; +} + +export const TaflSource: React.FC = ({ + entries: taflEntries, + isSearching: _isSearching, + loadProgress: _loadProgress, + onError, + onGenerationResult, +}) => { + const { channels, setChannels, zones, setZones } = useImportStores(); + + const [taflSearchFilter, setTaflSearchFilter] = useState(''); + const [selectedTaflEntries, setSelectedTaflEntries] = useState>(new Set()); + const [expandedTaflGroups, setExpandedTaflGroups] = useState>(new Set()); + const [isAddingTafl, setIsAddingTafl] = useState(false); + + // Compute filtered TAFL entries for display + const filteredTaflEntries = taflSearchFilter.trim() + ? taflEntries.filter(entry => + entry.c.toLowerCase().includes(taflSearchFilter.toLowerCase()) + ) + : taflEntries; + + // Deduplicate entries: if same name AND frequency, only keep one + const uniqueFilteredEntries = new Map(); + const entryIndexMap = new Map(); // Map unique key to original index + + for (let i = 0; i < filteredTaflEntries.length; i++) { + const entry = filteredTaflEntries[i]; + const key = `${entry.c}|${entry.f}`; // Use name + frequency (in kHz) as unique key + if (!uniqueFilteredEntries.has(key)) { + uniqueFilteredEntries.set(key, entry); + entryIndexMap.set(key, i); + } + } + + const deduplicatedEntries = Array.from(uniqueFilteredEntries.values()); + + // Map deduplicated entries to their original indices in filteredTaflEntries + const filteredTaflIndices = deduplicatedEntries.map(entry => { + const key = `${entry.c}|${entry.f}`; + return entryIndexMap.get(key) ?? filteredTaflEntries.findIndex(e => e === entry); + }); + + // Group deduplicated entries by name prefix for display + const taflGroups = groupTaflEntriesByName(deduplicatedEntries, 2); + const taflGroupArray = Array.from(taflGroups.entries()).sort((a, b) => a[0].localeCompare(b[0])); + + const handleSelectAllFilteredTafl = () => { + const newSelected = new Set(selectedTaflEntries); + filteredTaflIndices.forEach(idx => newSelected.add(idx)); + setSelectedTaflEntries(newSelected); + }; + + const handleDeselectAllTafl = () => { + setSelectedTaflEntries(new Set()); + }; + + const handleAddTaflChannels = async () => { + if (selectedTaflEntries.size === 0) { + onError('Please select at least one TAFL entry'); + return; + } + + setIsAddingTafl(true); + onError(''); + + try { + // Get selected entries + const selectedTaflList = Array.from(selectedTaflEntries) + .map(i => taflEntries[i]) + .filter(Boolean); + + if (selectedTaflList.length === 0) { + throw new Error('No TAFL entries selected'); + } + + const nextChannelNumber = getNextChannelNumber(channels); + + // Generate channels and zones for selected entries + // TAFL always uses individual zones grouped by name + const result = generateTaflChannels( + nextChannelNumber, + selectedTaflList, // Pass selected entries + false, // Always use individual zones (not single zone) + true // Always group by name + ); + + if (result.channels.length === 0) { + onError('No channels to add from selected TAFL entries'); + return; + } + + // Add channels + const updatedChannels = [...channels, ...result.channels]; + setChannels(updatedChannels); + + // Add zones + const updatedZones = [...zones, ...result.zones]; + setZones(updatedZones); + + onGenerationResult({ + channels: result.channels.length, + zones: result.zones.length, + }); + + // Clear selection + setSelectedTaflEntries(new Set()); + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to add TAFL channels'); + } finally { + setIsAddingTafl(false); + } + }; + + if (taflEntries.length === 0) return null; + + return ( + + TAFL Entries + +
+ + setTaflSearchFilter(e.target.value)} + placeholder="Search entries..." + className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white" + /> +
+ <> +
+ + {filteredTaflEntries.length} of {taflEntries.length} TAFL Entr{filteredTaflEntries.length !== 1 ? 'ies' : 'y'} + {taflSearchFilter.trim() && ` (filtered)`} + + +
+ +
+ {taflGroupArray.map(([groupName, groupEntries]) => { + // Get original indices for this group (using deduplicated entry mapping) + const groupIndices = groupEntries.map(entry => { + const key = `${entry.c}|${entry.f}`; + return entryIndexMap.get(key) ?? filteredTaflEntries.findIndex(e => e === entry); + }).filter(idx => idx !== -1); + + const allSelected = groupIndices.every(idx => selectedTaflEntries.has(idx)); + const someSelected = groupIndices.some(idx => selectedTaflEntries.has(idx)); + const isGroup = groupEntries.length > 1; + const isExpanded = expandedTaflGroups.has(groupName); + + const handleToggleGroup = () => { + const newSelected = new Set(selectedTaflEntries); + if (allSelected) { + // Deselect all in group + groupIndices.forEach(idx => newSelected.delete(idx)); + } else { + // Select all in group + groupIndices.forEach(idx => newSelected.add(idx)); + } + setSelectedTaflEntries(newSelected); + }; + + const handleToggleExpand = (e: React.MouseEvent) => { + e.stopPropagation(); + const newExpanded = new Set(expandedTaflGroups); + if (isExpanded) { + newExpanded.delete(groupName); + } else { + newExpanded.add(groupName); + } + setExpandedTaflGroups(newExpanded); + }; + + return ( +
+ {isGroup && ( +
+
+ { + e.stopPropagation(); + const input = e.target as HTMLInputElement; + input.indeterminate = someSelected && !allSelected; + }} + className="mr-2" + /> + + + {groupName} ({groupEntries.length} entries) + +
+
+ )} + {(isGroup ? isExpanded : true) && ( +
+ {groupEntries.map((entry) => { + // Find all indices in filteredTaflEntries that match this entry (name + frequency) + const matchingIndices = filteredTaflEntries + .map((e, idx) => e.c === entry.c && e.f === entry.f ? idx : -1) + .filter(idx => idx !== -1); + + // Use first matching index as the key for display + const displayIndex = matchingIndices[0] ?? -1; + if (displayIndex === -1) return null; + + // Check if any of the matching entries are selected + const isSelected = matchingIndices.some(idx => selectedTaflEntries.has(idx)); + + const handleToggleEntry = () => { + const newSelected = new Set(selectedTaflEntries); + if (isSelected) { + // Deselect all matching entries + matchingIndices.forEach(idx => newSelected.delete(idx)); + } else { + // Select all matching entries (they're duplicates, so select all) + matchingIndices.forEach(idx => newSelected.add(idx)); + } + setSelectedTaflEntries(newSelected); + }; + + return ( +
+
+
+
+ e.stopPropagation()} + className="mr-2" + /> + {entry.c} + {matchingIndices.length > 1 && ( + + ({matchingIndices.length} duplicates) + + )} +
+
+
+ {'distance' in entry && typeof entry.distance === 'number' + ? `${entry.distance.toFixed(1)} miles away` + : 'Distance unknown'} +
+
+ Frequency: +
+ + {(entry.f / 1000.0).toFixed(3)} MHz + +
+
+
+
+
+
+ ); + })} +
+ )} +
+ ); + })} +
+ + {selectedTaflEntries.size > 0 && ( + + )} + +
+ ); +}; diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 2364e2c..fa6f9d0 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -1,18 +1,15 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { useRadioStore } from '../../store/radioStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { Modal } from '../ui/Modal'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; const USER_GESTURE_MESSAGE = 'Unable to read from radio, please read from a radio or load a codeplug to continue'; export const StatusBar: React.FC = () => { const { radioInfo, connectionError, setConnectionError } = useRadioStore(); - const effectiveModel = useEffectiveRadioModel(); + const { caps } = useRadioCapabilities(); const [showFirmwareWarning, setShowFirmwareWarning] = useState(false); const showUserGestureInBar = connectionError?.includes('Please click the button directly') ?? false; - - const caps = useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); const hasRealFirmware = !!(radioInfo?.firmware && radioInfo.firmware !== '-' && radioInfo.firmware.trim() !== ''); const EXPECTED_FIRMWARE = 'DM32.01.L01.048'; const isNewerFirmware = !!(hasRealFirmware && caps?.isFirmware049OrNewer?.(radioInfo!.firmware)); diff --git a/src/components/layout/TabNavigation.tsx b/src/components/layout/TabNavigation.tsx index 906e2a8..a502c41 100644 --- a/src/components/layout/TabNavigation.tsx +++ b/src/components/layout/TabNavigation.tsx @@ -1,7 +1,6 @@ import React, { useMemo, useEffect } from 'react'; import { useDebugStore } from '../../store/debugStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; interface TabNavigationProps { activeTab: string; @@ -25,8 +24,7 @@ export const TabNavigation: React.FC = ({ onTabChange, }) => { const { debugMode } = useDebugStore(); - const effectiveModel = useEffectiveRadioModel(); - const caps = useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); + const { caps } = useRadioCapabilities(); const tabs = useMemo(() => { return ALL_TABS.filter((tab) => { diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index bb1396b..8ff41c0 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -8,13 +8,12 @@ import { useRadioSettingsStore } from '../../store/radioSettingsStore'; import { useDigitalEmergencyStore } from '../../store/digitalEmergencyStore'; import { useAnalogEmergencyStore } from '../../store/analogEmergencyStore'; import { useRadioStore } from '../../store/radioStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { useQuickMessagesStore } from '../../store/quickMessagesStore'; import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore'; import { useQuickContactsStore } from '../../store/quickContactsStore'; import { useRXGroupsStore } from '../../store/rxGroupsStore'; import { useEncryptionKeysStore } from '../../store/encryptionKeysStore'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; import { getRadioPickerOptions, getMigrationTargetModels } from '../../radios'; import { validateCodeplugForWrite } from '../../services/validation/codeplugValidator'; import { migrateCodeplug, type MigrationLoss } from '../../services/codeplugMigration'; @@ -34,7 +33,7 @@ export const Toolbar: React.FC = () => { const { systems: digitalEmergencies, config: digitalEmergencyConfig, setSystems: setDigitalEmergencies, setConfig: setDigitalEmergencyConfig } = useDigitalEmergencyStore(); const { systems: analogEmergencies, setSystems: setAnalogEmergencies } = useAnalogEmergencyStore(); const { radioInfo, setRadioInfo, setShowPickRadioModal, setSelectedRadioModel } = useRadioStore(); - const effectiveModel = useEffectiveRadioModel(); + const { caps, model: effectiveModel } = useRadioCapabilities(); const { messages, setMessages } = useQuickMessagesStore(); const { radioIds: dmrRadioIds, setRadioIds } = useDMRRadioIDsStore(); const { contacts: quickContacts, setContacts: setQuickContacts } = useQuickContactsStore(); @@ -375,7 +374,6 @@ export const Toolbar: React.FC = () => { return; } // Run radio-specific validations only when model is known; combine with experimental warning in one modal - const caps = getCapabilitiesForModel(effectiveModel); const { warnings } = validateCodeplugForWrite(channels, zones, caps?.writeValidations, dmrRadioIds); let message = EXPERIMENTAL_WRITE_WARNING; if (warnings.length > 0) { diff --git a/src/components/settings/SettingsTab.tsx b/src/components/settings/SettingsTab.tsx index 749f068..2a67991 100644 --- a/src/components/settings/SettingsTab.tsx +++ b/src/components/settings/SettingsTab.tsx @@ -1,7 +1,7 @@ -import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import Cropper, { Area } from 'react-easy-crop'; import { useRadioStore } from '../../store/radioStore'; -import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; +import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { useRadioConnection } from '../../hooks/useRadioConnection'; import { parseBootImageHeader, rgb565ToImageData, imageDataToRgb565, buildBootImagePayload, BOOT_IMAGE } from '../../utils/bootImage'; import { useChannelsStore } from '../../store/channelsStore'; @@ -9,7 +9,6 @@ import { useZonesStore } from '../../store/zonesStore'; import { useContactsStore } from '../../store/contactsStore'; import { useRadioSettingsStore } from '../../store/radioSettingsStore'; import { useCalibrationStore } from '../../store/calibrationStore'; -import { getCapabilitiesForModel } from '../../radios/capabilities'; import { CALIBRATION_PARAM_NAMES } from '../../models/Calibration'; import { Modal } from '../ui/Modal'; import { Card } from '../ui/Card'; @@ -89,8 +88,7 @@ export const SettingsTab: React.FC = () => { const [showCalibration, setShowCalibration] = useState(false); const [showFirmwareWarning, setShowFirmwareWarning] = useState(false); - const effectiveModel = useEffectiveRadioModel(); - const caps = useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); + const { caps, model: effectiveModel } = useRadioCapabilities(); const EXPECTED_FIRMWARE = 'DM32.01.L01.048'; const hasRealFirmware = !!(radioInfo?.firmware && radioInfo.firmware !== '-' && radioInfo.firmware.trim() !== ''); const isNewerFirmware = !!(hasRealFirmware && caps?.isFirmware049OrNewer?.(radioInfo!.firmware)); diff --git a/src/hooks/useImportStores.ts b/src/hooks/useImportStores.ts new file mode 100644 index 0000000..50b3df9 --- /dev/null +++ b/src/hooks/useImportStores.ts @@ -0,0 +1,8 @@ +import { useChannelsStore } from '../store/channelsStore'; +import { useZonesStore } from '../store/zonesStore'; + +export function useImportStores() { + const { channels, setChannels } = useChannelsStore(); + const { zones, setZones } = useZonesStore(); + return { channels, setChannels, zones, setZones }; +} diff --git a/src/hooks/useLocationState.ts b/src/hooks/useLocationState.ts new file mode 100644 index 0000000..346abe3 --- /dev/null +++ b/src/hooks/useLocationState.ts @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { getCurrentLocation, geocodeLocation } from '../services/repeaterFinder'; + +export type LocationType = 'coordinates' | 'city' | 'current'; + +export interface ResolvedLocation { + lat: number; + lon: number; + radius: number; +} + +export function useLocationState() { + const [locationType, setLocationType] = useState('current'); + const [latitude, setLatitude] = useState(''); + const [longitude, setLongitude] = useState(''); + const [city, setCity] = useState(''); + const [state, setState] = useState(''); + const [searchRadius, setSearchRadius] = useState('50'); + + const resolveCoordinates = async (): Promise => { + let lat: number; + let lon: number; + + if (locationType === 'current') { + const loc = await getCurrentLocation(); + lat = loc.latitude; + lon = loc.longitude; + } else if (locationType === 'coordinates') { + const parsedLat = parseFloat(latitude); + const parsedLon = parseFloat(longitude); + if (isNaN(parsedLat) || isNaN(parsedLon) || !latitude.trim() || !longitude.trim()) { + throw new Error('Invalid coordinates. Please enter valid latitude and longitude.'); + } + if (parsedLat < -90 || parsedLat > 90) throw new Error('Latitude must be between -90 and 90'); + if (parsedLon < -180 || parsedLon > 180) throw new Error('Longitude must be between -180 and 180'); + lat = parsedLat; + lon = parsedLon; + } else { + if (!city.trim()) throw new Error('Please enter a city name.'); + const geocoded = await geocodeLocation(city, state); + if (!geocoded) throw new Error('Could not find location. Please check the city and state names, or use coordinates instead.'); + lat = geocoded.latitude; + lon = geocoded.longitude; + setLatitude(lat.toFixed(6)); + setLongitude(lon.toFixed(6)); + } + + const radius = parseFloat(searchRadius) || 50; + if (isNaN(radius) || radius <= 0) throw new Error('Please enter a valid search radius (greater than 0).'); + + return { lat, lon, radius }; + }; + + return { + locationType, setLocationType, + latitude, setLatitude, + longitude, setLongitude, + city, setCity, + state, setState, + searchRadius, setSearchRadius, + resolveCoordinates, + }; +} diff --git a/src/hooks/useRadioCapabilities.ts b/src/hooks/useRadioCapabilities.ts new file mode 100644 index 0000000..535bd30 --- /dev/null +++ b/src/hooks/useRadioCapabilities.ts @@ -0,0 +1,10 @@ +import { useMemo } from 'react'; +import { useEffectiveRadioModel } from './useEffectiveRadioModel'; +import { getCapabilitiesForModel } from '../radios/capabilities'; +import type { RadioCapabilities } from '../types/radioCapabilities'; + +export function useRadioCapabilities(): { caps: RadioCapabilities | null; model: string | null } { + const model = useEffectiveRadioModel(); + const caps = useMemo(() => getCapabilitiesForModel(model), [model]); + return { caps, model }; +} diff --git a/src/services/csv/csvImporter.ts b/src/services/csv/csvImporter.ts index d9004e8..16ac372 100644 --- a/src/services/csv/csvImporter.ts +++ b/src/services/csv/csvImporter.ts @@ -36,6 +36,28 @@ export function parseCSV(content: string): string[][] { }); } +export function getValue(headers: string[], row: string[], headerName: string): string { + const index = headers.findIndex(h => h.includes(headerName.toLowerCase())); + return index >= 0 && index < row.length ? row[index].trim() : ''; +} + +export function getBool(headers: string[], row: string[], headerName: string): boolean { + const val = getValue(headers, row, headerName).toLowerCase(); + return val === 'yes' || val === 'true' || val === '1'; +} + +export function getFloat(headers: string[], row: string[], headerName: string, defaultValue = 0): number { + const val = getValue(headers, row, headerName); + const num = parseFloat(val); + return isNaN(num) ? defaultValue : num; +} + +export function getInt(headers: string[], row: string[], headerName: string, defaultValue = 0): number { + const val = getValue(headers, row, headerName); + const num = parseInt(val); + return isNaN(num) ? defaultValue : num; +} + export function importChannelsFromCSV(content: string): ImportResult { try { const rows = parseCSV(content); @@ -52,76 +74,60 @@ export function importChannelsFromCSV(content: string): ImportResult { if (row.length === 0 || row.every(cell => !cell.trim())) continue; try { - const getValue = (headerName: string): string => { - const index = headers.findIndex(h => h.includes(headerName.toLowerCase())); - return index >= 0 && index < row.length ? row[index].trim() : ''; - }; - - const getBool = (headerName: string): boolean => { - const val = getValue(headerName).toLowerCase(); - return val === 'yes' || val === 'true' || val === '1'; - }; - - const getNumber = (headerName: string, defaultValue: number = 0): number => { - const val = getValue(headerName); - const num = parseFloat(val); - return isNaN(num) ? defaultValue : num; - }; - const channel: Channel = { - number: getNumber('channel number', 0) || (i), - name: getValue('name') || `Channel ${i}`, - rxFrequency: getNumber('rx frequency', 0), - txFrequency: getNumber('tx frequency', 0), - mode: (getValue('mode') as Channel['mode']) || 'Analog', - bandwidth: (getValue('bandwidth') as Channel['bandwidth']) || '25kHz', - power: (getValue('power') as Channel['power']) || 'High', - forbidTx: getBool('forbid tx'), - loneWorker: getBool('lone worker'), - scanAdd: false, // Not used in UI, default to false - scanListId: getNumber('scan list', 0), - forbidTalkaround: getBool('forbid talkaround'), - unknown1A_6_4: getNumber('unknown1a_6_4', 0), - unknown1A_3: getBool('unknown1a_3'), - aprsReceive: getBool('aprs receive'), - emergencyIndicator: getBool('emergency'), - emergencyAck: getBool('emergency ack'), - emergencySystemId: getNumber('emergency id', 0), - aprsReportMode: (getValue('aprs tx') as Channel['aprsReportMode']) || 'Off', - unknown1C_1_0: getNumber('unknown1c_1_0', 0), - voxFunction: getBool('vox'), - scramble: getBool('scramble'), - compander: getBool('compander'), - talkback: getBool('talkback'), - unknown1D_3_0: getNumber('unknown1d_3_0', 0), - squelchLevel: getNumber('squelch', 3), - digitalEmergencySystemId: getNumber('digital emergency system id', 0), - pttIdDisplay: getBool('ptt id display'), - pttId: getNumber('ptt id', 0), - colorCode: getNumber('color code', 0), + number: getFloat(headers, row, 'channel number', 0) || (i), + name: getValue(headers, row, 'name') || `Channel ${i}`, + rxFrequency: getFloat(headers, row, 'rx frequency', 0), + txFrequency: getFloat(headers, row, 'tx frequency', 0), + mode: (getValue(headers, row, 'mode') as Channel['mode']) || 'Analog', + bandwidth: (getValue(headers, row, 'bandwidth') as Channel['bandwidth']) || '25kHz', + power: (getValue(headers, row, 'power') as Channel['power']) || 'High', + forbidTx: getBool(headers, row, 'forbid tx'), + loneWorker: getBool(headers, row, 'lone worker'), + scanAdd: false, + scanListId: getFloat(headers, row, 'scan list', 0), + forbidTalkaround: getBool(headers, row, 'forbid talkaround'), + unknown1A_6_4: getFloat(headers, row, 'unknown1a_6_4', 0), + unknown1A_3: getBool(headers, row, 'unknown1a_3'), + aprsReceive: getBool(headers, row, 'aprs receive'), + emergencyIndicator: getBool(headers, row, 'emergency'), + emergencyAck: getBool(headers, row, 'emergency ack'), + emergencySystemId: getFloat(headers, row, 'emergency id', 0), + aprsReportMode: (getValue(headers, row, 'aprs tx') as Channel['aprsReportMode']) || 'Off', + unknown1C_1_0: getFloat(headers, row, 'unknown1c_1_0', 0), + voxFunction: getBool(headers, row, 'vox'), + scramble: getBool(headers, row, 'scramble'), + compander: getBool(headers, row, 'compander'), + talkback: getBool(headers, row, 'talkback'), + unknown1D_3_0: getFloat(headers, row, 'unknown1d_3_0', 0), + squelchLevel: getFloat(headers, row, 'squelch', 3), + digitalEmergencySystemId: getFloat(headers, row, 'digital emergency system id', 0), + pttIdDisplay: getBool(headers, row, 'ptt id display'), + pttId: getFloat(headers, row, 'ptt id', 0), + colorCode: getFloat(headers, row, 'color code', 0), rxCtcssDcs: { - type: (getValue('rx ctcss/dcs type') as 'CTCSS' | 'DCS' | 'None') || 'None', - value: getNumber('rx ctcss/dcs value'), + type: (getValue(headers, row, 'rx ctcss/dcs type') as 'CTCSS' | 'DCS' | 'None') || 'None', + value: getFloat(headers, row, 'rx ctcss/dcs value'), }, txCtcssDcs: { - type: (getValue('tx ctcss/dcs type') as 'CTCSS' | 'DCS' | 'None') || 'None', - value: getNumber('tx ctcss/dcs value'), + type: (getValue(headers, row, 'tx ctcss/dcs type') as 'CTCSS' | 'DCS' | 'None') || 'None', + value: getFloat(headers, row, 'tx ctcss/dcs value'), }, - companderDup: getBool('compander dup'), - voxRelated: getBool('vox related'), - unknown25_7_6: getNumber('unknown25_7_6', 0), - unknown25_3_0: getNumber('unknown25_3_0', 0), - pttIdDisplay2: getBool('ptt id display2'), - rxSquelchMode: (getValue('rx squelch mode') as Channel['rxSquelchMode']) || 'Carrier/CTC', - unknown26_3_1: getNumber('unknown26_3_1', 0), - unknown26_0: getBool('unknown26_0'), - stepFrequency: getNumber('step frequency', 5), - signalingType: (getValue('signaling type') as Channel['signalingType']) || 'None', - pttIdType: (getValue('ptt id type') as Channel['pttIdType']) || 'Off', - unknown29_3_2: getNumber('unknown29_3_2', 0), - unknown29_1_0: getNumber('unknown29_1_0', 0), - unknown2A: getNumber('unknown2a', 0), - contactId: getNumber('contact id', 0), + companderDup: getBool(headers, row, 'compander dup'), + voxRelated: getBool(headers, row, 'vox related'), + unknown25_7_6: getFloat(headers, row, 'unknown25_7_6', 0), + unknown25_3_0: getFloat(headers, row, 'unknown25_3_0', 0), + pttIdDisplay2: getBool(headers, row, 'ptt id display2'), + rxSquelchMode: (getValue(headers, row, 'rx squelch mode') as Channel['rxSquelchMode']) || 'Carrier/CTC', + unknown26_3_1: getFloat(headers, row, 'unknown26_3_1', 0), + unknown26_0: getBool(headers, row, 'unknown26_0'), + stepFrequency: getFloat(headers, row, 'step frequency', 5), + signalingType: (getValue(headers, row, 'signaling type') as Channel['signalingType']) || 'None', + pttIdType: (getValue(headers, row, 'ptt id type') as Channel['pttIdType']) || 'Off', + unknown29_3_2: getFloat(headers, row, 'unknown29_3_2', 0), + unknown29_1_0: getFloat(headers, row, 'unknown29_1_0', 0), + unknown2A: getFloat(headers, row, 'unknown2a', 0), + contactId: getFloat(headers, row, 'contact id', 0), }; channels.push(channel); @@ -159,22 +165,11 @@ export function importContactsFromCSV(content: string): ImportResult { if (row.length === 0 || row.every(cell => !cell.trim())) continue; try { - const getValue = (headerName: string): string => { - const index = headers.findIndex(h => h.includes(headerName.toLowerCase())); - return index >= 0 && index < row.length ? row[index].trim() : ''; - }; - - const getNumber = (headerName: string, defaultValue: number = 0): number => { - const val = getValue(headerName); - const num = parseInt(val); - return isNaN(num) ? defaultValue : num; - }; - const contact: Contact = { - id: getNumber('id', 0) || (i), - name: getValue('name') || `Contact ${i}`, - dmrId: getNumber('dmr id', 0), - callSign: getValue('call sign') || undefined, + id: getInt(headers, row, 'id', 0) || (i), + name: getValue(headers, row, 'name') || `Contact ${i}`, + dmrId: getInt(headers, row, 'dmr id', 0), + callSign: getValue(headers, row, 'call sign') || undefined, }; contacts.push(contact); @@ -195,4 +190,3 @@ export function importContactsFromCSV(content: string): ImportResult { }; } } - diff --git a/src/utils/importHelpers.ts b/src/utils/importHelpers.ts new file mode 100644 index 0000000..f80ed70 --- /dev/null +++ b/src/utils/importHelpers.ts @@ -0,0 +1,16 @@ +import type { Channel } from '../models'; + +export function getNextChannelNumber(channels: Channel[]): number { + const existing = new Set(channels.map(ch => ch.number)); + let next = 1; + while (existing.has(next)) next++; + return next; +} + +export function selectionCardClass(isSelected: boolean): string { + return `border rounded p-3 cursor-pointer transition-colors ${ + isSelected + ? 'border-neon-cyan bg-neon-cyan bg-opacity-10' + : 'border-neon-cyan border-opacity-30 hover:border-neon-cyan border-opacity-50' + }`; +} diff --git a/tests/unit/airportChannels.test.ts b/tests/unit/airportChannels.test.ts new file mode 100644 index 0000000..ac620b7 --- /dev/null +++ b/tests/unit/airportChannels.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { generateAirportChannels } from '../../src/services/airportChannels'; +import type { AirportData } from '../../src/data/airportsData'; + +function airport(code: string, lat: number, lon: number, frequencies: number | [number, string][]): AirportData { + return { c: code, l: [lat, lon], f: frequencies }; +} + +const yvr = airport('CYVR', 49.194, -123.183, [[118100, 'TWR'], [121900, 'GND']]); +const klax = airport('KLAX', 33.942, -118.408, [[119800, 'TWR'], [121650, 'GND']]); + +describe('generateAirportChannels', () => { + it('throws when no airports provided', () => { + expect(() => generateAirportChannels(1, [])).toThrow(); + }); + + it('generates at least one channel per airport', () => { + const result = generateAirportChannels(1, [yvr]); + expect(result.channels.length).toBeGreaterThan(0); + }); + + it('assigns channel numbers starting at startChannelNumber', () => { + const result = generateAirportChannels(10, [yvr]); + expect(result.channels[0].number).toBe(10); + }); + + it('creates sequential channel numbers', () => { + const result = generateAirportChannels(1, [yvr]); + result.channels.forEach((ch, i) => { + expect(ch.number).toBe(i + 1); + }); + }); + + it('creates one zone per airport by default', () => { + const result = generateAirportChannels(1, [yvr, klax]); + expect(result.zones.length).toBe(2); + }); + + it('creates one zone total when singleZone is true', () => { + const result = generateAirportChannels(1, [yvr, klax], true); + expect(result.zones.length).toBe(1); + }); + + it('all channel names are 16 chars or fewer', () => { + const result = generateAirportChannels(1, [yvr, klax]); + result.channels.forEach(ch => { + expect(ch.name.length).toBeLessThanOrEqual(16); + }); + }); + + it('all zone names are 16 chars or fewer', () => { + const result = generateAirportChannels(1, [yvr, klax]); + result.zones.forEach(z => { + expect(z.name.length).toBeLessThanOrEqual(16); + }); + }); + + it('zone channels reference actual channel numbers', () => { + const result = generateAirportChannels(1, [yvr]); + const channelNumbers = new Set(result.channels.map(c => c.number)); + result.zones.forEach(z => { + z.channels.forEach(n => { + expect(channelNumbers.has(n)).toBe(true); + }); + }); + }); + + it('summary matches actual counts', () => { + const result = generateAirportChannels(1, [yvr, klax]); + expect(result.summary.channelsCreated).toBe(result.channels.length); + expect(result.summary.zonesCreated).toBe(result.zones.length); + }); +}); diff --git a/tests/unit/channelHelpers.test.ts b/tests/unit/channelHelpers.test.ts new file mode 100644 index 0000000..1d99a2f --- /dev/null +++ b/tests/unit/channelHelpers.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { createDefaultChannel, validateChannelForEncoding } from '../../src/utils/channelHelpers'; + +describe('createDefaultChannel', () => { + it('returns a complete channel with sensible defaults', () => { + const ch = createDefaultChannel(); + expect(ch.number).toBe(1); + expect(ch.name).toBe(''); + expect(ch.rxFrequency).toBeCloseTo(146.52, 2); + expect(ch.txFrequency).toBeCloseTo(146.52, 2); + expect(ch.mode).toBe('Analog'); + expect(ch.bandwidth).toBe('25kHz'); + expect(ch.power).toBe('High'); + expect(ch.squelchLevel).toBe(3); + expect(ch.forbidTx).toBe(false); + expect(ch.colorCode).toBe(0); + expect(ch.contactId).toBe(0); + }); + + it('applies overrides over defaults', () => { + const ch = createDefaultChannel({ number: 42, name: 'Repeater', rxFrequency: 440.5 }); + expect(ch.number).toBe(42); + expect(ch.name).toBe('Repeater'); + expect(ch.rxFrequency).toBeCloseTo(440.5, 1); + expect(ch.mode).toBe('Analog'); // default still applied + }); + + it('defaults rxCtcssDcs and txCtcssDcs to None', () => { + const ch = createDefaultChannel(); + expect(ch.rxCtcssDcs.type).toBe('None'); + expect(ch.txCtcssDcs.type).toBe('None'); + }); + + it('override can set tone type', () => { + const ch = createDefaultChannel({ + rxCtcssDcs: { type: 'CTCSS', value: 100 }, + txCtcssDcs: { type: 'CTCSS', value: 100 }, + }); + expect(ch.rxCtcssDcs.type).toBe('CTCSS'); + expect(ch.rxCtcssDcs.value).toBe(100); + }); + + it('returns a new object each call', () => { + const a = createDefaultChannel(); + const b = createDefaultChannel(); + expect(a).not.toBe(b); + }); + + it('all unknown fields default to 0 or false', () => { + const ch = createDefaultChannel(); + expect(ch.unknown1A_6_4).toBe(0); + expect(ch.unknown1A_3).toBe(false); + expect(ch.unknown1C_1_0).toBe(0); + expect(ch.unknown1D_3_0).toBe(0); + expect(ch.unknown2A).toBe(0); + }); +}); + +describe('validateChannelForEncoding', () => { + it('returns true for a fully-specified channel', () => { + const ch = createDefaultChannel({ name: 'Test' }); + expect(validateChannelForEncoding(ch)).toBe(true); + }); + + it('throws when name is missing', () => { + const ch = createDefaultChannel() as any; + delete ch.name; + expect(() => validateChannelForEncoding(ch)).toThrow('name'); + }); + + it('throws when rxFrequency is missing', () => { + const ch = createDefaultChannel() as any; + delete ch.rxFrequency; + expect(() => validateChannelForEncoding(ch)).toThrow('rxFrequency'); + }); + + it('throws when txFrequency is missing', () => { + const ch = createDefaultChannel() as any; + delete ch.txFrequency; + expect(() => validateChannelForEncoding(ch)).toThrow('txFrequency'); + }); + + it('throws when mode is missing', () => { + const ch = createDefaultChannel() as any; + delete ch.mode; + expect(() => validateChannelForEncoding(ch)).toThrow('mode'); + }); + + it('throws when rxCtcssDcs is missing', () => { + const ch = createDefaultChannel() as any; + delete ch.rxCtcssDcs; + expect(() => validateChannelForEncoding(ch)).toThrow('rxCtcssDcs'); + }); + + it('throws when txCtcssDcs is missing', () => { + const ch = createDefaultChannel() as any; + delete ch.txCtcssDcs; + expect(() => validateChannelForEncoding(ch)).toThrow('txCtcssDcs'); + }); +}); diff --git a/tests/unit/channelMerger.test.ts b/tests/unit/channelMerger.test.ts new file mode 100644 index 0000000..41049c8 --- /dev/null +++ b/tests/unit/channelMerger.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { mergeOverlappingChannels, getChannelFrequencyKey, getChannelFullKey } from '../../src/services/channelMerger'; +import { createDefaultChannel } from '../../src/utils/channelHelpers'; + +function ch(number: number, rx: number, tx: number, name = `CH${number}`) { + return createDefaultChannel({ number, rxFrequency: rx, txFrequency: tx, name }); +} + +describe('mergeOverlappingChannels', () => { + it('passes through unique-frequency channels unchanged in count', () => { + const { mergedChannels } = mergeOverlappingChannels([[ch(1, 146.52, 146.52), ch(2, 147.0, 147.6)]]); + expect(mergedChannels).toHaveLength(2); + }); + + it('deduplicates channels with identical rx+tx frequencies', () => { + const set1 = [ch(1, 146.52, 146.52)]; + const set2 = [ch(2, 146.52, 146.52)]; + const { mergedChannels } = mergeOverlappingChannels([set1, set2]); + expect(mergedChannels).toHaveLength(1); + }); + + it('assigns sequential numbers starting from startChannelNumber', () => { + const channels = [ch(1, 146.52, 146.52), ch(2, 147.0, 147.6)]; + const { mergedChannels } = mergeOverlappingChannels([channels], 10); + expect(mergedChannels[0].number).toBe(10); + expect(mergedChannels[1].number).toBe(11); + }); + + it('maps original channel numbers to merged numbers', () => { + const set1 = [ch(1, 146.52, 146.52)]; + const set2 = [ch(5, 146.52, 146.52)]; + const { channelMapping } = mergeOverlappingChannels([set1, set2], 1); + expect(channelMapping.get(1)).toBe(1); + expect(channelMapping.get(5)).toBe(1); + }); + + it('handles multiple channel sets without cross-contamination', () => { + const set1 = [ch(1, 146.52, 146.52), ch(2, 151.625, 151.625)]; + const set2 = [ch(1, 462.5625, 467.5625)]; + const { mergedChannels } = mergeOverlappingChannels([set1, set2]); + expect(mergedChannels).toHaveLength(3); + }); + + it('returns empty array for empty input', () => { + const { mergedChannels, channelMapping } = mergeOverlappingChannels([]); + expect(mergedChannels).toHaveLength(0); + expect(channelMapping.size).toBe(0); + }); + + it('defaults startChannelNumber to 1', () => { + const { mergedChannels } = mergeOverlappingChannels([[ch(99, 146.52, 146.52)]]); + expect(mergedChannels[0].number).toBe(1); + }); +}); + +describe('getChannelFrequencyKey', () => { + it('formats rx and tx with 4 decimal places', () => { + const key = getChannelFrequencyKey(ch(1, 146.52, 146.52)); + expect(key).toBe('146.5200-146.5200'); + }); + + it('produces the same key for channels with identical frequencies', () => { + const a = ch(1, 147.0, 147.6); + const b = ch(2, 147.0, 147.6); + expect(getChannelFrequencyKey(a)).toBe(getChannelFrequencyKey(b)); + }); +}); + +describe('getChannelFullKey', () => { + it('includes both frequency and name', () => { + const key = getChannelFullKey(ch(1, 146.52, 146.52, 'TestCh')); + expect(key).toContain('146.5200'); + expect(key).toContain('TestCh'); + }); +}); diff --git a/tests/unit/csvImporter.test.ts b/tests/unit/csvImporter.test.ts new file mode 100644 index 0000000..af943c4 --- /dev/null +++ b/tests/unit/csvImporter.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect } from 'vitest'; +import { parseCSV, getValue, getBool, getFloat, getInt, importChannelsFromCSV, importContactsFromCSV } from '../../src/services/csv/csvImporter'; + +// ─── parseCSV ─────────────────────────────────────────────────────────────── + +describe('parseCSV', () => { + it('parses a simple two-row CSV', () => { + const result = parseCSV('a,b,c\n1,2,3'); + expect(result).toEqual([['a', 'b', 'c'], ['1', '2', '3']]); + }); + + it('trims leading/trailing spaces from fields', () => { + const result = parseCSV(' a , b , c '); + expect(result[0]).toEqual(['a', 'b', 'c']); + }); + + it('handles quoted fields containing commas', () => { + const result = parseCSV('"hello, world",b'); + expect(result[0]).toEqual(['hello, world', 'b']); + }); + + it('handles escaped quotes inside quoted fields', () => { + const result = parseCSV('"say ""hi""",b'); + expect(result[0]).toEqual(['say "hi"', 'b']); + }); + + it('handles empty fields', () => { + const result = parseCSV('a,,c'); + expect(result[0]).toEqual(['a', '', 'c']); + }); + + it('filters blank lines', () => { + const result = parseCSV('a,b\n\n1,2\n \n3,4'); + expect(result).toHaveLength(3); + }); + + it('handles Windows line endings (CRLF)', () => { + const result = parseCSV('a,b\r\n1,2'); + // \r ends up trimmed as part of the field or the line split + expect(result).toHaveLength(2); + expect(result[0][0]).toBe('a'); + }); + + it('returns empty array for empty string', () => { + expect(parseCSV('')).toEqual([]); + }); + + it('returns empty array for whitespace-only string', () => { + expect(parseCSV(' \n ')).toEqual([]); + }); +}); + +// ─── row accessor helpers ──────────────────────────────────────────────────── + +describe('getValue', () => { + const headers = ['channel number', 'name', 'rx frequency']; + const row = ['5', 'Test', '146.520']; + + it('returns the matching field value', () => { + expect(getValue(headers, row, 'name')).toBe('Test'); + }); + + it('is case-insensitive on the lookup name', () => { + expect(getValue(headers, row, 'NAME')).toBe('Test'); + }); + + it('uses partial header match', () => { + expect(getValue(headers, row, 'rx')).toBe('146.520'); + }); + + it('returns empty string when header not found', () => { + expect(getValue(headers, row, 'nonexistent')).toBe(''); + }); + + it('returns empty string when row is shorter than header index', () => { + expect(getValue(headers, ['5'], 'rx frequency')).toBe(''); + }); +}); + +describe('getBool', () => { + const headers = ['flag']; + + it.each([['yes'], ['true'], ['1']])('treats "%s" as true', (val) => { + expect(getBool(headers, [val], 'flag')).toBe(true); + }); + + it.each([['no'], ['false'], ['0'], [''], ['off']])('treats "%s" as false', (val) => { + expect(getBool(headers, [val], 'flag')).toBe(false); + }); +}); + +describe('getFloat', () => { + const headers = ['val']; + + it('parses a decimal number', () => { + expect(getFloat(headers, ['446.09375'], 'val')).toBeCloseTo(446.09375, 5); + }); + + it('returns defaultValue for non-numeric input', () => { + expect(getFloat(headers, ['bad'], 'val', 99)).toBe(99); + }); + + it('returns 0 by default for missing/bad input', () => { + expect(getFloat(headers, [''], 'val')).toBe(0); + }); +}); + +describe('getInt', () => { + const headers = ['val']; + + it('parses an integer', () => { + expect(getInt(headers, ['3112345'], 'val')).toBe(3112345); + }); + + it('truncates decimals', () => { + expect(getInt(headers, ['3112345.9'], 'val')).toBe(3112345); + }); + + it('returns defaultValue for non-numeric input', () => { + expect(getInt(headers, ['bad'], 'val', 7)).toBe(7); + }); +}); + +// ─── importChannelsFromCSV ─────────────────────────────────────────────────── + +const CHANNEL_HEADER = 'Channel Number,Name,RX Frequency,TX Frequency,Mode,Bandwidth,Power'; +const channelRow = (overrides: Record = {}) => { + const defaults: Record = { + 'Channel Number': '1', + 'Name': 'Test Chan', + 'RX Frequency': '146.520', + 'TX Frequency': '146.520', + 'Mode': 'Analog', + 'Bandwidth': '25kHz', + 'Power': 'High', + }; + const merged = { ...defaults, ...overrides }; + return Object.values(merged).join(','); +}; + +describe('importChannelsFromCSV', () => { + it('fails with only a header row', () => { + const result = importChannelsFromCSV(CHANNEL_HEADER); + expect(result.success).toBe(false); + expect(result.errors?.[0]).toMatch(/header row and one data row/); + }); + + it('fails on empty input', () => { + const result = importChannelsFromCSV(''); + expect(result.success).toBe(false); + }); + + it('imports a valid minimal channel row', () => { + const csv = `${CHANNEL_HEADER}\n${channelRow()}`; + const result = importChannelsFromCSV(csv); + expect(result.success).toBe(true); + expect(result.channels).toHaveLength(1); + const ch = result.channels![0]; + expect(ch.name).toBe('Test Chan'); + expect(ch.rxFrequency).toBeCloseTo(146.52, 3); + expect(ch.txFrequency).toBeCloseTo(146.52, 3); + expect(ch.mode).toBe('Analog'); + }); + + it('falls back to row index when Channel Number is missing', () => { + const csv = `Name,RX Frequency,TX Frequency\nMy Chan,146.520,146.520`; + const result = importChannelsFromCSV(csv); + expect(result.channels![0].number).toBe(1); + }); + + it('falls back to default name when Name column is absent', () => { + const csv = `RX Frequency,TX Frequency\n146.520,146.520`; + const result = importChannelsFromCSV(csv); + expect(result.channels![0].name).toBe('Channel 1'); + }); + + it('skips blank rows', () => { + const csv = `${CHANNEL_HEADER}\n${channelRow()}\n \n${channelRow({ 'Channel Number': '2', 'Name': 'Second' })}`; + const result = importChannelsFromCSV(csv); + expect(result.channels).toHaveLength(2); + }); + + it('imports multiple channels in order', () => { + const rows = [1, 2, 3].map(n => channelRow({ 'Channel Number': String(n), 'Name': `Ch ${n}` })); + const csv = [CHANNEL_HEADER, ...rows].join('\n'); + const result = importChannelsFromCSV(csv); + expect(result.channels).toHaveLength(3); + expect(result.channels!.map(c => c.number)).toEqual([1, 2, 3]); + }); + + it('header match is case-insensitive and partial', () => { + // "rx frequency" matches column header "RX Frequency" + const csv = `channel number,name,rx frequency,tx frequency\n5,Foo,155.340,155.340`; + const result = importChannelsFromCSV(csv); + expect(result.channels![0].rxFrequency).toBeCloseTo(155.34, 2); + }); + + it('getBool recognises yes/true/1 as true', () => { + const header = `${CHANNEL_HEADER},Forbid TX,VOX,Compander`; + const row = `${channelRow()},yes,true,1`; + const result = importChannelsFromCSV(`${header}\n${row}`); + const ch = result.channels![0]; + expect(ch.forbidTx).toBe(true); + expect(ch.voxFunction).toBe(true); + expect(ch.compander).toBe(true); + }); + + it('getBool treats no/false/0/empty as false', () => { + const header = `${CHANNEL_HEADER},Forbid TX,VOX,Compander`; + const row = `${channelRow()},no,false,0`; + const result = importChannelsFromCSV(`${header}\n${row}`); + const ch = result.channels![0]; + expect(ch.forbidTx).toBe(false); + expect(ch.voxFunction).toBe(false); + expect(ch.compander).toBe(false); + }); + + it('getNumber uses parseFloat (allows decimals)', () => { + const csv = `${CHANNEL_HEADER}\n${channelRow({ 'RX Frequency': '446.09375' })}`; + const result = importChannelsFromCSV(csv); + expect(result.channels![0].rxFrequency).toBeCloseTo(446.09375, 5); + }); + + it('getNumber defaults to 0 for non-numeric value', () => { + const csv = `${CHANNEL_HEADER}\n${channelRow({ 'RX Frequency': 'bad' })}`; + const result = importChannelsFromCSV(csv); + expect(result.channels![0].rxFrequency).toBe(0); + }); +}); + +// ─── importContactsFromCSV ─────────────────────────────────────────────────── + +const CONTACT_HEADER = 'ID,Name,DMR ID,Call Sign'; +const contactRow = (overrides: Record = {}) => { + const defaults: Record = { + 'ID': '1', + 'Name': 'Alice', + 'DMR ID': '3112345', + 'Call Sign': 'VE7XYZ', + }; + return Object.values({ ...defaults, ...overrides }).join(','); +}; + +describe('importContactsFromCSV', () => { + it('fails with only a header row', () => { + const result = importContactsFromCSV(CONTACT_HEADER); + expect(result.success).toBe(false); + }); + + it('imports a valid contact row', () => { + const csv = `${CONTACT_HEADER}\n${contactRow()}`; + const result = importContactsFromCSV(csv); + expect(result.success).toBe(true); + expect(result.contacts).toHaveLength(1); + const c = result.contacts![0]; + expect(c.name).toBe('Alice'); + expect(c.dmrId).toBe(3112345); + expect(c.callSign).toBe('VE7XYZ'); + }); + + it('falls back to row index when no column containing "id" is present', () => { + // Note: partial header matching means "DMR ID" would also match the 'id' lookup. + // Use a header with no "id" substring to exercise the true fallback. + const csv = `Name,DMR Number\nBob,9999`; + const result = importContactsFromCSV(csv); + expect(result.contacts![0].id).toBe(1); + }); + + it('partial header match: "id" matches the first column whose name contains "id"', () => { + // "DMR ID" contains "id", so getNumber('id') finds it and returns its value — + // documenting the known partial-match behaviour that the refactor must preserve. + const csv = `Name,DMR ID\nBob,9999`; + const result = importContactsFromCSV(csv); + // getNumber('id') matches "DMR ID", returning 9999, not a fallback + expect(result.contacts![0].id).toBe(9999); + }); + + it('falls back to default name when Name column is absent', () => { + const csv = `DMR ID\n12345`; + const result = importContactsFromCSV(csv); + expect(result.contacts![0].name).toBe('Contact 1'); + }); + + it('getNumber uses parseInt (truncates decimals)', () => { + // DMR IDs should be integers + const csv = `${CONTACT_HEADER}\n${contactRow({ 'DMR ID': '3112345.9' })}`; + const result = importContactsFromCSV(csv); + expect(result.contacts![0].dmrId).toBe(3112345); + }); + + it('callSign is undefined when column is absent', () => { + const csv = `ID,Name,DMR ID\n1,Bob,9999`; + const result = importContactsFromCSV(csv); + expect(result.contacts![0].callSign).toBeUndefined(); + }); + + it('imports multiple contacts', () => { + const rows = [1, 2, 3].map(n => contactRow({ 'ID': String(n), 'Name': `Person ${n}`, 'DMR ID': String(1000 + n) })); + const csv = [CONTACT_HEADER, ...rows].join('\n'); + const result = importContactsFromCSV(csv); + expect(result.contacts).toHaveLength(3); + expect(result.contacts!.map(c => c.id)).toEqual([1, 2, 3]); + }); + + it('skips blank rows', () => { + const csv = `${CONTACT_HEADER}\n${contactRow()}\n \n${contactRow({ 'ID': '2', 'Name': 'Bob' })}`; + const result = importContactsFromCSV(csv); + expect(result.contacts).toHaveLength(2); + }); +}); diff --git a/tests/unit/fixedChannels.test.ts b/tests/unit/fixedChannels.test.ts new file mode 100644 index 0000000..cc24bdb --- /dev/null +++ b/tests/unit/fixedChannels.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { + getFRSChannels, + getGMRSChannels, + getMURSChannels, + getHamCallingFrequencies, + getChannelsForSet, + getAvailableFixedChannelSets, +} from '../../src/services/fixedChannels'; + +describe('getFRSChannels', () => { + it('returns 22 FRS channels', () => { + expect(getFRSChannels()).toHaveLength(22); + }); + + it('respects custom start number', () => { + const channels = getFRSChannels(10); + expect(channels[0].number).toBe(10); + expect(channels[21].number).toBe(31); + }); + + it('all channels have valid frequencies above 460 MHz', () => { + getFRSChannels().forEach(ch => { + expect(ch.rxFrequency).toBeGreaterThan(460); + }); + }); + + it('all channel names are 16 chars or fewer', () => { + getFRSChannels().forEach(ch => { + expect(ch.name.length).toBeLessThanOrEqual(16); + }); + }); +}); + +describe('getMURSChannels', () => { + it('returns 5 MURS channels', () => { + expect(getMURSChannels()).toHaveLength(5); + }); + + it('respects custom start number', () => { + const channels = getMURSChannels(20); + expect(channels[0].number).toBe(20); + }); +}); + +describe('getGMRSChannels', () => { + it('returns channels', () => { + expect(getGMRSChannels().length).toBeGreaterThan(0); + }); + + it('respects custom start number', () => { + const channels = getGMRSChannels(5); + expect(channels[0].number).toBe(5); + }); +}); + +describe('getHamCallingFrequencies', () => { + it('returns channels', () => { + expect(getHamCallingFrequencies().length).toBeGreaterThan(0); + }); + + it('respects custom start number', () => { + const channels = getHamCallingFrequencies(100); + expect(channels[0].number).toBe(100); + }); +}); + +describe('getChannelsForSet', () => { + it('returns channels for FRS set', () => { + expect(getChannelsForSet('FRS')).toHaveLength(22); + }); + + it('returns empty array for unknown set', () => { + expect(getChannelsForSet('NONEXISTENT')).toHaveLength(0); + }); + + it('respects start channel number', () => { + const channels = getChannelsForSet('MURS', 50); + expect(channels[0].number).toBe(50); + }); +}); + +describe('getAvailableFixedChannelSets', () => { + it('returns at least FRS, GMRS, and MURS', () => { + const sets = getAvailableFixedChannelSets(); + const names = sets.map(s => s.name); + expect(names).toContain('FRS'); + expect(names).toContain('GMRS'); + expect(names).toContain('MURS'); + }); + + it('every set has a non-empty description', () => { + getAvailableFixedChannelSets().forEach(s => { + expect(s.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/tests/unit/importHelpers.test.ts b/tests/unit/importHelpers.test.ts new file mode 100644 index 0000000..3e229d5 --- /dev/null +++ b/tests/unit/importHelpers.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { getNextChannelNumber, selectionCardClass } from '../../src/utils/importHelpers'; +import type { Channel } from '../../src/models'; + +function ch(number: number): Channel { + return { number } as unknown as Channel; +} + +describe('getNextChannelNumber', () => { + it('returns 1 when channel list is empty', () => { + expect(getNextChannelNumber([])).toBe(1); + }); + + it('returns next sequential number when list is contiguous from 1', () => { + expect(getNextChannelNumber([ch(1), ch(2), ch(3)])).toBe(4); + }); + + it('returns 1 when lowest slot is free (gap at start)', () => { + expect(getNextChannelNumber([ch(2), ch(3)])).toBe(1); + }); + + it('fills the first gap in a non-contiguous list', () => { + expect(getNextChannelNumber([ch(1), ch(3), ch(4)])).toBe(2); + }); + + it('handles a single channel at 1', () => { + expect(getNextChannelNumber([ch(1)])).toBe(2); + }); + + it('handles channels in unsorted order', () => { + expect(getNextChannelNumber([ch(3), ch(1), ch(2)])).toBe(4); + }); + + it('handles large gaps — always returns the lowest free slot', () => { + expect(getNextChannelNumber([ch(1), ch(2), ch(100)])).toBe(3); + }); +}); + +describe('selectionCardClass', () => { + it('contains common layout classes in both states', () => { + const base = 'border rounded p-3 cursor-pointer transition-colors'; + expect(selectionCardClass(true)).toContain(base); + expect(selectionCardClass(false)).toContain(base); + }); + + it('selected state includes cyan background', () => { + expect(selectionCardClass(true)).toContain('bg-neon-cyan bg-opacity-10'); + }); + + it('unselected state includes reduced opacity border', () => { + expect(selectionCardClass(false)).toContain('border-opacity-30'); + }); + + it('selected and unselected return different strings', () => { + expect(selectionCardClass(true)).not.toBe(selectionCardClass(false)); + }); +}); diff --git a/tests/unit/mmdvmChannels.test.ts b/tests/unit/mmdvmChannels.test.ts new file mode 100644 index 0000000..6c59012 --- /dev/null +++ b/tests/unit/mmdvmChannels.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { + isValidMMDVMFrequency, + generateMMDVMChannels, + MMDVM_FREQ_MIN_MHZ, + MMDVM_FREQ_MAX_MHZ, + type MMDVMChannelEntry, +} from '../../src/services/mmdvmChannels'; + +const entries: MMDVMChannelEntry[] = [ + { channelName: 'Local', talkGroupName: 'Local', talkGroupId: 9 }, + { channelName: 'Canada', talkGroupName: 'Canada', talkGroupId: 302 }, +]; + +const baseOptions = { + frequencyMhz: 433.0, + entries, + firstChannelNumber: 1, + firstContactId: 1, + dmrRadioIdIndex: undefined, +}; + +describe('isValidMMDVMFrequency', () => { + it('accepts frequencies within range', () => { + expect(isValidMMDVMFrequency(MMDVM_FREQ_MIN_MHZ)).toBe(true); + expect(isValidMMDVMFrequency(MMDVM_FREQ_MAX_MHZ)).toBe(true); + expect(isValidMMDVMFrequency(433.0)).toBe(true); + }); + + it('rejects frequencies outside range', () => { + expect(isValidMMDVMFrequency(MMDVM_FREQ_MIN_MHZ - 1)).toBe(false); + expect(isValidMMDVMFrequency(MMDVM_FREQ_MAX_MHZ + 1)).toBe(false); + }); + + it('rejects NaN', () => { + expect(isValidMMDVMFrequency(NaN)).toBe(false); + }); +}); + +describe('generateMMDVMChannels', () => { + it('creates one channel and one contact per entry', () => { + const result = generateMMDVMChannels(baseOptions); + expect(result.channels).toHaveLength(entries.length); + expect(result.contacts).toHaveLength(entries.length); + }); + + it('assigns sequential channel numbers starting from firstChannelNumber', () => { + const result = generateMMDVMChannels({ ...baseOptions, firstChannelNumber: 50 }); + expect(result.channels[0].number).toBe(50); + expect(result.channels[1].number).toBe(51); + }); + + it('assigns sequential contact IDs starting from firstContactId', () => { + const result = generateMMDVMChannels({ ...baseOptions, firstContactId: 100 }); + expect(result.contacts[0].id).toBe(100); + expect(result.contacts[1].id).toBe(101); + }); + + it('links each channel to its contact', () => { + const result = generateMMDVMChannels(baseOptions); + result.channels.forEach((ch, i) => { + expect(ch.contactId).toBe(result.contacts[i].id); + }); + }); + + it('creates a single zone containing all channel numbers', () => { + const result = generateMMDVMChannels(baseOptions); + expect(result.zone).toBeDefined(); + expect(result.zone.channels).toHaveLength(entries.length); + result.channels.forEach(ch => { + expect(result.zone.channels).toContain(ch.number); + }); + }); + + it('uses provided zone name', () => { + const result = generateMMDVMChannels({ ...baseOptions, zoneName: 'MyHotspot' }); + expect(result.zone.name).toBe('MyHotspot'); + }); + + it('defaults zone name to MMDVM when not provided', () => { + const result = generateMMDVMChannels(baseOptions); + expect(result.zone.name).toBe('MMDVM'); + }); + + it('truncates zone name to 16 characters', () => { + const result = generateMMDVMChannels({ ...baseOptions, zoneName: 'A'.repeat(30) }); + expect(result.zone.name.length).toBeLessThanOrEqual(16); + }); + + it('truncates channel names to 16 characters', () => { + const longEntries: MMDVMChannelEntry[] = [ + { channelName: 'A'.repeat(30), talkGroupName: 'TG', talkGroupId: 1 }, + ]; + const result = generateMMDVMChannels({ ...baseOptions, entries: longEntries }); + expect(result.channels[0].name.length).toBeLessThanOrEqual(16); + }); + + it('uses rx=tx (simplex) frequency', () => { + const result = generateMMDVMChannels({ ...baseOptions, frequencyMhz: 433.5 }); + result.channels.forEach(ch => { + expect(ch.rxFrequency).toBe(433.5); + expect(ch.txFrequency).toBe(433.5); + }); + }); + + it('throws on invalid frequency', () => { + expect(() => generateMMDVMChannels({ ...baseOptions, frequencyMhz: 100 })).toThrow(); + }); + + it('throws when entries is empty', () => { + expect(() => generateMMDVMChannels({ ...baseOptions, entries: [] })).toThrow(); + }); + + it('stores dmrRadioIdIndex on each channel when provided', () => { + const result = generateMMDVMChannels({ ...baseOptions, dmrRadioIdIndex: 2 }); + result.channels.forEach(ch => { + expect(ch.dmrRadioIdIndex).toBe(2); + }); + }); +}); diff --git a/tests/unit/radioSettingsStore.test.ts b/tests/unit/radioSettingsStore.test.ts new file mode 100644 index 0000000..b180636 --- /dev/null +++ b/tests/unit/radioSettingsStore.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useRadioSettingsStore } from '../../src/store/radioSettingsStore'; +import type { RadioSettings } from '../../src/models/RadioSettings'; + +// Helpers for exercising deepEqual's nested-object and array branches +function settingsWithNested(nested: unknown, arr: unknown[]): RadioSettings { + return { squelchLevel: 3, nested, arr } as unknown as RadioSettings; +} + +function makeSettings(overrides: Record = {}): RadioSettings { + return { squelchLevel: 3, backlightBrightness: 3, ...overrides } as unknown as RadioSettings; +} + +beforeEach(() => { + useRadioSettingsStore.setState({ + settings: null, + originalSettings: null, + changedFields: new Set(), + }); +}); + +describe('setSettings', () => { + it('stores settings and a deep-cloned original', () => { + const s = makeSettings({ squelchLevel: 5 }); + useRadioSettingsStore.getState().setSettings(s); + const { settings, originalSettings } = useRadioSettingsStore.getState(); + expect(settings?.squelchLevel).toBe(5); + expect(originalSettings?.squelchLevel).toBe(5); + expect(settings).not.toBe(originalSettings); // different object references + }); + + it('clears changedFields on load', () => { + useRadioSettingsStore.getState().setSettings(makeSettings()); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 9 } as any); + useRadioSettingsStore.getState().setSettings(makeSettings()); // reload + expect(useRadioSettingsStore.getState().changedFields.size).toBe(0); + }); + + it('accepts null to clear settings', () => { + useRadioSettingsStore.getState().setSettings(makeSettings()); + useRadioSettingsStore.getState().setSettings(null); + expect(useRadioSettingsStore.getState().settings).toBeNull(); + expect(useRadioSettingsStore.getState().originalSettings).toBeNull(); + }); +}); + +describe('updateSettings', () => { + it('marks field as changed when value differs from original', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 7 } as any); + expect(useRadioSettingsStore.getState().changedFields.has('squelchLevel')).toBe(true); + }); + + it('removes field from changedFields when value reverts to original', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 7 } as any); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 3 } as any); // revert + expect(useRadioSettingsStore.getState().changedFields.has('squelchLevel')).toBe(false); + }); + + it('tracks multiple changed fields independently', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3, backlightBrightness: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 7, backlightBrightness: 5 } as any); + const { changedFields } = useRadioSettingsStore.getState(); + expect(changedFields.has('squelchLevel')).toBe(true); + expect(changedFields.has('backlightBrightness')).toBe(true); + }); +}); + +describe('clearChanges', () => { + it('empties changedFields', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 7 } as any); + useRadioSettingsStore.getState().clearChanges(); + expect(useRadioSettingsStore.getState().changedFields.size).toBe(0); + }); + + it('advances originalSettings to current settings', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 7 } as any); + useRadioSettingsStore.getState().clearChanges(); + expect(useRadioSettingsStore.getState().originalSettings?.squelchLevel).toBe(7); + }); + + // This test captures the exact bug that was fixed: write→edit→write skipping the second write. + // After clearChanges(), editing back to what was the pre-write value is now a real change — + // it should appear in changedFields and trigger a write. + it('after clearChanges, reverting to pre-clear value is treated as a change', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 7 } as any); + useRadioSettingsStore.getState().clearChanges(); // simulates successful write + + // Now edit back to what the original value was before the write + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 3 } as any); + + // Must register as changed — original is now 7, current is 3 + expect(useRadioSettingsStore.getState().changedFields.has('squelchLevel')).toBe(true); + }); + + it('after clearChanges, keeping the current value shows no change', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 7 } as any); + useRadioSettingsStore.getState().clearChanges(); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 7 } as any); // same as written value + expect(useRadioSettingsStore.getState().changedFields.has('squelchLevel')).toBe(false); + }); +}); + +// ─── deepEqual branch coverage via updateSettings ──────────────────────────── + +describe('updateSettings with nested objects (deepEqual branches)', () => { + it('detects a change in a nested object field', () => { + useRadioSettingsStore.getState().setSettings( + settingsWithNested({ tone: 100 }, [1, 2, 3]) + ); + useRadioSettingsStore.getState().updateSettings( + { nested: { tone: 200 } } as any + ); + expect(useRadioSettingsStore.getState().changedFields.has('nested')).toBe(true); + }); + + it('does not mark changed when nested object is equal', () => { + useRadioSettingsStore.getState().setSettings( + settingsWithNested({ tone: 100 }, [1, 2, 3]) + ); + useRadioSettingsStore.getState().updateSettings( + { nested: { tone: 100 } } as any + ); + expect(useRadioSettingsStore.getState().changedFields.has('nested')).toBe(false); + }); + + it('detects a change in an array field', () => { + useRadioSettingsStore.getState().setSettings( + settingsWithNested({ tone: 100 }, [1, 2, 3]) + ); + useRadioSettingsStore.getState().updateSettings( + { arr: [1, 2, 9] } as any + ); + expect(useRadioSettingsStore.getState().changedFields.has('arr')).toBe(true); + }); + + it('does not mark changed when array is equal', () => { + useRadioSettingsStore.getState().setSettings( + settingsWithNested({ tone: 100 }, [1, 2, 3]) + ); + useRadioSettingsStore.getState().updateSettings( + { arr: [1, 2, 3] } as any + ); + expect(useRadioSettingsStore.getState().changedFields.has('arr')).toBe(false); + }); + + it('detects array length change', () => { + useRadioSettingsStore.getState().setSettings( + settingsWithNested({}, [1, 2, 3]) + ); + useRadioSettingsStore.getState().updateSettings( + { arr: [1, 2] } as any + ); + expect(useRadioSettingsStore.getState().changedFields.has('arr')).toBe(true); + }); + + it('detects nested object key count change', () => { + useRadioSettingsStore.getState().setSettings( + settingsWithNested({ a: 1 }, []) + ); + useRadioSettingsStore.getState().updateSettings( + { nested: { a: 1, b: 2 } } as any + ); + expect(useRadioSettingsStore.getState().changedFields.has('nested')).toBe(true); + }); +}); + +describe('hasChanges / getChangedFields', () => { + it('hasChanges returns false when nothing is modified', () => { + useRadioSettingsStore.getState().setSettings(makeSettings()); + expect(useRadioSettingsStore.getState().hasChanges()).toBe(false); + }); + + it('hasChanges returns true after a modification', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 5 } as any); + expect(useRadioSettingsStore.getState().hasChanges()).toBe(true); + }); + + it('getChangedFields lists modified field names', () => { + useRadioSettingsStore.getState().setSettings(makeSettings({ squelchLevel: 3 })); + useRadioSettingsStore.getState().updateSettings({ squelchLevel: 5 } as any); + expect(useRadioSettingsStore.getState().getChangedFields()).toContain('squelchLevel'); + }); +}); diff --git a/tests/unit/rptrsChannels.test.ts b/tests/unit/rptrsChannels.test.ts new file mode 100644 index 0000000..34b84ed --- /dev/null +++ b/tests/unit/rptrsChannels.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { generateRptrsChannels } from '../../src/services/rptrsChannels'; +import type { RptrData } from '../../src/data/rptrsData'; + +function rptr(overrides: Partial = {}): RptrData { + return { + locator: 1, + id: 1, + callsign: 'VE7XYZ', + city: 'Vancouver', + state: 'BC', + country: 'CA', + frequency: '440.58750', + color_code: 1, + offset: '+5.000', + assigned: '', + ts_linked: 'TS1 TS2', + trustee: '', + map_info: '', + map: 0, + ipsc_network: 'Brandmeister', + lat: '49.194', + lng: '-123.183', + status: 'ACTIVE', + ...overrides, + }; +} + +const repeater1 = rptr({ id: 1, callsign: 'VE7XYZ', frequency: '440.58750', ts_linked: 'TS1 TS2' }); +const repeater2 = rptr({ id: 2, callsign: 'VE7ABC', frequency: '443.00000', offset: '+5.000', city: 'Victoria', state: 'BC', ts_linked: 'TS1' }); + +describe('generateRptrsChannels', () => { + it('throws when no repeaters provided', () => { + expect(() => generateRptrsChannels(1, [])).toThrow(); + }); + + it('creates two channels per repeater when ts_linked has TS1 and TS2', () => { + const result = generateRptrsChannels(1, [repeater1], false, false, true); + expect(result.channels.length).toBe(2); + }); + + it('creates one channel per repeater when createSeparateTimeslots is false', () => { + const result = generateRptrsChannels(1, [repeater1], false, false, false); + expect(result.channels.length).toBe(1); + }); + + it('creates one channel when repeater only has TS1', () => { + const ts1Only = rptr({ ts_linked: 'TS1' }); + const result = generateRptrsChannels(1, [ts1Only], false, false, true); + expect(result.channels.length).toBe(1); + }); + + it('assigns channel numbers starting at startChannelNumber', () => { + const result = generateRptrsChannels(10, [repeater2], false, false, false); + expect(result.channels[0].number).toBe(10); + }); + + it('creates one zone when singleZone is true', () => { + const result = generateRptrsChannels(1, [repeater1, repeater2], true, false, false); + expect(result.zones.length).toBe(1); + }); + + it('all channel names are 16 chars or fewer', () => { + const result = generateRptrsChannels(1, [repeater1, repeater2], false, false, true); + result.channels.forEach(ch => { + expect(ch.name.length).toBeLessThanOrEqual(16); + }); + }); + + it('zone channels reference actual channel numbers', () => { + const result = generateRptrsChannels(1, [repeater1], false, false, true); + const channelNumbers = new Set(result.channels.map(c => c.number)); + result.zones.forEach(z => { + z.channels.forEach(n => { + expect(channelNumbers.has(n)).toBe(true); + }); + }); + }); + + it('summary matches actual counts', () => { + const result = generateRptrsChannels(1, [repeater1, repeater2], false, false, true); + expect(result.summary.channelsCreated).toBe(result.channels.length); + expect(result.summary.zonesCreated).toBe(result.zones.length); + }); +}); diff --git a/tests/unit/structures.test.ts b/tests/unit/structures.test.ts new file mode 100644 index 0000000..407a8ac --- /dev/null +++ b/tests/unit/structures.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest'; +import { + decodeBCDFrequency, + encodeBCDFrequency, + decodeCTCSSDCS, + encodeCTCSSDCS, +} from '../../src/radios/dm32uv/structures'; + +// ─── BCD frequency ──────────────────────────────────────────────────────────── + +describe('decodeBCDFrequency', () => { + it('throws for fewer than 4 bytes', () => { + expect(() => decodeBCDFrequency(new Uint8Array([0x00, 0x00, 0x00]))).toThrow('4 bytes'); + }); + + it('decodes 146.52 MHz from radio byte order', () => { + // Bytes stored LSB-first: [0x00, 0x20, 0x65, 0x14] → 14652000 → 146.52 MHz + expect(decodeBCDFrequency(new Uint8Array([0x00, 0x20, 0x65, 0x14]))).toBeCloseTo(146.52, 4); + }); + + it('decodes 440.000 MHz from radio byte order', () => { + // [0x00, 0x00, 0x00, 0x44] → 44000000 → 440.000 MHz + expect(decodeBCDFrequency(new Uint8Array([0x00, 0x00, 0x00, 0x44]))).toBeCloseTo(440.0, 4); + }); + + it('decodes all-zeros as 0.0 MHz', () => { + expect(decodeBCDFrequency(new Uint8Array([0x00, 0x00, 0x00, 0x00]))).toBe(0); + }); +}); + +describe('encodeBCDFrequency', () => { + it('encodes 146.52 MHz to correct byte order', () => { + expect(encodeBCDFrequency(146.52)).toEqual(new Uint8Array([0x00, 0x20, 0x65, 0x14])); + }); + + it('encodes 440.000 MHz to correct byte order', () => { + expect(encodeBCDFrequency(440.0)).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x44])); + }); + + it('always returns exactly 4 bytes', () => { + expect(encodeBCDFrequency(146.52)).toHaveLength(4); + expect(encodeBCDFrequency(0)).toHaveLength(4); + }); +}); + +describe('BCD frequency round-trip', () => { + const frequencies = [146.52, 440.0, 162.4, 462.5625, 87.5]; + + for (const freq of frequencies) { + it(`round-trips ${freq} MHz`, () => { + expect(decodeBCDFrequency(encodeBCDFrequency(freq))).toBeCloseTo(freq, 3); + }); + } +}); + +// ─── CTCSS/DCS decode ───────────────────────────────────────────────────────── + +describe('decodeCTCSSDCS', () => { + it('returns None for empty buffer', () => { + expect(decodeCTCSSDCS(new Uint8Array([]))).toEqual({ type: 'None' }); + }); + + it('returns None for single byte', () => { + expect(decodeCTCSSDCS(new Uint8Array([0x00]))).toEqual({ type: 'None' }); + }); + + it('returns None for 0xFF 0xFF sentinel', () => { + expect(decodeCTCSSDCS(new Uint8Array([0xFF, 0xFF]))).toEqual({ type: 'None' }); + }); + + it('returns None when decoded CTCSS frequency is zero', () => { + // Both bytes 0x00: hundreds=tens=ones=decimal=0 → frequency=0 → None + expect(decodeCTCSSDCS(new Uint8Array([0x00, 0x00]))).toEqual({ type: 'None' }); + }); + + it('decodes CTCSS 67.0 Hz', () => { + // low=0x70 (ones=7, decimal=0), high=0x06 (hundreds=0, tens=6) + const r = decodeCTCSSDCS(new Uint8Array([0x70, 0x06])); + expect(r.type).toBe('CTCSS'); + expect(r.value).toBeCloseTo(67.0, 1); + }); + + it('decodes CTCSS 100.0 Hz', () => { + // low=0x00, high=0x10 (hundreds=1, tens=0) + const r = decodeCTCSSDCS(new Uint8Array([0x00, 0x10])); + expect(r.type).toBe('CTCSS'); + expect(r.value).toBeCloseTo(100.0, 1); + }); + + it('decodes CTCSS 127.3 Hz', () => { + const r = decodeCTCSSDCS(new Uint8Array([0x73, 0x12])); + expect(r.type).toBe('CTCSS'); + expect(r.value).toBeCloseTo(127.3, 1); + }); + + it('decodes CTCSS 203.5 Hz', () => { + const r = decodeCTCSSDCS(new Uint8Array([0x35, 0x20])); + expect(r.type).toBe('CTCSS'); + expect(r.value).toBeCloseTo(203.5, 1); + }); + + it('decodes DCS normal polarity (high=0x80, code in low byte)', () => { + // code 23 decimal, high=0x80 → DCS, not inverted + expect(decodeCTCSSDCS(new Uint8Array([0x17, 0x80]))).toEqual({ + type: 'DCS', + value: 23, + polarity: 'N', + }); + }); + + it('decodes DCS inverted polarity (high >= 0xC0)', () => { + // code 23, high=0xC0 → DCS, inverted + expect(decodeCTCSSDCS(new Uint8Array([0x17, 0xC0]))).toEqual({ + type: 'DCS', + value: 23, + polarity: 'P', + }); + }); + + it('decodes DCS codes > 255 using high nibble of high byte', () => { + // high=0x81: DCS (>=0x80), not inverted (<0xC0), highNibble=0x01 → code=(1<<8)|0x2C=300 + expect(decodeCTCSSDCS(new Uint8Array([0x2C, 0x81]))).toEqual({ + type: 'DCS', + value: 300, + polarity: 'N', + }); + }); +}); + +// ─── CTCSS/DCS encode ───────────────────────────────────────────────────────── + +describe('encodeCTCSSDCS', () => { + it('encodes None to [0x00, 0x00]', () => { + expect(encodeCTCSSDCS({ type: 'None' })).toEqual(new Uint8Array([0x00, 0x00])); + }); + + it('encodes None with explicit undefined value to [0x00, 0x00]', () => { + expect(encodeCTCSSDCS({ type: 'None', value: undefined })).toEqual(new Uint8Array([0x00, 0x00])); + }); + + it('encodes CTCSS 67.0 Hz', () => { + expect(encodeCTCSSDCS({ type: 'CTCSS', value: 67.0 })).toEqual(new Uint8Array([0x70, 0x06])); + }); + + it('encodes CTCSS 100.0 Hz', () => { + expect(encodeCTCSSDCS({ type: 'CTCSS', value: 100.0 })).toEqual(new Uint8Array([0x00, 0x10])); + }); + + it('encodes CTCSS 127.3 Hz', () => { + expect(encodeCTCSSDCS({ type: 'CTCSS', value: 127.3 })).toEqual(new Uint8Array([0x73, 0x12])); + }); + + it('encodes CTCSS 203.5 Hz', () => { + expect(encodeCTCSSDCS({ type: 'CTCSS', value: 203.5 })).toEqual(new Uint8Array([0x35, 0x20])); + }); + + it('encodes DCS normal polarity as [code, 0x80]', () => { + expect(encodeCTCSSDCS({ type: 'DCS', value: 23, polarity: 'N' })).toEqual( + new Uint8Array([0x17, 0x80]) + ); + }); + + it('always returns exactly 2 bytes', () => { + expect(encodeCTCSSDCS({ type: 'None' })).toHaveLength(2); + expect(encodeCTCSSDCS({ type: 'CTCSS', value: 100 })).toHaveLength(2); + expect(encodeCTCSSDCS({ type: 'DCS', value: 23, polarity: 'N' })).toHaveLength(2); + }); +}); + +// ─── CTCSS/DCS round-trips ──────────────────────────────────────────────────── + +describe('CTCSS/DCS round-trip', () => { + it('None round-trips through encode → decode', () => { + expect(decodeCTCSSDCS(encodeCTCSSDCS({ type: 'None' }))).toEqual({ type: 'None' }); + }); + + const ctcssTones = [67.0, 100.0, 127.3, 203.5]; + + for (const tone of ctcssTones) { + it(`CTCSS ${tone} Hz round-trips`, () => { + const decoded = decodeCTCSSDCS(encodeCTCSSDCS({ type: 'CTCSS', value: tone })); + expect(decoded.type).toBe('CTCSS'); + expect(decoded.value).toBeCloseTo(tone, 1); + }); + } + + it('DCS normal polarity round-trips for codes ≤ 255', () => { + const input = { type: 'DCS' as const, value: 23, polarity: 'N' as const }; + expect(decodeCTCSSDCS(encodeCTCSSDCS(input))).toEqual(input); + }); + + // Known encoder bug: encodeCTCSSDCS uses polarityBit=0x01 for inverted ('P'), producing + // high byte 0x81. But decodeCTCSSDCS expects high >= 0xC0 for inverted and reads + // bit 0 of the high byte as part of the code's highNibble. The round-trip is broken: + // encode({type:'DCS', value:23, polarity:'P'}) → [0x17, 0x81] + // decode([0x17, 0x81]) → {type:'DCS', value:279, polarity:'N'} ← wrong value and polarity + // This test locks in the current broken behaviour so any future fix is a deliberate change. + it('DCS inverted polarity does NOT round-trip (known encoder bug)', () => { + const input = { type: 'DCS' as const, value: 23, polarity: 'P' as const }; + const roundTripped = decodeCTCSSDCS(encodeCTCSSDCS(input)); + expect(roundTripped).not.toEqual(input); + }); +}); diff --git a/tests/unit/taflChannels.test.ts b/tests/unit/taflChannels.test.ts new file mode 100644 index 0000000..b00ad02 --- /dev/null +++ b/tests/unit/taflChannels.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { generateTaflChannels } from '../../src/services/taflChannels'; +import type { TaflData } from '../../src/data/taflData'; + +function tafl(code: string, lat: number, lon: number, freqKhz: number): TaflData { + return { c: code, l: [lat, lon], f: freqKhz }; +} + +const entries: TaflData[] = [ + tafl('Dow_Chemical_Can', 42.5, -82.1, 470000), + tafl('Dow_Chemical_US', 43.6, -83.9, 471000), + tafl('Safety_Net', 49.2, -122.8, 155400), +]; + +describe('generateTaflChannels', () => { + it('throws when no entries provided', () => { + expect(() => generateTaflChannels(1, [])).toThrow(); + }); + + it('generates one channel per entry', () => { + const result = generateTaflChannels(1, entries); + expect(result.channels).toHaveLength(entries.length); + }); + + it('assigns channel numbers starting at startChannelNumber', () => { + const result = generateTaflChannels(20, entries); + expect(result.channels[0].number).toBe(20); + expect(result.channels[2].number).toBe(22); + }); + + it('converts frequency from kHz to MHz correctly', () => { + const result = generateTaflChannels(1, [tafl('TEST', 43.0, -80.0, 470000)]); + expect(result.channels[0].rxFrequency).toBeCloseTo(470.0, 3); + }); + + it('creates one zone per entry when groupByName is false', () => { + const result = generateTaflChannels(1, entries, false, false); + expect(result.zones.length).toBe(entries.length); + }); + + it('creates one zone when singleZone is true', () => { + const result = generateTaflChannels(1, entries, true, false); + expect(result.zones.length).toBe(1); + }); + + it('all channel names are 16 chars or fewer', () => { + const result = generateTaflChannels(1, entries); + result.channels.forEach(ch => { + expect(ch.name.length).toBeLessThanOrEqual(16); + }); + }); + + it('zone channels reference actual channel numbers', () => { + const result = generateTaflChannels(1, entries); + const channelNumbers = new Set(result.channels.map(c => c.number)); + result.zones.forEach(z => { + z.channels.forEach(n => { + expect(channelNumbers.has(n)).toBe(true); + }); + }); + }); + + it('summary matches actual counts', () => { + const result = generateTaflChannels(1, entries); + expect(result.summary.channelsCreated).toBe(result.channels.length); + expect(result.summary.zonesCreated).toBe(result.zones.length); + }); +}); diff --git a/tests/unit/validators.test.ts b/tests/unit/validators.test.ts new file mode 100644 index 0000000..7bbaa08 --- /dev/null +++ b/tests/unit/validators.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect } from 'vitest'; +import { + isNoTxFrequency, + isRxInNoTxBand, + isValidFrequencyRange, + isValidChannelFrequency, + isValidFrequency, + getFrequencyBand, + NO_TX_FREQUENCY, +} from '../../src/services/validation/frequencyValidator'; +import { validateChannel, validateChannels } from '../../src/services/validation/channelValidator'; +import { isValidDMRId, isValidColorCode, isValidTimeSlot } from '../../src/services/validation/dmrValidator'; +import { DEFAULT_BAND_LIMITS } from '../../src/types/radioCapabilities'; +import type { Channel } from '../../src/models/Channel'; + +// ─── frequencyValidator ────────────────────────────────────────────────────── + +describe('isNoTxFrequency', () => { + it('recognises the sentinel value', () => { + expect(isNoTxFrequency(NO_TX_FREQUENCY)).toBe(true); + expect(isNoTxFrequency(1666.0)).toBe(true); + }); + + it('rejects normal frequencies', () => { + expect(isNoTxFrequency(146.52)).toBe(false); + expect(isNoTxFrequency(440.0)).toBe(false); + expect(isNoTxFrequency(1667.0)).toBe(false); + }); +}); + +describe('isRxInNoTxBand', () => { + it('identifies aviation/FM receive-only band', () => { + expect(isRxInNoTxBand(87)).toBe(true); + expect(isRxInNoTxBand(120)).toBe(true); + expect(isRxInNoTxBand(135.9)).toBe(true); + }); + + it('rejects frequencies outside the band', () => { + expect(isRxInNoTxBand(86.9)).toBe(false); + expect(isRxInNoTxBand(136)).toBe(false); // upper bound is exclusive + expect(isRxInNoTxBand(146.52)).toBe(false); + }); +}); + +describe('isValidFrequencyRange', () => { + it('accepts VHF frequencies within default limits', () => { + expect(isValidFrequencyRange(87)).toBe(true); // bottom of VHF + expect(isValidFrequencyRange(146.52)).toBe(true); + expect(isValidFrequencyRange(174)).toBe(true); // top of VHF + }); + + it('accepts UHF frequencies within default limits', () => { + expect(isValidFrequencyRange(400)).toBe(true); + expect(isValidFrequencyRange(440)).toBe(true); + expect(isValidFrequencyRange(470)).toBe(true); + }); + + it('rejects out-of-band frequencies', () => { + expect(isValidFrequencyRange(300)).toBe(false); // between VHF and UHF + expect(isValidFrequencyRange(50)).toBe(false); + expect(isValidFrequencyRange(500)).toBe(false); + }); + + it('uses custom band limits when provided', () => { + const limits = { vhfMin: 136, vhfMax: 174, uhfMin: 400, uhfMax: 480 }; + expect(isValidFrequencyRange(136, limits)).toBe(true); + expect(isValidFrequencyRange(87, limits)).toBe(false); // outside custom VHF min + expect(isValidFrequencyRange(475, limits)).toBe(true); // inside custom UHF max + }); +}); + +describe('isValidChannelFrequency', () => { + function ch(rx: number, tx: number, forbidTx = false): Channel { + return { rxFrequency: rx, txFrequency: tx, forbidTx } as unknown as Channel; + } + + it('accepts a valid VHF simplex channel', () => { + expect(isValidChannelFrequency(ch(146.52, 146.52))).toBe(true); + }); + + it('accepts a valid UHF channel', () => { + expect(isValidChannelFrequency(ch(440.0, 445.0))).toBe(true); + }); + + it('rejects a channel with zero RX', () => { + expect(isValidChannelFrequency(ch(0, 146.52))).toBe(false); + }); + + it('rejects a channel with zero TX (non no-TX band)', () => { + expect(isValidChannelFrequency(ch(146.52, 0))).toBe(false); + }); + + it('accepts a no-TX aviation channel (RX in 87-136, forbidTx, sentinel TX)', () => { + expect(isValidChannelFrequency(ch(120.0, NO_TX_FREQUENCY, true))).toBe(true); + }); + + it('rejects a no-TX aviation channel when forbidTx is false', () => { + // TX sentinel but forbidTx not set — not a valid no-TX channel + expect(isValidChannelFrequency(ch(120.0, NO_TX_FREQUENCY, false))).toBe(false); + }); +}); + +describe('isValidFrequency', () => { + it('returns false for zero or negative', () => { + expect(isValidFrequency(0)).toBe(false); + expect(isValidFrequency(-1)).toBe(false); + }); + + it('returns true for any positive frequency when no band limits given', () => { + expect(isValidFrequency(300)).toBe(true); // no limits = anything goes + }); + + it('applies band limits when provided', () => { + expect(isValidFrequency(146.52, DEFAULT_BAND_LIMITS)).toBe(true); + expect(isValidFrequency(300, DEFAULT_BAND_LIMITS)).toBe(false); + }); +}); + +describe('getFrequencyBand', () => { + it('returns Unknown when no band limits provided', () => { + expect(getFrequencyBand(146.52)).toBe('Unknown'); + }); + + it('identifies VHF', () => { + expect(getFrequencyBand(146.52, DEFAULT_BAND_LIMITS)).toBe('VHF'); + }); + + it('identifies UHF', () => { + expect(getFrequencyBand(440.0, DEFAULT_BAND_LIMITS)).toBe('UHF'); + }); + + it('returns Unknown for out-of-band', () => { + expect(getFrequencyBand(300, DEFAULT_BAND_LIMITS)).toBe('Unknown'); + }); +}); + +// ─── channelValidator ──────────────────────────────────────────────────────── + +function validChannel(overrides: Partial = {}): Channel { + return { + number: 1, + name: 'Test Channel', + rxFrequency: 146.52, + txFrequency: 146.52, + mode: 'Analog', + forbidTx: false, + colorCode: 1, + contactId: 1, + ...overrides, + } as unknown as Channel; +} + +describe('validateChannel', () => { + it('returns no errors for a valid analog channel', () => { + expect(validateChannel(validChannel())).toHaveLength(0); + }); + + it('requires a non-empty name', () => { + const errors = validateChannel(validChannel({ name: '' })); + expect(errors.some(e => e.field === 'name')).toBe(true); + }); + + it('requires name 16 chars or fewer', () => { + const errors = validateChannel(validChannel({ name: 'A'.repeat(17) })); + expect(errors.some(e => e.field === 'name')).toBe(true); + }); + + it('accepts name of exactly 16 chars', () => { + const errors = validateChannel(validChannel({ name: 'A'.repeat(16) })); + expect(errors.some(e => e.field === 'name')).toBe(false); + }); + + it('requires positive RX frequency', () => { + const errors = validateChannel(validChannel({ rxFrequency: 0 })); + expect(errors.some(e => e.field === 'rxFrequency')).toBe(true); + }); + + it('requires positive TX frequency for non no-TX channels', () => { + const errors = validateChannel(validChannel({ txFrequency: 0 })); + expect(errors.some(e => e.field === 'txFrequency')).toBe(true); + }); + + it('accepts no-TX sentinel when RX is in 87-136 and forbidTx is set', () => { + const ch = validChannel({ rxFrequency: 120.0, txFrequency: NO_TX_FREQUENCY, forbidTx: true }); + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'txFrequency')).toBe(false); + }); + + it('rejects channel number below 1', () => { + const errors = validateChannel(validChannel({ number: 0 })); + expect(errors.some(e => e.field === 'number')).toBe(true); + }); + + it('rejects channel number above maxChannels', () => { + const errors = validateChannel(validChannel({ number: 4001 }), null, 4000); + expect(errors.some(e => e.field === 'number')).toBe(true); + }); + + it('accepts channel number at the maxChannels limit', () => { + const errors = validateChannel(validChannel({ number: 4000 }), null, 4000); + expect(errors.some(e => e.field === 'number')).toBe(false); + }); + + it('flags RX frequency outside band limits', () => { + const errors = validateChannel(validChannel({ rxFrequency: 300, txFrequency: 300 }), DEFAULT_BAND_LIMITS); + expect(errors.some(e => e.field === 'rxFrequency')).toBe(true); + }); + + it('accepts RX frequency within band limits', () => { + const errors = validateChannel(validChannel(), DEFAULT_BAND_LIMITS); + expect(errors.some(e => e.field === 'rxFrequency')).toBe(false); + }); + + it('validates color code for digital channels', () => { + const ch = validChannel({ mode: 'Digital', colorCode: 16 }); // 16 is out of 0-15 + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'colorCode')).toBe(true); + }); + + it('accepts valid color code 0-15 for digital channels', () => { + const ch = validChannel({ mode: 'Digital', colorCode: 15, contactId: 1 }); + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'colorCode')).toBe(false); + }); + + it('does not validate color code for analog channels', () => { + const ch = validChannel({ mode: 'Analog', colorCode: 16 }); + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'colorCode')).toBe(false); + }); + + it('flags contactId out of range for digital channels', () => { + const ch = validChannel({ mode: 'Digital', colorCode: 1, contactId: 251 }); + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'contactId')).toBe(true); + }); + + it('accepts contactId 0-250 for digital channels', () => { + const ch = validChannel({ mode: 'Digital', colorCode: 1, contactId: 250 }); + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'contactId')).toBe(false); + }); + + it('validates Fixed Digital mode the same as Digital', () => { + const ch = validChannel({ mode: 'Fixed Digital', colorCode: 16, contactId: 1 }); + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'colorCode')).toBe(true); + }); + + it('exercises slotOperation branch — non-zero slotOperation uses slot 2, still valid', () => { + const ch = validChannel({ mode: 'Digital', colorCode: 1, contactId: 1, slotOperation: 1 }); + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'slotOperation')).toBe(false); + }); + + it('exercises slotOperation branch — undefined slotOperation defaults to slot 1, still valid', () => { + const ch = validChannel({ mode: 'Digital', colorCode: 1, contactId: 1, slotOperation: undefined }); + const errors = validateChannel(ch); + expect(errors.some(e => e.field === 'slotOperation')).toBe(false); + }); +}); + +// ─── validateChannels (multi-channel wrapper) ──────────────────────────────── + +describe('validateChannels', () => { + it('returns empty map when all channels are valid', () => { + const channels = [validChannel({ number: 1 }), validChannel({ number: 2 })]; + expect(validateChannels(channels).size).toBe(0); + }); + + it('maps channel number to errors for invalid channels', () => { + const channels = [ + validChannel({ number: 1 }), + validChannel({ number: 2, name: '' }), + validChannel({ number: 3, rxFrequency: 0 }), + ]; + const result = validateChannels(channels); + expect(result.has(1)).toBe(false); + expect(result.has(2)).toBe(true); + expect(result.has(3)).toBe(true); + }); + + it('omits channels with no errors from the map', () => { + const channels = [validChannel({ number: 5 })]; + const result = validateChannels(channels); + expect(result.size).toBe(0); + }); +}); + +// ─── dmrValidator ──────────────────────────────────────────────────────────── + +describe('isValidDMRId', () => { + it('accepts valid DMR IDs (1 – 9999999)', () => { + expect(isValidDMRId(1)).toBe(true); + expect(isValidDMRId(3112345)).toBe(true); + expect(isValidDMRId(9999999)).toBe(true); + }); + + it('rejects 0 and negative', () => { + expect(isValidDMRId(0)).toBe(false); + expect(isValidDMRId(-1)).toBe(false); + }); + + it('rejects IDs above 9999999', () => { + expect(isValidDMRId(10000000)).toBe(false); + }); +}); + +describe('isValidColorCode', () => { + it('accepts 0 through 15', () => { + expect(isValidColorCode(0)).toBe(true); + expect(isValidColorCode(15)).toBe(true); + }); + + it('rejects values outside 0–15', () => { + expect(isValidColorCode(-1)).toBe(false); + expect(isValidColorCode(16)).toBe(false); + }); +}); + +describe('isValidTimeSlot', () => { + it('accepts 1 and 2', () => { + expect(isValidTimeSlot(1)).toBe(true); + expect(isValidTimeSlot(2)).toBe(true); + }); + + it('rejects anything else', () => { + expect(isValidTimeSlot(0)).toBe(false); + expect(isValidTimeSlot(3)).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..bafcba5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/unit/**/*.test.ts'], + globals: false, + }, +});