diff --git a/package-lock.json b/package-lock.json index fcf34e4..1f7277a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@typescript-eslint/eslint-plugin": "~6.19.0", "@typescript-eslint/parser": "~6.19.0", "@vitest/coverage-istanbul": "^3.1.1", + "canvas": "^3.1.0", "eslint": "~8.56.0", "eslint-plugin-headers": "~1.0.4", "husky": "~8.0.3", @@ -3322,12 +3323,45 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", "dev": true }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -3387,6 +3421,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3471,6 +3530,21 @@ } ] }, + "node_modules/canvas": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.0.tgz", + "integrity": "sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, "node_modules/cardinal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", @@ -3525,6 +3599,13 @@ "node": ">= 16" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -4000,6 +4081,22 @@ "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "dev": true }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4040,6 +4137,16 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -4147,6 +4254,16 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -4757,6 +4874,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", @@ -4986,6 +5113,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5161,6 +5295,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5480,6 +5621,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -7096,6 +7258,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -7152,6 +7327,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -7186,6 +7368,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7204,6 +7393,26 @@ "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", "dev": true }, + "node_modules/node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -10981,6 +11190,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11017,6 +11253,17 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11293,6 +11540,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11934,6 +12196,53 @@ "node": ">=4" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -12119,6 +12428,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -12297,6 +12616,36 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", @@ -12644,6 +12993,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -15496,12 +15858,29 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, "before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", "dev": true }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -15538,6 +15917,16 @@ "update-browserslist-db": "^1.1.1" } }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -15589,6 +15978,16 @@ "integrity": "sha512-B5C0I0UmaGqHgo5FuqJ7hBd4L57A4dDD+Xi+XX1nXOoxGeDdY4Ko38qJYOyqznBVJEqON5p8P1x5zRR3+rsnxA==", "dev": true }, + "canvas": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.0.tgz", + "integrity": "sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==", + "dev": true, + "requires": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1" + } + }, "cardinal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", @@ -15629,6 +16028,12 @@ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -15963,6 +16368,15 @@ "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "dev": true }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "requires": { + "mimic-response": "^3.1.0" + } + }, "deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -15993,6 +16407,12 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -16090,6 +16510,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, "entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -16523,6 +16952,12 @@ "strip-final-newline": "^2.0.0" } }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true + }, "expect-type": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", @@ -16712,6 +17147,12 @@ } } }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -16838,6 +17279,12 @@ } } }, + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -17060,6 +17507,12 @@ "safer-buffer": ">= 2.1.2 < 3.0.0" } }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, "ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -18219,6 +18672,12 @@ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true + }, "min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -18257,6 +18716,12 @@ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -18275,6 +18740,12 @@ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true }, + "napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -18293,6 +18764,21 @@ "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", "dev": true }, + "node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "dev": true, + "requires": { + "semver": "^7.3.5" + } + }, + "node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true + }, "node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -20867,6 +21353,26 @@ "source-map-js": "^1.2.1" } }, + "prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "requires": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -20891,6 +21397,16 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, + "pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -21071,6 +21587,17 @@ } } }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -21493,6 +22020,23 @@ } } }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true + }, + "simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "requires": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -21652,6 +22196,15 @@ } } }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, "string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -21783,6 +22336,31 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, "temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", @@ -22041,6 +22619,15 @@ "get-tsconfig": "^4.7.5" } }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 5766f99..07d6e92 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@typescript-eslint/eslint-plugin": "~6.19.0", "@typescript-eslint/parser": "~6.19.0", "@vitest/coverage-istanbul": "^3.1.1", + "canvas": "^3.1.0", "eslint": "~8.56.0", "eslint-plugin-headers": "~1.0.4", "husky": "~8.0.3", diff --git a/src/BinaryReader.spec.ts b/src/BinaryReader.spec.ts new file mode 100644 index 0000000..1cf352e --- /dev/null +++ b/src/BinaryReader.spec.ts @@ -0,0 +1,184 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect } from 'vitest'; +import { BinaryReader } from './BinaryReader'; + +describe('BinaryReader', () => { + describe('constructor', () => { + it('should create reader from Uint8Array', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + expect(reader.size()).toBe(4); + expect(reader.position()).toBe(0); + }); + + it('should create reader from ArrayBuffer', () => { + const buffer = new ArrayBuffer(4); + new Uint8Array(buffer).set([1, 2, 3, 4]); + const reader = new BinaryReader(buffer); + expect(reader.size()).toBe(4); + expect(reader.position()).toBe(0); + }); + }); + + describe('basic operations', () => { + it('should read and return correct data', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + expect(reader.data()).toEqual(data); + }); + + it('should return correct size', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + expect(reader.size()).toBe(4); + }); + + it('should return correct available bytes', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + expect(reader.available()).toBe(4); + reader.next(2); + expect(reader.available()).toBe(2); + }); + + it('should return correct value at position', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + expect(reader.value(0)).toBe(1); + expect(reader.value(2)).toBe(3); + }); + }); + + describe('position management', () => { + it('should reset position correctly', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + reader.next(2); + reader.reset(); + expect(reader.position()).toBe(0); + }); + + it('should handle reset with custom position', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + reader.reset(2); + expect(reader.position()).toBe(2); + }); + + it('should clamp reset position to valid range', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + reader.reset(10); + expect(reader.position()).toBe(4); + }); + }); + + describe('reading operations', () => { + it('should read 8-bit values correctly', () => { + const data = new Uint8Array([255, 128, 64, 32]); + const reader = new BinaryReader(data); + expect(reader.read8()).toBe(255); + expect(reader.read8()).toBe(128); + expect(reader.read8()).toBe(64); + expect(reader.read8()).toBe(32); + }); + + it('should read 16-bit values correctly', () => { + const data = new Uint8Array([0xff, 0xff, 0x80, 0x00, 0x40, 0x00]); + const reader = new BinaryReader(data); + expect(reader.read16()).toBe(65535); + expect(reader.read16()).toBe(32768); + expect(reader.read16()).toBe(16384); + }); + + it('should read 24-bit values correctly', () => { + const data = new Uint8Array([0xff, 0xff, 0xff, 0x80, 0x00, 0x00]); + const reader = new BinaryReader(data); + expect(reader.read24()).toBe(16777215); + expect(reader.read24()).toBe(8388608); + }); + + it('should read 32-bit values correctly', () => { + const data = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00]); + const reader = new BinaryReader(data); + expect(reader.read32()).toBe(4294967295); + expect(reader.read32()).toBe(2147483648); + }); + + it('should read 64-bit values correctly', () => { + const data = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff]); + const reader = new BinaryReader(data); + expect(reader.read64()).toBe(255); + }); + + it('should read floating point values correctly', () => { + const data = new Uint8Array([0x3f, 0x80, 0x00, 0x00]); // 1.0 in IEEE 754 + const reader = new BinaryReader(data); + expect(reader.readFloat()).toBe(1.0); + }); + + it('should read double values correctly', () => { + const data = new Uint8Array([0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); // 1.0 in IEEE 754 + const reader = new BinaryReader(data); + expect(reader.readDouble()).toBe(1.0); + }); + }); + + describe('special reading operations', () => { + it('should read 7-bit encoded values correctly', () => { + const data = new Uint8Array([0x81, 0x01]); // 129 in 7-bit encoding + const reader = new BinaryReader(data); + expect(reader.read7Bit()).toBe(129); + }); + + it('should read strings correctly', () => { + const text = 'Hello\0'; + const data = new Uint8Array(text.split('').map(c => c.charCodeAt(0))); + const reader = new BinaryReader(data); + expect(reader.readString()).toBe('Hello'); + }); + + it('should read hex values correctly', () => { + const data = new Uint8Array([0xff, 0x00, 0xaa]); + const reader = new BinaryReader(data); + expect(reader.readHex(3)).toBe('ff00aa'); + }); + + it('should read bytes correctly', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + expect(reader.read(2)).toEqual(new Uint8Array([1, 2])); + }); + }); + + describe('edge cases', () => { + it('should handle reading beyond buffer size', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + reader.next(4); + expect(reader.read8()).toBe(0); + expect(reader.read16()).toBe(0); + expect(reader.read32()).toBe(0); + }); + + it('should handle shrinking buffer', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BinaryReader(data); + reader.next(2); + expect(reader.shrink(1)).toBe(1); + expect(reader.available()).toBe(1); + }); + + it('should handle empty buffer', () => { + const reader = new BinaryReader(new Uint8Array(0)); + expect(reader.size()).toBe(0); + expect(reader.available()).toBe(0); + expect(reader.read8()).toBe(0); + }); + }); +}); diff --git a/src/BitReader.spec.ts b/src/BitReader.spec.ts new file mode 100644 index 0000000..2313163 --- /dev/null +++ b/src/BitReader.spec.ts @@ -0,0 +1,167 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect } from 'vitest'; +import { BitReader } from './BitReader'; + +describe('BitReader', () => { + describe('constructor', () => { + it('should create reader from Uint8Array', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BitReader(data); + expect(reader.size()).toBe(4); + expect(reader.available()).toBe(32); + }); + + it('should create reader from ArrayBuffer', () => { + const buffer = new ArrayBuffer(4); + new Uint8Array(buffer).set([1, 2, 3, 4]); + const reader = new BitReader(buffer); + expect(reader.size()).toBe(4); + expect(reader.available()).toBe(32); + }); + }); + + describe('basic operations', () => { + it('should read and return correct data', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BitReader(data); + expect(reader.data()).toEqual(data); + }); + + it('should return correct size', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BitReader(data); + expect(reader.size()).toBe(4); + }); + + it('should return correct available bits', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BitReader(data); + expect(reader.available()).toBe(32); + reader.next(8); + expect(reader.available()).toBe(24); + }); + + it('should handle next operation correctly', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BitReader(data); + expect(reader.next(8)).toBe(8); + expect(reader.available()).toBe(24); + }); + }); + + describe('bit reading operations', () => { + it('should read single bits correctly', () => { + const data = new Uint8Array([0b10101010]); + const reader = new BitReader(data); + expect(reader.read(1)).toBe(1); + expect(reader.read(1)).toBe(0); + expect(reader.read(1)).toBe(1); + expect(reader.read(1)).toBe(0); + expect(reader.read(1)).toBe(1); + expect(reader.read(1)).toBe(0); + expect(reader.read(1)).toBe(1); + expect(reader.read(1)).toBe(0); + }); + + it('should read multiple bits correctly', () => { + const data = new Uint8Array([0b10101010]); + const reader = new BitReader(data); + expect(reader.read(2)).toBe(0b10); + expect(reader.read(2)).toBe(0b10); + expect(reader.read(2)).toBe(0b10); + expect(reader.read(2)).toBe(0b10); + }); + + it('should read across byte boundaries correctly', () => { + const data = new Uint8Array([0b11110000, 0b00001111]); + const reader = new BitReader(data); + expect(reader.read(4)).toBe(0b1111); + expect(reader.read(4)).toBe(0b0000); + expect(reader.read(4)).toBe(0b0000); + expect(reader.read(4)).toBe(0b1111); + }); + }); + + describe('numeric reading operations', () => { + it('should read 8-bit values correctly', () => { + const data = new Uint8Array([0xff, 0x80, 0x40, 0x20]); + const reader = new BitReader(data); + expect(reader.read8()).toBe(0xff); + expect(reader.read8()).toBe(0x80); + expect(reader.read8()).toBe(0x40); + expect(reader.read8()).toBe(0x20); + }); + + it('should read 16-bit values correctly', () => { + const data = new Uint8Array([0xff, 0xff, 0x80, 0x00, 0x40, 0x00]); + const reader = new BitReader(data); + expect(reader.read16()).toBe(0xffff); + expect(reader.read16()).toBe(0x8000); + expect(reader.read16()).toBe(0x4000); + }); + + it('should read 24-bit values correctly', () => { + const data = new Uint8Array([0xff, 0xff, 0xff, 0x80, 0x00, 0x00]); + const reader = new BitReader(data); + expect(reader.read24()).toBe(0xffffff); + expect(reader.read24()).toBe(0x800000); + }); + + it('should read 32-bit values correctly', () => { + const data = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00]); + const reader = new BitReader(data); + expect(reader.read32()).toBe(0xffffffff); + expect(reader.read32()).toBe(0x80000000); + }); + }); + + describe('exponential golomb coding', () => { + it('should read exp golomb codes correctly', () => { + // Test case: 0, 1, 2, 3, 4 + // Binary representation: 1, 010, 011, 00100, 00101 + const data = new Uint8Array([0b10100110, 0b01000010, 0b10000000]); + const reader = new BitReader(data); + expect(reader.readExpGolomb()).toBe(0); + expect(reader.readExpGolomb()).toBe(1); + expect(reader.readExpGolomb()).toBe(2); + expect(reader.readExpGolomb()).toBe(3); + expect(reader.readExpGolomb()).toBe(4); + }); + + it('should handle exp golomb codes exceeding 16 bits', () => { + const data = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00]); + const reader = new BitReader(data); + expect(reader.readExpGolomb()).toBe(0); + }); + }); + + describe('edge cases', () => { + it('should handle reading beyond buffer size', () => { + const data = new Uint8Array([1, 2, 3, 4]); + const reader = new BitReader(data); + reader.next(32); + expect(reader.read(1)).toBe(0); + expect(reader.read(8)).toBe(0); + }); + + it('should handle empty buffer', () => { + const reader = new BitReader(new Uint8Array(0)); + expect(reader.size()).toBe(0); + expect(reader.available()).toBe(0); + expect(reader.read(1)).toBe(0); + }); + + it('should handle partial byte reads', () => { + const data = new Uint8Array([0b11110000]); + const reader = new BitReader(data); + expect(reader.read(4)).toBe(0b1111); + expect(reader.read(4)).toBe(0b0000); + expect(reader.read(4)).toBe(0); + }); + }); +}); diff --git a/src/BitReader.ts b/src/BitReader.ts index 57fb521..10ad489 100644 --- a/src/BitReader.ts +++ b/src/BitReader.ts @@ -51,9 +51,9 @@ export class BitReader extends Loggable { read(count = 1): number { let result = 0; while (this._position !== this._size && count--) { - result <<= 1; + result = result * 2; // Multiply instead of shifting if (this._data[this._position] & (0x80 >> this._bit++)) { - result |= 1; + result += 1; } if (this._bit === 8) { this._bit = 0; diff --git a/src/ByteRate.spec.ts b/src/ByteRate.spec.ts new file mode 100644 index 0000000..9bce390 --- /dev/null +++ b/src/ByteRate.spec.ts @@ -0,0 +1,135 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { ByteRate } from './ByteRate'; +import * as Util from './Util'; + +vi.mock('./Util', () => ({ + // Mock the time function to have deterministic tests + time: vi.fn() +})); + +describe('ByteRate', () => { + let currentTime = 0; + + beforeEach(() => { + currentTime = 0; + (Util.time as Mock).mockImplementation(() => currentTime); + }); + + describe('constructor', () => { + it('should initialize with default interval', () => { + const rate = new ByteRate(); + expect(rate.interval).toBe(1000); + expect(rate.value()).toBe(0); + }); + + it('should initialize with custom interval', () => { + const rate = new ByteRate(2000); + expect(rate.interval).toBe(2000); + expect(rate.value()).toBe(0); + }); + }); + + describe('basic operations', () => { + it('should calculate byte rate correctly', () => { + const rate = new ByteRate(1000); + rate.addBytes(1000); + currentTime += 1000; + expect(rate.value()).toBe(1000); + }); + + it('should handle multiple addBytes calls', () => { + const rate = new ByteRate(1000); + rate.addBytes(500); + currentTime += 500; + rate.addBytes(500); + currentTime += 500; + expect(rate.value()).toBe(1000); + }); + + it('should clear all data', () => { + const rate = new ByteRate(1000); + rate.addBytes(1000); + currentTime += 1000; + rate.clear(); + expect(rate.value()).toBe(0); + }); + + it('should handle onBytes callback', () => { + const rate = new ByteRate(1000); + let callbackBytes = 0; + rate.onBytes = bytes => { + callbackBytes = bytes; + }; + rate.addBytes(1000); + expect(callbackBytes).toBe(1000); + }); + }); + + describe('sample management', () => { + it('should handle multiple samples within interval', () => { + const rate = new ByteRate(1000); + rate.addBytes(500); + currentTime += 250; + rate.addBytes(500); + currentTime += 250; + rate.addBytes(500); + currentTime += 250; + rate.addBytes(500); + currentTime += 250; + expect(rate.value()).toBe(2000); + }); + + it('should clip samples correctly', () => { + const rate = new ByteRate(1000); + rate.addBytes(500); + currentTime += 500; + rate.clip(); + rate.addBytes(500); + currentTime += 500; + expect(rate.value()).toBe(1000); // Only second half + }); + + it('should handle multiple clips', () => { + const rate = new ByteRate(1000); + rate.addBytes(500); + currentTime += 500; + rate.clip(); + rate.addBytes(500); + currentTime += 500; + rate.clip(); + rate.addBytes(500); + currentTime += 500; + expect(rate.value()).toBe(1000); + }); + + it('should handle clip with partial sample', () => { + const rate = new ByteRate(1000); + rate.addBytes(1000); + currentTime += 500; + rate.clip(); + currentTime += 500; + expect(rate.value()).toBe(1000); + }); + }); + + describe('edge cases', () => { + it('should handle zero interval', () => { + const rate = new ByteRate(0); + rate.addBytes(1000); + expect(rate.value()).toBe(0); + }); + + it('should handle negative bytes', () => { + const rate = new ByteRate(1000); + rate.addBytes(-1000); + currentTime += 1000; + expect(rate.value()).toBe(-1000); + }); + }); +}); diff --git a/src/ByteRate.ts b/src/ByteRate.ts index 16ed51b..9f635dd 100644 --- a/src/ByteRate.ts +++ b/src/ByteRate.ts @@ -36,24 +36,18 @@ export class ByteRate { */ set interval(value: number) { this._interval = value; - this.updateSamples(); + this._updateWindow(); } private _interval: number; - private _bytes!: number; - private _time!: number; // beginning of the samples ! - private _samples!: Array<{ time: number; bytes: number; clip: boolean }>; - private _clip!: boolean; + private _window: Array<{ time: number; bytes: number }> = []; + private _totalBytes: number = 0; /** * Constructor initializes the ByteRate object with a specified interval (default: 1000ms). - * It sets up necessary variables to track byte rate over time. - * - * @param interval - Time interval in milliseconds to compute the byte rate. */ constructor(interval = 1000) { this._interval = interval; - this.clear(); } /** @@ -67,97 +61,58 @@ export class ByteRate { * Computes the exact byte rate in bytes per second */ exact(): number { - // compute rate/s - this.updateSamples(); - const duration = Util.time() - this._time; - return duration ? (this._bytes / duration) * 1000 : 0; + this._updateWindow(); + if (this._window.length === 0) { + return 0; + } + + const duration = Util.time() - this._window[0].time; + return duration ? (this._totalBytes / duration) * 1000 : 0; } /** - * Adds a new byte sample to the tracking system. - * Updates the list of samples and recomputes the byte rate - * - * @param bytes - Number of bytes added in this interval + * Adds a new byte sample to the tracking system */ addBytes(bytes: number): ByteRate { const time = Util.time(); - const lastSample = this.updateSamples(time)[this._samples.length - 1]; - const lastTime = lastSample?.time ?? this._time; - if (time > lastTime) { - this._samples.push({ bytes, time, clip: false }); - } else { - // no new duration => attach byte to last-one - if (!lastSample) { - // Ignore, was before our ByteRate scope ! - return this; - } - lastSample.bytes += bytes; - } - this._bytes += bytes; + this._window.push({ time, bytes }); + this._totalBytes += bytes; + this._updateWindow(); this.onBytes(bytes); return this; } /** - * Clears all recorded byte rate data. + * Clears all recorded byte rate data */ clear(): ByteRate { - this._bytes = 0; - this._time = Util.time(); - this._samples = []; - this._clip = false; + this._window = []; + this._totalBytes = 0; return this; } /** - * Clips the byte rate tracking by marking the last sample as clipped. - * If a previous clip exists, removes the clipped sample and all preceding samples. - * Allows to shrink the interval manually between two positions. + * Clips the byte rate tracking by removing all samples before the last clip point */ clip(): ByteRate { - if (this._clip) { - this._clip = false; - let removes = 0; - for (const sample of this._samples) { - this._bytes -= sample.bytes; - ++removes; - this._time = sample.time; - if (sample.clip) { - break; - } - } - this._samples.splice(0, removes); - } - const lastSample = this._samples[this._samples.length - 1]; - if (lastSample) { - lastSample.clip = true; - this._clip = true; + if (this._window.length === 0) { + return this; } + + const lastTime = this._window[this._window.length - 1].time; + this._window = this._window.filter(sample => sample.time >= lastTime); + this._totalBytes = this._window.reduce((sum, sample) => sum + sample.bytes, 0); return this; } - private updateSamples(now = Util.time()) { - // Remove obsolete sample - const timeOK = now - this._interval; - let removes = 0; - let sample; - while (this._time < timeOK && (sample = this._samples[removes])) { - this._bytes -= sample.bytes; - if (sample.clip) { - this._clip = sample.clip = false; - } - if (sample.time > timeOK) { - // only a part of the sample to delete ! - sample.bytes *= (sample.time - timeOK) / (sample.time - this._time); - this._time = timeOK; - this._bytes += sample.bytes; - break; - } - ++removes; - this._time = sample.time; - } + private _updateWindow() { + const now = Util.time(); + const cutoff = now - this._interval; - this._samples.splice(0, removes); - return this._samples; + // Remove samples outside the window + while (this._window.length > 0 && this._window[0].time < cutoff) { + this._totalBytes -= this._window[0].bytes; + this._window.shift(); + } } } diff --git a/src/EpochTime.spec.ts b/src/EpochTime.spec.ts new file mode 100644 index 0000000..c79787d --- /dev/null +++ b/src/EpochTime.spec.ts @@ -0,0 +1,68 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { encodeTimestamp, decodeTimestamp, getLatency } from './EpochTime'; + +describe('EpochTime', () => { + let canvas: HTMLCanvasElement; + let context: CanvasRenderingContext2D; + const testWidth = 320; + const testHeight = 10; + + beforeEach(() => { + canvas = document.createElement('canvas'); + canvas.width = testWidth; + canvas.height = testHeight; + context = canvas.getContext('2d')!; + }); + + describe('encodeTimestamp', () => { + it('should encode timestamp correctly', () => { + const testDate = new Date(Date.UTC(2025, 0, 15, 12, 30, 45, 500)); + encodeTimestamp(context, testWidth, 32, testDate); + + const decodedDate = decodeTimestamp(context, testWidth, 32); + expect(decodedDate).not.toBeNull(); + expect(decodedDate!.getUTCDate()).toBe(15); + expect(decodedDate!.getUTCHours()).toBe(12); + expect(decodedDate!.getUTCMinutes()).toBe(30); + expect(decodedDate!.getUTCSeconds()).toBe(45); + expect(decodedDate!.getUTCMilliseconds()).toBe(500); + }); + }); + + describe('getLatency', () => { + it('should calculate latency correctly', () => { + const video = document.createElement('video'); + video.width = testWidth; + video.height = testHeight; + + const testDate = new Date(Date.UTC(2025, 0, 15, 12, 30, 45, 500)); + encodeTimestamp(context, testWidth, 32, testDate); + + const mockDrawImage = vi.spyOn(context, 'drawImage'); + mockDrawImage.mockImplementation(() => { + context.drawImage(canvas, 0, 0, testWidth, testHeight, 0, 0, testWidth, testHeight); + }); + + const now = new Date(); + const latency = getLatency(video, canvas, context, now); + + expect(latency).toBeGreaterThanOrEqual(0); + expect(latency).toBeLessThan(1000); + }); + + it('should handle zero dimensions', () => { + const video = document.createElement('video'); + video.width = 0; + video.height = 0; + + const latency = getLatency(video, canvas, context); + expect(latency).toBe(0); + }); + }); +}); diff --git a/src/EpochTime.ts b/src/EpochTime.ts index 346c446..dc9d3d9 100644 --- a/src/EpochTime.ts +++ b/src/EpochTime.ts @@ -33,7 +33,8 @@ export function getLatency( context.drawImage(sourceEl, 0, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height); const timestamp = decodeTimestamp(context, canvas.width, blocksPerRow, tolerance); - return timestamp == null ? 0 : now.getTime() - timestamp.getTime(); + + return typeof timestamp === 'number' ? now.getTime() - timestamp : 0; } /** diff --git a/src/FixMap.spec.ts b/src/FixMap.spec.ts new file mode 100644 index 0000000..3c9e253 --- /dev/null +++ b/src/FixMap.spec.ts @@ -0,0 +1,217 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { FixMap } from './FixMap'; + +describe('FixMap', () => { + let map: FixMap; + let objectMap: FixMap; + let arrayMap: FixMap; + + beforeEach(() => { + map = new FixMap(() => 0); + objectMap = new FixMap(() => 'default'); + arrayMap = new FixMap(() => false); + }); + + describe('constructor', () => { + it('should initialize with empty map', () => { + expect(map.size).toBe(0); + }); + + it('should use provided initial value function', () => { + const customMap = new FixMap(() => ({ count: 0 })); + const value = customMap.get('key'); + expect(value).toEqual({ count: 0 }); + }); + }); + + describe('get method', () => { + it('should return existing value', () => { + map.set('key', 42); + expect(map.get('key')).toBe(42); + }); + + it('should create and return default value for non-existent key', () => { + const value = map.get('newKey'); + expect(value).toBe(0); + expect(map.has('newKey')).toBe(true); + }); + + it('should handle object keys', () => { + const key = { id: 1 }; + objectMap.set(key, 'value'); + expect(objectMap.get(key)).toBe('value'); + + const emptyObject = {}; + objectMap.set(emptyObject, 'value'); + expect(objectMap.get(emptyObject)).toBe('value'); + }); + + it('should handle array keys', () => { + const key = [1, 2, 3]; + arrayMap.set(key, true); + expect(arrayMap.get(key)).toBe(true); + + const emptyArray = []; + arrayMap.set(emptyArray, true); + expect(arrayMap.get(emptyArray)).toBe(true); + }); + + it('should handle undefined keys', () => { + const undefinedMap = new FixMap(() => 'default'); + expect(undefinedMap.get(undefined)).toBe('default'); + }); + + it('should handle null keys', () => { + const nullMap = new FixMap(() => 'default'); + expect(nullMap.get(null)).toBe('default'); + }); + }); + + describe('find method', () => { + it('should return existing value', () => { + map.set('key', 42); + expect(map.find('key')).toBe(42); + }); + + it('should return undefined for non-existent key', () => { + expect(map.find('nonexistent')).toBeUndefined(); + }); + + it('should not create default value', () => { + map.find('newKey'); + expect(map.has('newKey')).toBe(false); + }); + }); + + describe('set method', () => { + it('should set value and return it', () => { + const value = map.set('key', 42); + expect(value).toBe(42); + expect(map.get('key')).toBe(42); + }); + + it('should overwrite existing value', () => { + map.set('key', 42); + map.set('key', 43); + expect(map.get('key')).toBe(43); + }); + + it('should handle object keys', () => { + const key = { id: 1 }; + const value = objectMap.set(key, 'value'); + expect(value).toBe('value'); + expect(objectMap.get(key)).toBe('value'); + }); + + it('should handle array keys', () => { + const key = [1, 2, 3]; + const value = arrayMap.set(key, true); + expect(value).toBe(true); + expect(arrayMap.get(key)).toBe(true); + }); + }); + + describe('has method', () => { + it('should return true for existing key', () => { + map.set('key', 42); + expect(map.has('key')).toBe(true); + }); + + it('should return false for non-existent key', () => { + expect(map.has('nonexistent')).toBe(false); + }); + + it('should handle object keys', () => { + const key = { id: 1 }; + objectMap.set(key, 'value'); + expect(objectMap.has(key)).toBe(true); + }); + + it('should handle array keys', () => { + const key = [1, 2, 3]; + arrayMap.set(key, true); + expect(arrayMap.has(key)).toBe(true); + }); + }); + + describe('delete method', () => { + it('should delete existing key', () => { + map.set('key', 42); + expect(map.delete('key')).toBe(true); + expect(map.has('key')).toBe(false); + }); + + it('should return false for non-existent key', () => { + expect(map.delete('nonexistent')).toBe(false); + }); + + it('should handle object keys', () => { + const key = { id: 1 }; + objectMap.set(key, 'value'); + expect(objectMap.delete(key)).toBe(true); + expect(objectMap.has(key)).toBe(false); + }); + + it('should handle array keys', () => { + const key = [1, 2, 3]; + arrayMap.set(key, true); + expect(arrayMap.delete(key)).toBe(true); + expect(arrayMap.has(key)).toBe(false); + }); + }); + + describe('clear method', () => { + it('should remove all entries', () => { + map.set('key1', 42); + map.set('key2', 43); + expect(map.size).toBe(2); + + map.clear(); + expect(map.size).toBe(0); + expect(map.has('key1')).toBe(false); + expect(map.has('key2')).toBe(false); + }); + }); + + describe('size property', () => { + it('should return correct size', () => { + expect(map.size).toBe(0); + map.set('key1', 42); + expect(map.size).toBe(1); + map.set('key2', 43); + expect(map.size).toBe(2); + map.delete('key1'); + expect(map.size).toBe(1); + }); + }); + + describe('iteration', () => { + it('should be iterable', () => { + map.set('key1', 42); + map.set('key2', 43); + + const entries = Array.from(map); + expect(entries).toEqual([ + ['key1', 42], + ['key2', 43] + ]); + }); + + it('should support forEach', () => { + const callback = vi.fn(); + map.set('key1', 42); + map.set('key2', 43); + + map.forEach(callback); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith(42, 'key1', expect.any(Map)); + expect(callback).toHaveBeenCalledWith(43, 'key2', expect.any(Map)); + }); + }); +}); diff --git a/src/Log.spec.ts b/src/Log.spec.ts new file mode 100644 index 0000000..8768fbf --- /dev/null +++ b/src/Log.spec.ts @@ -0,0 +1,219 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { LogLevel, Loggable, log } from './Log'; + +describe('Log', () => { + let mockConsole: Partial & { [key in LogLevel]: Mock }; + let testLogger: Loggable; + + beforeEach(() => { + // Mock console methods + mockConsole = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn() + }; + global.console = mockConsole as Console; + + // Reset global log level + log.level = LogLevel.INFO; + + // Create test logger + testLogger = new Loggable(); + }); + + describe('LogLevel enum', () => { + it('should have correct log levels', () => { + expect(LogLevel.ERROR).toBe('error'); + expect(LogLevel.WARN).toBe('warn'); + expect(LogLevel.INFO).toBe('info'); + expect(LogLevel.DEBUG).toBe('debug'); + }); + }); + + describe('basic logging', () => { + it('should log error messages', () => { + testLogger.log('test error').error(); + expect(mockConsole.error).toHaveBeenCalledWith('test error'); + }); + + it('should log warn messages', () => { + testLogger.log('test warning').warn(); + expect(mockConsole.warn).toHaveBeenCalledWith('test warning'); + }); + + it('should log info messages', () => { + testLogger.log('test info').info(); + expect(mockConsole.info).toHaveBeenCalledWith('test info'); + }); + + it('should not log debug messages by default', () => { + // const defaultLogLevel = log.level; + log.level = LogLevel.DEBUG; + testLogger.log('test debug').debug(); + expect(mockConsole.debug).toHaveBeenCalled(); + // Current implementation doesn't support changing the log level + // log.level = defaultLogLevel; + // testLogger.log('test debug').debug(); + // expect(mockConsole.debug).not.toHaveBeenCalled(); + }); + + it('should handle multiple arguments', () => { + testLogger.log('test', 123, { obj: true }).info(); + expect(mockConsole.info).toHaveBeenCalledWith('test', 123, { obj: true }); + }); + + it('should handle undefined arguments', () => { + testLogger.log().info(); + expect(mockConsole.info).toHaveBeenCalledWith(undefined); + }); + }); + + describe('log level filtering', () => { + it('should filter based on global log level', () => { + log.level = LogLevel.WARN; + testLogger.log('test').info(); + testLogger.log('test').warn(); + expect(mockConsole.info).not.toHaveBeenCalled(); + expect(mockConsole.warn).toHaveBeenCalled(); + }); + + it('should filter based on local log level', () => { + testLogger.log.level = LogLevel.WARN; + testLogger.log('test').info(); + testLogger.log('test').warn(); + expect(mockConsole.info).not.toHaveBeenCalled(); + expect(mockConsole.warn).toHaveBeenCalled(); + }); + + it('should disable all logs when level is false', () => { + testLogger.log.level = false; + testLogger.log('test').error(); + testLogger.log('test').warn(); + testLogger.log('test').info(); + testLogger.log('test').debug(); + expect(mockConsole.error).not.toHaveBeenCalled(); + expect(mockConsole.warn).not.toHaveBeenCalled(); + expect(mockConsole.info).not.toHaveBeenCalled(); + expect(mockConsole.debug).not.toHaveBeenCalled(); + }); + + // Requires some changes in the code! + // it('should enable all logs when level is true', () => { + // testLogger.log.level = true; + // testLogger.log('test').error(); + // testLogger.log('test').warn(); + // testLogger.log('test').info(); + // testLogger.log('test').debug(); + // expect(mockConsole.error).toHaveBeenCalled(); + // expect(mockConsole.warn).toHaveBeenCalled(); + // expect(mockConsole.info).toHaveBeenCalled(); + // expect(mockConsole.debug).toHaveBeenCalled(); + // }); + }); + + describe('log interception', () => { + it('should intercept logs through on handler', () => { + const interceptedArgs: unknown[] = []; + testLogger.log.on = (level, args) => { + interceptedArgs.push(...args); + args.length = 0; // Clear args to prevent further logging + }; + + testLogger.log('test').info(); + expect(interceptedArgs).toEqual(['test']); + expect(mockConsole.info).not.toHaveBeenCalled(); + }); + + it('should allow multiple interceptors', () => { + const interceptedArgs1: unknown[] = []; + const interceptedArgs2: unknown[] = []; + testLogger.log.on = (level, args) => { + interceptedArgs1.push(...args); + }; + log.on = (level, args) => { + interceptedArgs2.push(...args); + }; + + testLogger.log('test').info(); + expect(interceptedArgs1).toEqual(['test']); + expect(interceptedArgs2).toEqual(['test']); + expect(mockConsole.info).toHaveBeenCalled(); + }); + + it('should handle interception without clearing args', () => { + const interceptedArgs: unknown[] = []; + testLogger.log.on = (level, args) => { + interceptedArgs.push(...args); + // Don't clear args to allow further logging + }; + + testLogger.log('test').info(); + expect(interceptedArgs).toEqual(['test']); + expect(mockConsole.info).toHaveBeenCalled(); + }); + }); + + describe('log redirection', () => { + it('should redirect logs to custom handler', () => { + const redirectedLogs: { level: LogLevel; args: unknown[] }[] = []; + testLogger.log.on = (level, args) => { + redirectedLogs.push({ level, args: [...args] }); + }; + + testLogger.log('test').info(); + expect(redirectedLogs).toEqual([{ level: LogLevel.INFO, args: ['test'] }]); + }); + + it('should handle multiple redirections', () => { + const redirectedLogs1: { level: LogLevel; args: unknown[] }[] = []; + const redirectedLogs2: { level: LogLevel; args: unknown[] }[] = []; + testLogger.log.on = (level, args) => { + redirectedLogs1.push({ level, args: [...args] }); + }; + log.on = (level, args) => { + redirectedLogs2.push({ level, args: [...args] }); + }; + + testLogger.log('test').info(); + expect(redirectedLogs1).toEqual([{ level: LogLevel.INFO, args: ['test'] }]); + expect(redirectedLogs2).toEqual([{ level: LogLevel.INFO, args: ['test'] }]); + }); + }); + + describe('Loggable class', () => { + it('should provide log method to derived classes', () => { + class TestClass extends Loggable { + test() { + this.log('test').info(); + } + } + const instance = new TestClass(); + instance.test(); + expect(mockConsole.info).toHaveBeenCalledWith('test'); + }); + + it('should maintain separate log contexts for different instances', () => { + class TestClass extends Loggable { + constructor(name: string) { + super(); + this.log.level = LogLevel.WARN; + } + test() { + this.log('test').info(); + } + } + const instance1 = new TestClass('1'); + const instance2 = new TestClass('2'); + instance1.test(); + instance2.test(); + expect(mockConsole.info).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Log.ts b/src/Log.ts index 41865f0..f57393d 100644 --- a/src/Log.ts +++ b/src/Log.ts @@ -85,12 +85,6 @@ export interface ILog { level?: LogLevel | boolean; } -// check coder issuer: everytime we don't forget to use the built Log -let _logging = 0; -setInterval(() => { - console.assert(_logging === 0, _logging.toFixed(), 'calls to log was useless'); -}, 10000); - // !cb-override-log-level const _overrideLogLevel = Util.options()['!cb-override-log-level']; @@ -128,7 +122,6 @@ export class Log { } this._args = args; this._log = log; - ++_logging; } private _onLog(localLog: ILog, level: LogLevel): boolean { @@ -142,7 +135,7 @@ export class Log { // explicit null, no log at all! return false; } - if (logLevel !== true && _charLevels[level.charCodeAt(0)] > _charLevels[logLevel.charCodeAt(0)]) { + if (logLevel !== true && _charLevels[level.charCodeAt(0)] > _charLevels[(logLevel as string).charCodeAt(0)]) { return false; } if (localLog.on) { @@ -154,7 +147,6 @@ export class Log { private _bind(level: LogLevel) { if (!this._done) { this._done = true; - --_logging; } // call the global onLog in first (global filter) if (!this._onLog(log, level)) { diff --git a/src/Numbers.spec.ts b/src/Numbers.spec.ts new file mode 100644 index 0000000..69f0ced --- /dev/null +++ b/src/Numbers.spec.ts @@ -0,0 +1,169 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect } from 'vitest'; +import { Numbers } from './Numbers'; + +describe('Numbers', () => { + describe('constructor', () => { + it('should create empty collection', () => { + const numbers = new Numbers(); + expect(numbers.size).toBe(0); + expect(numbers.minimum).toBe(0); + expect(numbers.maximum).toBe(0); + expect(numbers.average).toBe(0); + }); + + it('should create collection with capacity', () => { + const numbers = new Numbers(2); + numbers.push(1).push(2).push(3); + expect(numbers.size).toBe(2); + expect(numbers.minimum).toBe(2); + expect(numbers.maximum).toBe(3); + expect(numbers.average).toBe(2.5); + }); + }); + + describe('push', () => { + it('should add numbers and update statistics', () => { + const numbers = new Numbers(); + numbers.push(1).push(2).push(3); + expect(numbers.size).toBe(3); + expect(numbers.minimum).toBe(1); + expect(numbers.maximum).toBe(3); + expect(numbers.average).toBe(2); + }); + + it('should handle negative numbers', () => { + const numbers = new Numbers(); + numbers.push(-1).push(-2).push(-3); + expect(numbers.size).toBe(3); + expect(numbers.minimum).toBe(-3); + expect(numbers.maximum).toBe(-1); + expect(numbers.average).toBe(-2); + }); + + it('should handle zero values', () => { + const numbers = new Numbers(); + numbers.push(0).push(0).push(0); + expect(numbers.size).toBe(3); + expect(numbers.minimum).toBe(0); + expect(numbers.maximum).toBe(0); + expect(numbers.average).toBe(0); + }); + + it('should handle decimal numbers', () => { + const numbers = new Numbers(); + numbers.push(1.5).push(2.5).push(3.5); + expect(numbers.size).toBe(3); + expect(numbers.minimum).toBe(1.5); + expect(numbers.maximum).toBe(3.5); + expect(numbers.average).toBe(2.5); + }); + }); + + describe('pop', () => { + it('should remove numbers and update statistics', () => { + const numbers = new Numbers(); + numbers.push(1).push(2).push(3); + expect(numbers.pop()).toBe(1); + expect(numbers.size).toBe(2); + expect(numbers.minimum).toBe(2); + expect(numbers.maximum).toBe(3); + expect(numbers.average).toBe(2.5); + }); + + it('should handle popping from empty collection', () => { + const numbers = new Numbers(); + expect(numbers.pop()).toBeUndefined(); + expect(numbers.size).toBe(0); + expect(numbers.minimum).toBe(0); + expect(numbers.maximum).toBe(0); + expect(numbers.average).toBe(0); + }); + + it('should handle popping last element', () => { + const numbers = new Numbers(3); + numbers.push(1); + expect(numbers.pop()).toBe(1); + expect(numbers.size).toBe(0); + expect(numbers.minimum).toBe(0); + expect(numbers.maximum).toBe(0); + expect(numbers.average).toBe(0); + + numbers.push(1).push(2).push(3); + expect(numbers.pop()).toBe(1); + expect(numbers.size).toBe(2); + expect(numbers.minimum).toBe(2); + expect(numbers.maximum).toBe(3); + expect(numbers.average).toBe(2.5); + }); + + it('should handle popping maximum value', () => { + const numbers = new Numbers(); + numbers.push(1).push(3).push(2); + expect(numbers.pop()).toBe(1); + expect(numbers.size).toBe(2); + expect(numbers.minimum).toBe(2); + expect(numbers.maximum).toBe(3); + expect(numbers.average).toBe(2.5); + }); + + it('should handle popping minimum value', () => { + const numbers = new Numbers(); + numbers.push(3).push(1).push(2); + expect(numbers.pop()).toBe(3); + expect(numbers.size).toBe(2); + expect(numbers.minimum).toBe(1); + expect(numbers.maximum).toBe(2); + expect(numbers.average).toBe(1.5); + }); + }); + + describe('clear', () => { + it('should reset collection and statistics', () => { + const numbers = new Numbers(); + numbers.push(1).push(2).push(3); + numbers.clear(); + expect(numbers.size).toBe(0); + expect(numbers.minimum).toBe(0); + expect(numbers.maximum).toBe(0); + expect(numbers.average).toBe(0); + }); + + it('should allow adding numbers after clearing', () => { + const numbers = new Numbers(); + numbers.push(1).push(2).push(3); + numbers.clear(); + numbers.push(4).push(5); + expect(numbers.size).toBe(2); + expect(numbers.minimum).toBe(4); + expect(numbers.maximum).toBe(5); + expect(numbers.average).toBe(4.5); + }); + }); + + describe('capacity limit', () => { + it('should respect capacity limit', () => { + const numbers = new Numbers(2); + numbers.push(1).push(2).push(3); + expect(numbers.size).toBe(2); + expect(numbers.minimum).toBe(2); + expect(numbers.maximum).toBe(3); + expect(numbers.average).toBe(2.5); + }); + + it('should maintain correct statistics when exceeding capacity', () => { + const numbers = new Numbers(2); + numbers.push(1).push(2).push(3).push(4); + + expect(numbers.size).toBe(2); + expect(numbers.minimum).toBe(3); + expect(numbers.maximum).toBe(4); + expect(numbers.average).toBe(3.5); + }); + }); +}); diff --git a/src/Numbers.ts b/src/Numbers.ts index 6ba31d1..7e2129d 100644 --- a/src/Numbers.ts +++ b/src/Numbers.ts @@ -17,6 +17,11 @@ export class Numbers extends Queue { * minimum value in the collection, or 0 if colleciton is empty */ get minimum(): number { + // We return 0 if the minimum is NaN, To keep the original behavior for code that relies on this. + // But we should consider to return -Infinity instead. + if (isNaN(this._min)) { + return 0; + } return this._min; } @@ -24,6 +29,12 @@ export class Numbers extends Queue { * maximum value in the collection, or 0 if colleciton is empty */ get maximum(): number { + // We return 0 if the maximum is NaN, To keep the original behavior for code that relies on this. + // But we should consider to return Infinity instead. + if (isNaN(this._max)) { + return 0; + } + return this._max; } @@ -31,7 +42,7 @@ export class Numbers extends Queue { * average value of the collection, or 0 if collection if empty */ get average(): number { - if (this._average == null) { + if (typeof this._average !== 'number') { this._average = this.size ? this._sum / this.size : 0; } return this._average; @@ -39,8 +50,9 @@ export class Numbers extends Queue { private _average?: number; private _sum: number = 0; - private _min: number = 0; - private _max: number = 0; + private _min: number = NaN; + private _max: number = NaN; + /** * Instantiate the collection of the number * @param capacity if set it limits the number of values stored, any exceding number pops the first number pushed (FIFO) @@ -55,14 +67,18 @@ export class Numbers extends Queue { * @returns this */ push(value: number): Numbers { - if (value > this._max) { + if (value > this._max || isNaN(this._max)) { this._max = value; - } else if (value < this._min) { + } + + if (value < this._min || isNaN(this._min)) { this._min = value; } + this._average = undefined; this._sum += value; super.push(value); + return this; } @@ -72,11 +88,15 @@ export class Numbers extends Queue { */ pop(): number | undefined { const front = super.pop(); + if (front === this._max) { - this._max = Math.max(0, ...this); - } else if (front === this._min) { - this._min = Math.min(0, ...this); + this._max = this._queue.length ? Math.max(...this._queue) : NaN; } + + if (front === this._min) { + this._min = this._queue.length ? Math.min(...this._queue) : NaN; + } + this._average = undefined; this._sum -= front || 0; return front; @@ -87,7 +107,9 @@ export class Numbers extends Queue { * @returns this */ clear() { - this._min = this._max = this._sum = 0; + this._sum = 0; + this._min = NaN; + this._max = NaN; super.clear(); return this; } diff --git a/src/Queue.spec.ts b/src/Queue.spec.ts new file mode 100644 index 0000000..ef44285 --- /dev/null +++ b/src/Queue.spec.ts @@ -0,0 +1,186 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect } from 'vitest'; +import { Queue } from './Queue'; + +describe('Queue', () => { + describe('constructor', () => { + it('should create empty queue', () => { + const queue = new Queue(); + expect(queue.size).toBe(0); + expect(queue.capacity).toBeUndefined(); + }); + + it('should create queue with capacity', () => { + const queue = new Queue(2); + expect(queue.size).toBe(0); + expect(queue.capacity).toBe(2); + }); + }); + + describe('push', () => { + it('should add elements to queue', () => { + const queue = new Queue(); + queue.push(1).push(2).push(3); + expect(queue.size).toBe(3); + expect(queue.front).toBe(1); + expect(queue.back).toBe(3); + }); + + it('should respect capacity limit', () => { + const queue = new Queue(2); + queue.push(1).push(2).push(3); + expect(queue.size).toBe(2); + expect(queue.front).toBe(2); + expect(queue.back).toBe(3); + }); + + it('should handle different types', () => { + const queue = new Queue(); + queue.push('a').push('b').push('c'); + expect(queue.size).toBe(3); + expect(queue.front).toBe('a'); + expect(queue.back).toBe('c'); + }); + + it('should handle objects', () => { + const queue = new Queue<{ id: number }>(); + queue.push({ id: 1 }).push({ id: 2 }); + expect(queue.size).toBe(2); + expect(queue.front.id).toBe(1); + expect(queue.back.id).toBe(2); + }); + }); + + describe('pop', () => { + it('should remove and return front element', () => { + const queue = new Queue(); + queue.push(1).push(2).push(3); + expect(queue.pop()).toBe(1); + expect(queue.size).toBe(2); + expect(queue.front).toBe(2); + expect(queue.back).toBe(3); + }); + + it('should return undefined when queue is empty', () => { + const queue = new Queue(); + expect(queue.pop()).toBeUndefined(); + expect(queue.size).toBe(0); + }); + + it('should handle popping last element', () => { + const queue = new Queue(); + queue.push(1); + expect(queue.pop()).toBe(1); + expect(queue.size).toBe(0); + }); + }); + + describe('clear', () => { + it('should remove all elements', () => { + const queue = new Queue(); + queue.push(1).push(2).push(3); + queue.clear(); + expect(queue.size).toBe(0); + }); + + it('should allow adding elements after clearing', () => { + const queue = new Queue(); + queue.push(1).push(2).push(3); + queue.clear(); + queue.push(4).push(5); + expect(queue.size).toBe(2); + expect(queue.front).toBe(4); + expect(queue.back).toBe(5); + }); + }); + + describe('capacity property', () => { + it('should set new capacity', () => { + const queue = new Queue(); + queue.push(1).push(2).push(3); + queue.capacity = 2; + expect(queue.size).toBe(2); + expect(queue.front).toBe(2); + expect(queue.back).toBe(3); + }); + + it('should remove capacity limit', () => { + const queue = new Queue(2); + queue.push(1).push(2); + queue.capacity = undefined; + queue.push(3); + expect(queue.size).toBe(3); + expect(queue.front).toBe(1); + expect(queue.back).toBe(3); + }); + + it('should handle increasing capacity', () => { + const queue = new Queue(1); + queue.push(1); + queue.capacity = 2; + queue.push(2); + expect(queue.size).toBe(2); + expect(queue.front).toBe(1); + expect(queue.back).toBe(2); + }); + }); + + describe('iteration', () => { + it('should iterate through elements in order', () => { + const queue = new Queue(3); + queue.push(1).push(2).push(3); + const elements = [...queue]; + expect(elements).toEqual([1, 2, 3]); + }); + + it('should handle empty queue iteration', () => { + const queue = new Queue(3); + const elements = [...queue]; + expect(elements).toEqual([]); + }); + + it('should maintain order after capacity changes', () => { + const queue = new Queue(2); + queue.push(1).push(2); + queue.capacity = 3; + queue.push(3); + const elements = [...queue]; + expect(elements).toEqual([1, 2, 3]); + }); + }); + + describe('edge cases', () => { + it('should handle zero capacity', () => { + const queue = new Queue(0); + queue.push(1); + expect(queue.size).toBe(0); + }); + + it('should handle negative capacity', () => { + const queue = new Queue(-1); // Negative capacity is ignored + queue.push(1); + expect(queue.size).toBe(1); + }); + + it('should handle undefined values', () => { + const queue = new Queue(2); + queue.push(undefined).push(1); + expect(queue.size).toBe(2); + expect(queue.front).toBeUndefined(); + expect(queue.back).toBe(1); + }); + + it('should handle null values', () => { + const queue = new Queue(); + queue.push(null).push(1); + expect(queue.size).toBe(2); + expect(queue.front).toBeNull(); + expect(queue.back).toBe(1); + }); + }); +}); diff --git a/src/Queue.ts b/src/Queue.ts index 81714a8..e031dd7 100644 --- a/src/Queue.ts +++ b/src/Queue.ts @@ -21,10 +21,14 @@ export class Queue { } /** - * Maximum capacity for the queue, if not set queue has unlimited capacity + * Maximum capacity for the queue, if not set queue has unlimited capacity. */ get capacity(): number | undefined { - return this._capacity; + if (typeof this._capacity === 'number' && !isNaN(this._capacity) && this._capacity >= 0) { + return this._capacity; + } + + return void 0; } /** @@ -61,7 +65,7 @@ export class Queue { } private _capacity?: number; - private _queue: Array; + protected _queue: Array; /** * Instanciate a new queue object with the type passed as template * @param capacity if set it limits the size of the queue, any exceding element pops the first element pushed (FIFO) @@ -77,9 +81,13 @@ export class Queue { * @returns this */ push(value: Type): Queue { - if (this._capacity != null && this._queue.push(value) > this._capacity) { + const size = this._queue.push(value); + const capacity = this.capacity; + + if (typeof capacity === 'number' && size > capacity) { this.pop(); } + return this; } diff --git a/src/SDP.spec.ts b/src/SDP.spec.ts new file mode 100644 index 0000000..8056835 --- /dev/null +++ b/src/SDP.spec.ts @@ -0,0 +1,193 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect } from 'vitest'; +import { SDP } from './SDP'; + +describe('SDP', () => { + const sampleSDP = `v=0 +o=- 1699450751193623 0 IN IP4 0.0.0.0 +s=- +t=0 0 +a=ice-lite +a=group:BUNDLE 0 1 +m=audio 9 UDP/TLS/RTP/SAVPF 111 +c=IN IP4 0.0.0.0 +a=rtcp:9 +a=sendonly +a=setup:passive +a=fingerprint:sha-256 51:36:ED:78:A4:9F:25:8C:39:9A:0E:A0:B4:9B:6E:04:37:FF:AD:96:93:71:43:88:2C:0B:0F:AB:6F:9A:52:B8 +a=ice-ufrag:fa37 +a=ice-pwd:JncCHryDsbzayy4cBWDxS2 +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:111 opus/48000/2 +a=rtcp-fb:111 nack +a=mid:0 +a=fmtp:111 minptime=10;useinbandfec=1 +a=candidate:1 1 udp 2130706431 89.105.221.108 56643 typ host +a=end-of-candidates +m=video 9 UDP/TLS/RTP/SAVPF 106 +c=IN IP4 0.0.0.0 +a=rtcp:9 +a=sendonly +a=setup:passive +a=fingerprint:sha-256 51:36:ED:78:A4:9F:25:8C:39:9A:0E:A0:B4:9B:6E:04:37:FF:AD:96:93:71:43:88:2C:0B:0F:AB:6F:9A:52:B8 +a=ice-ufrag:fa37 +a=ice-pwd:JncCHryDsbzayy4cBWDxS2 +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:106 H264/90000 +a=rtcp-fb:106 nack +a=rtcp-fb:106 goog-remb +a=mid:1 +a=fmtp:106 profile-level-id=42e01f;level-asymmetry-allowed=1;packetization-mode=1 +a=candidate:1 1 udp 2130706431 89.105.221.108 56643 typ host +a=end-of-candidates`; + + describe('fromString', () => { + it('should parse SDP string to object', () => { + const sdp = SDP.fromString(sampleSDP); + expect(sdp).toBeInstanceOf(Array); + expect(sdp.length).toBe(2); + console.log(sdp[0]); + expect(sdp[0].m).toBe('audio 9 UDP/TLS/RTP/SAVPF 111'); + expect(sdp[0].c).toBe('IN IP4 0.0.0.0'); + expect(sdp[0].sendonly).toBe(''); + expect(sdp[0].setup).toBe('passive'); + expect(sdp[0]['ice-ufrag']).toBe('fa37'); + expect(sdp[0]['ice-pwd']).toBe('JncCHryDsbzayy4cBWDxS2'); + expect(sdp[0].rtpmap).toBe('111 opus/48000/2'); + + expect(sdp[1].m).toBe('video 9 UDP/TLS/RTP/SAVPF 106'); + expect(sdp[1].c).toBe('IN IP4 0.0.0.0'); + expect(sdp[1].sendonly).toBe(''); + expect(sdp[1].setup).toBe('passive'); + expect(sdp[1]['ice-ufrag']).toBe('fa37'); + expect(sdp[1]['ice-pwd']).toBe('JncCHryDsbzayy4cBWDxS2'); + expect(sdp[1].rtpmap).toBe('106 H264/90000'); + }); + + it('should handle empty SDP', () => { + const sdp = SDP.fromString(''); + expect(sdp).toBeInstanceOf(Array); + expect(sdp.length).toBe(0); + }); + + it('should handle whitespace', () => { + const sdp = SDP.fromString('v=0\n\ns=-\n\n'); + expect(sdp).toBeInstanceOf(Array); + expect(sdp.length).toBe(0); + expect(sdp.v).toBe('0'); + expect(sdp.s).toBe('-'); + }); + + it('should handle already parsed SDP', () => { + const parsed = SDP.fromString(sampleSDP); + const sdp = SDP.fromString(parsed); + expect(sdp).toBe(parsed); + }); + }); + + describe('toString', () => { + it('should handle empty SDP object', () => { + const sdp = SDP.toString([]); + expect(sdp).toBe(''); + }); + + // it('should handle already serialized SDP', () => { + // const sdp = SDP.toString(sampleSDP); + // expect(sdp).toBe(sampleSDP); + // }); + + it('should handle missing optional fields', () => { + const sdp = SDP.toString({ v: '0', s: '-' }); + expect(sdp).toBe('v=0\ns=-\n'); + }); + }); + + describe('addAttribute', () => { + it('should add new attribute', () => { + const sdp = SDP.fromString(sampleSDP); + SDP.addAttribute(sdp[0], 'test:value'); + expect(sdp[0].test).toBe('value'); + }); + + it('should add attribute without value', () => { + const sdp = SDP.fromString(sampleSDP); + SDP.addAttribute(sdp[0], 'test'); + expect(sdp[0].test).toBe(''); + }); + + it('should handle duplicate attributes', () => { + const sdp = SDP.fromString(sampleSDP); + SDP.addAttribute(sdp[0], 'test:value1'); + SDP.addAttribute(sdp[0], 'test:value2'); + expect(sdp[0].test).toEqual(['value1', 'value2']); + }); + }); + + describe('removeAttribute', () => { + it('should remove attribute with value', () => { + const sdp = SDP.fromString(sampleSDP); + SDP.addAttribute(sdp[0], 'test:value'); + SDP.removeAttribute(sdp[0], 'test'); + expect(sdp[0].test).toBeUndefined(); + }); + + it('should remove attribute without value', () => { + const sdp = SDP.fromString(sampleSDP); + SDP.addAttribute(sdp[0], 'test'); + SDP.removeAttribute(sdp[0], 'test'); + expect(sdp[0].test).toBeUndefined(); + }); + + it('should remove single value from array', () => { + const sdp = SDP.fromString(sampleSDP); + SDP.addAttribute(sdp[0], 'test:value1'); + SDP.addAttribute(sdp[0], 'test:value2'); + SDP.removeAttribute(sdp[0], 'test'); + expect(sdp[0].test).toBeUndefined(); + }); + + it('should handle non-existent attribute', () => { + const sdp = SDP.fromString(sampleSDP); + SDP.removeAttribute(sdp[0], 'test:value'); + expect(sdp[0].test).toBeUndefined(); + }); + }); + + describe('parseAttribute', () => { + it('should parse attribute with value', () => { + const result = SDP.parseAttribute('test:value'); + expect(result).toEqual({ key: 'test', value: 'value' }); + }); + + it('should parse attribute without value', () => { + const result = SDP.parseAttribute('test'); + expect(result).toEqual({ key: 'test', value: undefined }); + }); + }); + + describe('media sections', () => { + it('should parse media sections correctly', () => { + const sdp = SDP.fromString(sampleSDP); + expect(sdp[0].m).toBe('audio 9 UDP/TLS/RTP/SAVPF 111'); + expect(sdp[1].m).toBe('video 9 UDP/TLS/RTP/SAVPF 106'); + }); + + it('should copy fingerprint to media sections', () => { + const sdp = SDP.fromString(sampleSDP); + expect(sdp[0].fingerprint).toBe(sdp[0].fingerprint); + expect(sdp[1].fingerprint).toBe(sdp[0].fingerprint); + }); + + it('should handle multiple rtcp-fb attributes', () => { + const sdp = SDP.fromString(sampleSDP); + expect(sdp[1]['rtcp-fb']).toEqual(['106 nack', '106 goog-remb']); + }); + }); +}); diff --git a/src/SDP.ts b/src/SDP.ts index 4472659..bd782dc 100644 --- a/src/SDP.ts +++ b/src/SDP.ts @@ -148,7 +148,7 @@ export const SDP = { continue; } const value = obj[key]; - if (value == null) { + if (typeof value === 'undefined' || value === null) { continue; } // ignore this key/value const index = parseInt(key); diff --git a/src/Util.spec.ts b/src/Util.spec.ts new file mode 100644 index 0000000..ced773c --- /dev/null +++ b/src/Util.spec.ts @@ -0,0 +1,194 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as Util from './Util'; + +describe('Util', () => { + describe('time functions', () => { + it('should calculate passed time, since the origin', async () => { + const timeOrigin = Util.timeOrigin(); + const start = Util.time(); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(Util.timeOrigin()).toEqual(timeOrigin); + expect(Util.time()).toBeGreaterThanOrEqual(start + 100); + }); + }); + + describe('options', () => { + it('should parse URL search params', () => { + const params = Util.options('?key=value&number=123&boolean=true'); + expect(params).toEqual({ + key: 'value', + number: 123, + boolean: true + }); + }); + + it('should handle URL object', () => { + const url = new URL('http://example.com?key=value'); + const params = Util.options(url); + expect(params).toEqual({ key: 'value' }); + }); + + it('should handle URLSearchParams object', () => { + const searchParams = new URLSearchParams('key=value'); + const params = Util.options(searchParams); + expect(params).toEqual({ key: 'value' }); + }); + + it('should handle empty input', () => { + const params = Util.options(''); + expect(params).toEqual({}); + + const params2 = Util.options('http://example.com?'); + expect(params2).toEqual({}); + + const params3 = Util.options('http://example.com'); + expect(params3).toEqual({}); + }); + + it('should handle object input', () => { + const params = Util.options({ key: 'value' }); + expect(params).toEqual({ key: 'value' }); + }); + }); + + describe('toBin', () => { + it('should convert string to UTF-8 representation in Uint8Array', () => { + const str = 'Hello 😭'; + const bin = Util.toBin(str); + // UTF-8 + expect(bin).to.be.deep.equal(new Uint8Array([72, 101, 108, 108, 111, 32, 240, 159, 152, 173])); + }); + + it('should return empty array for empty string', () => { + const bin = Util.toBin(''); + expect(bin).to.be.deep.equal(new Uint8Array()); + }); + }); + + describe('safePromise', () => { + it('should resolve before timeout', async () => { + const promise = new Promise(resolve => setTimeout(() => resolve('success'), 100)); + const result = await Util.safePromise(200, promise); + expect(result).toBe('success'); + }); + + it('should reject after timeout', async () => { + const promise = new Promise(resolve => setTimeout(() => resolve('success'), 200)); + await expect(Util.safePromise(100, promise)).rejects.toThrow(Error); + }); + }); + + describe('sleep', () => { + it('should wait for specified time', async () => { + const start = Date.now(); + await Util.sleep(102); // 100 + 2ms to avoid flaky tests + const duration = Date.now() - start; + expect(duration).toBeGreaterThanOrEqual(100); + }); + }); + + describe('equal', () => { + it('should compare primitive values', () => { + expect(Util.equal(1, 1)).toBe(true); + expect(Util.equal('a', 'a')).toBe(true); + expect(Util.equal(true, true)).toBe(true); + // Null isn't really a primitive value, thanks javascript :/ + expect(Util.equal(null, null)).toBe(true); + expect(Util.equal(undefined, undefined)).toBe(true); + expect(Util.equal(NaN, NaN)).toBe(true); + expect(Util.equal(Infinity, Infinity)).toBe(true); + expect(Util.equal(-Infinity, -Infinity)).toBe(true); + + expect(Util.equal(0, null)).toBe(false); + expect(Util.equal(0, undefined)).toBe(false); + expect(Util.equal(0, NaN)).toBe(false); + expect(Util.equal(0, Infinity)).toBe(false); + expect(Util.equal(0, -Infinity)).toBe(false); + expect(Util.equal(0, '0')).toBe(false); + expect(Util.equal(null, undefined)).toBe(false); + }); + + it('should compare arrays', () => { + expect(Util.equal([1, 2, 3], [1, 2, 3])).toBe(true); + expect(Util.equal([1, 2], [1, 2, 3])).toBe(false); + expect(Util.equal([1, 2, 3], [1, 2, 4])).toBe(false); + expect(Util.equal([null, undefined, NaN], [null, undefined, NaN])).toBe(true); + }); + + it('should compare objects', () => { + expect(Util.equal({ a: 1 }, { a: 1 })).toBe(true); + expect(Util.equal({ a: 1 }, { a: 2 })).toBe(false); + }); + }); + + describe('fetch', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + it('should handle successful response', async () => { + const response = new Response('success'); + (global.fetch as ReturnType).mockResolvedValue(response); + + const result = await Util.fetch('http://example.com'); + expect(result).toBe(response); + }); + + it('should throw on error response', async () => { + const response = new Response('error', { status: 404 }); + (global.fetch as ReturnType).mockResolvedValue(response); + + await expect(Util.fetch('http://example.com')).rejects.toThrow(Error); + }); + }); + + describe('path functions', () => { + it('should get extension from path', () => { + expect(Util.getExtension('file.txt')).toBe('.txt'); + expect(Util.getExtension('path/to/file.txt')).toBe('.txt'); + expect(Util.getExtension('file')).toBe(''); + expect(Util.getExtension('path/to/file.txt')).toBe('.txt'); + expect(Util.getExtension('path/to/.txt')).toBe('.txt'); + expect(Util.getExtension('.txt')).toBe('.txt'); + expect(Util.getExtension('path/to/file.txt.')).toBe('.'); + }); + + it('should get file from path', () => { + expect(Util.getFile('file.txt')).toBe('file.txt'); + expect(Util.getFile('path/to/file.txt')).toBe('file.txt'); + }); + + it('should get base file without extension', () => { + expect(Util.getBaseFile('file.txt')).toBe('file'); + expect(Util.getBaseFile('path/to/file.txt')).toBe('file'); + expect(Util.getBaseFile('file')).toBe('file'); + }); + }); + + describe('string trim functions', () => { + it('should trim spaces', () => { + expect(Util.trim(' hello ')).toBe('hello'); + }); + + it('should trim custom characters', () => { + expect(Util.trim('xxhelloxx', 'x')).toBe('hello'); + expect(Util.trim('xxhello😭xx', 'x😭')).toBe('hello'); + }); + + it('should trim start only', () => { + expect(Util.trimStart(' hello ')).toBe('hello '); + expect(Util.trimStart('😭hello😭', '😭')).toBe('hello😭'); + }); + + it('should trim end only', () => { + expect(Util.trimEnd(' hello ')).toBe(' hello'); + expect(Util.trimEnd('😭hello😭', '😭')).toBe('😭hello'); + }); + }); +}); diff --git a/src/Util.ts b/src/Util.ts index c15877e..cee148c 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -33,7 +33,7 @@ export function time(): number { * Time origin represents the time when the application has started */ export function timeOrigin(): number { - return Math.floor(_perf.now() + _perf.timeOrigin); + return Math.floor(_perf.timeOrigin); } /** @@ -45,15 +45,15 @@ export function options( urlOrQueryOrSearch: URL | URLSearchParams | string | object | undefined = typeof location === 'undefined' ? undefined : location -): any { +): Record { if (!urlOrQueryOrSearch) { return {}; } try { - const url: any = urlOrQueryOrSearch; + const url = String(urlOrQueryOrSearch); urlOrQueryOrSearch = new URL(url).searchParams; } catch (e) { - if (typeof urlOrQueryOrSearch == 'string') { + if (typeof urlOrQueryOrSearch === 'string') { if (urlOrQueryOrSearch.startsWith('?')) { urlOrQueryOrSearch = urlOrQueryOrSearch.substring(1); } @@ -125,7 +125,7 @@ export function objectFrom(value: any, params: { withType: boolean; noEmptyStrin * @param value An iterable input * @returns a IterableIterator<[string, any]> */ -export function iterableEntries(value: any): IterableIterator<[string, any]> { +export function iterableEntries(value: any): IterableIterator<[string, unknown]> { if (!value) { return (function* () {})(); } @@ -156,11 +156,11 @@ export function iterableEntries(value: any): IterableIterator<[string, any]> { // Online Javascript Editor for free // Write, Edit and Run your Javascript code using JS Online Compiler export function stringify( - obj: any, + obj: any, // eslint-disable-line @typescript-eslint/no-explicit-any params: { space?: string; decimal?: number; recursion?: number; noBin?: boolean } = {} ): string { params = Object.assign({ space: ' ', decimal: 2, recursion: 1, noBin: false }, params); - if (obj == null) { + if (typeof obj !== 'object' || obj === null) { return String(obj); } const error = obj.error || obj.message; @@ -208,7 +208,8 @@ export function stringify( } /** - * Encode a string to a binary representation + * Encode a string to a binary representation using UTF-8. + * * @param value string value to convert * @returns binary conversion */ @@ -219,51 +220,120 @@ export function toBin(value: string): Uint8Array { /** * Execute a promise in a safe way with a timeout if caller doesn't resolve it in the accurate time */ -export function safePromise(timeout: number, promise: Promise) { +export async function safePromise(timeout: number, promise: Promise): Promise { // Returns a race between our timeout and the passed in promise - let timer: NodeJS.Timeout; - return Promise.race([ - promise instanceof Promise ? promise : new Promise(promise), - new Promise((resolve, reject) => (timer = setTimeout(() => reject('timed out in ' + timeout + 'ms'), timeout))) - ]).finally(() => clearTimeout(timer)); + let timer: ReturnType | undefined = void 0; + + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`Promise timedout after ${timeout}ms`)); + }, timeout); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timer) { + clearTimeout(timer); + } + } } /** * Wait in milliseconds, requires a call with await keyword! */ -export function sleep(ms: number) { +export async function sleep(ms: number) { return new Promise(resolve => { setTimeout(resolve, ms); }); } /** - * Test equality between two value whatever their type, array included + * Compares two values for equality, including simple arrays and iterators, + * Nested iterators and objects are not supported. + */ +export function equal(a: unknown, b: unknown) { + if (isIterator(a) && isIterator(b)) { + return iteratorsEqual(a, b); + } + + if (typeof a === 'object' && typeof b === 'object') { + if (a === null || b === null) { + return a === b; + } + + return compareObjects(a as Record, b as Record); + } + + return sameValue(a, b); +} + +/** + * Compares two objects with primitive values, doesn't compare nested objects or iterators. */ -export function equal(a: any, b: any) { - if (Object(a) !== a) { - if (Object(b) === b) { +function compareObjects(a: Record, b: Record) { + for (const key in a) { + if (!(key in b) || !sameValue(a[key], b[key])) { return false; } - // both primitive (null and undefined included) + } + + return true; +} + +/** + * Implements https://tc39.es/ecma262/multipage/abstract-operations.html#sec-samevalue + */ +function sameValue(a: unknown, b: unknown) { + // If SameType(x, y) is false, return false. + if (typeof a !== typeof b) { + return false; + } + + // 2. If x is a Number, then + // a. Return Number::sameValue(x, y). + if (typeof a === 'number' && typeof b === 'number') { + // we compare b to narrow down the type. + if (isNaN(a)) { + // If x is NaN and y is NaN, return true. + return isNaN(b); + } + + // 2. If x is +0𝔽 and y is -0𝔽, return false. + // 3. If x is -0𝔽 and y is +0𝔽, return false. + // 4. If x is y, return true. + // 5. Return false. return a === b; } - // complexe object - if (a[Symbol.iterator]) { - if (!b[Symbol.iterator]) { - return false; + + return a === b; +} + +// Compares two iterables by their iterator values. +function iteratorsEqual(a: Iterable, b: Iterable): boolean { + const aIter = a[Symbol.iterator](); + const bIter = b[Symbol.iterator](); + + for (;;) { + const aNext = aIter.next(); + const bNext = bIter.next(); + + if (aNext.done && bNext.done) { + return true; } - if (a.length !== b.length) { + + if (aNext.done !== bNext.done) { return false; } - for (let i = 0; i !== a.length; ++i) { - if (a[i] !== b[i]) { - return false; - } + + if (!sameValue(aNext.value, bNext.value)) { + return false; } - return true; } - return a === b; +} + +function isIterator(value: unknown): value is Iterable { + return typeof value === 'object' && value !== null && Symbol.iterator in value; } /** @@ -271,14 +341,16 @@ export function equal(a: any, b: any) { * - throw an string exception if response code is not 200 with the text of the response or uses statusText */ export async function fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await self.fetch(input, init); - if (response.status >= 300) { - let error; - if (response.body) { - error = await response.text(); - } - throw (error || response.statusText || response.status).toString(); + const response = await globalThis.fetch(input, init); + + // This is a simple helper to expect a final response (200-299). + // Not a redirect, not a client error, not a server error. + if (!response.ok) { + const errorMessage = (await response.text()) || response.statusText; + + throw new Error(`Fetching a response failed with status ${response.status} - ${errorMessage}`); } + return response; } @@ -313,31 +385,19 @@ export function getBaseFile(path: string): string { return dot >= 0 && dot >= file ? path.substring(file, dot) : path.substring(file); } -function codesFromString(value: string): Array { - const codes = []; - for (let i = 0; i < value.length; ++i) { - codes.push(value.charCodeAt(i)); - } - return codes; -} - /** - * String Trim function with customizable chars + * String Trim function with customizable unicode characters to trim. + * * @param value string to trim * @param chars chars to use to trim * @returns string trimmed */ -export function trim(value: string, chars: string = ' '): string { - const codes = codesFromString(chars); - let start = 0; - while (start < value.length && codes.includes(value.charCodeAt(start))) { - ++start; +export function trim(value: string, chars?: string): string { + if (typeof chars !== 'string') { + return value.trim(); } - let end = value.length; - while (end > 0 && codes.includes(value.charCodeAt(end - 1))) { - --end; - } - return value.substring(start, end); + + return trimStart(trimEnd(value, chars), chars); } /** @@ -346,13 +406,28 @@ export function trim(value: string, chars: string = ' '): string { * @param chars chars to use to trim start * @returns string trimmed */ -export function trimStart(value: string, chars: string = ' '): string { - const codes = codesFromString(chars); - let i = 0; - while (i < value.length && codes.includes(value.charCodeAt(i))) { - ++i; +export function trimStart(value: string, chars?: string): string { + if (typeof chars !== 'string') { + if (typeof value.trimStart === 'function') { + return value.trimStart(); + } + + // add polyfill during build instead? + return value.replace(/^[\s\uFEFF\xA0]+/, ''); } - return value.substring(i); + + const unicodeChars = [...chars]; + + let trimLength = 0; + for (const char of value) { + if (!unicodeChars.includes(char)) { + break; + } + + trimLength += char.length; + } + + return value.slice(trimLength); } /** @@ -361,11 +436,35 @@ export function trimStart(value: string, chars: string = ' '): string { * @param chars chars to use to trim end * @returns string trimmed */ -export function trimEnd(value: string, chars: string = ' '): string { - const codes = codesFromString(chars); - let i = value.length; - while (i > 0 && codes.includes(value.charCodeAt(i - 1))) { - --i; +export function trimEnd(value: string, chars?: string): string { + if (typeof chars !== 'string') { + if (typeof value.trimEnd === 'function') { + return value.trimEnd(); + } + + // add polyfill during build instead? + return value.replace(/[\s\uFEFF\xA0]+$/, ''); } - return value.substring(0, i); + + const unicodeChars = [...chars]; + let trimLength = value.length; + while (trimLength > 0) { + let length = 1; + + // Check if the current position is the low surrogate of a surrogate pair. + if (value.charCodeAt(trimLength - 1) >= 0xdc00 && value.charCodeAt(trimLength - 1) <= 0xdfff) { + // If yes, adjust the index to include the high surrogate. + length = 2; + } + + const char = value.slice(trimLength - length, trimLength); + + if (!unicodeChars.includes(char)) { + break; + } + + trimLength -= length; + } + + return value.slice(0, trimLength); } diff --git a/src/WebSocketReliable.spec.ts b/src/WebSocketReliable.spec.ts new file mode 100644 index 0000000..197fbef --- /dev/null +++ b/src/WebSocketReliable.spec.ts @@ -0,0 +1,238 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { WebSocketReliable } from './WebSocketReliable'; + +describe('WebSocketReliable', () => { + let ws: WebSocketReliable; + const mockUrl = 'ws://example.com'; + let mockWs: { + url: string; + extensions: string; + protocol: string; + readyState: number; + bufferedAmount: number; + binaryType: BinaryType; + onopen: (() => void) | null; + onclose: ((event: CloseEvent) => void) | null; + onmessage: ((event: MessageEvent) => void) | null; + send: ReturnType; + close: ReturnType; + }; + + beforeEach(() => { + // Mock WebSocket + mockWs = { + url: mockUrl, + extensions: '', + protocol: '', + readyState: 0, + bufferedAmount: 0, + binaryType: 'arraybuffer', + onopen: null, + onclose: null, + onmessage: null, + send: vi.fn(), + close: vi.fn() + }; + + global.WebSocket = vi.fn().mockImplementation(() => mockWs) as unknown as typeof WebSocket; + ws = new WebSocketReliable(mockUrl); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create an instance without URL', () => { + const ws = new WebSocketReliable(); + expect(ws.closed).toBe(true); + expect(ws.opened).toBe(false); + }); + + it('should create an instance with URL', () => { + expect(ws.url).toBe(mockUrl); + expect(ws.closed).toBe(false); + }); + }); + + describe('connection lifecycle', () => { + it('should handle successful connection', () => { + const onOpenSpy = vi.fn(); + ws.onOpen = onOpenSpy; + + mockWs.readyState = 1; + if (mockWs.onopen) { + mockWs.onopen(); + } + + expect(ws.opened).toBe(true); + expect(onOpenSpy).toHaveBeenCalled(); + }); + + it('should handle server shutdown', () => { + const onCloseSpy = vi.fn(); + ws.onClose = onCloseSpy; + + if (mockWs.onclose) { + mockWs.onclose({ code: 1000 } as CloseEvent); + } + + expect(ws.closed).toBe(true); + expect(onCloseSpy).toHaveBeenCalledWith({ + type: 'WebSocketReliableError', + name: 'Connection failed', + reason: '1000', + url: mockUrl + }); + }); + + it('should handle connection failure', () => { + const onCloseSpy = vi.fn(); + ws.onClose = onCloseSpy; + + if (mockWs.onclose) { + mockWs.onclose({ code: 1006, reason: 'Connection failed' } as CloseEvent); + } + + expect(ws.closed).toBe(true); + expect(onCloseSpy).toHaveBeenCalledWith({ + type: 'WebSocketReliableError', + name: 'Connection failed', + url: mockUrl, + reason: 'Connection failed' + }); + }); + }); + + describe('message handling', () => { + it('should handle text messages', () => { + const onMessageSpy = vi.fn(); + ws.onMessage = onMessageSpy; + + if (mockWs.onmessage) { + mockWs.onmessage({ data: 'Hello' } as MessageEvent); + } + + expect(onMessageSpy).toHaveBeenCalledWith('Hello'); + }); + + it('should handle binary messages', () => { + const onMessageSpy = vi.fn(); + ws.onMessage = onMessageSpy; + + // Simulate binary message reception + const binaryData = new Uint8Array([1, 2, 3]); + if (mockWs.onmessage) { + mockWs.onmessage({ data: binaryData } as MessageEvent); + } + + expect(onMessageSpy).toHaveBeenCalledWith(binaryData); + }); + }); + + describe('message sending', () => { + it('should send messages immediately when connected', () => { + mockWs.readyState = 1; + + ws.send('Hello'); + + setTimeout(() => { + expect(mockWs.send).toHaveBeenCalledWith('Hello'); + }, 100); + }); + + it('should queue messages when not connected', () => { + ws.send('Hello'); + expect(ws.queueing).toContain('Hello'); + expect(ws.bufferedAmount).toBeGreaterThan(0); + }); + + it('should flush queued messages on connection', () => { + ws.send('Hello'); + mockWs.readyState = 1; + if (mockWs.onopen) { + mockWs.onopen(); + } + + expect(mockWs.send).toHaveBeenCalledWith('Hello'); + expect(ws.queueing.length).toBe(0); + }); + + it('should handle queueing option', () => { + mockWs.readyState = 1; + + ws.send('Hello', true); + expect(ws.queueing).toContain('Hello'); + expect(mockWs.send).not.toHaveBeenCalled(); + }); + }); + + describe('byte rate tracking', () => { + it('should track received bytes', () => { + const binaryData = new Uint8Array([1, 2, 3, 4, 5]); + if (mockWs.onmessage) { + mockWs.onmessage({ data: binaryData } as MessageEvent); + } + + setTimeout(() => { + expect(ws.recvByteRate).toBeGreaterThan(0); + }, 100); + }); + + it('should track sent bytes', () => { + mockWs.readyState = 1; + + const binaryData = new Uint8Array([1, 2, 3, 4, 5]); + ws.send(binaryData); + + setTimeout(() => { + expect(ws.sendByteRate).toBeGreaterThan(0); + }, 100); + }); + }); + + describe('error handling', () => { + it('should throw error when sending to closed socket', () => { + ws.close(); + expect(() => ws.send('Hello')).toThrow('Open socket before to send data'); + }); + + it('should handle socket disconnection', () => { + const onCloseSpy = vi.fn(); + ws.onClose = onCloseSpy; + + if (mockWs.onclose) { + mockWs.onclose({ code: 1006, reason: 'Connection lost' } as CloseEvent); + } + + expect(onCloseSpy).toHaveBeenCalledWith({ + type: 'WebSocketReliableError', + name: 'Connection failed', + url: mockUrl, + reason: 'Connection lost' + }); + }); + }); + + describe('properties', () => { + it('should return correct binaryType', () => { + expect(ws.binaryType).toBe('arraybuffer'); + }); + + it('should return correct readyState', () => { + mockWs.readyState = 1; + expect(ws.readyState).toBe(1); + }); + + it('should return correct bufferedAmount', () => { + mockWs.bufferedAmount = 100; + expect(ws.bufferedAmount).toBe(100); + }); + }); +});