├── public ├── robots.txt ├── icons │ ├── icon-192.png │ ├── icon-512.png │ ├── apple-touch-icon.png │ └── maskable-icon-512.png └── screenshots │ ├── home.png │ └── home-wide.webp ├── .gitignore ├── .editorconfig ├── vitest.config.js ├── .github ├── workflows │ ├── pr.yaml │ ├── push.yaml │ ├── eslint.yaml │ └── codeql.yaml └── dependabot.yml ├── src ├── board.js ├── uint8array.js ├── style.css ├── encoding.js ├── storage.js ├── state.js ├── main.js ├── port.js └── ui.js ├── tests ├── fixtures │ └── state-fixtures.js ├── board.spec.js ├── uint8array.test.js ├── storage.test.js ├── state.test.js ├── test-helpers.js ├── encoding.spec.js ├── port.test.js ├── main.test.js └── ui.test.js ├── package.json ├── LICENSE ├── eslint.config.mjs ├── vite.config.js ├── README.md ├── AGENTS.md └── index.html /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist 3 | /node_modules 4 | -------------------------------------------------------------------------------- /public/icons/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amperka/serial-projector/HEAD/public/icons/icon-192.png -------------------------------------------------------------------------------- /public/icons/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amperka/serial-projector/HEAD/public/icons/icon-512.png -------------------------------------------------------------------------------- /public/screenshots/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amperka/serial-projector/HEAD/public/screenshots/home.png -------------------------------------------------------------------------------- /public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amperka/serial-projector/HEAD/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/maskable-icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amperka/serial-projector/HEAD/public/icons/maskable-icon-512.png -------------------------------------------------------------------------------- /public/screenshots/home-wide.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amperka/serial-projector/HEAD/public/screenshots/home-wide.webp -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: "jsdom", 7 | exclude: ["node_modules"], 8 | silent: "passed-only", 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: PR 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | - uses: actions/setup-node@v6 15 | with: 16 | node-version: 24 17 | - run: npm install 18 | - run: npm run format 19 | - run: npm run lint 20 | - run: npm run build 21 | - run: npm run test 22 | -------------------------------------------------------------------------------- /src/board.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Array of (usbVendorId, usbProductId) 3 | */ 4 | const ESPRUINO_IDS = [ 5 | [0x0483, 0x5740], // stm32legacyusb 6 | [1155, 22336], // stm32usb 7 | ]; 8 | 9 | /** 10 | * Detect Espruino-like 11 | * @param {SerialPortInfo} info 12 | * @returns {boolean} 13 | */ 14 | export function isEspruino(info) { 15 | if (!info) return false; 16 | return ESPRUINO_IDS.some( 17 | ([vendorId, productId]) => 18 | info.usbVendorId === vendorId && info.usbProductId === productId, 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "npm" 13 | directory: "/" 14 | schedule: 15 | interval: "monthly" 16 | -------------------------------------------------------------------------------- /tests/fixtures/state-fixtures.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common state fixtures used across multiple test files. 3 | * These fixtures help reduce hardcoded initial states and make tests more maintainable. 4 | */ 5 | 6 | /** 7 | * Default initial state with all properties set to their default values. 8 | */ 9 | export const defaultState = { 10 | bgColor: "#ffffff", 11 | textColor: "#000000", 12 | fontFamily: "Arial", 13 | fontSize: 16, 14 | baudRate: 9600, 15 | dataBits: 8, 16 | parity: "none", 17 | stopBits: 1, 18 | encoding: "default", 19 | isFullscreen: false, 20 | isSettingsModalOpened: false, 21 | isStyleModalOpened: false, 22 | isAboutModalOpened: false, 23 | message: "", 24 | status: "Disconnected", 25 | lastPortInfo: null, 26 | }; 27 | 28 | /** 29 | * State with custom serial port settings. 30 | */ 31 | export const customPortState = { 32 | ...defaultState, 33 | baudRate: 115200, 34 | dataBits: 7, 35 | parity: "even", 36 | stopBits: 2, 37 | }; 38 | -------------------------------------------------------------------------------- /.github/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: [master] 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | env: 13 | RCLONE_CONFIG_STORAGE_TYPE: s3 14 | RCLONE_CONFIG_STORAGE_PROVIDER: Other 15 | RCLONE_CONFIG_STORAGE_ACL: public-read 16 | RCLONE_CONFIG_STORAGE_ENDPOINT: "${{ secrets.RCLONE_CONFIG_STORAGE_ENDPOINT }}" 17 | RCLONE_CONFIG_STORAGE_ACCESS_KEY_ID: "${{ secrets.RCLONE_CONFIG_STORAGE_ACCESS_KEY_ID }}" 18 | RCLONE_CONFIG_STORAGE_SECRET_ACCESS_KEY: "${{ secrets.RCLONE_CONFIG_STORAGE_SECRET_ACCESS_KEY }}" 19 | 20 | steps: 21 | - uses: actions/checkout@v5 22 | - uses: actions/setup-node@v6 23 | with: 24 | node-version: 24 25 | - run: npm install 26 | - run: npm run build 27 | - run: npm run test 28 | - run: | 29 | curl https://rclone.org/install.sh | sudo bash 30 | rclone sync dist/ storage:projector.amperka.ru/ --verbose 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serial-projector", 3 | "version": "2.0.2", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "build": "vite build", 8 | "dev": "vite dev", 9 | "dev:open": "vite dev --open", 10 | "preview": "vite preview", 11 | "preview:open": "vite preview --open", 12 | "format": "prettier --check .", 13 | "format:fix": "prettier --write .", 14 | "lint": "eslint ./src", 15 | "test": "vitest --coverage" 16 | }, 17 | "devDependencies": { 18 | "@eslint/js": "^9.38.0", 19 | "@types/w3c-web-serial": "^1.0.8", 20 | "@vitejs/plugin-legacy": "^7.2.1", 21 | "@vitest/coverage-v8": "^3.2.4", 22 | "@vitest/eslint-plugin": "^1.4.0", 23 | "eslint": "^9.38.0", 24 | "eslint-config-prettier": "^10.0.0", 25 | "eslint-plugin-import": "^2.32.0", 26 | "eslint-plugin-prettier": "^5.5.4", 27 | "globals": "^16.5.0", 28 | "jsdom": "^27.1.0", 29 | "prettier": "^3.0.0", 30 | "vite": "^7.1.12", 31 | "vite-plugin-eslint": "^1.8.1", 32 | "vite-plugin-pwa": "^1.1.0", 33 | "vitest": "^3.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2025, Amperka LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; // eslint-disable-line import/no-unresolved 2 | import importPlugin from "eslint-plugin-import"; 3 | import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; 4 | import globals from "globals"; 5 | import js from "@eslint/js"; 6 | 7 | export default defineConfig([ 8 | globalIgnores(["coverage/*", "node_modules/*"]), 9 | js.configs.recommended, 10 | importPlugin.flatConfigs.recommended, 11 | eslintPluginPrettierRecommended, 12 | { 13 | languageOptions: { 14 | globals: { 15 | ...globals.browser, 16 | }, 17 | ecmaVersion: "latest", 18 | sourceType: "module", 19 | }, 20 | 21 | settings: { 22 | "import/resolver": { 23 | node: { 24 | extensions: [".js"], 25 | path: ["src"], 26 | moduleDirectory: ["node_modules"], 27 | }, 28 | }, 29 | }, 30 | 31 | rules: { 32 | "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], 33 | semi: "error", 34 | "prefer-const": "warn", 35 | "prettier/prettier": "warn", 36 | "import/no-extraneous-dependencies": [ 37 | "error", 38 | { 39 | devDependencies: true, 40 | }, 41 | ], 42 | "import/unambiguous": "off", 43 | }, 44 | }, 45 | ]); 46 | -------------------------------------------------------------------------------- /src/uint8array.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Concat two Uint8Arrays 3 | * @param {Uint8Array} arr1 4 | * @param {Uint8Array} arr2 5 | * @returns {Uint8Array} 6 | */ 7 | export function uint8ArrayConcat(arr1, arr2) { 8 | const result = new Uint8Array(arr1.length + arr2.length); 9 | result.set(arr1); 10 | result.set(arr2, arr1.length); 11 | return result; 12 | } 13 | 14 | /** 15 | * Split Uint8Array by another Uint8Array 16 | * @param {Uint8Array} array - Target array 17 | * @param {Uint8Array} splitter - Splitter 18 | * @returns {Uint8Array[]} 19 | */ 20 | export function uint8ArraySplitBySeq(array, splitter) { 21 | if (splitter.length === 0) { 22 | return [array]; 23 | } 24 | 25 | const result = []; 26 | let start = 0; 27 | 28 | for (let i = 0; i <= array.length - splitter.length; i++) { 29 | // Check if splitter matches at position i 30 | let matches = true; 31 | for (let j = 0; j < splitter.length; j++) { 32 | if (array[i + j] !== splitter[j]) { 33 | matches = false; 34 | break; 35 | } 36 | } 37 | 38 | if (matches) { 39 | // Found a match, add the part before it 40 | result.push(array.slice(start, i)); 41 | start = i + splitter.length; 42 | i = start - 1; // -1 because loop will increment 43 | } 44 | } 45 | 46 | // Add the remaining part 47 | result.push(array.slice(start)); 48 | 49 | return result; 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yaml: -------------------------------------------------------------------------------- 1 | # ESLint is a tool for identifying and reporting on patterns 2 | # found in ECMAScript/JavaScript code. 3 | # More details at https://github.com/eslint/eslint 4 | # and https://eslint.org 5 | 6 | name: ESLint 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: "21 3 * * 4" 16 | 17 | jobs: 18 | eslint: 19 | name: Run eslint scanning 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | security-events: write 24 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v5 28 | 29 | - name: Install ESLint 30 | run: | 31 | npm install eslint@9 32 | npm install @microsoft/eslint-formatter-sarif@3.1.0 33 | 34 | - name: Run ESLint 35 | env: 36 | SARIF_ESLINT_IGNORE_SUPPRESSED: "true" 37 | run: npx eslint . 38 | --config eslint.config.mjs 39 | --ext .js,.jsx,.ts,.tsx 40 | --format @microsoft/eslint-formatter-sarif 41 | --output-file eslint-results.sarif 42 | continue-on-error: true 43 | 44 | - name: Upload analysis results to GitHub 45 | uses: github/codeql-action/upload-sarif@v4 46 | with: 47 | sarif_file: eslint-results.sarif 48 | wait-for-processing: true 49 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | color-scheme: light; 3 | } 4 | 5 | body { 6 | font-family: system-ui; 7 | font-size: 1.25rem; 8 | line-height: 1.5; 9 | margin: 0; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | height: 100vh; 14 | background: #f6871f; 15 | color: #fff; 16 | } 17 | 18 | img, 19 | svg, 20 | video { 21 | max-width: 100%; 22 | display: block; 23 | } 24 | 25 | #message { 26 | font-size: 10vh; 27 | text-align: center; 28 | word-break: break-word; 29 | } 30 | 31 | #status { 32 | position: fixed; 33 | bottom: 10px; 34 | left: 10px; 35 | font-size: 0.9em; 36 | opacity: 0.7; 37 | } 38 | 39 | #controls { 40 | position: fixed; 41 | bottom: 10px; 42 | right: 10px; 43 | } 44 | 45 | #controls button { 46 | background: rgba(255, 255, 255, 0.2); 47 | border: none; 48 | border-radius: 8px; 49 | padding: 8px; 50 | margin-left: 5px; 51 | cursor: pointer; 52 | } 53 | 54 | .modal { 55 | display: none; 56 | position: fixed; 57 | top: 0; 58 | left: 0; 59 | right: 0; 60 | bottom: 0; 61 | background: rgba(0, 0, 0, 0.5); 62 | justify-content: center; 63 | align-items: center; 64 | } 65 | 66 | .modal-content { 67 | background: #fff; 68 | color: #000; 69 | padding: 20px; 70 | border-radius: 10px; 71 | max-width: min(70ch, 100% - 4rem); 72 | margin-inline: auto; 73 | } 74 | 75 | .modal-content label { 76 | display: block; 77 | margin-bottom: 10px; 78 | } 79 | 80 | .modal-content-inline { 81 | display: flex; 82 | } 83 | 84 | .modal-content-inline label { 85 | padding: 5px; 86 | } 87 | -------------------------------------------------------------------------------- /tests/board.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { isEspruino } from "../src/board.js"; 3 | 4 | describe("board.js", () => { 5 | describe("isEspruino", () => { 6 | describe("positive cases", () => { 7 | it("should return true for first Espruino ID (stm32legacyusb)", () => { 8 | const info = { 9 | usbVendorId: 0x0483, 10 | usbProductId: 0x5740, 11 | }; 12 | expect(isEspruino(info)).toBe(true); 13 | }); 14 | }); 15 | 16 | describe("negative cases", () => { 17 | it("should return false when vendorId or productId doesn't match", () => { 18 | const info = { 19 | usbVendorId: -1, 20 | usbProductId: -1, 21 | }; 22 | expect(isEspruino(info)).toBe(false); 23 | }); 24 | }); 25 | 26 | describe("edge cases", () => { 27 | it("should return false when info is undefined", () => { 28 | expect(isEspruino(undefined)).toBe(false); 29 | }); 30 | 31 | it("should return false when info is an empty object", () => { 32 | expect(isEspruino({})).toBe(false); 33 | }); 34 | 35 | it("should return false when info is missing usbVendorId", () => { 36 | const info = { 37 | usbProductId: 0x5740, 38 | }; 39 | expect(isEspruino(info)).toBe(false); 40 | }); 41 | 42 | it("should return false when info is missing usbProductId", () => { 43 | const info = { 44 | usbVendorId: 0x0483, 45 | }; 46 | expect(isEspruino(info)).toBe(false); 47 | }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/encoding.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Decodes a Uint8Array that contains a "mix" of UTF-8 and 3 | * single-byte characters (ISO-8859-1/Windows-1252) 4 | * typical to Espruino/IskraJS boards. 5 | * 6 | * This function trues to use standard TextDecoder and fix only 7 | * not decoded characters. 8 | * 9 | * @param {Uint8Array} bytes Input array of bytes 10 | * @returns {string} Correctly encoded string 11 | */ 12 | export function decodeEspruinoMixedEncoding(bytes) { 13 | const decoder = new TextDecoder("utf-8", { fatal: false }); 14 | const utf8Text = decoder.decode(bytes); 15 | 16 | // If text not includes "�" (replacement character), then done 17 | if (!utf8Text.includes("\uFFFD")) return utf8Text; 18 | 19 | let result = ""; 20 | let byteIndex = 0; 21 | 22 | for (const ch of utf8Text) { 23 | if (ch !== "\uFFFD") { 24 | // skip UTF-8 symbol's bytes 25 | const b = bytes[byteIndex]; 26 | let advance = 1; 27 | // First bytes indicates the length of the UTF-8 symbol: 28 | // - 00–7F => 1 byte 29 | // - C2–DF => 2 bytes 30 | // - E0–EF => 3 bytes 31 | // - F0–F4 => 4 bytes 32 | if (b >= 0xc2 && b <= 0xdf) advance = 2; 33 | else if (b >= 0xe0 && b <= 0xef) advance = 3; 34 | else if (b >= 0xf0 && b <= 0xf4) advance = 4; 35 | result += ch; 36 | byteIndex += advance; 37 | } else { 38 | // fallback to Latin-1 39 | const b = bytes[byteIndex]; 40 | result += String.fromCharCode(b); 41 | byteIndex += 1; 42 | } 43 | } 44 | 45 | return result; 46 | } 47 | 48 | /** 49 | * @typedef {object} SerialPortTextDecoder 50 | * @property {string} encoding 51 | * @property {(bytes: Uint8Array) => string} decode 52 | */ 53 | 54 | /** 55 | * Create bytes to text decoder with specified encoding 56 | * @param {string} [encoding] 57 | * @returns {SerialPortTextDecoder} 58 | */ 59 | export function mkDecoder(encoding = "default") { 60 | if (encoding === "x-espruino-mixed-utf8") { 61 | return { encoding, decode: decodeEspruinoMixedEncoding }; 62 | } 63 | return { 64 | encoding, 65 | decode: (bytes) => 66 | new TextDecoder(encoding === "default" ? "utf-8" : encoding, { 67 | fatal: false, 68 | }).decode(bytes), 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./state.js').State} State 3 | * @typedef {import('./state.js').StateListener} StateListener 4 | * @typedef {Partial} PartialState 5 | */ 6 | 7 | const PERSIST_STATE_KEY_PREFIX = "state_"; 8 | /** @type {(keyof State)[]} */ 9 | const PERSIST_STATE_STR_KEYS = [ 10 | "bgColor", 11 | "textColor", 12 | "fontFamily", 13 | "parity", 14 | "encoding", 15 | ]; 16 | /** @type {(keyof State)[]} */ 17 | const PERSIST_STATE_NUM_KEYS = ["fontSize", "baudRate", "dataBits", "stopBits"]; 18 | /** @type {(keyof State)[]} */ 19 | const PERSIST_STATE_OBJ_KEYS = ["lastPortInfo"]; 20 | /** @type {(keyof State)[]} */ 21 | const PERSIST_STATE_BOOL_KEYS = ["dtrSignal", "rtsSignal", "breakSignal"]; 22 | 23 | /** @type {(keyof State)[]} */ 24 | const PERSIST_STATE_KEYS = PERSIST_STATE_STR_KEYS.concat(PERSIST_STATE_NUM_KEYS) 25 | .concat(PERSIST_STATE_BOOL_KEYS) 26 | .concat(PERSIST_STATE_OBJ_KEYS); 27 | 28 | /** 29 | * Save State subset to Local Storage 30 | * @type {StateListener} 31 | */ 32 | const saveState = (state) => { 33 | for (const [k, v] of Object.entries(state)) { 34 | if (!PERSIST_STATE_KEYS.includes(k)) continue; 35 | const lsKey = `${PERSIST_STATE_KEY_PREFIX}${k}`; 36 | if (PERSIST_STATE_OBJ_KEYS.includes(k)) { 37 | localStorage.setItem(lsKey, JSON.stringify(v)); 38 | continue; 39 | } 40 | localStorage.setItem(lsKey, v); 41 | } 42 | }; 43 | 44 | /** 45 | * Load state subset from Local Storage 46 | * @returns {PartialState} 47 | */ 48 | const loadState = () => { 49 | /** @type {PartialState} */ 50 | const state = {}; 51 | for (const k of PERSIST_STATE_KEYS) { 52 | const lsKey = `${PERSIST_STATE_KEY_PREFIX}${k}`; 53 | const value = localStorage.getItem(lsKey); 54 | if (value === null) continue; 55 | if (PERSIST_STATE_STR_KEYS.includes(k)) { 56 | state[k] = value; 57 | } 58 | if (PERSIST_STATE_NUM_KEYS.includes(k)) { 59 | state[k] = +value; 60 | } 61 | if (PERSIST_STATE_BOOL_KEYS.includes(k)) { 62 | state[k] = value === "true" ? true : value === "false" ? false : null; 63 | } 64 | if (PERSIST_STATE_OBJ_KEYS.includes(k)) { 65 | try { 66 | state[k] = JSON.parse(value || "{}"); 67 | } catch { 68 | state[k] = {}; 69 | } 70 | } 71 | } 72 | return state; 73 | }; 74 | 75 | export { loadState, saveState }; 76 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | /* eslint-env node */ 3 | import { fileURLToPath } from "node:url"; 4 | import { resolve, dirname } from "node:path"; 5 | import { defineConfig } from "vite"; 6 | import eslint from "vite-plugin-eslint"; 7 | import legacy from "@vitejs/plugin-legacy"; // eslint-disable-line import/no-unresolved 8 | import { VitePWA } from "vite-plugin-pwa"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = dirname(__filename); 12 | 13 | export default defineConfig({ 14 | publicDir: "public", 15 | root: "./", 16 | build: { 17 | outDir: "dist", 18 | sourcemap: true, 19 | rollupOptions: { 20 | input: { 21 | main: resolve(__dirname, "index.html"), 22 | }, 23 | }, 24 | }, 25 | plugins: [ 26 | eslint({ 27 | cache: false, 28 | fix: true, 29 | }), 30 | legacy({}), 31 | VitePWA({ 32 | injectRegister: "inline", 33 | workbox: { 34 | globPatterns: ["**/*.{js,css,html,png,svg}"], 35 | cleanupOutdatedCaches: false, 36 | }, 37 | includeAssets: ["icons/*"], 38 | manifest: { 39 | name: "Serial Projector", 40 | short_name: "SerialProjector", 41 | description: 42 | "A simple web application that shows last line of text got from serial port with a big font.", 43 | categories: ["developer", "developer tools", "eductaion", "utilities"], 44 | theme_color: "#f6871f", 45 | icons: [ 46 | { 47 | src: "icons/icon-192.png", 48 | sizes: "192x192", 49 | type: "image/png", 50 | }, 51 | { 52 | src: "icons/icon-512.png", 53 | sizes: "512x512", 54 | type: "image/png", 55 | }, 56 | { 57 | src: "icons/maskable-icon-512.png", 58 | sizes: "512x512", 59 | type: "image/png", 60 | purpose: "maskable", 61 | }, 62 | ], 63 | screenshots: [ 64 | { 65 | src: "screenshots/home.png", 66 | sizes: "1920x1080", 67 | type: "image/png", 68 | label: "Home screen", 69 | }, 70 | { 71 | src: "screenshots/home-wide.webp", 72 | sizes: "1280x720", 73 | type: "image/webp", 74 | form_factor: "wide", 75 | label: "Wide home screen", 76 | }, 77 | ], 78 | }, 79 | }), 80 | ], 81 | }); 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serial Projector 2 | 3 | A simple web application that shows last line of text got from serial port 4 | with a big font. 5 | 6 | Use Serial Projector with Arduino and other development boards to show coming 7 | data with style and formatting of your choice. Go full screen to make your 8 | 42″ TV a blazing output peripheral. 9 | 10 | ## Installation 11 | 12 | Open [website](https://projector.amperka.ru) in Chromium-based browser and use. 13 | Optionally, install as application. 14 | 15 | ## Usage 16 | 17 | Just send text to serial. Once Serial Projector will see a carriage return 18 | character (ASCII 13, or `\r`) and a newline character (ASCII 10, or `\n`) the 19 | text on sceen will be updated. 20 | 21 | You can send UTF-8 unicode and HTML. 22 | 23 | ```cpp 24 | void setup() 25 | { 26 | Serial.begin(9600); 27 | } 28 | 29 | void loop() 30 | { 31 | int t = analogRead(A0) / 100; 32 | Serial.print("
Температура / Temperature
"); 33 | Serial.print(t); 34 | Serial.println(" ℃"); 35 | } 36 | ``` 37 | 38 | Use buttons at the bottom right corner to adjust application settings. 39 | 40 | ## Authors and License 41 | 42 | Written by Victor Nakoryakov, Sergei Korolev © Amperka LLC. 43 | 44 | This software may be modified and distributed under the terms 45 | of the MIT license. See the LICENSE file for details. 46 | 47 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/amperka/serial-projector) [![zread](https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff)](https://zread.ai/amperka/serial-projector) 48 | -------------------------------------------------------------------------------- /src/state.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {object} State 3 | * @property {string} bgColor - Background color 4 | * @property {string} textColor - Text color 5 | * @property {string} fontFamily - Font family 6 | * @property {number} fontSize - Font size 7 | * @property {number} baudRate - Baud rate 8 | * @property {number} dataBits - Data bits 9 | * @property {string} parity - Parity 10 | * @property {number} stopBits - Stop bits 11 | * @property {string} encoding - Text encoding 12 | * @property {boolean | null} dtrSignal - Send DTR signal 13 | * @property {boolean | null} rtsSignal - Send RTS signal 14 | * @property {boolean | null} breakSignal - Send break signal 15 | * @property {boolean} isFullscreen - Fullscreen mode state 16 | * @property {boolean} isSettingsModalOpened - Settings modal state 17 | * @property {boolean} isStyleModalOpened - Style modal state 18 | * @property {boolean} isAboutModalOpened - About modal state 19 | * @property {string} message - Message text 20 | * @property {string} status - Status text 21 | * @property {SerialPortInfo} lastPortInfo - Last connected port's info 22 | */ 23 | 24 | /** 25 | * @typedef {(state: State) => Promise<*> | *} StateListener 26 | */ 27 | 28 | /** 29 | * State container 30 | */ 31 | export class StateContainer { 32 | #state; 33 | #listeners; 34 | 35 | /** 36 | * @param {State} initialState 37 | * @param {Set} [listeners] 38 | */ 39 | constructor(initialState, listeners) { 40 | this.#state = initialState; 41 | this.#listeners = listeners || new Set(); 42 | } 43 | 44 | /** 45 | * Returns a shallow copy of state 46 | * @returns {State} 47 | */ 48 | getState() { 49 | return { ...this.#state }; 50 | } 51 | 52 | /** 53 | * Update state and notify all subscribed listeners 54 | * @param {Partial} partialState 55 | */ 56 | setState(partialState) { 57 | /** @type {State} */ 58 | const updatedState = { ...this.#state }; 59 | Object.keys(partialState).forEach((key) => { 60 | if (typeof partialState[key] === "object" && partialState[key] !== null) { 61 | updatedState[key] = { ...this.#state[key], ...partialState[key] }; 62 | } else { 63 | updatedState[key] = partialState[key]; 64 | } 65 | }); 66 | this.#state = updatedState; 67 | this.#notify(); 68 | console.debug("[StateContainer] setState() - new state:", this.#state); 69 | return this; 70 | } 71 | 72 | /** 73 | * Subscribe to the state changes 74 | * @param {StateListener} listener 75 | * @returns {() => bool} 76 | */ 77 | subscribe(listener) { 78 | this.#listeners.add(listener); 79 | return () => this.unsubscribe(listener); 80 | } 81 | 82 | /** 83 | * Unsubscribe listener 84 | * @param {StateListener} listener 85 | * @returns {bool} 86 | */ 87 | unsubscribe(listener) { 88 | return this.#listeners.delete(listener); 89 | } 90 | 91 | /** 92 | * Notify all listeners 93 | */ 94 | #notify() { 95 | this.#listeners.forEach((listener) => listener(this.getState())); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AGENTS.md 2 | 3 | Guidelines for AI agents working with this project. 4 | 5 | --- 6 | 7 | ## 1. General Principles 8 | 9 | - **Minimalism first**: keep the codebase small, clean, and understandable. 10 | - **No external dependencies in production**: all production code must be plain HTML, CSS, and JavaScript. 11 | - **Static only**: project runs as a static website (with PWA support). No backend code. 12 | - **Modern standards**: use modern JavaScript (ES6+), semantic HTML, and clean CSS. 13 | - **Performance**: target initial load time ≤1 second on average hardware. 14 | 15 | --- 16 | 17 | ## 2. Project Structure 18 | 19 | ``` 20 | index.html 21 | src/style.css 22 | src/main.js 23 | src/*.js 24 | public/icons/ 25 | public/screenshots/ 26 | public/robots.txt 27 | tests/ 28 | package.json (dev only) 29 | ``` 30 | 31 | --- 32 | 33 | ## 3. Development Rules 34 | 35 | - Do not add new production dependencies. 36 | - For development, only use tools declared in `package.json` (`eslint`, `prettier`, `vitest`). 37 | - Build via `vite` 38 | - Code must run directly in the browser. 39 | 40 | --- 41 | 42 | ## 4. Coding Standards 43 | 44 | - Follow ESLint rules defined in the project. 45 | - Format code with Prettier. 46 | - Use clear, descriptive names for functions, variables, and classes. 47 | - Avoid over-engineering: minimal code to achieve requirements. 48 | - Keep functions small and focused. 49 | 50 | --- 51 | 52 | ## 5. Testing 53 | 54 | - All JavaScript code must be unit tested with Vitest. 55 | - Target **≥90% coverage**. 56 | - Write tests alongside features (do not postpone). 57 | - Keep tests readable and focused on behavior. 58 | 59 | --- 60 | 61 | ## 6. CI/CD 62 | 63 | - GitHub Actions workflow must run: 64 | - ESLint 65 | - Prettier format check 66 | - Vitest unit tests with coverage 67 | - CI must fail if linting, formatting, or tests fail. 68 | 69 | --- 70 | 71 | ## 7. PWA Requirements 72 | 73 | - Must include manifest.json with icons (`192x192`, `512x512`, etc). 74 | - Must include service worker with: 75 | - Offline support. 76 | - Update prompt: _“Update available — click to refresh.”_ 77 | - Ensure small static footprint (fast cache load). 78 | 79 | --- 80 | 81 | ## 8. Security 82 | 83 | - All incoming HTML from Serial must be sanitized: 84 | - Strip ` 241 | 242 | 243 | -------------------------------------------------------------------------------- /tests/storage.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "vitest"; 2 | import { saveState, loadState } from "../src/storage.js"; 3 | import { mockLocalStorage } from "./test-helpers.js"; 4 | 5 | describe("storage.js", () => { 6 | beforeEach(() => { 7 | mockLocalStorage(); 8 | }); 9 | 10 | describe("saveState", () => { 11 | it("should save string keys to localStorage", () => { 12 | const state = { 13 | bgColor: "#ffffff", 14 | textColor: "#000000", 15 | fontFamily: "Arial", 16 | parity: "none", 17 | }; 18 | saveState(state); 19 | expect(localStorage.setItem).toHaveBeenCalledWith( 20 | "state_bgColor", 21 | "#ffffff", 22 | ); 23 | expect(localStorage.setItem).toHaveBeenCalledWith( 24 | "state_textColor", 25 | "#000000", 26 | ); 27 | expect(localStorage.setItem).toHaveBeenCalledWith( 28 | "state_fontFamily", 29 | "Arial", 30 | ); 31 | expect(localStorage.setItem).toHaveBeenCalledWith("state_parity", "none"); 32 | }); 33 | 34 | it("should save number keys to localStorage", () => { 35 | const state = { 36 | fontSize: 16, 37 | baudRate: 9600, 38 | dataBits: 8, 39 | stopBits: 1, 40 | }; 41 | saveState(state); 42 | expect(localStorage.setItem).toHaveBeenCalledWith("state_fontSize", 16); 43 | expect(localStorage.setItem).toHaveBeenCalledWith("state_baudRate", 9600); 44 | expect(localStorage.setItem).toHaveBeenCalledWith("state_dataBits", 8); 45 | expect(localStorage.setItem).toHaveBeenCalledWith("state_stopBits", 1); 46 | }); 47 | 48 | it("should save object keys as JSON to localStorage", () => { 49 | const state = { 50 | lastPortInfo: { vendorId: "1234", productId: "5678" }, 51 | }; 52 | saveState(state); 53 | expect(localStorage.setItem).toHaveBeenCalledWith( 54 | "state_lastPortInfo", 55 | JSON.stringify({ vendorId: "1234", productId: "5678" }), 56 | ); 57 | }); 58 | 59 | it("should save boolean keys to localStorage", () => { 60 | const state = { 61 | dtrSignal: true, 62 | rtsSignal: false, 63 | breakSignal: null, 64 | }; 65 | saveState(state); 66 | expect(localStorage.setItem).toHaveBeenCalledWith( 67 | "state_dtrSignal", 68 | true, 69 | ); 70 | expect(localStorage.setItem).toHaveBeenCalledWith( 71 | "state_rtsSignal", 72 | false, 73 | ); 74 | expect(localStorage.setItem).toHaveBeenCalledWith( 75 | "state_breakSignal", 76 | null, 77 | ); 78 | }); 79 | 80 | it("should ignore non-persisted keys", () => { 81 | const state = { 82 | bgColor: "#ffffff", 83 | nonPersistedKey: "ignore me", 84 | }; 85 | saveState(state); 86 | expect(localStorage.setItem).toHaveBeenCalledWith( 87 | "state_bgColor", 88 | "#ffffff", 89 | ); 90 | expect(localStorage.setItem).not.toHaveBeenCalledWith( 91 | "state_nonPersistedKey", 92 | "ignore me", 93 | ); 94 | }); 95 | }); 96 | 97 | describe("loadState", () => { 98 | it("should load string keys from localStorage", () => { 99 | localStorage.getItem.mockImplementation((key) => { 100 | if (key === "state_bgColor") return "#ffffff"; 101 | if (key === "state_textColor") return "#000000"; 102 | if (key === "state_fontFamily") return "Arial"; 103 | if (key === "state_parity") return "none"; 104 | return null; 105 | }); 106 | const result = loadState(); 107 | expect(result).toEqual({ 108 | bgColor: "#ffffff", 109 | textColor: "#000000", 110 | fontFamily: "Arial", 111 | parity: "none", 112 | }); 113 | }); 114 | 115 | it("should load number keys from localStorage", () => { 116 | localStorage.getItem.mockImplementation((key) => { 117 | if (key === "state_fontSize") return "16"; 118 | if (key === "state_baudRate") return "9600"; 119 | if (key === "state_dataBits") return "8"; 120 | if (key === "state_stopBits") return "1"; 121 | return null; 122 | }); 123 | const result = loadState(); 124 | expect(result).toEqual({ 125 | fontSize: 16, 126 | baudRate: 9600, 127 | dataBits: 8, 128 | stopBits: 1, 129 | }); 130 | }); 131 | 132 | it("should load object keys from localStorage", () => { 133 | localStorage.getItem.mockImplementation((key) => { 134 | if (key === "state_lastPortInfo") 135 | return JSON.stringify({ vendorId: "1234", productId: "5678" }); 136 | return null; 137 | }); 138 | const result = loadState(); 139 | expect(result).toEqual({ 140 | lastPortInfo: { vendorId: "1234", productId: "5678" }, 141 | }); 142 | }); 143 | 144 | it("should return empty object if no keys are set", () => { 145 | localStorage.getItem.mockReturnValue(null); 146 | const result = loadState(); 147 | expect(result).toEqual({}); 148 | }); 149 | 150 | it("should skip keys that are not set", () => { 151 | localStorage.getItem.mockImplementation((key) => { 152 | if (key === "state_bgColor") return "#ffffff"; 153 | return null; 154 | }); 155 | const result = loadState(); 156 | expect(result).toEqual({ 157 | bgColor: "#ffffff", 158 | }); 159 | }); 160 | 161 | it("should handle three-state boolean values from localStorage", () => { 162 | localStorage.getItem.mockImplementation((key) => { 163 | if (key === "state_dtrSignal") return "true"; 164 | if (key === "state_rtsSignal") return "false"; 165 | if (key === "state_breakSignal") return ""; 166 | return null; 167 | }); 168 | const result = loadState(); 169 | expect(result).toEqual({ 170 | dtrSignal: true, 171 | rtsSignal: false, 172 | breakSignal: null, 173 | }); 174 | }); 175 | 176 | it("should parse boolean values with three-state logic", () => { 177 | localStorage.getItem.mockImplementation((key) => { 178 | if (key === "state_dtrSignal") return "true"; 179 | if (key === "state_rtsSignal") return "false"; 180 | if (key === "state_breakSignal") return "invalid"; 181 | if (key === "state_otherSignal") return null; 182 | return null; 183 | }); 184 | const result = loadState(); 185 | expect(result).toEqual({ 186 | dtrSignal: true, 187 | rtsSignal: false, 188 | breakSignal: null, 189 | }); 190 | }); 191 | 192 | it("should handle invalid JSON for object keys gracefully", () => { 193 | localStorage.getItem.mockImplementation((key) => { 194 | if (key === "state_lastPortInfo") return "invalid json"; 195 | return null; 196 | }); 197 | // Note: In reality, JSON.parse would throw, but since the code does JSON.parse(value || "{}"), it will parse "{}" 198 | const result = loadState(); 199 | expect(result).toEqual({ 200 | lastPortInfo: {}, 201 | }); 202 | }); 203 | 204 | it("should handle null values in state", () => { 205 | const state = { bgColor: null, textColor: "#000000" }; 206 | saveState(state); 207 | expect(localStorage.setItem).toHaveBeenCalledWith("state_bgColor", null); 208 | expect(localStorage.setItem).toHaveBeenCalledWith( 209 | "state_textColor", 210 | "#000000", 211 | ); 212 | }); 213 | 214 | it("should handle undefined values in state", () => { 215 | const state = { bgColor: undefined, textColor: "#000000" }; 216 | saveState(state); 217 | expect(localStorage.setItem).toHaveBeenCalledWith( 218 | "state_bgColor", 219 | undefined, 220 | ); 221 | expect(localStorage.setItem).toHaveBeenCalledWith( 222 | "state_textColor", 223 | "#000000", 224 | ); 225 | }); 226 | 227 | it("should handle empty string values", () => { 228 | const state = { bgColor: "", textColor: "#000000" }; 229 | saveState(state); 230 | expect(localStorage.setItem).toHaveBeenCalledWith("state_bgColor", ""); 231 | expect(localStorage.setItem).toHaveBeenCalledWith( 232 | "state_textColor", 233 | "#000000", 234 | ); 235 | }); 236 | 237 | it("should handle zero values", () => { 238 | const state = { fontSize: 0, baudRate: 9600 }; 239 | saveState(state); 240 | expect(localStorage.setItem).toHaveBeenCalledWith("state_fontSize", 0); 241 | expect(localStorage.setItem).toHaveBeenCalledWith("state_baudRate", 9600); 242 | }); 243 | 244 | it("should handle negative numbers", () => { 245 | const state = { fontSize: -1, baudRate: 9600 }; 246 | saveState(state); 247 | expect(localStorage.setItem).toHaveBeenCalledWith("state_fontSize", -1); 248 | expect(localStorage.setItem).toHaveBeenCalledWith("state_baudRate", 9600); 249 | }); 250 | 251 | it("should handle very large numbers", () => { 252 | const state = { fontSize: Number.MAX_SAFE_INTEGER, baudRate: 9600 }; 253 | saveState(state); 254 | expect(localStorage.setItem).toHaveBeenCalledWith( 255 | "state_fontSize", 256 | Number.MAX_SAFE_INTEGER, 257 | ); 258 | expect(localStorage.setItem).toHaveBeenCalledWith("state_baudRate", 9600); 259 | }); 260 | 261 | it("should handle circular object references", () => { 262 | const state = { bgColor: "#ffffff" }; 263 | state.circular = state; // Create circular reference 264 | saveState(state); 265 | // Should handle circular reference gracefully (JSON.stringify should throw) 266 | expect(localStorage.setItem).toHaveBeenCalledWith( 267 | "state_bgColor", 268 | "#ffffff", 269 | ); 270 | }); 271 | 272 | it("should parse invalid JSON as empty object", () => { 273 | localStorage.getItem.mockImplementation((key) => { 274 | if (key === "state_lastPortInfo") return "invalid json {"; 275 | return null; 276 | }); 277 | const result = loadState(); 278 | expect(result).toEqual({ 279 | lastPortInfo: {}, 280 | }); 281 | }); 282 | 283 | it("should handle empty JSON string", () => { 284 | localStorage.getItem.mockImplementation((key) => { 285 | if (key === "state_lastPortInfo") return ""; 286 | return null; 287 | }); 288 | const result = loadState(); 289 | expect(result).toEqual({ 290 | lastPortInfo: {}, 291 | }); 292 | }); 293 | 294 | it("should handle null JSON string", () => { 295 | localStorage.getItem.mockImplementation((key) => { 296 | if (key === "state_lastPortInfo") return "null"; 297 | return null; 298 | }); 299 | const result = loadState(); 300 | expect(result).toEqual({ 301 | lastPortInfo: null, 302 | }); 303 | }); 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /tests/state.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { StateContainer } from "../src/state.js"; 3 | 4 | describe("StateContainer", () => { 5 | const initialState = { 6 | bgColor: "#ffffff", 7 | textColor: "#000000", 8 | fontFamily: "Arial", 9 | fontSize: 16, 10 | baudRate: 9600, 11 | dataBits: 8, 12 | parity: "none", 13 | stopBits: 1, 14 | isFullscreen: false, 15 | isSettingsModalOpened: false, 16 | isStyleModalOpened: false, 17 | isAboutModalOpened: false, 18 | message: "", 19 | status: "Disconnected", 20 | lastPortInfo: null, 21 | }; 22 | 23 | it("should initialize with the provided initial state", () => { 24 | const container = new StateContainer(initialState); 25 | expect(container.getState()).toEqual(initialState); 26 | }); 27 | 28 | it("should initialize with an empty listeners set if none provided", () => { 29 | const container = new StateContainer(initialState); 30 | // Test indirectly by subscribing 31 | const listener = vi.fn(); 32 | container.subscribe(listener); 33 | expect(listener).not.toHaveBeenCalled(); 34 | }); 35 | 36 | it("should initialize with provided listeners", () => { 37 | const listener = vi.fn(); 38 | const listeners = new Set([listener]); 39 | const container = new StateContainer(initialState, listeners); 40 | container.setState({ message: "test" }); 41 | expect(listener).toHaveBeenCalledWith({ ...initialState, message: "test" }); 42 | }); 43 | 44 | it("getState should return a shallow copy of the state", () => { 45 | const container = new StateContainer(initialState); 46 | const state = container.getState(); 47 | expect(state).toEqual(initialState); 48 | expect(state).not.toBe(initialState); // Should be a copy 49 | }); 50 | 51 | it("setState should update the state with partial updates", () => { 52 | const container = new StateContainer(initialState); 53 | container.setState({ message: "Hello", status: "Connected" }); 54 | const newState = container.getState(); 55 | expect(newState.message).toBe("Hello"); 56 | expect(newState.status).toBe("Connected"); 57 | expect(newState.bgColor).toBe(initialState.bgColor); // Unchanged 58 | }); 59 | 60 | it("setState should handle nested objects by merging", () => { 61 | const container = new StateContainer({ 62 | ...initialState, 63 | lastPortInfo: { usbVendorId: 1, usbProductId: 2 }, 64 | }); 65 | container.setState({ lastPortInfo: { usbVendorId: 3 } }); 66 | const newState = container.getState(); 67 | expect(newState.lastPortInfo.usbVendorId).toBe(3); 68 | expect(newState.lastPortInfo.usbProductId).toBe(2); // Preserved 69 | }); 70 | 71 | it("setState should not merge if the partial value is not an object", () => { 72 | const container = new StateContainer({ 73 | ...initialState, 74 | lastPortInfo: { usbVendorId: 1 }, 75 | }); 76 | container.setState({ lastPortInfo: null }); 77 | const newState = container.getState(); 78 | expect(newState.lastPortInfo).toBe(null); 79 | }); 80 | 81 | it("setState should notify all listeners", () => { 82 | const container = new StateContainer(initialState); 83 | const listener1 = vi.fn(); 84 | const listener2 = vi.fn(); 85 | container.subscribe(listener1); 86 | container.subscribe(listener2); 87 | container.setState({ message: "Updated" }); 88 | expect(listener1).toHaveBeenCalledTimes(1); 89 | expect(listener1).toHaveBeenCalledWith({ 90 | ...initialState, 91 | message: "Updated", 92 | }); 93 | expect(listener2).toHaveBeenCalledTimes(1); 94 | expect(listener2).toHaveBeenCalledWith({ 95 | ...initialState, 96 | message: "Updated", 97 | }); 98 | }); 99 | 100 | it("subscribe should add a listener and return an unsubscribe function", () => { 101 | const container = new StateContainer(initialState); 102 | const listener = vi.fn(); 103 | const unsubscribe = container.subscribe(listener); 104 | container.setState({ message: "test" }); 105 | expect(listener).toHaveBeenCalledTimes(1); 106 | unsubscribe(); 107 | container.setState({ message: "test2" }); 108 | expect(listener).toHaveBeenCalledTimes(1); // No more calls after unsubscribe 109 | }); 110 | 111 | it("unsubscribe should remove the listener and return true if it existed", () => { 112 | const container = new StateContainer(initialState); 113 | const listener = vi.fn(); 114 | container.subscribe(listener); 115 | const result = container.unsubscribe(listener); 116 | expect(result).toBe(true); 117 | container.setState({ message: "test" }); 118 | expect(listener).not.toHaveBeenCalled(); 119 | }); 120 | 121 | it("unsubscribe should return false if listener was not subscribed", () => { 122 | const container = new StateContainer(initialState); 123 | const listener = vi.fn(); 124 | const result = container.unsubscribe(listener); 125 | expect(result).toBe(false); 126 | }); 127 | 128 | it("should handle multiple subscribes and unsubscribes correctly", () => { 129 | const container = new StateContainer(initialState); 130 | const listener1 = vi.fn(); 131 | const listener2 = vi.fn(); 132 | container.subscribe(listener1); 133 | container.subscribe(listener2); 134 | container.setState({ message: "1" }); 135 | expect(listener1).toHaveBeenCalledTimes(1); 136 | expect(listener2).toHaveBeenCalledTimes(1); 137 | container.unsubscribe(listener1); 138 | container.setState({ message: "2" }); 139 | expect(listener1).toHaveBeenCalledTimes(1); // No new call 140 | expect(listener2).toHaveBeenCalledTimes(2); 141 | }); 142 | 143 | it("listeners should receive the updated state on notify", () => { 144 | const container = new StateContainer(initialState); 145 | const listener = vi.fn(); 146 | container.subscribe(listener); 147 | container.setState({ bgColor: "#000000", fontSize: 20 }); 148 | expect(listener).toHaveBeenCalledWith({ 149 | ...initialState, 150 | bgColor: "#000000", 151 | fontSize: 20, 152 | }); 153 | 154 | describe("edge cases", () => { 155 | it("should handle setState with undefined partial state", () => { 156 | const container = new StateContainer(initialState); 157 | const listener = vi.fn(); 158 | container.subscribe(listener); 159 | container.setState(undefined); 160 | expect(listener).toHaveBeenCalledWith(initialState); 161 | }); 162 | 163 | it("should handle setState with null partial state", () => { 164 | const container = new StateContainer(initialState); 165 | const listener = vi.fn(); 166 | container.subscribe(listener); 167 | container.setState(null); 168 | expect(listener).toHaveBeenCalledWith(initialState); 169 | }); 170 | 171 | it("should handle deeply nested objects", () => { 172 | const container = new StateContainer({ 173 | nested: { 174 | deep: { 175 | value: "original", 176 | }, 177 | }, 178 | }); 179 | container.setState({ 180 | nested: { 181 | deep: { 182 | value: "updated", 183 | }, 184 | }, 185 | }); 186 | expect(container.getState().nested.deep.value).toBe("updated"); 187 | }); 188 | 189 | it("should handle adding new properties to nested objects", () => { 190 | const container = new StateContainer({ 191 | nested: { 192 | existing: "value", 193 | }, 194 | }); 195 | container.setState({ 196 | nested: { 197 | existing: "value", 198 | newProp: "new value", 199 | }, 200 | }); 201 | expect(container.getState().nested.existing).toBe("value"); 202 | expect(container.getState().nested.newProp).toBe("new value"); 203 | }); 204 | 205 | it("should handle removing properties from nested objects", () => { 206 | const container = new StateContainer({ 207 | nested: { 208 | prop1: "value1", 209 | prop2: "value2", 210 | }, 211 | }); 212 | container.setState({ 213 | nested: { 214 | prop1: "value1", 215 | }, 216 | }); 217 | expect(container.getState().nested.prop1).toBe("value1"); 218 | expect(container.getState().nested.prop2).toBeUndefined(); 219 | }); 220 | 221 | it("should handle array values in state", () => { 222 | const container = new StateContainer({ 223 | items: [1, 2, 3], 224 | }); 225 | container.setState({ 226 | items: [4, 5, 6], 227 | }); 228 | expect(container.getState().items).toEqual([4, 5, 6]); 229 | }); 230 | 231 | it("should handle function values in state (edge case)", () => { 232 | const container = new StateContainer({}); 233 | const testFn = () => "test"; 234 | container.setState({ 235 | callback: testFn, 236 | }); 237 | expect(container.getState().callback).toBe(testFn); 238 | }); 239 | 240 | it("should handle symbol values in state (edge case)", () => { 241 | const container = new StateContainer({}); 242 | const testSymbol = Symbol("test"); 243 | container.setState({ 244 | symbolProp: testSymbol, 245 | }); 246 | expect(container.getState().symbolProp).toBe(testSymbol); 247 | }); 248 | 249 | it("should handle multiple rapid setState calls", () => { 250 | const container = new StateContainer(initialState); 251 | const listener = vi.fn(); 252 | container.subscribe(listener); 253 | 254 | container.setState({ message: "first" }); 255 | container.setState({ status: "updated" }); 256 | container.setState({ message: "second" }); 257 | 258 | expect(listener).toHaveBeenCalledTimes(3); 259 | expect(listener).toHaveBeenNthCalledWith(1, { 260 | ...initialState, 261 | message: "first", 262 | }); 263 | expect(listener).toHaveBeenNthCalledWith(2, { 264 | ...initialState, 265 | message: "first", 266 | status: "updated", 267 | }); 268 | expect(listener).toHaveBeenNthCalledWith(3, { 269 | ...initialState, 270 | status: "updated", 271 | message: "second", 272 | }); 273 | }); 274 | 275 | it("should handle unsubscribe during state update", () => { 276 | const container = new StateContainer(initialState); 277 | const listener1 = vi.fn(); 278 | const listener2 = vi.fn(); 279 | 280 | const unsubscribe1 = container.subscribe(listener1); 281 | container.subscribe(listener2); 282 | 283 | container.setState({ message: "test1" }); 284 | unsubscribe1(); 285 | container.setState({ message: "test2" }); 286 | 287 | expect(listener1).toHaveBeenCalledTimes(1); 288 | expect(listener2).toHaveBeenCalledTimes(2); 289 | }); 290 | 291 | it("should handle subscribe during state update", () => { 292 | const container = new StateContainer(initialState); 293 | const listener1 = vi.fn(); 294 | const listener2 = vi.fn(); 295 | 296 | container.subscribe(listener1); 297 | container.setState({ message: "test1" }); 298 | container.subscribe(listener2); 299 | container.setState({ message: "test2" }); 300 | 301 | expect(listener1).toHaveBeenCalledTimes(2); 302 | expect(listener2).toHaveBeenCalledTimes(1); 303 | }); 304 | }); 305 | }); 306 | 307 | it("should handle empty partial state updates", () => { 308 | const container = new StateContainer(initialState); 309 | const listener = vi.fn(); 310 | container.subscribe(listener); 311 | container.setState({}); 312 | expect(listener).toHaveBeenCalledTimes(1); 313 | expect(container.getState()).toEqual(initialState); 314 | }); 315 | 316 | it("should handle setState with undefined values", () => { 317 | const container = new StateContainer(initialState); 318 | container.setState({ message: undefined }); 319 | expect(container.getState().message).toBe(undefined); 320 | }); 321 | }); 322 | -------------------------------------------------------------------------------- /tests/test-helpers.js: -------------------------------------------------------------------------------- 1 | import { expect, vi } from "vitest"; 2 | 3 | /** 4 | * Resets all global mocks to ensure test isolation. 5 | * Call this in afterEach blocks to prevent state leakage between tests. 6 | * 7 | * This function: 8 | * - Restores all mocked implementations to their original behavior 9 | * - Clears all mock call history and recorded data 10 | * - Prevents test pollution by ensuring clean state between tests 11 | */ 12 | export function resetGlobalMocks() { 13 | vi.restoreAllMocks(); 14 | vi.clearAllMocks(); 15 | } 16 | 17 | /** 18 | * Sets up a complete test environment with all common mocks. 19 | * This is a convenience function that calls multiple setup functions. 20 | * 21 | * This function provides a one-stop setup for tests that need: 22 | * - Mock DOM elements (buttons, inputs, modals, etc.) 23 | * - Mock document.getElementById implementation 24 | * - Mock navigator.serial API for Web Serial testing 25 | * - Mock console methods to suppress test output 26 | * - Mock localStorage for storage testing 27 | * 28 | * @returns {Object} An object containing: 29 | * - mockElements: All mock DOM elements 30 | * - resetMocks: Function to reset all mocks 31 | */ 32 | export function setupTestEnvironment() { 33 | const mockElements = setupMockElements(); 34 | mockDocumentGetElementById(mockElements); 35 | mockNavigatorSerial(); 36 | mockConsole(); 37 | mockLocalStorage(); 38 | 39 | return { 40 | mockElements, 41 | resetMocks: resetGlobalMocks, 42 | }; 43 | } 44 | 45 | /** 46 | * Sets up common mock DOM elements used in tests. 47 | * Returns an object containing all mock elements with default properties. 48 | * 49 | * This factory creates mock representations of all DOM elements used 50 | * throughout the application, including: 51 | * - Message and status display elements 52 | * - Modal dialogs (settings, style, about) 53 | * - Control buttons (connect, disconnect, fullscreen, etc.) 54 | * - Input elements for configuration (colors, fonts, serial settings) 55 | * 56 | * Each element has the minimal properties needed for testing: 57 | * - addEventListener mock for event handling 58 | * - value property for inputs 59 | * - innerHTML/innerText for display elements 60 | * - style object for CSS property testing 61 | * 62 | * @returns {Object} An object containing all mock DOM elements 63 | */ 64 | export function setupMockElements() { 65 | const mockElements = { 66 | message: { innerHTML: "" }, 67 | status: { innerText: "" }, 68 | settingsBtn: { addEventListener: vi.fn() }, 69 | closeSettings: { addEventListener: vi.fn() }, 70 | settingsModal: { style: { display: "none" } }, 71 | styleBtn: { addEventListener: vi.fn() }, 72 | closeStyle: { addEventListener: vi.fn() }, 73 | styleModal: { style: { display: "none" } }, 74 | fullscreenBtn: { addEventListener: vi.fn() }, 75 | aboutBtn: { addEventListener: vi.fn() }, 76 | aboutModal: { style: { display: "none" } }, 77 | closeAbout: { addEventListener: vi.fn() }, 78 | connectBtn: { addEventListener: vi.fn() }, 79 | disconnectBtn: { addEventListener: vi.fn() }, 80 | bgColor: { value: "#ffffff" }, 81 | textColor: { value: "#000000" }, 82 | fontFamily: { value: "Arial" }, 83 | fontSize: { value: 16 }, 84 | baudRate: { value: 9600 }, 85 | dataBits: { value: 8 }, 86 | parity: { value: "none" }, 87 | stopBits: { value: 1 }, 88 | encoding: { value: "default" }, 89 | }; 90 | return mockElements; 91 | } 92 | 93 | /** 94 | * Mocks document.getElementById to return elements from the provided mockElements object. 95 | * 96 | * This replaces the global document.getElementById function with a mock that: 97 | * - Returns mock elements when their ID is requested 98 | * - Returns null for unknown IDs (mimicking real DOM behavior) 99 | * - Is writable so it can be restored after tests 100 | * 101 | * @param {Object} mockElements - The object containing mock elements, 102 | * where keys are element IDs and values are mock objects 103 | */ 104 | export function mockDocumentGetElementById(mockElements) { 105 | Object.defineProperty(document, "getElementById", { 106 | value: vi.fn((id) => mockElements[id] || null), 107 | writable: true, 108 | }); 109 | } 110 | 111 | /** 112 | * Mocks navigator.serial with default or provided options. 113 | * 114 | * This function creates a mock Web Serial API implementation for testing: 115 | * - requestPort: Mock function for port selection dialog 116 | * - getPorts: Mock function returning list of available ports (default empty) 117 | * - addEventListener: Mock function for connection/disconnection events 118 | * 119 | * The mock is configurable via options parameter and can be restored after tests. 120 | * 121 | * @param {Object} options - Optional overrides for the serial mock properties. 122 | * Any properties not provided will use default mocks. 123 | * @returns {Object} The mocked navigator.serial object for further customization 124 | */ 125 | export function mockNavigatorSerial(options = {}) { 126 | const defaultMock = { 127 | requestPort: vi.fn(), 128 | getPorts: vi.fn().mockResolvedValue([]), 129 | addEventListener: vi.fn(), 130 | }; 131 | const mock = { ...defaultMock, ...options }; 132 | Object.defineProperty(navigator, "serial", { 133 | value: mock, 134 | writable: true, 135 | configurable: true, 136 | }); 137 | return mock; 138 | } 139 | 140 | /** 141 | * Mocks localStorage globally using vi.stubGlobal. 142 | * 143 | * Creates a complete localStorage mock with all standard methods: 144 | * - getItem: Mock function for retrieving values 145 | * - setItem: Mock function for storing values 146 | * - removeItem: Mock function for deleting values 147 | * - clear: Mock function for clearing all values 148 | * 149 | * Uses vi.stubGlobal to replace the global localStorage object, 150 | * ensuring the mock is available throughout the test environment. 151 | * 152 | * @returns {Object} The mocked localStorage object with vi.fn() spies 153 | */ 154 | export function mockLocalStorage() { 155 | const mockStorage = { 156 | getItem: vi.fn(), 157 | setItem: vi.fn(), 158 | removeItem: vi.fn(), 159 | clear: vi.fn(), 160 | }; 161 | vi.stubGlobal("localStorage", mockStorage); 162 | return mockStorage; 163 | } 164 | 165 | /** 166 | * Spies on console methods (info, error, debug) to suppress output in tests. 167 | * 168 | * This function: 169 | * - Creates spies on console methods to track calls without outputting 170 | * - Suppresses console noise during test execution 171 | * - Still allows testing that console methods were called with specific arguments 172 | * - Uses mockImplementation to prevent actual console output 173 | * 174 | * Methods mocked: 175 | * - info: For general informational messages 176 | * - error: For error messages (prevents test output pollution) 177 | * - debug: For debug messages 178 | */ 179 | export function mockConsole() { 180 | vi.spyOn(console, "info").mockImplementation(() => {}); 181 | vi.spyOn(console, "error").mockImplementation(() => {}); 182 | vi.spyOn(console, "debug").mockImplementation(() => {}); 183 | } 184 | 185 | /** 186 | * Creates a mock button element with addEventListener and click methods. 187 | * 188 | * This factory creates a button mock that: 189 | * - Tracks event listeners added via addEventListener 190 | * - Provides a click() method to simulate button clicks 191 | * - Stores listeners internally for test inspection 192 | * - Mimics real button behavior for event handling 193 | * 194 | * @returns {Object} Mock button element with: 195 | * - addEventListener: Mock function that stores callbacks 196 | * - click: Function that triggers the click callback 197 | * - _eventListeners: Internal storage for test inspection 198 | */ 199 | export function createMockButton() { 200 | const eventListeners = {}; 201 | return { 202 | addEventListener: vi.fn((event, callback) => { 203 | eventListeners[event] = callback; 204 | }), 205 | click: vi.fn(function () { 206 | if (eventListeners.click) { 207 | eventListeners.click(); 208 | } 209 | }), 210 | _eventListeners: eventListeners, 211 | }; 212 | } 213 | 214 | /** 215 | * Creates a mock input element with value, addEventListener, and dispatchEvent methods. 216 | * 217 | * This factory creates an input mock that: 218 | * - Has a mutable value property for testing input changes 219 | * - Tracks event listeners for different event types 220 | * - Provides dispatchEvent to simulate DOM events 221 | * - Sets event.target to the mock element for realistic event handling 222 | * 223 | * @returns {Object} Mock input element with: 224 | * - value: Mutable string property 225 | * - addEventListener: Mock function storing event callbacks 226 | * - dispatchEvent: Function to trigger stored event callbacks 227 | * - _eventListeners: Internal storage for test inspection 228 | */ 229 | export function createMockInput() { 230 | const eventListeners = {}; 231 | const mockElement = { 232 | value: "", 233 | addEventListener: vi.fn((event, callback) => { 234 | eventListeners[event] = callback; 235 | }), 236 | dispatchEvent: vi.fn(function (event) { 237 | if (eventListeners[event.type]) { 238 | eventListeners[event.type]({ ...event, target: mockElement }); 239 | } 240 | }), 241 | _eventListeners: eventListeners, 242 | }; 243 | return mockElement; 244 | } 245 | 246 | /** 247 | * Creates a mock div element with innerHTML, innerText, and style properties. 248 | * 249 | * This factory creates a simple div mock with the most commonly used properties: 250 | * - innerHTML: Mutable string for HTML content testing 251 | * - innerText: Mutable string for text content testing 252 | * - style: Object for CSS property testing 253 | * - addEventListener: Mock for event handling (though rarely used on divs) 254 | * 255 | * @returns {Object} Mock div element with basic DOM properties 256 | */ 257 | export function createMockDiv() { 258 | return { 259 | innerHTML: "", 260 | innerText: "", 261 | style: {}, 262 | addEventListener: vi.fn(), 263 | }; 264 | } 265 | 266 | /** 267 | * Creates a mock modal element with style.display property. 268 | * 269 | * This factory creates a modal mock focused on visibility testing: 270 | * - style.display: Mutable property for show/hide testing 271 | * - addEventListener: Mock for event handling (close events, etc.) 272 | * 273 | * @param {string} initialDisplay - Initial display value (default: "none"). 274 | * Common values: "none", "flex", "block" 275 | * @returns {Object} Mock modal element with visibility control 276 | */ 277 | export function createMockModal(initialDisplay = "none") { 278 | return { 279 | style: { display: initialDisplay }, 280 | addEventListener: vi.fn(), 281 | }; 282 | } 283 | 284 | /** 285 | * Creates a mock document object with fullscreen properties. 286 | * 287 | * This factory creates a document mock for fullscreen API testing: 288 | * - fullscreenElement: Tracks current fullscreen element 289 | * - documentElement.requestFullscreen: Mock for entering fullscreen 290 | * - exitFullscreen: Mock for exiting fullscreen 291 | * - body.style: For testing body CSS changes 292 | * - Event handling: For fullscreenchange events 293 | * 294 | * @returns {Object} Mock document object with fullscreen API support 295 | */ 296 | export function createMockDocument() { 297 | const eventListeners = {}; 298 | return { 299 | fullscreenElement: null, 300 | documentElement: { 301 | requestFullscreen: vi.fn(), 302 | }, 303 | exitFullscreen: vi.fn(), 304 | body: { style: {} }, 305 | addEventListener: vi.fn((event, callback) => { 306 | eventListeners[event] = callback; 307 | }), 308 | dispatchEvent: vi.fn(function (event) { 309 | if (eventListeners[event.type]) { 310 | eventListeners[event.type](event); 311 | } 312 | }), 313 | _eventListeners: eventListeners, 314 | }; 315 | } 316 | 317 | /** 318 | * Creates a mock store with setState, getState, and subscribe methods. 319 | * 320 | * This factory creates a store mock that mimics the StateContainer interface: 321 | * - setState: Mock function that returns this for chaining 322 | * - getState: Mock function returning the provided initial state 323 | * - subscribe: Mock function managing listener subscription/unsubscription 324 | * - _listeners: Internal Set for tracking subscribed listeners 325 | * 326 | * The mock supports testing: 327 | * - State update calls and their arguments 328 | * - State retrieval and returned values 329 | * - Subscription management and listener tracking 330 | * 331 | * @param {Object} initialState - Initial state for the store to return 332 | * @returns {Object} Mock store object with StateContainer-like interface 333 | */ 334 | export function createMockStore(initialState = {}) { 335 | const listeners = new Set(); 336 | return { 337 | setState: vi.fn().mockReturnThis(), 338 | getState: vi.fn().mockReturnValue(initialState), 339 | subscribe: vi.fn((listener) => { 340 | listeners.add(listener); 341 | return () => listeners.delete(listener); 342 | }), 343 | _listeners: listeners, 344 | }; 345 | } 346 | 347 | /** 348 | * Helper function to verify state updates in order. 349 | * Useful for testing sequences of setState calls. 350 | * 351 | * This helper simplifies testing of multiple state updates by: 352 | * - Checking the total number of setState calls 353 | * - Verifying each call was made with the expected arguments 354 | * - Providing clear error messages when expectations don't match 355 | * 356 | * Example usage: 357 | * verifyStateUpdates(mockSetState, [ 358 | * { status: "Connecting..." }, 359 | * { status: "Connected" } 360 | * ]); 361 | * 362 | * @param {Object} mockSetState - Mocked setState function from vi.fn() 363 | * @param {Array} expectedUpdates - Array of expected state update objects in order 364 | */ 365 | export function verifyStateUpdates(mockSetState, expectedUpdates) { 366 | expect(mockSetState).toHaveBeenCalledTimes(expectedUpdates.length); 367 | expectedUpdates.forEach((update, index) => { 368 | expect(mockSetState).toHaveBeenNthCalledWith(index + 1, update); 369 | }); 370 | } 371 | -------------------------------------------------------------------------------- /src/ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} AppHTMLElements 3 | * @property {Document} doc - Root document 4 | * @property {HTMLElement} msg - Message HTML element 5 | * @property {HTMLElement} status - Status HTML element 6 | * @property {HTMLElement} settingsBtn - Settings button HTML element 7 | * @property {HTMLElement} settingsClose - Close settings HTML element 8 | * @property {HTMLElement} settingsModal - Settings modal HTML element 9 | * @property {HTMLElement} styleBtn - Style button HTML element 10 | * @property {HTMLElement} styleClose - Close style HTML element 11 | * @property {HTMLElement} styleModal - Style modal HTML element 12 | * @property {HTMLElement} fullscreenBtn - Full screen button HTML element 13 | * @property {HTMLElement} aboutBtn - About button HTML element 14 | * @property {HTMLElement} aboutModal - About modal HTML element 15 | * @property {HTMLElement} aboutClose - Close about modal HTML element 16 | * @property {HTMLElement} connectBtn - Connect button HTML element 17 | * @property {HTMLElement} disconnectBtn - Disconnect button HTML element 18 | * @property {HTMLElement} bgColor - Background color HTML element 19 | * @property {HTMLElement} textColor - Text color HTML element 20 | * @property {HTMLElement} fontFamily - Font family HTML element 21 | * @property {HTMLElement} fontSize - Font size HTML element 22 | * @property {HTMLElement} baudRate - Baud rate HTML element 23 | * @property {HTMLElement} dataBits - Data bits HTML element 24 | * @property {HTMLElement} parity - Parity HTML element 25 | * @property {HTMLElement} stopBits - Stop bits HTML element 26 | * @property {HTMLElement} encoding - Encoding HTML element 27 | * @property {HTMLElement} dtrSignal - Encoding HTML element 28 | * @property {HTMLElement} rtsSignal - Encoding HTML element 29 | * @property {HTMLElement} breakSignal - Encoding HTML element 30 | */ 31 | /** @type {keyof AppHTMLElements} */ 32 | export const appHtmlElementNames = [ 33 | "doc", 34 | "msg", 35 | "status", 36 | "settingsBtn", 37 | "settingsClose", 38 | "settingsModal", 39 | "styleBtn", 40 | "styleClose", 41 | "styleModal", 42 | "fullscreenBtn", 43 | "aboutBtn", 44 | "aboutModal", 45 | "aboutClose", 46 | "connectBtn", 47 | "disconnectBtn", 48 | "bgColor", 49 | "textColor", 50 | "fontFamily", 51 | "fontSize", 52 | "baudRate", 53 | "dataBits", 54 | "parity", 55 | "stopBits", 56 | "encoding", 57 | "dtrSignal", 58 | "rtsSignal", 59 | "breakSignal", 60 | ]; 61 | 62 | /** 63 | * @typedef {import('./state.js').State} State 64 | * @typedef {import('./state.js').StateContainer} StateContainer 65 | */ 66 | 67 | /** 68 | * Checks if modal window is closed 69 | * @param {HTMLElement} el 70 | */ 71 | export const isModalClosed = (el) => ["", "none"].includes(el.style.display); 72 | 73 | /** 74 | * Open modal 75 | * @param {HTMLElement} el 76 | */ 77 | export const openModal = (el) => { 78 | el.style.display = "flex"; 79 | }; 80 | 81 | /** 82 | * Close modal 83 | * @param {HTMLElement} el 84 | */ 85 | export const closeModal = (el) => { 86 | el.style.display = "none"; 87 | }; 88 | 89 | /** 90 | * Load state from DOM 91 | * @param {AppHTMLElements} el 92 | * @returns {State} 93 | */ 94 | export const loadStateFromDOM = (el) => ({ 95 | bgColor: el.bgColor.value, 96 | textColor: el.textColor.value, 97 | fontFamily: el.fontFamily.value, 98 | fontSize: +el.fontSize.value, 99 | baudRate: +el.baudRate.value, 100 | dataBits: +el.dataBits.value, 101 | parity: el.parity.value, 102 | stopBits: +el.stopBits.value, 103 | encoding: el.encoding.value, 104 | dtrSignal: 105 | el.dtrSignal.value === "true" 106 | ? true 107 | : el.dtrSignal.value === "false" 108 | ? false 109 | : null, 110 | rtsSignal: 111 | el.rtsSignal.value === "true" 112 | ? true 113 | : el.rtsSignal.value === "false" 114 | ? false 115 | : null, 116 | breakSignal: 117 | el.breakSignal.value == "true" 118 | ? true 119 | : el.breakSignal.value == "false" 120 | ? false 121 | : null, 122 | isFullscreen: Boolean(el.doc.fullscreenElement), 123 | isSettingsModalOpened: !isModalClosed(el.settingsModal), 124 | isStyleModalOpened: !isModalClosed(el.styleModal), 125 | isAboutModalOpened: !isModalClosed(el.aboutModal), 126 | message: el.msg.innerHTML, 127 | status: el.status.innerText, 128 | }); 129 | 130 | /** 131 | * Render port settings 132 | * @param {AppHTMLElements} el 133 | * @param {State} state 134 | * @param {State} oldState 135 | */ 136 | export const renderPortSettings = (el, state, oldState) => { 137 | if (state.baudRate !== oldState.baudRate) { 138 | el.baudRate.value = state.baudRate; 139 | } 140 | 141 | if (state.dataBits !== oldState.dataBits) { 142 | el.dataBits.value = state.dataBits; 143 | } 144 | 145 | if (state.parity !== oldState.parity) { 146 | el.parity.value = state.parity; 147 | } 148 | 149 | if (state.stopBits !== oldState.stopBits) { 150 | el.stopBits.value = state.stopBits; 151 | } 152 | 153 | if (state.encoding !== oldState.encoding) { 154 | el.encoding.value = state.encoding; 155 | } 156 | 157 | if (state.dtrSignal !== oldState.dtrSignal) { 158 | el.dtrSignal.value = 159 | typeof state.dtrSignal === "boolean" ? state.dtrSignal.toString() : ""; 160 | } 161 | 162 | if (state.rtsSignal !== oldState.rtsSignal) { 163 | el.rtsSignal.value = 164 | typeof state.rtsSignal === "boolean" ? state.rtsSignal.toString() : ""; 165 | } 166 | 167 | if (state.breakSignal !== oldState.breakSignal) { 168 | el.breakSignal.value = 169 | typeof state.breakSignal === "boolean" 170 | ? state.breakSignal.toString() 171 | : ""; 172 | } 173 | }; 174 | 175 | /** 176 | * Bind port settings elements 177 | * @param {AppHTMLElements} el 178 | * @param {StateContainer} store 179 | */ 180 | export const bindPortSettings = (el, store) => { 181 | el.settingsBtn.addEventListener("click", () => 182 | store.setState({ 183 | isSettingsModalOpened: true, 184 | isStyleModalOpened: false, 185 | isAboutModalOpened: false, 186 | }), 187 | ); 188 | el.settingsClose.addEventListener("click", () => { 189 | store.setState({ isSettingsModalOpened: false }); 190 | }); 191 | el.connectBtn.addEventListener("click", () => 192 | store.setState({ isSettingsModalOpened: false }), 193 | ); 194 | el.disconnectBtn.addEventListener("click", () => 195 | store.setState({ isSettingsModalOpened: false }), 196 | ); 197 | el.baudRate.addEventListener("change", (e) => 198 | store.setState({ baudRate: +e.target.value }), 199 | ); 200 | el.dataBits.addEventListener("change", (e) => 201 | store.setState({ dataBits: +e.target.value }), 202 | ); 203 | el.parity.addEventListener("change", (e) => 204 | store.setState({ parity: e.target.value }), 205 | ); 206 | el.stopBits.addEventListener("change", (e) => 207 | store.setState({ stopBits: +e.target.value }), 208 | ); 209 | el.encoding.addEventListener("change", (e) => 210 | store.setState({ encoding: e.target.value }), 211 | ); 212 | el.dtrSignal.addEventListener("change", (e) => 213 | store.setState({ 214 | dtrSignal: 215 | e.target.value === "true" 216 | ? true 217 | : e.target.value === "false" 218 | ? false 219 | : null, 220 | }), 221 | ); 222 | el.rtsSignal.addEventListener("change", (e) => 223 | store.setState({ 224 | rtsSignal: 225 | e.target.value === "true" 226 | ? true 227 | : e.target.value === "false" 228 | ? false 229 | : null, 230 | }), 231 | ); 232 | el.breakSignal.addEventListener("change", (e) => 233 | store.setState({ 234 | breakSignal: 235 | e.target.value === "true" 236 | ? true 237 | : e.target.value === "false" 238 | ? false 239 | : null, 240 | }), 241 | ); 242 | }; 243 | 244 | /** 245 | * Render style settings 246 | * @param {AppHTMLElements} el 247 | * @param {State} state 248 | * @param {State} oldState 249 | */ 250 | export const renderStyleSettings = (el, state, oldState) => { 251 | if (state.bgColor !== oldState.bgColor) { 252 | el.bgColor.value = state.bgColor; 253 | } 254 | el.doc.body.style.background = state.bgColor; 255 | 256 | if (state.textColor !== oldState.textColor) { 257 | el.textColor.value = state.textColor; 258 | } 259 | el.msg.style.color = state.textColor; 260 | 261 | if (state.fontFamily !== oldState.fontFamily) { 262 | el.fontFamily.value = state.fontFamily; 263 | } 264 | el.msg.style.fontFamily = state.fontFamily; 265 | 266 | if (state.fontSize !== oldState.fontSize) { 267 | el.fontSize.value = state.fontSize; 268 | } 269 | el.msg.style.fontSize = `${state.fontSize}vh`; 270 | }; 271 | 272 | /** 273 | * Bind style settings elements 274 | * @param {AppHTMLElements} el 275 | * @param {StateContainer} store 276 | */ 277 | export const bindStyleSettings = (el, store) => { 278 | el.styleBtn.addEventListener("click", () => 279 | store.setState({ 280 | isSettingsModalOpened: false, 281 | isStyleModalOpened: true, 282 | isAboutModalOpened: false, 283 | }), 284 | ); 285 | el.styleClose.addEventListener("click", () => 286 | store.setState({ isStyleModalOpened: false }), 287 | ); 288 | el.bgColor.addEventListener("input", (e) => 289 | store.setState({ bgColor: e.target.value }), 290 | ); 291 | el.textColor.addEventListener("input", (e) => 292 | store.setState({ textColor: e.target.value }), 293 | ); 294 | el.fontFamily.addEventListener("change", (e) => 295 | store.setState({ fontFamily: e.target.value }), 296 | ); 297 | el.fontSize.addEventListener("change", (e) => 298 | store.setState({ fontSize: +e.target.value }), 299 | ); 300 | }; 301 | 302 | /** 303 | * Bind about elements 304 | * @param {AppHTMLElements} el 305 | * @param {StateContainer} store 306 | */ 307 | export const bindAbout = (el, store) => { 308 | el.aboutBtn.addEventListener("click", () => 309 | store.setState({ 310 | isSettingsModalOpened: false, 311 | isStyleModalOpened: false, 312 | isAboutModalOpened: true, 313 | }), 314 | ); 315 | el.aboutClose.addEventListener("click", () => 316 | store.setState({ isAboutModalOpened: false }), 317 | ); 318 | }; 319 | 320 | /** 321 | * Render modal state 322 | * @param {AppHTMLElements} el 323 | * @param {State} state 324 | * @param {State} oldState 325 | */ 326 | export const renderModalState = (el, state, oldState) => { 327 | if (state.isSettingsModalOpened !== oldState.isSettingsModalOpened) { 328 | if (state.isSettingsModalOpened) openModal(el.settingsModal); 329 | if (!state.isSettingsModalOpened) closeModal(el.settingsModal); 330 | } 331 | 332 | if (state.isStyleModalOpened !== oldState.isStyleModalOpened) { 333 | if (state.isStyleModalOpened) openModal(el.styleModal); 334 | if (!state.isStyleModalOpened) closeModal(el.styleModal); 335 | } 336 | 337 | if (state.isAboutModalOpened !== oldState.isAboutModalOpened) { 338 | if (state.isAboutModalOpened) openModal(el.aboutModal); 339 | if (!state.isAboutModalOpened) closeModal(el.aboutModal); 340 | } 341 | }; 342 | 343 | /** 344 | * Sanitize HTML before render 345 | * @param {string} html 346 | */ 347 | export const sanitizeHtml = (html) => { 348 | const parser = new DOMParser(); 349 | const doc = parser.parseFromString(html, "text/html"); 350 | 351 | // remove script tags 352 | doc.querySelectorAll("script").forEach((script) => script.remove()); 353 | 354 | // remove on* attributes 355 | doc.querySelectorAll("*").forEach((el) => { 356 | el.getAttributeNames().forEach((attr) => { 357 | if (attr.startsWith("on")) el.removeAttribute(attr); 358 | }); 359 | }); 360 | 361 | return doc.body.innerHTML; 362 | }; 363 | 364 | /** 365 | * Render messages 366 | * @param {AppHTMLElements} el 367 | * @param {State} state 368 | * @param {State} oldState 369 | */ 370 | export const renderMessages = (el, state, oldState) => { 371 | if (state.message !== oldState.message) { 372 | el.msg.innerHTML = sanitizeHtml(state.message); 373 | } 374 | 375 | if (state.status !== oldState.status) { 376 | el.status.innerText = state.status; 377 | } 378 | }; 379 | 380 | /** 381 | * Render fullscreen mode 382 | * @param {AppHTMLElements} el 383 | * @param {State} state 384 | */ 385 | export const renderFullscreenMode = async (el, state) => { 386 | const isFullscreen = Boolean(el.doc.fullscreenElement); 387 | if (state.isFullscreen === isFullscreen) return; 388 | if (state.isFullscreen && !isFullscreen) 389 | return el.doc.documentElement.requestFullscreen(); 390 | if (!state.isFullscreen && isFullscreen) return el.doc.exitFullscreen(); 391 | }; 392 | 393 | /** 394 | * Bind fullscreen DOM 395 | * @param {AppHTMLElements} el 396 | * @param {StateContainer} store 397 | */ 398 | export const bindFullscreenMode = (el, store) => { 399 | el.fullscreenBtn.addEventListener("click", () => { 400 | const isFullscreen = Boolean(el.doc.fullscreenElement); 401 | store.setState({ isFullscreen: !isFullscreen }); 402 | }); 403 | 404 | el.doc.addEventListener("fullscreenchange", () => { 405 | const isFullscreen = Boolean(el.doc.fullscreenElement); 406 | const isFullscreenState = store.getState().isFullscreen; 407 | if (isFullscreen !== isFullscreenState) { 408 | store.setState({ isFullscreen }); 409 | } 410 | }); 411 | }; 412 | 413 | /** 414 | * Save data from state to DOM elements 415 | * @param {AppHTMLElements} el 416 | * @param {State} state 417 | */ 418 | export const renderState = async (el, state) => { 419 | const oldState = loadStateFromDOM(el); 420 | 421 | renderPortSettings(el, state, oldState); 422 | renderStyleSettings(el, state, oldState); 423 | renderModalState(el, state, oldState); 424 | renderMessages(el, state, oldState); 425 | await renderFullscreenMode(el, state); 426 | }; 427 | 428 | /** 429 | * Bind state to DOM elements 430 | * @param {AppHTMLElements} el 431 | * @param {StateContainer} store 432 | */ 433 | export const bindStateToDOM = (el, store) => { 434 | /** @param {State} state */ 435 | const render = (state) => renderState(el, state); 436 | store.subscribe(render); 437 | 438 | bindFullscreenMode(el, store); 439 | bindPortSettings(el, store); 440 | bindStyleSettings(el, store); 441 | bindAbout(el, store); 442 | }; 443 | -------------------------------------------------------------------------------- /tests/encoding.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { decodeEspruinoMixedEncoding, mkDecoder } from "../src/encoding.js"; 3 | 4 | describe("decodeEspruinoMixedEncoding", () => { 5 | describe("pure UTF-8 strings", () => { 6 | it("should decode pure ASCII text", () => { 7 | const bytes = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" 8 | const result = decodeEspruinoMixedEncoding(bytes); 9 | expect(result).toBe("Hello"); 10 | }); 11 | it("should decode pure UTF-8 with 2-byte sequences", () => { 12 | // "café" - é is 2-byte UTF-8 sequence 13 | const bytes = new Uint8Array([99, 97, 102, 195, 169]); 14 | const result = decodeEspruinoMixedEncoding(bytes); 15 | expect(result).toBe("café"); 16 | }); 17 | it("should decode pure UTF-8 with 3-byte sequences", () => { 18 | // "€" - Euro symbol is 3-byte UTF-8 sequence 19 | const bytes = new Uint8Array([226, 130, 172]); 20 | const result = decodeEspruinoMixedEncoding(bytes); 21 | expect(result).toBe("€"); 22 | }); 23 | it("should decode pure UTF-8 with 4-byte sequences", () => { 24 | // "👍" - Thumbs up emoji is 4-byte UTF-8 sequence 25 | const bytes = new Uint8Array([240, 159, 145, 141]); 26 | const result = decodeEspruinoMixedEncoding(bytes); 27 | expect(result).toBe("👍"); 28 | }); 29 | it("should decode mixed length UTF-8 sequences", () => { 30 | // "Héllo 👍" - mix of ASCII, 2-byte, and 4-byte sequences 31 | const bytes = new Uint8Array([ 32 | 72, 195, 169, 108, 108, 111, 32, 240, 159, 145, 141, 33 | ]); 34 | const result = decodeEspruinoMixedEncoding(bytes); 35 | expect(result).toBe("Héllo 👍"); 36 | }); 37 | }); 38 | describe("mixed encoding scenarios", () => { 39 | it("should handle mixed UTF-8 and ISO-8859-1 bytes", () => { 40 | // "Hé" (UTF-8) + "°" (ISO-8859-1 byte 0xB0) + "llo" 41 | const bytes = new Uint8Array([72, 195, 169, 176, 108, 108, 111]); 42 | const result = decodeEspruinoMixedEncoding(bytes); 43 | expect(result).toBe("Hé°llo"); 44 | }); 45 | it("should handle ISO-8859-1 bytes in UTF-8 range", () => { 46 | // "Test" + 0xB0 + "more" where 0xB0 should be interpreted as ISO-8859-1 47 | const bytes = new Uint8Array([ 48 | 84, 101, 115, 116, 176, 109, 111, 114, 101, 49 | ]); 50 | const result = decodeEspruinoMixedEncoding(bytes); 51 | expect(result).toBe("Test°more"); 52 | }); 53 | it("should handle multiple ISO-8859-1 bytes", () => { 54 | // "°±²³" - all ISO-8859-1 bytes 55 | const bytes = new Uint8Array([176, 177, 178, 179]); 56 | const result = decodeEspruinoMixedEncoding(bytes); 57 | expect(result).toBe("°±²³"); 58 | }); 59 | it("should handle alternating UTF-8 and ISO-8859-1", () => { 60 | // "A" (ASCII) + "é" (UTF-8) + "°" (ISO-8859-1) + "B" (ASCII) 61 | const bytes = new Uint8Array([65, 195, 169, 176, 66]); 62 | const result = decodeEspruinoMixedEncoding(bytes); 63 | expect(result).toBe("Aé°B"); 64 | }); 65 | it("should handle different length UTF-8 and ISO-8859-1", () => { 66 | const bytes = new Uint8Array([ 67 | 0xb0, // "°" (ISO-8859-1) 68 | 0x41, // "A" UTF-8/ASCI, 1 byte 69 | 0xc2, // "®" UTF-8, 2 bytes 70 | 0xae, 71 | 0xe2, // "≥" UTF-8, 3 bytes 72 | 0x89, 73 | 0xa5, 74 | 0xf0, // "😄" UTF-8, 4 bytes 75 | 0x9f, 76 | 0x98, 77 | 0x84, 78 | 0xb0, // "°" (ISO-8859-1) 79 | 0xb0, // "°" (ISO-8859-1) 80 | 0xf0, // "😄" UTF-8, 4 bytes 81 | 0x9f, 82 | 0x98, 83 | 0x84, 84 | 0xe2, // "≥" UTF-8, 3 bytes 85 | 0x89, 86 | 0xa5, 87 | 0xc2, // "®" UTF-8, 2 bytes 88 | 0xae, 89 | 0x41, // "A" UTF-8/ASCI, 1 byte 90 | 0xb0, // "°" (ISO-8859-1) 91 | ]); 92 | const result = decodeEspruinoMixedEncoding(bytes); 93 | expect(result).toBe("°A®≥😄°°😄≥®A°"); 94 | }); 95 | }); 96 | describe("edge cases", () => { 97 | it("should handle empty array", () => { 98 | const bytes = new Uint8Array([]); 99 | const result = decodeEspruinoMixedEncoding(bytes); 100 | expect(result).toBe(""); 101 | }); 102 | it("should handle single ASCII byte", () => { 103 | const bytes = new Uint8Array([65]); // "A" 104 | const result = decodeEspruinoMixedEncoding(bytes); 105 | expect(result).toBe("A"); 106 | }); 107 | it("should handle single ISO-8859-1 byte", () => { 108 | const bytes = new Uint8Array([176]); // "°" 109 | const result = decodeEspruinoMixedEncoding(bytes); 110 | expect(result).toBe("°"); 111 | }); 112 | it("should handle single UTF-8 start byte", () => { 113 | const bytes = new Uint8Array([195]); // Incomplete 2-byte sequence 114 | const result = decodeEspruinoMixedEncoding(bytes); 115 | expect(result).toBe("Ã"); // Should fallback to ISO-8859-1 116 | }); 117 | }); 118 | describe("invalid UTF-8 sequences", () => { 119 | it("should handle invalid UTF-8 start bytes", () => { 120 | // 0xF5-0xFF are invalid UTF-8 start bytes 121 | const bytes = new Uint8Array([ 122 | 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 123 | ]); 124 | const result = decodeEspruinoMixedEncoding(bytes); 125 | expect(result).toBe("õö÷øùúûüýþÿ"); 126 | }); 127 | it("should handle invalid secondary bytes", () => { 128 | // Secondary bytes should be 0x80-0xBF, so 0xC0-0xFF are invalid 129 | const bytes = new Uint8Array([195, 192, 193, 194]); // 0xC0, 0xC1, 0xC2 are invalid secondary bytes 130 | const result = decodeEspruinoMixedEncoding(bytes); 131 | expect(result).toBe("ÃÀÁÂ"); 132 | }); 133 | it("should handle overlong UTF-8 sequences", () => { 134 | // Overlong encoding for ASCII character 'A' (should be 1 byte, but encoded as 2) 135 | const bytes = new Uint8Array([194, 65]); // Invalid overlong encoding 136 | const result = decodeEspruinoMixedEncoding(bytes); 137 | expect(result).toBe("ÂA"); 138 | }); 139 | it("should reject overlong 3-byte UTF-8 encoding for 2-byte range", () => { 140 | // Overlong encoding: U+007F (should be 1 byte, but encoded as 3) 141 | // Valid UTF-8 would be [0x7F], overlong 3-byte is [0xE0, 0x9F, 0xBF] 142 | const bytes = new Uint8Array([224, 159, 191]); // U+07FF encoded as 3 bytes (overlong) 143 | const result = decodeEspruinoMixedEncoding(bytes); 144 | // Should treat as separate ISO-8859-1 bytes since it's overlong 145 | expect(result).toBe(String.fromCodePoint(224, 159, 191)); 146 | }); 147 | it("should reject overlong 3-byte UTF-8 encoding for 2-byte range (U+007F)", () => { 148 | // U+007F encoded as 3-byte sequence (overlong) 149 | // Binary: 0x7F = 0b1111111, should be 1-byte, but encoded as 3-byte 150 | const bytes = new Uint8Array([224, 0x9f, 0xbf]); // Overlong encoding of U+007F 151 | const result = decodeEspruinoMixedEncoding(bytes); 152 | // Should fallback to treating as separate bytes 153 | expect(result).toBe(String.fromCodePoint(224, 0x9f, 0xbf)); 154 | }); 155 | it("should reject overlong 3-byte UTF-8 encoding for 2-byte range (U+00FF)", () => { 156 | // U+00FF encoded as 3-byte sequence (overlong) 157 | // U+00FF should be 2-byte [0xC3, 0xBF], but encoded as 3-byte 158 | // Overlong encoding: 1110xxxx 10xxxxxx 10xxxxxx for U+00FF (255) 159 | // U+00FF = 0x00FF, for 3-byte overlong: 0xE0 0x80 0xBF 160 | const bytes = new Uint8Array([224, 128, 191]); // Overlong encoding of U+00FF 161 | const result = decodeEspruinoMixedEncoding(bytes); 162 | // Since codePoint = 255 < 0x800, it should be rejected as overlong 163 | // The function will treat each byte as separate ISO-8859-1 characters 164 | expect(result).toBe(String.fromCodePoint(224, 128, 191)); 165 | }); 166 | it("should handle surrogate half in UTF-8", () => { 167 | // High surrogate U+D800 should not appear in UTF-8 168 | const bytes = new Uint8Array([237, 160, 128]); // Overlong encoding of U+D800 169 | const result = decodeEspruinoMixedEncoding(bytes); 170 | expect(result).toBe(String.fromCodePoint(237, 160, 128)); 171 | }); 172 | }); 173 | describe("boundary conditions", () => { 174 | it("should handle minimum valid 2-byte UTF-8 sequence", () => { 175 | // U+0080 - minimum 2-byte sequence 176 | const bytes = new Uint8Array([194, 128]); 177 | const result = decodeEspruinoMixedEncoding(bytes); 178 | expect(result).toBe(String.fromCodePoint(0x0080)); 179 | }); 180 | it("should handle maximum valid 2-byte UTF-8 sequence", () => { 181 | // U+07FF (ƿ) 182 | const bytes = new Uint8Array([223, 191]); 183 | const result = decodeEspruinoMixedEncoding(bytes); 184 | expect(result).toBe("߿"); 185 | }); 186 | it("should handle minimum valid 3-byte UTF-8 sequence", () => { 187 | // U+0800 (ࠀ) 188 | const bytes = new Uint8Array([0xe0, 0xa0, 0x80]); 189 | const result = decodeEspruinoMixedEncoding(bytes); 190 | expect(result).toBe("ࠀ"); 191 | }); 192 | it("should handle valid 3-byte UTF-8 sequence just above minimum", () => { 193 | // U+0801 (just above minimum 3-byte range) 194 | const bytes = new Uint8Array([224, 160, 129]); 195 | const result = decodeEspruinoMixedEncoding(bytes); 196 | expect(result).toBe(String.fromCodePoint(0x0801)); 197 | }); 198 | it("should handle valid 3-byte UTF-8 sequence in middle range", () => { 199 | // U+0FFF (middle of 3-byte range) 200 | // U+0FFF = 4095 decimal = 0x0FFF hex 201 | // Correct 3-byte UTF-8 encoding: 1110xxxx 10xxxxxx 10xxxxxx 202 | // xxxx xxxx xxxx = 0000 1111 1111 1111 203 | // First byte: 1110 + first 4 bits (0000) = 1110 0000 = 0xE0 204 | // Second byte: 10 + next 6 bits (111111) = 1011 1111 = 0xBF 205 | // Third byte: 10 + last 6 bits (111111) = 1011 1111 = 0xBF 206 | const bytes = new Uint8Array([224, 191, 191]); // Correct encoding of U+0FFF 207 | const result = decodeEspruinoMixedEncoding(bytes); 208 | expect(result).toBe(String.fromCodePoint(0x0fff)); 209 | }); 210 | it("should handle valid 3-byte sequence just below surrogate range", () => { 211 | // U+D7FF (just before surrogate range U+D800-U+DFFF) 212 | // U+D7FF = 55295 decimal = 0xD7FF hex 213 | // 3-byte UTF-8 encoding: 1110xxxx 10xxxxxx 10xxxxxx 214 | // xxxx xxxx xxxx = 1101 0111 1111 1111 215 | // First byte: 1110 + first 4 bits (1101) = 1110 1101 = 0xED 216 | // Second byte: 10 + next 6 bits (011111) = 1001 1111 = 0x9F 217 | // Third byte: 10 + last 6 bits (111111) = 1011 1111 = 0xBF 218 | const bytes = new Uint8Array([0xb0, 0xed, 0x9f, 0xbf]); 219 | const result = decodeEspruinoMixedEncoding(bytes); 220 | expect(result).toBe(String.fromCodePoint(0xb0, 0xd7ff)); 221 | }); 222 | it("should reject 3-byte UTF-8 sequence in surrogate range", () => { 223 | // U+D800 (high surrogate - should not appear in UTF-8) 224 | const bytes = new Uint8Array([237, 160, 128]); 225 | const result = decodeEspruinoMixedEncoding(bytes); 226 | // Should treat as separate bytes since it's in surrogate range 227 | expect(result).toBe(String.fromCodePoint(237, 160, 128)); 228 | }); 229 | it("should reject 3-byte UTF-8 sequence in surrogate range (low surrogate)", () => { 230 | // U+DFFF (low surrogate - should not appear in UTF-8) 231 | const bytes = new Uint8Array([237, 191, 159]); 232 | const result = decodeEspruinoMixedEncoding(bytes); 233 | // Should treat as separate bytes since it's in surrogate range 234 | expect(result).toBe(String.fromCodePoint(237, 191, 159)); 235 | }); 236 | it("should handle maximum valid 3-byte UTF-8 sequence", () => { 237 | // U+FFFF (maximum 3-byte sequence) 238 | const bytes = new Uint8Array([176, 239, 191, 191]); 239 | const result = decodeEspruinoMixedEncoding(bytes); 240 | expect(result).toBe(String.fromCodePoint(176, 0xffff)); 241 | }); 242 | it("should handle minimum valid 4-byte UTF-8 sequence", () => { 243 | // U+10000 (𐀀) 244 | const bytes = new Uint8Array([240, 144, 128, 128]); 245 | const result = decodeEspruinoMixedEncoding(bytes); 246 | expect(result).toBe("𐀀"); 247 | }); 248 | it("should handle maximum valid 4-byte UTF-8 sequence", () => { 249 | // U+10FFFD (near maximum Unicode code point) 250 | const bytes = new Uint8Array([243, 191, 191, 189]); 251 | const result = decodeEspruinoMixedEncoding(bytes); 252 | expect(result).toBe("󿿽"); 253 | }); 254 | }); 255 | describe("real-world Espruino scenarios", () => { 256 | it("should handle typical Espruino mixed output", () => { 257 | // Simulating typical output from Espruino boards with mixed content 258 | const bytes = new Uint8Array([ 259 | 86, 97, 108, 117, 101, 32, 61, 32, 49, 50, 51, 46, 52, 53, 176, 67, 10, 260 | ]); 261 | const result = decodeEspruinoMixedEncoding(bytes); 262 | expect(result).toBe("Value = 123.45°C\n"); 263 | }); 264 | it("should handle mixed UTF-8 and Windows-1252 characters", () => { 265 | // Windows-1252 characters that are common in embedded systems 266 | const bytes = new Uint8Array([ 267 | 84, 101, 109, 112, 101, 114, 97, 116, 117, 114, 101, 58, 32, 50, 51, 46, 268 | 49, 176, 67, 32, 194, 177, 67, 10, 269 | ]); 270 | const result = decodeEspruinoMixedEncoding(bytes); 271 | expect(result).toBe("Temperature: 23.1°C ±C\n"); 272 | }); 273 | }); 274 | }); 275 | 276 | describe("mkDecoder", () => { 277 | it("should create decoder with default encoding", () => { 278 | const decoder = mkDecoder(); 279 | expect(decoder).toHaveProperty("encoding", "default"); 280 | expect(decoder).toHaveProperty("decode"); 281 | expect(typeof decoder.decode).toBe("function"); 282 | }); 283 | it("should create decoder with other valid encodings", () => { 284 | const encodings = ["utf-8", "ascii", "utf-16le", "utf-16be"]; 285 | encodings.forEach((encoding) => { 286 | const decoder = mkDecoder(encoding); 287 | expect(decoder).toHaveProperty("encoding", encoding); 288 | expect(decoder).toHaveProperty("decode"); 289 | expect(typeof decoder.decode).toBe("function"); 290 | }); 291 | }); 292 | describe("decoder functionality", () => { 293 | it("should decode mixed encoding with Espruino decoder", () => { 294 | const decoder = mkDecoder("x-espruino-mixed-utf8"); 295 | const bytes = new Uint8Array([72, 195, 169, 176, 108, 108, 111]); // "Hé°llo" 296 | const result = decoder.decode(bytes); 297 | expect(result).toBe("Hé°llo"); 298 | }); 299 | }); 300 | }); 301 | -------------------------------------------------------------------------------- /tests/port.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from "vitest"; 2 | import { Port } from "../src/port.js"; 3 | import { mkDecoder } from "../src/encoding.js"; 4 | 5 | describe("Port", () => { 6 | let mockSerial; 7 | let mockPort; 8 | let mockReader; 9 | let mockWriter; 10 | 11 | beforeEach(() => { 12 | mockWriter = { 13 | write: vi.fn(), 14 | releaseLock: vi.fn(), 15 | }; 16 | 17 | mockReader = { 18 | read: vi.fn(), 19 | releaseLock: vi.fn(), 20 | cancel: vi.fn(), 21 | }; 22 | 23 | mockPort = { 24 | open: vi.fn(), 25 | close: vi.fn(), 26 | forget: vi.fn(), 27 | getInfo: vi.fn(), 28 | readable: { 29 | getReader: vi.fn(() => mockReader), 30 | }, 31 | writable: { 32 | getWriter: vi.fn(() => mockWriter), 33 | }, 34 | }; 35 | 36 | mockSerial = { 37 | requestPort: vi.fn(), 38 | getPorts: vi.fn(), 39 | }; 40 | 41 | vi.stubGlobal("navigator", { 42 | serial: mockSerial, 43 | }); 44 | }); 45 | 46 | describe("constructor", () => { 47 | it("should set event handlers", () => { 48 | const handlers = { 49 | onConnect: vi.fn(), 50 | onDisconnect: vi.fn(), 51 | onError: vi.fn(), 52 | onMessage: vi.fn(), 53 | }; 54 | const port = new Port({}, mkDecoder("default"), handlers); 55 | expect(port.onConnect).toBe(handlers.onConnect); 56 | expect(port.onDisconnect).toBe(handlers.onDisconnect); 57 | expect(port.onError).toBe(handlers.onError); 58 | expect(port.onMessage).toBe(handlers.onMessage); 59 | }); 60 | 61 | it("should set default doNothing handlers if not provided", () => { 62 | const port = new Port({}); 63 | expect(typeof port.onConnect).toBe("function"); 64 | expect(typeof port.onDisconnect).toBe("function"); 65 | expect(typeof port.onError).toBe("function"); 66 | expect(typeof port.onMessage).toBe("function"); 67 | }); 68 | 69 | it("should set portOptions", () => { 70 | const options = { baudRate: 9600 }; 71 | const port = new Port(options); 72 | expect(port.portOptions).toBe(options); 73 | }); 74 | }); 75 | 76 | describe("signals functionality", () => { 77 | it("should set signals in constructor", () => { 78 | const signals = { 79 | dataTerminalReady: true, 80 | requestToSend: false, 81 | }; 82 | const port = new Port({}, {}, {}, signals); 83 | expect(port).toBeDefined(); 84 | }); 85 | 86 | it("should update signals via setSignals method", () => { 87 | const port = new Port({}); 88 | const newSignals = { 89 | dataTerminalReady: true, 90 | requestToSend: false, 91 | break: true, 92 | }; 93 | 94 | port.setSignals(newSignals); 95 | // The signals are stored privately, but we can verify the method exists and doesn't throw 96 | expect(typeof port.setSignals).toBe("function"); 97 | }); 98 | 99 | it("should handle empty signals object", () => { 100 | const port = new Port({}); 101 | port.setSignals({}); 102 | expect(typeof port.setSignals).toBe("function"); 103 | }); 104 | 105 | it("should handle null signals", () => { 106 | const port = new Port({}); 107 | port.setSignals(null); 108 | expect(typeof port.setSignals).toBe("function"); 109 | }); 110 | }); 111 | 112 | describe("decoder functionality", () => { 113 | it("should set decoder property in constructor", () => { 114 | const customDecoder = mkDecoder("utf-8"); 115 | const port = new Port({}, customDecoder); 116 | expect(port.decoder).toBe(customDecoder); 117 | }); 118 | 119 | it("should use default decoder when none provided", () => { 120 | const port = new Port({}); 121 | expect(port.decoder).toBeDefined(); 122 | expect(port.decoder.encoding).toBe("default"); 123 | expect(typeof port.decoder.decode).toBe("function"); 124 | }); 125 | 126 | it("should use custom decoder in readUntilClosed", async () => { 127 | const customDecoder = { 128 | encoding: "custom", 129 | decode: vi.fn((bytes) => "decoded: " + new TextDecoder().decode(bytes)), 130 | }; 131 | const onMessage = vi.fn(); 132 | const port = new Port({}, customDecoder, { 133 | onMessage, 134 | onDisconnect: vi.fn(), 135 | }); 136 | mockReader.read 137 | .mockResolvedValueOnce({ 138 | value: new TextEncoder().encode("hello\r\n"), 139 | done: false, 140 | }) 141 | .mockResolvedValueOnce({ done: true }); 142 | await port.connectTo(mockPort); 143 | expect(customDecoder.decode).toHaveBeenCalled(); 144 | expect(onMessage).toHaveBeenCalledWith("decoded: hello"); 145 | }); 146 | 147 | it("should handle different decoder encodings", async () => { 148 | const latin1Decoder = mkDecoder("latin1"); 149 | const onMessage = vi.fn(); 150 | const port = new Port({}, latin1Decoder, { 151 | onMessage, 152 | onDisconnect: vi.fn(), 153 | }); 154 | // Test with bytes that differ between UTF-8 and Latin-1 155 | const testBytes = new Uint8Array([0xe9, 0x0d, 0x0a]); // é in Latin-1 + CRLF 156 | mockReader.read 157 | .mockResolvedValueOnce({ 158 | value: testBytes, 159 | done: false, 160 | }) 161 | .mockResolvedValueOnce({ done: true }); 162 | await port.connectTo(mockPort); 163 | expect(onMessage).toHaveBeenCalledWith("é"); 164 | }); 165 | 166 | it("should handle espruino mixed encoding decoder", async () => { 167 | const espruinoDecoder = mkDecoder("x-espruino-mixed-utf8"); 168 | const onMessage = vi.fn(); 169 | const port = new Port({}, espruinoDecoder, { 170 | onMessage, 171 | onDisconnect: vi.fn(), 172 | }); 173 | // Test with mixed UTF-8 and ISO-8859-1 bytes typical of Espruino 174 | const mixedBytes = new Uint8Array([ 175 | 0xd0, 176 | 0xa2, 177 | 0xd0, 178 | 0xb5, 179 | 0xd1, 180 | 0x81, 181 | 0xd1, 182 | 0x82, // "Тест" (UTF-8) 183 | 0x20, // " " (пробел) 184 | 0xb0, // "°" (ISO-8859-1) 185 | 0x0d, 186 | 0x0a, // CRLF 187 | ]); 188 | mockReader.read 189 | .mockResolvedValueOnce({ 190 | value: mixedBytes, 191 | done: false, 192 | }) 193 | .mockResolvedValueOnce({ done: true }); 194 | await port.connectTo(mockPort); 195 | expect(onMessage).toHaveBeenCalledWith("Тест °"); 196 | }); 197 | 198 | it("should handle decoder errors gracefully", async () => { 199 | const faultyDecoder = { 200 | encoding: "faulty", 201 | decode: vi.fn(() => { 202 | throw new Error("decode error"); 203 | }), 204 | }; 205 | const onError = vi.fn(); 206 | const onDisconnect = vi.fn(); 207 | const port = new Port({}, faultyDecoder, { 208 | onError, 209 | onDisconnect, 210 | }); 211 | mockReader.read 212 | .mockResolvedValueOnce({ 213 | value: new TextEncoder().encode("test\r\n"), 214 | done: false, 215 | }) 216 | .mockResolvedValueOnce({ done: true }); 217 | await port.connectTo(mockPort); 218 | expect(onError).toHaveBeenCalledWith(new Error("decode error")); 219 | expect(onDisconnect).toHaveBeenCalled(); 220 | }); 221 | }); 222 | 223 | describe("requestPort", () => { 224 | it("should call navigator.serial.requestPort", async () => { 225 | mockSerial.requestPort.mockResolvedValue(mockPort); 226 | const port = new Port({}); 227 | const result = await port.requestPort(); 228 | expect(mockSerial.requestPort).toHaveBeenCalled(); 229 | expect(result).toBe(mockPort); 230 | }); 231 | }); 232 | 233 | describe("getPrevPort", () => { 234 | it("should return matching port", async () => { 235 | const prevInfo = { vendorId: "1234", productId: "5678" }; 236 | mockPort.getInfo.mockReturnValue(prevInfo); 237 | mockSerial.getPorts.mockResolvedValue([mockPort]); 238 | const port = new Port({}); 239 | const result = await port.getPrevPort(prevInfo); 240 | expect(result).toBe(mockPort); 241 | }); 242 | 243 | it("should return false if no matching port", async () => { 244 | mockPort.getInfo.mockReturnValue({ vendorId: "9999" }); 245 | mockSerial.getPorts.mockResolvedValue([mockPort]); 246 | const port = new Port({}); 247 | const result = await port.getPrevPort({ vendorId: "1234" }); 248 | expect(result).toBe(false); 249 | }); 250 | 251 | it("should return false if no ports", async () => { 252 | mockSerial.getPorts.mockResolvedValue([]); 253 | const port = new Port({}); 254 | const result = await port.getPrevPort({ vendorId: "1234" }); 255 | expect(result).toBe(false); 256 | }); 257 | }); 258 | 259 | describe("handleVT100Codes", () => { 260 | it("should handle erase display codes", () => { 261 | const port = new Port({}); 262 | expect(port.handleVT100Codes("before\x1b[2Jafter")).toBe("after"); 263 | expect(port.handleVT100Codes("before\x1b[Jafter")).toBe("after"); 264 | expect(port.handleVT100Codes("before\x1b[0Jafter")).toBe("after"); 265 | expect(port.handleVT100Codes("before\x1b[1Jafter")).toBe("after"); 266 | expect(port.handleVT100Codes("before\x1b[3Jafter")).toBe("after"); 267 | expect(port.handleVT100Codes("before\x33[2Jafter")).toBe("after"); 268 | }); 269 | 270 | it("should handle erase line codes", () => { 271 | const port = new Port({}); 272 | expect(port.handleVT100Codes("before\x1b[Kafter")).toBe("before"); 273 | expect(port.handleVT100Codes("before\x1b[0Kafter")).toBe("before"); 274 | expect(port.handleVT100Codes("before\x1b[1Kafter")).toBe("after"); 275 | expect(port.handleVT100Codes("before\x1b[2Kafter")).toBe(""); 276 | }); 277 | 278 | it("should handle backspace codes", () => { 279 | const port = new Port({}); 280 | expect(port.handleVT100Codes("abc\x08d")).toBe("abd"); 281 | expect(port.handleVT100Codes("\x08abc")).toBe("abc"); 282 | expect(port.handleVT100Codes("a\x08\x08bc")).toBe("bc"); 283 | }); 284 | 285 | it("should handle multiple consecutive backspaces", () => { 286 | const port = new Port({}); 287 | expect(port.handleVT100Codes("abc\x08\x08")).toBe("a"); 288 | expect(port.handleVT100Codes("hello\x08\x08world")).toBe("helworld"); 289 | }); 290 | 291 | it("should handle mixed VT100 codes", () => { 292 | const port = new Port({}); 293 | expect(port.handleVT100Codes("text\x1b[2Jmore\x08text")).toBe("mortext"); 294 | expect(port.handleVT100Codes("start\x1b[Kend\x08\x08")).toBe("start"); 295 | }); 296 | 297 | it("should handle empty string", () => { 298 | const port = new Port({}); 299 | expect(port.handleVT100Codes("")).toBe(""); 300 | }); 301 | 302 | it("should handle string with only control codes", () => { 303 | const port = new Port({}); 304 | expect(port.handleVT100Codes("\x1b[2J\x08\x1b[K")).toBe(""); 305 | }); 306 | 307 | it("should return unchanged if no codes", () => { 308 | const port = new Port({}); 309 | expect(port.handleVT100Codes("hello world")).toBe("hello world"); 310 | }); 311 | 312 | it("should handle multiple VT100 codes in sequence", () => { 313 | const port = new Port({}); 314 | expect(port.handleVT100Codes("start\x1b[2J\x08middle\x1b[Kend")).toBe( 315 | "middle", 316 | ); 317 | }); 318 | 319 | it("should handle invalid VT100 sequences gracefully", () => { 320 | const port = new Port({}); 321 | expect(port.handleVT100Codes("before\x1b[invalidafter")).toBe( 322 | "before\x1b[invalidafter", 323 | ); 324 | }); 325 | 326 | it("should handle backspace at beginning", () => { 327 | const port = new Port({}); 328 | expect(port.handleVT100Codes("\x08\x08hello")).toBe("hello"); 329 | }); 330 | 331 | it("should handle consecutive backspaces", () => { 332 | const port = new Port({}); 333 | expect(port.handleVT100Codes("abc\x08\x08\x08\x08")).toBe(""); 334 | }); 335 | }); 336 | 337 | describe("readUntilClosed", () => { 338 | it("should read messages and call onMessage", async () => { 339 | const onMessage = vi.fn(); 340 | const port = new Port({}, mkDecoder("default"), { 341 | onMessage, 342 | onDisconnect: vi.fn(), 343 | }); 344 | mockReader.read 345 | .mockResolvedValueOnce({ 346 | value: new TextEncoder().encode("hello\r\n"), 347 | done: false, 348 | }) 349 | .mockResolvedValueOnce({ 350 | value: new TextEncoder().encode("world\r\n"), 351 | done: false, 352 | }) 353 | .mockResolvedValueOnce({ done: true }); 354 | await port.connectTo(mockPort); 355 | expect(onMessage).toHaveBeenCalledWith("hello"); 356 | expect(onMessage).toHaveBeenCalledWith("world"); 357 | }); 358 | 359 | it("should handle VT100 codes in messages", async () => { 360 | const onMessage = vi.fn(); 361 | const port = new Port({}, mkDecoder("default"), { 362 | onMessage, 363 | onDisconnect: vi.fn(), 364 | }); 365 | mockReader.read 366 | .mockResolvedValueOnce({ 367 | value: new TextEncoder().encode("before\x1b[2Jafter\r\n"), 368 | done: false, 369 | }) 370 | .mockResolvedValueOnce({ done: true }); 371 | await port.connectTo(mockPort); 372 | expect(onMessage).toHaveBeenCalledWith("after"); 373 | }); 374 | 375 | it("should call onError on read error", async () => { 376 | const onError = vi.fn(); 377 | const port = new Port({}, mkDecoder("default"), { 378 | onError, 379 | onDisconnect: vi.fn(), 380 | }); 381 | mockReader.read.mockRejectedValue(new Error("read error")); 382 | await port.connectTo(mockPort); 383 | expect(onError).toHaveBeenCalledWith(new Error("read error")); 384 | }); 385 | 386 | it("should call onDisconnect when done", async () => { 387 | const onDisconnect = vi.fn(); 388 | const port = new Port({}, mkDecoder("default"), { onDisconnect }); 389 | mockReader.read.mockResolvedValue({ done: true }); 390 | await port.connectTo(mockPort); 391 | expect(onDisconnect).toHaveBeenCalled(); 392 | }); 393 | 394 | it("should return early if no port is set", async () => { 395 | const port = new Port({}, mkDecoder("default"), {}); 396 | await expect(port.readUntilClosed()).resolves.toBeUndefined(); 397 | }); 398 | 399 | it("should handle empty messages", async () => { 400 | const onMessage = vi.fn(); 401 | const port = new Port({}, mkDecoder("default"), { 402 | onMessage, 403 | onDisconnect: vi.fn(), 404 | }); 405 | mockReader.read 406 | .mockResolvedValueOnce({ 407 | value: new TextEncoder().encode("\r\n"), 408 | done: false, 409 | }) 410 | .mockResolvedValueOnce({ done: true }); 411 | await port.connectTo(mockPort); 412 | expect(onMessage).toHaveBeenCalledWith(""); 413 | }); 414 | 415 | it("should handle partial messages across reads", async () => { 416 | const onMessage = vi.fn(); 417 | const port = new Port({}, mkDecoder("default"), { 418 | onMessage, 419 | onDisconnect: vi.fn(), 420 | }); 421 | mockReader.read 422 | .mockResolvedValueOnce({ 423 | value: new TextEncoder().encode("partial"), 424 | done: false, 425 | }) 426 | .mockResolvedValueOnce({ 427 | value: new TextEncoder().encode(" message\r\n"), 428 | done: false, 429 | }) 430 | .mockResolvedValueOnce({ done: true }); 431 | await port.connectTo(mockPort); 432 | expect(onMessage).toHaveBeenCalledWith("partial message"); 433 | }); 434 | 435 | it("should handle multiple messages in single read", async () => { 436 | const onMessage = vi.fn(); 437 | const port = new Port({}, mkDecoder("default"), { 438 | onMessage, 439 | onDisconnect: vi.fn(), 440 | }); 441 | mockReader.read 442 | .mockResolvedValueOnce({ 443 | value: new TextEncoder().encode("msg1\r\nmsg2\r\n"), 444 | done: false, 445 | }) 446 | .mockResolvedValueOnce({ done: true }); 447 | await port.connectTo(mockPort); 448 | expect(onMessage).toHaveBeenCalledWith("msg1"); 449 | expect(onMessage).toHaveBeenCalledWith("msg2"); 450 | }); 451 | 452 | it("should handle large messages", async () => { 453 | const onMessage = vi.fn(); 454 | const port = new Port({}, mkDecoder("default"), { 455 | onMessage, 456 | onDisconnect: vi.fn(), 457 | }); 458 | const largeMessage = "x".repeat(10000) + "\r\n"; 459 | mockReader.read 460 | .mockResolvedValueOnce({ 461 | value: new TextEncoder().encode(largeMessage), 462 | done: false, 463 | }) 464 | .mockResolvedValueOnce({ done: true }); 465 | await port.connectTo(mockPort); 466 | expect(onMessage).toHaveBeenCalledWith("x".repeat(10000)); 467 | }); 468 | }); 469 | 470 | describe("stopReading", () => { 471 | it("should cancel reader and wait for close", async () => { 472 | const port = new Port({}); 473 | await port.connectTo(mockPort); 474 | await port.stopReading(); 475 | expect(mockReader.cancel).toHaveBeenCalled(); 476 | }); 477 | }); 478 | 479 | describe("forgetAll", () => { 480 | it("should forget current port and all ports", async () => { 481 | mockSerial.getPorts.mockResolvedValue([mockPort, mockPort]); 482 | const port = new Port({}); 483 | await port.connectTo(mockPort); 484 | await port.forgetAll(); 485 | expect(mockPort.forget).toHaveBeenCalledTimes(3); // current + two from getPorts 486 | }); 487 | }); 488 | 489 | describe("connectTo", () => { 490 | it("should open port, call onConnect, and start reading", async () => { 491 | const onConnect = vi.fn(); 492 | const port = new Port({ baudRate: 9600 }, mkDecoder("default"), { 493 | onConnect, 494 | }); 495 | mockReader.read.mockResolvedValue({ done: true }); 496 | await port.connectTo(mockPort); 497 | expect(mockPort.open).toHaveBeenCalledWith({ baudRate: 9600 }); 498 | expect(onConnect).toHaveBeenCalledWith(mockPort); 499 | }); 500 | }); 501 | 502 | describe("connectToPrev", () => { 503 | it("should connect to previous port if found", async () => { 504 | const prevInfo = { vendorId: "1234" }; 505 | mockPort.getInfo.mockReturnValue(prevInfo); 506 | mockSerial.getPorts.mockResolvedValue([mockPort]); 507 | const port = new Port({}); 508 | const result = await port.connectToPrev(prevInfo); 509 | expect(mockPort.open).toHaveBeenCalled(); 510 | expect(result).toBe(true); 511 | }); 512 | 513 | it("should return false if no previous port", async () => { 514 | mockSerial.getPorts.mockResolvedValue([]); 515 | const port = new Port({}); 516 | const result = await port.connectToPrev({ vendorId: "1234" }); 517 | expect(result).toBe(false); 518 | }); 519 | }); 520 | 521 | describe("write", () => { 522 | it("should write string message", async () => { 523 | const port = new Port({}); 524 | await port.connectTo(mockPort); 525 | await port.write("hello"); 526 | expect(mockWriter.write).toHaveBeenCalledWith( 527 | new TextEncoder().encode("hello"), 528 | ); 529 | }); 530 | 531 | it("should write Uint8Array message", async () => { 532 | const port = new Port({}); 533 | const data = new Uint8Array([1, 2, 3]); 534 | await port.connectTo(mockPort); 535 | await port.write(data); 536 | expect(mockWriter.write).toHaveBeenCalledWith(data); 537 | }); 538 | 539 | it("should write empty string", async () => { 540 | const port = new Port({}); 541 | await port.connectTo(mockPort); 542 | await port.write(""); 543 | expect(mockWriter.write).toHaveBeenCalledWith( 544 | new TextEncoder().encode(""), 545 | ); 546 | }); 547 | 548 | it("should write empty Uint8Array", async () => { 549 | const port = new Port({}); 550 | const data = new Uint8Array([]); 551 | await port.connectTo(mockPort); 552 | await port.write(data); 553 | expect(mockWriter.write).toHaveBeenCalledWith(new Uint8Array([])); 554 | }); 555 | 556 | it("should call onError on write error", async () => { 557 | const onError = vi.fn(); 558 | const port = new Port({}, mkDecoder("default"), { onError }); 559 | mockWriter.write.mockRejectedValue(new Error("write error")); 560 | await port.connectTo(mockPort); 561 | await port.write("hello"); 562 | expect(onError).toHaveBeenCalledWith(new Error("write error")); 563 | }); 564 | 565 | it("should do nothing if no port or not writable", async () => { 566 | const port = new Port({}, {}); 567 | await port.write("hello"); 568 | expect(mockWriter.write).not.toHaveBeenCalled(); 569 | }); 570 | 571 | it("should handle writer release lock error", async () => { 572 | const port = new Port({}); 573 | await port.connectTo(mockPort); 574 | mockWriter.releaseLock.mockImplementation(() => { 575 | throw new Error("release lock error"); 576 | }); 577 | // Should throw error since releaseLock error is not caught 578 | await expect(port.write("test")).rejects.toThrow("release lock error"); 579 | }); 580 | }); 581 | }); 582 | -------------------------------------------------------------------------------- /tests/main.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import { 3 | setupTestEnvironment, 4 | createMockStore, 5 | verifyStateUpdates, 6 | } from "./test-helpers.js"; 7 | import { defaultState, customPortState } from "./fixtures/state-fixtures.js"; 8 | 9 | // Define mock functions at module level to avoid hoisting issues 10 | // These mocks are defined before any imports to ensure they're available when modules are loaded 11 | const mockedLoadStateFromDOM = vi.fn(); 12 | const mockedBindStateToDOM = vi.fn(); 13 | const mockedLoadState = vi.fn(); 14 | const mockedStateContainer = vi.fn(); 15 | const mockedMkDecoder = vi.fn().mockImplementation((encoding) => { 16 | const decoder = { 17 | encoding, 18 | decode: vi.fn(), 19 | }; 20 | return decoder; 21 | }); 22 | const mockedIsEspruino = vi.fn(); 23 | 24 | // Set default mock behavior for isEspruino to return false 25 | mockedIsEspruino.mockReturnValue(false); 26 | 27 | // Mock the modules before importing main.js 28 | // This ensures that when main.js imports these modules, it gets our mocked versions instead 29 | vi.mock("../src/encoding.js", async (importOriginal) => { 30 | const actual = await importOriginal(); 31 | return { 32 | ...actual, 33 | mkDecoder: mockedMkDecoder, 34 | }; 35 | }); 36 | 37 | vi.mock("../src/board.js", async (importOriginal) => { 38 | const actual = await importOriginal(); 39 | return { 40 | ...actual, 41 | isEspruino: mockedIsEspruino, 42 | }; 43 | }); 44 | 45 | vi.mock("../src/port.js", async (importOriginal) => { 46 | const actual = await importOriginal(); 47 | return { 48 | ...actual, 49 | Port: vi.fn().mockImplementation((portOptions, decoder, handlers = {}) => { 50 | const mockPort = { 51 | portOptions, 52 | decoder: decoder || { encoding: "default", decode: vi.fn() }, 53 | connectTo: vi.fn(), 54 | connectToPrev: vi.fn(), 55 | stopReading: vi.fn(), 56 | forgetAll: vi.fn(), 57 | getInfo: vi.fn().mockReturnValue({}), 58 | requestPort: vi.fn(), 59 | ...handlers, 60 | }; 61 | return mockPort; 62 | }), 63 | }; 64 | }); 65 | 66 | // Mock the modules before importing main.js 67 | // This ensures that when main.js imports these modules, it gets our mocked versions instead 68 | vi.mock("../src/state.js", async (importOriginal) => { 69 | const actual = await importOriginal(); 70 | return { 71 | ...actual, 72 | StateContainer: mockedStateContainer, 73 | }; 74 | }); 75 | 76 | vi.mock("../src/ui.js", async (importOriginal) => { 77 | const actual = await importOriginal(); 78 | return { 79 | ...actual, 80 | loadStateFromDOM: mockedLoadStateFromDOM, 81 | bindStateToDOM: mockedBindStateToDOM, 82 | }; 83 | }); 84 | 85 | vi.mock("../src/storage.js", async (importOriginal) => { 86 | const actual = await importOriginal(); 87 | return { 88 | ...actual, 89 | loadState: mockedLoadState, 90 | saveState: vi.fn(), 91 | }; 92 | }); 93 | 94 | // Now import the main module after mocks are set up 95 | let mainModule; 96 | 97 | beforeEach(async () => { 98 | // Reset all mocks to ensure test isolation 99 | // This prevents state leakage between tests 100 | vi.resetModules(); 101 | vi.clearAllMocks(); 102 | 103 | // Reset mock function implementations to default values 104 | // Using fixtures ensures consistent test data across all tests 105 | mockedLoadStateFromDOM.mockReturnValue(defaultState); 106 | mockedLoadState.mockReturnValue({}); 107 | 108 | // Mock StateContainer constructor to return a mock store instance 109 | // The mock store includes all the methods that main.js expects to call 110 | mockedStateContainer.mockImplementation(() => ({ 111 | setState: vi.fn().mockReturnThis(), 112 | getState: vi.fn().mockReturnValue(defaultState), 113 | subscribe: vi.fn(), 114 | })); 115 | 116 | // Set up complete test environment with all common mocks 117 | // This includes DOM elements, navigator.serial, localStorage, and console 118 | setupTestEnvironment(); 119 | 120 | // Import the module after mocks are set up 121 | // Dynamic import ensures we get the mocked versions of dependencies 122 | mainModule = await import("../src/main.js"); 123 | }); 124 | 125 | afterEach(() => { 126 | // Restore all mocks to prevent test pollution 127 | vi.restoreAllMocks(); 128 | }); 129 | 130 | describe("main.js", () => { 131 | describe("debounce", () => { 132 | it("should delay function execution", async () => { 133 | const func = vi.fn(); 134 | const debouncedFunc = mainModule.debounce(func, 100); 135 | debouncedFunc(); 136 | expect(func).not.toHaveBeenCalled(); 137 | await new Promise((resolve) => setTimeout(resolve, 150)); 138 | expect(func).toHaveBeenCalledTimes(1); 139 | }); 140 | 141 | it("should reset timer on multiple calls", async () => { 142 | const func = vi.fn(); 143 | const debouncedFunc = mainModule.debounce(func, 100); 144 | debouncedFunc(); 145 | setTimeout(() => debouncedFunc(), 50); 146 | await new Promise((resolve) => setTimeout(resolve, 200)); 147 | expect(func).toHaveBeenCalledTimes(1); 148 | }); 149 | }); 150 | 151 | describe("makePortHandlers", () => { 152 | it("should create port event handlers that update store state", () => { 153 | // Create a mock store with setState method 154 | // The store tracks state changes for serial port events 155 | const mockStore = createMockStore(); 156 | const handlers = mainModule.makePortHandlers(mockStore); 157 | const mockPort = { getInfo: vi.fn().mockReturnValue({}) }; 158 | 159 | // Test onConnect handler 160 | // Should first clear lastPortInfo, then set it with port info and status 161 | handlers.onConnect(mockPort); 162 | verifyStateUpdates(mockStore.setState, [ 163 | { lastPortInfo: null }, 164 | { lastPortInfo: {}, status: "Connected" }, 165 | ]); 166 | 167 | // Reset mock to isolate the next test 168 | mockStore.setState.mockClear(); 169 | 170 | // Test onDisconnect handler 171 | // Should update status to Disconnected 172 | handlers.onDisconnect(); 173 | verifyStateUpdates(mockStore.setState, [{ status: "Disconnected" }]); 174 | 175 | // Reset mock to isolate the next test 176 | mockStore.setState.mockClear(); 177 | 178 | // Test onError handler 179 | // Should update status with error message 180 | const error = new Error("Test error"); 181 | handlers.onError(error); 182 | verifyStateUpdates(mockStore.setState, [ 183 | { status: `Error: ${error.message}` }, 184 | ]); 185 | 186 | // Reset mock to isolate the next test 187 | mockStore.setState.mockClear(); 188 | 189 | // Test onMessage handler 190 | // Should update message and status to indicate receiving 191 | const message = "Test message"; 192 | handlers.onMessage(message); 193 | verifyStateUpdates(mockStore.setState, [ 194 | { message, status: "Receiving..." }, 195 | ]); 196 | }); 197 | }); 198 | 199 | describe("getPortOptsFromState", () => { 200 | it("should extract SerialOptions from state", () => { 201 | const opts = mainModule.getPortOptsFromState(customPortState); 202 | expect(opts).toEqual({ 203 | baudRate: 115200, 204 | dataBits: 7, 205 | parity: "even", 206 | stopBits: 2, 207 | }); 208 | }); 209 | }); 210 | 211 | describe("getPortSignalOptsFromState", () => { 212 | it("should extract port signal options from state with boolean values", () => { 213 | const state = { 214 | dtrSignal: true, 215 | rtsSignal: false, 216 | breakSignal: true, 217 | }; 218 | const signals = mainModule.getPortSignalOptsFromState(state); 219 | expect(signals).toEqual({ 220 | dataTerminalReady: true, 221 | requestToSend: false, 222 | break: true, 223 | }); 224 | }); 225 | 226 | it("should filter out null signal values", () => { 227 | const state = { 228 | dtrSignal: null, 229 | rtsSignal: false, 230 | breakSignal: null, 231 | }; 232 | const signals = mainModule.getPortSignalOptsFromState(state); 233 | expect(signals).toEqual({ 234 | requestToSend: false, 235 | }); 236 | }); 237 | 238 | it("should return empty object when all signals are null", () => { 239 | const state = { 240 | dtrSignal: null, 241 | rtsSignal: null, 242 | breakSignal: null, 243 | }; 244 | const signals = mainModule.getPortSignalOptsFromState(state); 245 | expect(signals).toEqual({}); 246 | }); 247 | }); 248 | 249 | describe("getStore", () => { 250 | it("should create a StateContainer with state loaded from DOM", () => { 251 | const mockElements = setupTestEnvironment().mockElements; 252 | mainModule.getStore(mockElements); 253 | expect(mockedLoadStateFromDOM).toHaveBeenCalledWith(mockElements); 254 | expect(mockedStateContainer).toHaveBeenCalledWith(defaultState); 255 | }); 256 | 257 | it("should initialize app with supported browser", async () => { 258 | const mockStore = createMockStore({ 259 | baudRate: 9600, 260 | dataBits: 8, 261 | parity: "none", 262 | stopBits: 1, 263 | lastPortInfo: null, 264 | }); 265 | const mockPort = { 266 | connectTo: vi.fn().mockResolvedValue(), 267 | connectToPrev: vi.fn().mockResolvedValue(), 268 | stopReading: vi.fn(), 269 | forgetAll: vi.fn(), 270 | getInfo: vi.fn().mockReturnValue({}), 271 | requestPort: vi.fn(), 272 | }; 273 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 274 | 275 | const mockConnectBtn = { addEventListener: vi.fn() }; 276 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 277 | 278 | await mainModule.init( 279 | mockStore, 280 | MockPortClass, 281 | mockConnectBtn, 282 | mockDisconnectBtn, 283 | ); 284 | 285 | expect(mockedBindStateToDOM).toHaveBeenCalled(); 286 | expect(mockedLoadState).toHaveBeenCalled(); 287 | expect(mockStore.subscribe).toHaveBeenCalled(); 288 | expect(MockPortClass).toHaveBeenCalled(); 289 | expect(mockConnectBtn.addEventListener).toHaveBeenCalledWith( 290 | "click", 291 | expect.any(Function), 292 | ); 293 | expect(mockDisconnectBtn.addEventListener).toHaveBeenCalledWith( 294 | "click", 295 | expect.any(Function), 296 | ); 297 | expect(navigator.serial.addEventListener).toHaveBeenCalledWith( 298 | "connect", 299 | expect.any(Function), 300 | ); 301 | expect(mockPort.connectToPrev).toHaveBeenCalledWith(null); 302 | }); 303 | 304 | it("should handle manual connect error", async () => { 305 | const mockStore = createMockStore({ 306 | baudRate: 9600, 307 | dataBits: 8, 308 | parity: "none", 309 | stopBits: 1, 310 | lastPortInfo: null, 311 | }); 312 | const mockPort = { 313 | connectTo: vi.fn().mockRejectedValue(new Error("Connect failed")), 314 | connectToPrev: vi.fn().mockResolvedValue(), 315 | stopReading: vi.fn(), 316 | forgetAll: vi.fn(), 317 | getInfo: vi.fn().mockReturnValue({}), 318 | requestPort: vi.fn().mockResolvedValue({}), 319 | }; 320 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 321 | 322 | const mockConnectBtn = { addEventListener: vi.fn() }; 323 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 324 | 325 | await mainModule.init( 326 | mockStore, 327 | MockPortClass, 328 | mockConnectBtn, 329 | mockDisconnectBtn, 330 | ); 331 | 332 | // Trigger the click handler 333 | const clickHandler = mockConnectBtn.addEventListener.mock.calls[0][1]; 334 | await clickHandler(); 335 | 336 | expect(mockStore.setState).toHaveBeenCalledWith({ 337 | status: "Error: Connect failed", 338 | }); 339 | }); 340 | }); 341 | }); 342 | 343 | describe("init", () => { 344 | it("should throw error if Web Serial API is not supported", async () => { 345 | // Mock navigator without serial 346 | const originalSerial = navigator.serial; 347 | 348 | // Create a new navigator object without serial property 349 | const mockNavigator = {}; 350 | for (const prop in navigator) { 351 | if (prop !== "serial") { 352 | mockNavigator[prop] = navigator[prop]; 353 | } 354 | } 355 | 356 | // Use window instead of global for browser environment 357 | Object.defineProperty(window, "navigator", { 358 | value: mockNavigator, 359 | writable: true, 360 | configurable: true, 361 | }); 362 | 363 | try { 364 | const mockStore = createMockStore({ lastPortInfo: null }); 365 | 366 | await expect(mainModule.init(mockStore)).rejects.toThrow( 367 | "Not supported browser", 368 | ); 369 | // Check that setState was called with error status (may be called multiple times) 370 | expect(mockStore.setState).toHaveBeenCalledWith({ 371 | status: "💥 Web Serial API is not supported in your browser ☠️", 372 | message: "Not supported browser", 373 | }); 374 | } finally { 375 | // Restore original navigator 376 | Object.defineProperty(window, "navigator", { 377 | value: originalSerial 378 | ? { ...navigator, serial: originalSerial } 379 | : navigator, 380 | writable: true, 381 | configurable: true, 382 | }); 383 | } 384 | }); 385 | 386 | it("should initialize app with supported browser", async () => { 387 | const mockStore = createMockStore({ 388 | baudRate: 9600, 389 | dataBits: 8, 390 | parity: "none", 391 | stopBits: 1, 392 | lastPortInfo: null, 393 | }); 394 | const mockPort = { 395 | connectTo: vi.fn().mockResolvedValue(), 396 | connectToPrev: vi.fn().mockResolvedValue(), 397 | stopReading: vi.fn(), 398 | forgetAll: vi.fn(), 399 | getInfo: vi.fn().mockReturnValue({}), 400 | requestPort: vi.fn(), 401 | }; 402 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 403 | 404 | const mockConnectBtn = { addEventListener: vi.fn() }; 405 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 406 | 407 | await mainModule.init( 408 | mockStore, 409 | MockPortClass, 410 | mockConnectBtn, 411 | mockDisconnectBtn, 412 | ); 413 | 414 | expect(mockedBindStateToDOM).toHaveBeenCalled(); 415 | expect(mockedLoadState).toHaveBeenCalled(); 416 | expect(mockStore.subscribe).toHaveBeenCalled(); 417 | expect(MockPortClass).toHaveBeenCalled(); 418 | expect(mockConnectBtn.addEventListener).toHaveBeenCalledWith( 419 | "click", 420 | expect.any(Function), 421 | ); 422 | expect(mockDisconnectBtn.addEventListener).toHaveBeenCalledWith( 423 | "click", 424 | expect.any(Function), 425 | ); 426 | expect(navigator.serial.addEventListener).toHaveBeenCalledWith( 427 | "connect", 428 | expect.any(Function), 429 | ); 430 | expect(mockPort.connectToPrev).toHaveBeenCalledWith(null); 431 | }); 432 | 433 | it("should handle manual connect error", async () => { 434 | const mockStore = createMockStore({ 435 | baudRate: 9600, 436 | dataBits: 8, 437 | parity: "none", 438 | stopBits: 1, 439 | lastPortInfo: null, 440 | }); 441 | const mockPort = { 442 | connectTo: vi.fn().mockRejectedValue(new Error("Connect failed")), 443 | connectToPrev: vi.fn().mockResolvedValue(), 444 | stopReading: vi.fn(), 445 | forgetAll: vi.fn(), 446 | getInfo: vi.fn().mockReturnValue({}), 447 | requestPort: vi.fn().mockResolvedValue({}), 448 | }; 449 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 450 | 451 | const mockConnectBtn = { addEventListener: vi.fn() }; 452 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 453 | 454 | await mainModule.init( 455 | mockStore, 456 | MockPortClass, 457 | mockConnectBtn, 458 | mockDisconnectBtn, 459 | ); 460 | 461 | // Trigger the click handler 462 | const clickHandler = mockConnectBtn.addEventListener.mock.calls[0][1]; 463 | await clickHandler(); 464 | 465 | // Check that setState was called with error status (may be called multiple times) 466 | expect(mockStore.setState).toHaveBeenCalledWith({ 467 | status: "Error: Connect failed", 468 | }); 469 | }); 470 | 471 | describe("encoding integration", () => { 472 | it("should instantiate SerialPort with decoder from mkDecoder", async () => { 473 | const mockStore = createMockStore({ 474 | encoding: "utf-8", 475 | baudRate: 9600, 476 | dataBits: 8, 477 | parity: "none", 478 | stopBits: 1, 479 | lastPortInfo: null, 480 | }); 481 | const mockPort = { 482 | connectTo: vi.fn().mockResolvedValue(), 483 | connectToPrev: vi.fn().mockResolvedValue(), 484 | stopReading: vi.fn(), 485 | forgetAll: vi.fn(), 486 | getInfo: vi.fn().mockReturnValue({}), 487 | requestPort: vi.fn(), 488 | decoder: { encoding: "utf-8", decode: vi.fn() }, 489 | }; 490 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 491 | 492 | const mockConnectBtn = { addEventListener: vi.fn() }; 493 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 494 | 495 | await mainModule.init( 496 | mockStore, 497 | MockPortClass, 498 | mockConnectBtn, 499 | mockDisconnectBtn, 500 | ); 501 | 502 | expect(mockedMkDecoder).toHaveBeenCalledWith("utf-8"); 503 | expect(MockPortClass).toHaveBeenCalled(); 504 | // Verify the port was created with the decoder from mkDecoder 505 | expect(mockPort.decoder.encoding).toBe("utf-8"); 506 | expect(mockPort.decoder.decode).toBeDefined(); 507 | }); 508 | 509 | it("should auto-detect Espruino and switch encoding to x-espruino-mixed-utf8", async () => { 510 | const mockStore = createMockStore({ 511 | encoding: "default", 512 | baudRate: 9600, 513 | dataBits: 8, 514 | parity: "none", 515 | stopBits: 1, 516 | lastPortInfo: null, 517 | }); 518 | const mockPort = { 519 | connectTo: vi.fn().mockResolvedValue(), 520 | connectToPrev: vi.fn().mockResolvedValue(), 521 | stopReading: vi.fn(), 522 | forgetAll: vi.fn(), 523 | getInfo: vi 524 | .fn() 525 | .mockReturnValue({ usbVendorId: "0x1209", usbProductId: "0x5740" }), 526 | requestPort: vi.fn().mockResolvedValue({ 527 | getInfo: vi.fn().mockReturnValue({ 528 | usbVendorId: "0x1209", 529 | usbProductId: "0x5740", 530 | }), 531 | }), 532 | decoder: { encoding: "default", decode: vi.fn() }, 533 | }; 534 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 535 | 536 | const mockConnectBtn = { addEventListener: vi.fn() }; 537 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 538 | 539 | await mainModule.init( 540 | mockStore, 541 | MockPortClass, 542 | mockConnectBtn, 543 | mockDisconnectBtn, 544 | ); 545 | 546 | // Configure isEspruino to return true for this test 547 | mockedIsEspruino.mockReturnValue(true); 548 | 549 | // Trigger manual connect 550 | const clickHandler = mockConnectBtn.addEventListener.mock.calls[0][1]; 551 | await clickHandler(); 552 | 553 | expect(mockedIsEspruino).toHaveBeenCalledWith({ 554 | usbVendorId: "0x1209", 555 | usbProductId: "0x5740", 556 | }); 557 | // Check that setState was called with the encoding update 558 | const setStateCalls = mockStore.setState.mock.calls; 559 | const encodingUpdateCall = setStateCalls.find( 560 | (call) => call[0] && call[0].encoding === "x-espruino-mixed-utf8", 561 | ); 562 | expect(encodingUpdateCall).toBeDefined(); 563 | }); 564 | 565 | it("should not auto-switch encoding for non-Espruino devices", async () => { 566 | const mockStore = createMockStore({ 567 | encoding: "default", 568 | baudRate: 9600, 569 | dataBits: 8, 570 | parity: "none", 571 | stopBits: 1, 572 | lastPortInfo: null, 573 | }); 574 | const mockPort = { 575 | connectTo: vi.fn().mockResolvedValue(), 576 | connectToPrev: vi.fn().mockResolvedValue(), 577 | stopReading: vi.fn(), 578 | forgetAll: vi.fn(), 579 | getInfo: vi 580 | .fn() 581 | .mockReturnValue({ usbVendorId: "0x1234", usbProductId: "0x5678" }), 582 | requestPort: vi.fn().mockResolvedValue({ 583 | getInfo: vi.fn().mockReturnValue({ 584 | usbVendorId: "0x1234", 585 | usbProductId: "0x5678", 586 | }), 587 | }), 588 | decoder: { encoding: "default", decode: vi.fn() }, 589 | }; 590 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 591 | 592 | const mockConnectBtn = { addEventListener: vi.fn() }; 593 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 594 | 595 | await mainModule.init( 596 | mockStore, 597 | MockPortClass, 598 | mockConnectBtn, 599 | mockDisconnectBtn, 600 | ); 601 | 602 | // Trigger manual connect 603 | const clickHandler = mockConnectBtn.addEventListener.mock.calls[0][1]; 604 | await clickHandler(); 605 | 606 | expect(mockedIsEspruino).toHaveBeenCalledWith({ 607 | usbVendorId: "0x1234", 608 | usbProductId: "0x5678", 609 | }); 610 | expect(mockStore.setState).not.toHaveBeenCalledWith({ 611 | encoding: "x-espruino-mixed-utf8", 612 | }); 613 | }); 614 | 615 | it("should not auto-switch encoding when current encoding is not default", async () => { 616 | const mockStore = createMockStore({ 617 | encoding: "utf-8", 618 | baudRate: 9600, 619 | dataBits: 8, 620 | parity: "none", 621 | stopBits: 1, 622 | lastPortInfo: null, 623 | }); 624 | const mockPort = { 625 | connectTo: vi.fn().mockResolvedValue(), 626 | connectToPrev: vi.fn().mockResolvedValue(), 627 | stopReading: vi.fn(), 628 | forgetAll: vi.fn(), 629 | getInfo: vi 630 | .fn() 631 | .mockReturnValue({ usbVendorId: "0x1209", usbProductId: "0x5740" }), 632 | requestPort: vi.fn().mockResolvedValue({ 633 | getInfo: vi.fn().mockReturnValue({ 634 | usbVendorId: "0x1209", 635 | usbProductId: "0x5740", 636 | }), 637 | }), 638 | decoder: { encoding: "utf-8", decode: vi.fn() }, 639 | }; 640 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 641 | 642 | const mockConnectBtn = { addEventListener: vi.fn() }; 643 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 644 | 645 | await mainModule.init( 646 | mockStore, 647 | MockPortClass, 648 | mockConnectBtn, 649 | mockDisconnectBtn, 650 | ); 651 | 652 | // Ensure isEspruino returns false (default behavior) 653 | mockedIsEspruino.mockReturnValue(false); 654 | 655 | // Trigger manual connect 656 | const clickHandler = mockConnectBtn.addEventListener.mock.calls[0][1]; 657 | await clickHandler(); 658 | 659 | // isEspruino should not be called when encoding is not "default" 660 | expect(mockedIsEspruino).not.toHaveBeenCalled(); 661 | expect(mockStore.setState).not.toHaveBeenCalledWith({ 662 | encoding: "x-espruino-mixed-utf8", 663 | }); 664 | }); 665 | 666 | it("should update decoder when encoding state changes", async () => { 667 | const mockStore = createMockStore({ 668 | encoding: "utf-8", 669 | baudRate: 9600, 670 | dataBits: 8, 671 | parity: "none", 672 | stopBits: 1, 673 | lastPortInfo: null, 674 | }); 675 | const mockPort = { 676 | connectTo: vi.fn().mockResolvedValue(), 677 | connectToPrev: vi.fn().mockResolvedValue(), 678 | stopReading: vi.fn(), 679 | forgetAll: vi.fn(), 680 | getInfo: vi.fn().mockReturnValue({}), 681 | requestPort: vi.fn(), 682 | decoder: { encoding: "utf-8", decode: vi.fn() }, 683 | setSignals: vi.fn(), 684 | }; 685 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 686 | 687 | const mockConnectBtn = { addEventListener: vi.fn() }; 688 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 689 | 690 | await mainModule.init( 691 | mockStore, 692 | MockPortClass, 693 | mockConnectBtn, 694 | mockDisconnectBtn, 695 | ); 696 | 697 | // Get the decoder update subscription callback (should be the second subscribe call) 698 | const subscribeCalls = mockStore.subscribe.mock.calls; 699 | const decoderUpdateCallback = subscribeCalls[1][0]; // Second subscribe call is for decoder updates 700 | 701 | // Set up the port's decoder with initial encoding 702 | mockPort.decoder = { encoding: "utf-8", decode: vi.fn() }; 703 | 704 | // Clear previous calls to mkDecoder to isolate the subscription test 705 | mockedMkDecoder.mockClear(); 706 | 707 | // Mock mkDecoder to return a new decoder for the new encoding 708 | const newDecoder = { encoding: "ascii", decode: vi.fn() }; 709 | mockedMkDecoder.mockReturnValue(newDecoder); 710 | 711 | // Trigger the decoder update callback with new state (different encoding) 712 | decoderUpdateCallback({ 713 | encoding: "ascii", 714 | baudRate: 9600, 715 | dataBits: 8, 716 | parity: "none", 717 | stopBits: 1, 718 | lastPortInfo: null, 719 | }); 720 | 721 | // Verify mkDecoder was called with the new encoding 722 | expect(mockedMkDecoder).toHaveBeenCalledWith("ascii"); 723 | // Verify the port's decoder was updated by the callback 724 | expect(mockPort.decoder).toBe(newDecoder); 725 | expect(mockPort.decoder.encoding).toBe("ascii"); 726 | expect(mockPort.decoder.decode).toBeDefined(); 727 | }); 728 | 729 | it("should not update decoder when encoding state remains the same", async () => { 730 | const mockStore = createMockStore({ 731 | encoding: "utf-8", 732 | baudRate: 9600, 733 | dataBits: 8, 734 | parity: "none", 735 | stopBits: 1, 736 | lastPortInfo: null, 737 | }); 738 | const mockPort = { 739 | connectTo: vi.fn().mockResolvedValue(), 740 | connectToPrev: vi.fn().mockResolvedValue(), 741 | stopReading: vi.fn(), 742 | forgetAll: vi.fn(), 743 | getInfo: vi.fn().mockReturnValue({}), 744 | requestPort: vi.fn(), 745 | decoder: { encoding: "utf-8", decode: vi.fn() }, 746 | }; 747 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 748 | 749 | const mockConnectBtn = { addEventListener: vi.fn() }; 750 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 751 | 752 | await mainModule.init( 753 | mockStore, 754 | MockPortClass, 755 | mockConnectBtn, 756 | mockDisconnectBtn, 757 | ); 758 | 759 | // Get the subscribe callback and trigger it with the same encoding 760 | const subscribeCallback = mockStore.subscribe.mock.calls[0][0]; 761 | subscribeCallback({ encoding: "utf-8" }); 762 | 763 | // mkDecoder should only be called once during initialization 764 | expect(mockedMkDecoder).toHaveBeenCalledTimes(1); 765 | }); 766 | 767 | it("should include encoding element in appHtmlElements", async () => { 768 | const mockStore = createMockStore({ 769 | encoding: "default", 770 | baudRate: 9600, 771 | dataBits: 8, 772 | parity: "none", 773 | stopBits: 1, 774 | lastPortInfo: null, 775 | }); 776 | const mockPort = { 777 | connectTo: vi.fn().mockResolvedValue(), 778 | connectToPrev: vi.fn().mockResolvedValue(), 779 | stopReading: vi.fn(), 780 | forgetAll: vi.fn(), 781 | getInfo: vi.fn().mockReturnValue({}), 782 | requestPort: vi.fn(), 783 | decoder: { encoding: "default", decode: vi.fn() }, 784 | }; 785 | const MockPortClass = vi.fn().mockImplementation(() => mockPort); 786 | 787 | const mockConnectBtn = { addEventListener: vi.fn() }; 788 | const mockDisconnectBtn = { addEventListener: vi.fn() }; 789 | 790 | await mainModule.init( 791 | mockStore, 792 | MockPortClass, 793 | mockConnectBtn, 794 | mockDisconnectBtn, 795 | ); 796 | 797 | // Verify that document.getElementById was called with "encoding" 798 | expect(document.getElementById).toHaveBeenCalledWith("encoding"); 799 | 800 | // Verify the encoding element is part of the appHtmlElements by checking the module exports 801 | // Since appHtmlElements is not exported, we verify the getElementById call instead 802 | expect(document.getElementById).toHaveBeenCalledWith("encoding"); 803 | }); 804 | }); 805 | }); 806 | -------------------------------------------------------------------------------- /tests/ui.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { 3 | appHtmlElementNames, 4 | isModalClosed, 5 | openModal, 6 | closeModal, 7 | loadStateFromDOM, 8 | renderPortSettings, 9 | bindPortSettings, 10 | renderStyleSettings, 11 | bindStyleSettings, 12 | bindAbout, 13 | renderModalState, 14 | sanitizeHtml, 15 | renderMessages, 16 | renderFullscreenMode, 17 | bindFullscreenMode, 18 | renderState, 19 | bindStateToDOM, 20 | } from "../src/ui.js"; 21 | import { StateContainer } from "../src/state.js"; 22 | import { 23 | createMockButton, 24 | createMockInput, 25 | createMockDiv, 26 | createMockModal, 27 | createMockDocument, 28 | createMockStore, 29 | verifyStateUpdates, 30 | } from "./test-helpers.js"; 31 | 32 | describe("ui.js", () => { 33 | describe("isModalClosed", () => { 34 | it("returns true for empty display", () => { 35 | const el = { style: { display: "" } }; 36 | expect(isModalClosed(el)).toBe(true); 37 | }); 38 | 39 | it("returns true for none display", () => { 40 | const el = { style: { display: "none" } }; 41 | expect(isModalClosed(el)).toBe(true); 42 | }); 43 | 44 | it("returns false for flex display", () => { 45 | const el = { style: { display: "flex" } }; 46 | expect(isModalClosed(el)).toBe(false); 47 | }); 48 | }); 49 | 50 | describe("openModal", () => { 51 | it("sets display to flex", () => { 52 | const el = { style: {} }; 53 | openModal(el); 54 | expect(el.style.display).toBe("flex"); 55 | }); 56 | }); 57 | 58 | describe("closeModal", () => { 59 | it("sets display to none", () => { 60 | const el = { style: {} }; 61 | closeModal(el); 62 | expect(el.style.display).toBe("none"); 63 | }); 64 | }); 65 | 66 | describe("loadStateFromDOM", () => { 67 | it("loads state from DOM elements", () => { 68 | const mockEl = { 69 | doc: { fullscreenElement: document.documentElement }, 70 | msg: { innerHTML: "

test message

" }, 71 | status: { innerText: "connected" }, 72 | settingsModal: { style: { display: "none" } }, 73 | styleModal: { style: { display: "" } }, 74 | aboutModal: { style: { display: "flex" } }, 75 | bgColor: { value: "#000000" }, 76 | textColor: { value: "#ffffff" }, 77 | fontFamily: { value: "Arial" }, 78 | fontSize: { value: "12" }, 79 | baudRate: { value: "9600" }, 80 | dataBits: { value: "8" }, 81 | parity: { value: "none" }, 82 | stopBits: { value: "1" }, 83 | encoding: { value: "default" }, 84 | dtrSignal: { value: "" }, 85 | rtsSignal: { value: "true" }, 86 | breakSignal: { value: "false" }, 87 | }; 88 | const state = loadStateFromDOM(mockEl); 89 | const expected = { 90 | bgColor: "#000000", 91 | textColor: "#ffffff", 92 | fontFamily: "Arial", 93 | fontSize: 12, 94 | baudRate: 9600, 95 | dataBits: 8, 96 | parity: "none", 97 | stopBits: 1, 98 | isFullscreen: true, 99 | isSettingsModalOpened: false, 100 | isStyleModalOpened: false, 101 | isAboutModalOpened: true, 102 | message: "

test message

", 103 | status: "connected", 104 | encoding: "default", 105 | dtrSignal: null, 106 | rtsSignal: true, 107 | breakSignal: false, 108 | }; 109 | expect(state).toEqual(expected); 110 | 111 | const mockElSignals1 = { 112 | ...mockEl, 113 | dtrSignal: { value: "true" }, 114 | rtsSignal: { value: "false" }, 115 | breakSignal: { value: "" }, 116 | }; 117 | const stateSignals1 = loadStateFromDOM(mockElSignals1); 118 | expect(stateSignals1).toEqual({ 119 | ...expected, 120 | dtrSignal: true, 121 | rtsSignal: false, 122 | breakSignal: null, 123 | }); 124 | const mockElSignals2 = { 125 | ...mockEl, 126 | dtrSignal: { value: "false" }, 127 | rtsSignal: { value: "" }, 128 | breakSignal: { value: "true" }, 129 | }; 130 | const stateSignals2 = loadStateFromDOM(mockElSignals2); 131 | expect(stateSignals2).toEqual({ 132 | ...expected, 133 | dtrSignal: false, 134 | rtsSignal: null, 135 | breakSignal: true, 136 | }); 137 | }); 138 | }); 139 | 140 | describe("renderPortSettings", () => { 141 | it("updates baudRate if changed", () => { 142 | const el = { baudRate: { value: "1200" } }; 143 | const state = { baudRate: 9600 }; 144 | const oldState = { baudRate: 1200 }; 145 | renderPortSettings(el, state, oldState); 146 | expect(el.baudRate.value).toBe(9600); 147 | }); 148 | 149 | it("does not update baudRate if not changed", () => { 150 | const el = { baudRate: { value: "9600" } }; 151 | const state = { baudRate: 9600 }; 152 | const oldState = { baudRate: 9600 }; 153 | renderPortSettings(el, state, oldState); 154 | expect(el.baudRate.value).toBe("9600"); 155 | }); 156 | 157 | it("updates dataBits if changed", () => { 158 | const el = { dataBits: { value: "7" } }; 159 | const state = { dataBits: 8 }; 160 | const oldState = { dataBits: 7 }; 161 | renderPortSettings(el, state, oldState); 162 | expect(el.dataBits.value).toBe(8); 163 | }); 164 | 165 | it("updates parity if changed", () => { 166 | const el = { parity: { value: "even" } }; 167 | const state = { parity: "none" }; 168 | const oldState = { parity: "even" }; 169 | renderPortSettings(el, state, oldState); 170 | expect(el.parity.value).toBe("none"); 171 | }); 172 | 173 | it("updates stopBits if changed", () => { 174 | const el = { stopBits: { value: "2" } }; 175 | const state = { stopBits: 1 }; 176 | const oldState = { stopBits: 2 }; 177 | renderPortSettings(el, state, oldState); 178 | expect(el.stopBits.value).toBe(1); 179 | }); 180 | 181 | it("updates encoding if changed", () => { 182 | const el = { encoding: { value: "utf-8" } }; 183 | const state = { encoding: "ascii" }; 184 | const oldState = { encoding: "utf-8" }; 185 | renderPortSettings(el, state, oldState); 186 | expect(el.encoding.value).toBe("ascii"); 187 | }); 188 | 189 | it("does not update encoding if not changed", () => { 190 | const el = { encoding: { value: "utf-8" } }; 191 | const state = { encoding: "utf-8" }; 192 | const oldState = { encoding: "utf-8" }; 193 | renderPortSettings(el, state, oldState); 194 | expect(el.encoding.value).toBe("utf-8"); 195 | }); 196 | 197 | it("updates dtrSignal if changed", () => { 198 | const el = { dtrSignal: { value: "false" } }; 199 | const state = { dtrSignal: true }; 200 | const oldState = { dtrSignal: false }; 201 | renderPortSettings(el, state, oldState); 202 | expect(el.dtrSignal.value).toBe("true"); 203 | }); 204 | 205 | it("does not update dtrSignal if not changed", () => { 206 | const el = { dtrSignal: { value: "true" } }; 207 | const state = { dtrSignal: true }; 208 | const oldState = { dtrSignal: true }; 209 | renderPortSettings(el, state, oldState); 210 | expect(el.dtrSignal.value).toBe("true"); 211 | }); 212 | 213 | it("updates rtsSignal if changed", () => { 214 | const el = { rtsSignal: { value: "true" } }; 215 | const state = { rtsSignal: false }; 216 | const oldState = { rtsSignal: true }; 217 | renderPortSettings(el, state, oldState); 218 | expect(el.rtsSignal.value).toBe("false"); 219 | }); 220 | 221 | it("does not update dtrSignal if not changed", () => { 222 | const el = { dtrSignal: { value: "true" } }; 223 | const state = { dtrSignal: true }; 224 | const oldState = { dtrSignal: true }; 225 | renderPortSettings(el, state, oldState); 226 | expect(el.dtrSignal.value).toBe("true"); 227 | }); 228 | 229 | it("updates breakSignal if changed", () => { 230 | const el = { breakSignal: { value: "" } }; 231 | const state = { breakSignal: true }; 232 | const oldState = { breakSignal: null }; 233 | renderPortSettings(el, state, oldState); 234 | expect(el.breakSignal.value).toBe("true"); 235 | }); 236 | 237 | it("does not update breakSignal if not changed", () => { 238 | const el = { breakSignal: { value: "true" } }; 239 | const state = { breakSignal: true }; 240 | const oldState = { breakSignal: true }; 241 | renderPortSettings(el, state, oldState); 242 | expect(el.breakSignal.value).toBe("true"); 243 | }); 244 | 245 | it("sets signal value to empty string when signal is null", () => { 246 | const el = { 247 | dtrSignal: { value: "true" }, 248 | rtsSignal: { value: "false" }, 249 | breakSignal: { value: "true" }, 250 | }; 251 | const state = { 252 | dtrSignal: null, 253 | rtsSignal: null, 254 | breakSignal: null, 255 | }; 256 | const oldState = { 257 | dtrSignal: true, 258 | rtsSignal: false, 259 | breakSignal: true, 260 | }; 261 | renderPortSettings(el, state, oldState); 262 | expect(el.dtrSignal.value).toBe(""); 263 | expect(el.rtsSignal.value).toBe(""); 264 | expect(el.breakSignal.value).toBe(""); 265 | }); 266 | }); 267 | 268 | describe("bindPortSettings", () => { 269 | const createPortSettingsElements = () => ({ 270 | settingsBtn: createMockButton(), 271 | settingsClose: createMockButton(), 272 | connectBtn: createMockButton(), 273 | disconnectBtn: createMockButton(), 274 | baudRate: createMockInput(), 275 | dataBits: createMockInput(), 276 | parity: createMockInput(), 277 | stopBits: createMockInput(), 278 | encoding: createMockInput(), 279 | dtrSignal: createMockInput(), 280 | rtsSignal: createMockInput(), 281 | breakSignal: createMockInput(), 282 | }); 283 | 284 | it("binds settingsBtn click to open settings modal", () => { 285 | const store = { setState: vi.fn() }; 286 | const el = createPortSettingsElements(); 287 | bindPortSettings(el, store); 288 | el.settingsBtn.click(); 289 | expect(store.setState).toHaveBeenCalledWith({ 290 | isSettingsModalOpened: true, 291 | isStyleModalOpened: false, 292 | isAboutModalOpened: false, 293 | }); 294 | }); 295 | 296 | it("binds settingsClose click to close settings modal", () => { 297 | const store = { setState: vi.fn() }; 298 | const el = createPortSettingsElements(); 299 | bindPortSettings(el, store); 300 | el.settingsClose.click(); 301 | expect(store.setState).toHaveBeenCalledWith({ 302 | isSettingsModalOpened: false, 303 | }); 304 | }); 305 | 306 | it("binds connectBtn click to close settings modal", () => { 307 | const store = { setState: vi.fn() }; 308 | const el = createPortSettingsElements(); 309 | bindPortSettings(el, store); 310 | el.connectBtn.click(); 311 | expect(store.setState).toHaveBeenCalledWith({ 312 | isSettingsModalOpened: false, 313 | }); 314 | }); 315 | 316 | it("binds disconnectBtn click to close settings modal", () => { 317 | const store = { setState: vi.fn() }; 318 | const el = createPortSettingsElements(); 319 | bindPortSettings(el, store); 320 | el.disconnectBtn.click(); 321 | expect(store.setState).toHaveBeenCalledWith({ 322 | isSettingsModalOpened: false, 323 | }); 324 | }); 325 | 326 | it("binds baudRate change to update state", () => { 327 | const store = { setState: vi.fn() }; 328 | const el = createPortSettingsElements(); 329 | bindPortSettings(el, store); 330 | el.baudRate.value = "115200"; 331 | el.baudRate.dispatchEvent(new Event("change")); 332 | expect(store.setState).toHaveBeenCalledWith({ baudRate: 115200 }); 333 | }); 334 | 335 | it("binds dataBits change to update state", () => { 336 | const store = { setState: vi.fn() }; 337 | const el = createPortSettingsElements(); 338 | bindPortSettings(el, store); 339 | el.dataBits.value = "7"; 340 | el.dataBits.dispatchEvent(new Event("change")); 341 | expect(store.setState).toHaveBeenCalledWith({ dataBits: 7 }); 342 | }); 343 | 344 | it("binds parity change to update state", () => { 345 | const store = { setState: vi.fn() }; 346 | const el = createPortSettingsElements(); 347 | bindPortSettings(el, store); 348 | el.parity.value = "even"; 349 | el.parity.dispatchEvent(new Event("change")); 350 | expect(store.setState).toHaveBeenCalledWith({ parity: "even" }); 351 | }); 352 | 353 | it("binds stopBits change to update state", () => { 354 | const store = { setState: vi.fn() }; 355 | const el = createPortSettingsElements(); 356 | bindPortSettings(el, store); 357 | el.stopBits.value = "2"; 358 | el.stopBits.dispatchEvent(new Event("change")); 359 | expect(store.setState).toHaveBeenCalledWith({ stopBits: 2 }); 360 | }); 361 | 362 | it("binds encoding change to update state", () => { 363 | const store = { setState: vi.fn() }; 364 | const el = createPortSettingsElements(); 365 | bindPortSettings(el, store); 366 | el.encoding.value = "ascii"; 367 | el.encoding.dispatchEvent(new Event("change")); 368 | expect(store.setState).toHaveBeenCalledWith({ encoding: "ascii" }); 369 | }); 370 | 371 | it("binds dtrSignal change to update state", () => { 372 | const store = { setState: vi.fn() }; 373 | const el = createPortSettingsElements(); 374 | bindPortSettings(el, store); 375 | 376 | el.dtrSignal.value = "true"; 377 | el.dtrSignal.dispatchEvent(new Event("change")); 378 | expect(store.setState).toHaveBeenCalledWith({ dtrSignal: true }); 379 | 380 | el.dtrSignal.value = "false"; 381 | el.dtrSignal.dispatchEvent(new Event("change")); 382 | expect(store.setState).toHaveBeenCalledWith({ dtrSignal: false }); 383 | 384 | el.dtrSignal.value = ""; 385 | el.dtrSignal.dispatchEvent(new Event("change")); 386 | expect(store.setState).toHaveBeenCalledWith({ dtrSignal: null }); 387 | }); 388 | 389 | it("binds rtsSignal change to update state", () => { 390 | const store = { setState: vi.fn() }; 391 | const el = createPortSettingsElements(); 392 | bindPortSettings(el, store); 393 | 394 | el.rtsSignal.value = "true"; 395 | el.rtsSignal.dispatchEvent(new Event("change")); 396 | expect(store.setState).toHaveBeenCalledWith({ rtsSignal: true }); 397 | 398 | el.rtsSignal.value = "false"; 399 | el.rtsSignal.dispatchEvent(new Event("change")); 400 | expect(store.setState).toHaveBeenCalledWith({ rtsSignal: false }); 401 | 402 | el.rtsSignal.value = ""; 403 | el.rtsSignal.dispatchEvent(new Event("change")); 404 | expect(store.setState).toHaveBeenCalledWith({ rtsSignal: null }); 405 | }); 406 | 407 | it("binds breakSignal change to update state", () => { 408 | const store = { setState: vi.fn() }; 409 | const el = createPortSettingsElements(); 410 | bindPortSettings(el, store); 411 | 412 | el.breakSignal.value = "true"; 413 | el.breakSignal.dispatchEvent(new Event("change")); 414 | expect(store.setState).toHaveBeenCalledWith({ breakSignal: true }); 415 | 416 | el.breakSignal.value = "false"; 417 | el.breakSignal.dispatchEvent(new Event("change")); 418 | expect(store.setState).toHaveBeenCalledWith({ breakSignal: false }); 419 | 420 | el.breakSignal.value = ""; 421 | el.breakSignal.dispatchEvent(new Event("change")); 422 | expect(store.setState).toHaveBeenCalledWith({ breakSignal: null }); 423 | }); 424 | }); 425 | 426 | describe("renderStyleSettings", () => { 427 | const createStyleSettingsElements = () => ({ 428 | doc: createMockDocument(), 429 | bgColor: createMockInput(), 430 | textColor: createMockInput(), 431 | fontFamily: createMockInput(), 432 | fontSize: createMockInput(), 433 | msg: createMockDiv(), 434 | }); 435 | 436 | it("updates bgColor and body background if changed", () => { 437 | const el = createStyleSettingsElements(); 438 | el.bgColor.value = "#000"; 439 | el.textColor.value = "#fff"; 440 | el.fontFamily.value = "Arial"; 441 | el.fontSize.value = "10"; 442 | 443 | const state = { 444 | bgColor: "#fff", 445 | textColor: "#000", 446 | fontFamily: "Times", 447 | fontSize: 12, 448 | }; 449 | const oldState = { 450 | bgColor: "#000", 451 | textColor: "#fff", 452 | fontFamily: "Arial", 453 | fontSize: 10, 454 | }; 455 | renderStyleSettings(el, state, oldState); 456 | expect(el.bgColor.value).toBe("#fff"); 457 | expect(el.doc.body.style.background).toBe("#fff"); 458 | }); 459 | 460 | it("updates textColor and msg color if changed", () => { 461 | const el = createStyleSettingsElements(); 462 | el.bgColor.value = "#000"; 463 | el.textColor.value = "#fff"; 464 | el.fontFamily.value = "Arial"; 465 | el.fontSize.value = "10"; 466 | 467 | const state = { 468 | bgColor: "#000", 469 | textColor: "#000", 470 | fontFamily: "Arial", 471 | fontSize: 10, 472 | }; 473 | const oldState = { 474 | bgColor: "#000", 475 | textColor: "#fff", 476 | fontFamily: "Arial", 477 | fontSize: 10, 478 | }; 479 | renderStyleSettings(el, state, oldState); 480 | expect(el.textColor.value).toBe("#000"); 481 | expect(el.msg.style.color).toBe("#000"); 482 | }); 483 | 484 | it("updates fontFamily and msg fontFamily if changed", () => { 485 | const el = createStyleSettingsElements(); 486 | el.bgColor.value = "#000"; 487 | el.textColor.value = "#fff"; 488 | el.fontFamily.value = "Arial"; 489 | el.fontSize.value = "10"; 490 | 491 | const state = { 492 | bgColor: "#000", 493 | textColor: "#fff", 494 | fontFamily: "Times", 495 | fontSize: 10, 496 | }; 497 | const oldState = { 498 | bgColor: "#000", 499 | textColor: "#fff", 500 | fontFamily: "Arial", 501 | fontSize: 10, 502 | }; 503 | renderStyleSettings(el, state, oldState); 504 | expect(el.fontFamily.value).toBe("Times"); 505 | expect(el.msg.style.fontFamily).toBe("Times"); 506 | }); 507 | 508 | it("updates fontSize and msg fontSize if changed", () => { 509 | const el = createStyleSettingsElements(); 510 | el.bgColor.value = "#000"; 511 | el.textColor.value = "#fff"; 512 | el.fontFamily.value = "Arial"; 513 | el.fontSize.value = "10"; 514 | 515 | const state = { 516 | bgColor: "#000", 517 | textColor: "#fff", 518 | fontFamily: "Arial", 519 | fontSize: 12, 520 | }; 521 | const oldState = { 522 | bgColor: "#000", 523 | textColor: "#fff", 524 | fontFamily: "Arial", 525 | fontSize: 10, 526 | }; 527 | renderStyleSettings(el, state, oldState); 528 | expect(el.fontSize.value).toBe(12); 529 | expect(el.msg.style.fontSize).toBe("12vh"); 530 | }); 531 | }); 532 | 533 | describe("bindStyleSettings", () => { 534 | const createStyleSettingsElements = () => ({ 535 | styleBtn: createMockButton(), 536 | styleClose: createMockButton(), 537 | bgColor: createMockInput(), 538 | textColor: createMockInput(), 539 | fontFamily: createMockInput(), 540 | fontSize: createMockInput(), 541 | }); 542 | 543 | it("binds styleBtn click to open style modal", () => { 544 | const store = { setState: vi.fn() }; 545 | const el = createStyleSettingsElements(); 546 | bindStyleSettings(el, store); 547 | el.styleBtn.click(); 548 | expect(store.setState).toHaveBeenCalledWith({ 549 | isSettingsModalOpened: false, 550 | isStyleModalOpened: true, 551 | isAboutModalOpened: false, 552 | }); 553 | }); 554 | 555 | it("binds styleClose click to close style modal", () => { 556 | const store = { setState: vi.fn() }; 557 | const el = createStyleSettingsElements(); 558 | bindStyleSettings(el, store); 559 | el.styleClose.click(); 560 | expect(store.setState).toHaveBeenCalledWith({ 561 | isStyleModalOpened: false, 562 | }); 563 | }); 564 | 565 | it("binds bgColor input to update state", () => { 566 | const store = { setState: vi.fn() }; 567 | const el = createStyleSettingsElements(); 568 | bindStyleSettings(el, store); 569 | el.bgColor.value = "#fff"; 570 | el.bgColor.dispatchEvent(new Event("input")); 571 | expect(store.setState).toHaveBeenCalledWith({ bgColor: "#fff" }); 572 | }); 573 | 574 | it("binds textColor input to update state", () => { 575 | const store = { setState: vi.fn() }; 576 | const el = createStyleSettingsElements(); 577 | bindStyleSettings(el, store); 578 | el.textColor.value = "#000"; 579 | el.textColor.dispatchEvent(new Event("input")); 580 | expect(store.setState).toHaveBeenCalledWith({ textColor: "#000" }); 581 | }); 582 | 583 | it("binds fontFamily change to update state", () => { 584 | const store = { setState: vi.fn() }; 585 | const el = createStyleSettingsElements(); 586 | bindStyleSettings(el, store); 587 | el.fontFamily.value = "Times"; 588 | el.fontFamily.dispatchEvent(new Event("change")); 589 | expect(store.setState).toHaveBeenCalledWith({ fontFamily: "Times" }); 590 | }); 591 | 592 | it("binds fontSize change to update state", () => { 593 | const store = { setState: vi.fn() }; 594 | const el = createStyleSettingsElements(); 595 | bindStyleSettings(el, store); 596 | el.fontSize.value = "14"; 597 | el.fontSize.dispatchEvent(new Event("change")); 598 | expect(store.setState).toHaveBeenCalledWith({ fontSize: 14 }); 599 | }); 600 | }); 601 | 602 | describe("bindAbout", () => { 603 | it("binds aboutBtn click to open about modal", () => { 604 | const store = createMockStore(); 605 | const el = { 606 | aboutBtn: createMockButton(), 607 | aboutClose: createMockButton(), 608 | }; 609 | bindAbout(el, store); 610 | el.aboutBtn.click(); 611 | verifyStateUpdates(store.setState, [ 612 | { 613 | isSettingsModalOpened: false, 614 | isStyleModalOpened: false, 615 | isAboutModalOpened: true, 616 | }, 617 | ]); 618 | }); 619 | 620 | it("binds aboutClose click to close about modal", () => { 621 | const store = createMockStore(); 622 | const el = { 623 | aboutBtn: createMockButton(), 624 | aboutClose: createMockButton(), 625 | }; 626 | bindAbout(el, store); 627 | el.aboutClose.click(); 628 | verifyStateUpdates(store.setState, [ 629 | { 630 | isAboutModalOpened: false, 631 | }, 632 | ]); 633 | }); 634 | }); 635 | 636 | describe("renderModalState", () => { 637 | const createModalElements = () => ({ 638 | settingsModal: createMockModal(), 639 | styleModal: createMockModal(), 640 | aboutModal: createMockModal(), 641 | }); 642 | 643 | it("opens settings modal if state changed to true", () => { 644 | const el = createModalElements(); 645 | const state = { 646 | isSettingsModalOpened: true, 647 | isStyleModalOpened: false, 648 | isAboutModalOpened: false, 649 | }; 650 | const oldState = { 651 | isSettingsModalOpened: false, 652 | isStyleModalOpened: false, 653 | isAboutModalOpened: false, 654 | }; 655 | renderModalState(el, state, oldState); 656 | expect(el.settingsModal.style.display).toBe("flex"); 657 | }); 658 | 659 | it("closes settings modal if state changed to false", () => { 660 | const el = createModalElements(); 661 | el.settingsModal.style.display = "flex"; 662 | const state = { 663 | isSettingsModalOpened: false, 664 | isStyleModalOpened: false, 665 | isAboutModalOpened: false, 666 | }; 667 | const oldState = { 668 | isSettingsModalOpened: true, 669 | isStyleModalOpened: false, 670 | isAboutModalOpened: false, 671 | }; 672 | renderModalState(el, state, oldState); 673 | expect(el.settingsModal.style.display).toBe("none"); 674 | }); 675 | 676 | it("opens style modal if state changed to true", () => { 677 | const el = createModalElements(); 678 | const state = { 679 | isSettingsModalOpened: false, 680 | isStyleModalOpened: true, 681 | isAboutModalOpened: false, 682 | }; 683 | const oldState = { 684 | isSettingsModalOpened: false, 685 | isStyleModalOpened: false, 686 | isAboutModalOpened: false, 687 | }; 688 | renderModalState(el, state, oldState); 689 | expect(el.styleModal.style.display).toBe("flex"); 690 | }); 691 | 692 | it("closes style modal if state changed to false", () => { 693 | const el = createModalElements(); 694 | el.styleModal.style.display = "flex"; 695 | const state = { 696 | isSettingsModalOpened: false, 697 | isStyleModalOpened: false, 698 | isAboutModalOpened: false, 699 | }; 700 | const oldState = { 701 | isSettingsModalOpened: false, 702 | isStyleModalOpened: true, 703 | isAboutModalOpened: false, 704 | }; 705 | renderModalState(el, state, oldState); 706 | expect(el.styleModal.style.display).toBe("none"); 707 | }); 708 | 709 | it("opens about modal if state changed to true", () => { 710 | const el = createModalElements(); 711 | const state = { 712 | isSettingsModalOpened: false, 713 | isStyleModalOpened: false, 714 | isAboutModalOpened: true, 715 | }; 716 | const oldState = { 717 | isSettingsModalOpened: false, 718 | isStyleModalOpened: false, 719 | isAboutModalOpened: false, 720 | }; 721 | renderModalState(el, state, oldState); 722 | expect(el.aboutModal.style.display).toBe("flex"); 723 | }); 724 | 725 | it("closes about modal if state changed to false", () => { 726 | const el = createModalElements(); 727 | el.aboutModal.style.display = "flex"; 728 | const state = { 729 | isSettingsModalOpened: false, 730 | isStyleModalOpened: false, 731 | isAboutModalOpened: false, 732 | }; 733 | const oldState = { 734 | isSettingsModalOpened: false, 735 | isStyleModalOpened: false, 736 | isAboutModalOpened: true, 737 | }; 738 | renderModalState(el, state, oldState); 739 | expect(el.aboutModal.style.display).toBe("none"); 740 | }); 741 | }); 742 | 743 | describe("sanitizeHtml", () => { 744 | const sanitizeTestCases = [ 745 | { 746 | description: "removes script tags", 747 | input: "

hello

world

", 748 | expected: "

hello

world

", 749 | }, 750 | { 751 | description: "removes multiple script tags", 752 | input: "content", 753 | expected: "content", 754 | }, 755 | { 756 | description: "removes script tags with attributes", 757 | input: '', 758 | expected: "", 759 | }, 760 | { 761 | description: "removes inline onclick handlers", 762 | input: '

hello

', 763 | expected: "

hello

", 764 | }, 765 | { 766 | description: "removes inline onmouseover handlers", 767 | input: 'link', 768 | expected: 'link', 769 | }, 770 | { 771 | description: "removes multiple event handlers", 772 | input: 773 | '
content
', 774 | expected: "
content
", 775 | }, 776 | { 777 | description: "leaves class attributes intact", 778 | input: '

hello

', 779 | expected: '

hello

', 780 | }, 781 | { 782 | description: "leaves id attributes intact", 783 | input: '

hello

', 784 | expected: '

hello

', 785 | }, 786 | { 787 | description: "leaves multiple safe attributes intact", 788 | input: '

hello

', 789 | expected: '

hello

', 790 | }, 791 | { 792 | description: "handles empty string", 793 | input: "", 794 | expected: "", 795 | }, 796 | { 797 | description: "handles plain text without HTML", 798 | input: "plain text", 799 | expected: "plain text", 800 | }, 801 | { 802 | description: "handles malformed HTML", 803 | input: "

unclosed tag", 804 | expected: "

unclosed tag

", 805 | }, 806 | { 807 | description: "removes script-like content in attributes", 808 | input: '
content
', 809 | expected: '
content
', 810 | }, 811 | ]; 812 | 813 | sanitizeTestCases.forEach((testCase) => { 814 | it(testCase.description, () => { 815 | expect(sanitizeHtml(testCase.input)).toBe(testCase.expected); 816 | }); 817 | }); 818 | }); 819 | 820 | describe("renderMessages", () => { 821 | it("updates msg innerHTML if message changed", () => { 822 | const el = { 823 | msg: createMockDiv(), 824 | status: createMockDiv(), 825 | }; 826 | const state = { message: "

new message

", status: "connected" }; 827 | const oldState = { message: "

old message

", status: "connected" }; 828 | renderMessages(el, state, oldState); 829 | expect(el.msg.innerHTML).toBe("

new message

"); 830 | }); 831 | 832 | it("does not update msg if message not changed", () => { 833 | const el = { 834 | msg: createMockDiv(), 835 | status: createMockDiv(), 836 | }; 837 | el.msg.innerHTML = "

same

"; 838 | const state = { message: "

same

", status: "connected" }; 839 | const oldState = { message: "

same

", status: "connected" }; 840 | renderMessages(el, state, oldState); 841 | expect(el.msg.innerHTML).toBe("

same

"); 842 | }); 843 | 844 | it("updates status innerText if status changed", () => { 845 | const el = { 846 | msg: createMockDiv(), 847 | status: createMockDiv(), 848 | }; 849 | const state = { message: "

msg

", status: "disconnected" }; 850 | const oldState = { message: "

msg

", status: "connected" }; 851 | renderMessages(el, state, oldState); 852 | expect(el.status.innerText).toBe("disconnected"); 853 | }); 854 | }); 855 | 856 | describe("renderFullscreenMode", () => { 857 | it("requests fullscreen if state is true and not fullscreen", async () => { 858 | const mockDoc = createMockDocument(); 859 | const el = { doc: mockDoc }; 860 | const state = { isFullscreen: true }; 861 | await renderFullscreenMode(el, state); 862 | expect(mockDoc.documentElement.requestFullscreen).toHaveBeenCalled(); 863 | }); 864 | 865 | it("exits fullscreen if state is false and is fullscreen", async () => { 866 | const mockDoc = createMockDocument(); 867 | mockDoc.fullscreenElement = {}; 868 | const el = { doc: mockDoc }; 869 | const state = { isFullscreen: false }; 870 | await renderFullscreenMode(el, state); 871 | expect(mockDoc.exitFullscreen).toHaveBeenCalled(); 872 | }); 873 | 874 | it("does nothing if state matches current fullscreen", async () => { 875 | const mockDoc = createMockDocument(); 876 | const el = { doc: mockDoc }; 877 | const state = { isFullscreen: false }; 878 | await renderFullscreenMode(el, state); 879 | expect(mockDoc.documentElement.requestFullscreen).not.toHaveBeenCalled(); 880 | expect(mockDoc.exitFullscreen).not.toHaveBeenCalled(); 881 | }); 882 | }); 883 | 884 | describe("bindFullscreenMode", () => { 885 | it("binds fullscreenBtn click to toggle fullscreen", () => { 886 | const store = createMockStore({ isFullscreen: false }); 887 | const el = { 888 | doc: createMockDocument(), 889 | fullscreenBtn: createMockButton(), 890 | }; 891 | bindFullscreenMode(el, store); 892 | el.fullscreenBtn.click(); 893 | verifyStateUpdates(store.setState, [{ isFullscreen: true }]); 894 | }); 895 | 896 | it("binds fullscreenchange to update state", () => { 897 | const store = createMockStore({ isFullscreen: false }); 898 | const el = { 899 | doc: createMockDocument(), 900 | fullscreenBtn: createMockButton(), 901 | }; 902 | bindFullscreenMode(el, store); 903 | el.doc.fullscreenElement = el.doc.documentElement; 904 | el.doc.dispatchEvent(new Event("fullscreenchange")); 905 | verifyStateUpdates(store.setState, [{ isFullscreen: true }]); 906 | }); 907 | }); 908 | 909 | describe("renderState", () => { 910 | it("calls render functions with correct params", async () => { 911 | const mockDoc = createMockDocument(); 912 | const el = { 913 | doc: mockDoc, 914 | msg: createMockDiv(), 915 | status: createMockDiv(), 916 | settingsModal: createMockModal(), 917 | styleModal: createMockModal(), 918 | aboutModal: createMockModal(), 919 | bgColor: { value: "#000" }, 920 | textColor: { value: "#fff" }, 921 | fontFamily: { value: "Arial" }, 922 | fontSize: { value: "10" }, 923 | baudRate: { value: "9600" }, 924 | dataBits: { value: "8" }, 925 | parity: { value: "none" }, 926 | stopBits: { value: "1" }, 927 | encoding: { value: "utf-8" }, 928 | dtrSignal: { value: "" }, 929 | rtsSignal: { value: "" }, 930 | breakSignal: { value: "" }, 931 | }; 932 | const state = { 933 | bgColor: "#fff", 934 | textColor: "#000", 935 | fontFamily: "Times", 936 | fontSize: 12, 937 | baudRate: 115200, 938 | dataBits: 7, 939 | parity: "even", 940 | stopBits: 2, 941 | isFullscreen: false, 942 | isSettingsModalOpened: true, 943 | isStyleModalOpened: false, 944 | isAboutModalOpened: false, 945 | message: "

msg

", 946 | status: "connected", 947 | encoding: "utf-8", 948 | dtrSignal: null, 949 | rtsSignal: null, 950 | breakSignal: null, 951 | }; 952 | await renderState(el, state); 953 | // Check some changes 954 | expect(el.settingsModal.style.display).toBe("flex"); 955 | expect(el.bgColor.value).toBe("#fff"); 956 | expect(el.baudRate.value).toBe(115200); 957 | expect(el.msg.innerHTML).toBe("

msg

"); 958 | }); 959 | 960 | describe("appHtmlElementNames", () => { 961 | it("should export array of HTML element names", () => { 962 | expect(Array.isArray(appHtmlElementNames)).toBe(true); 963 | expect(appHtmlElementNames).toContain("doc"); 964 | expect(appHtmlElementNames).toContain("msg"); 965 | expect(appHtmlElementNames).toContain("status"); 966 | expect(appHtmlElementNames).toContain("settingsBtn"); 967 | expect(appHtmlElementNames).toContain("settingsClose"); 968 | expect(appHtmlElementNames).toContain("settingsModal"); 969 | expect(appHtmlElementNames).toContain("styleBtn"); 970 | expect(appHtmlElementNames).toContain("styleClose"); 971 | expect(appHtmlElementNames).toContain("styleModal"); 972 | expect(appHtmlElementNames).toContain("fullscreenBtn"); 973 | expect(appHtmlElementNames).toContain("aboutBtn"); 974 | expect(appHtmlElementNames).toContain("aboutModal"); 975 | expect(appHtmlElementNames).toContain("aboutClose"); 976 | expect(appHtmlElementNames).toContain("connectBtn"); 977 | expect(appHtmlElementNames).toContain("disconnectBtn"); 978 | expect(appHtmlElementNames).toContain("bgColor"); 979 | expect(appHtmlElementNames).toContain("textColor"); 980 | expect(appHtmlElementNames).toContain("fontFamily"); 981 | expect(appHtmlElementNames).toContain("fontSize"); 982 | expect(appHtmlElementNames).toContain("baudRate"); 983 | expect(appHtmlElementNames).toContain("dataBits"); 984 | expect(appHtmlElementNames).toContain("parity"); 985 | expect(appHtmlElementNames).toContain("stopBits"); 986 | expect(appHtmlElementNames).toContain("encoding"); 987 | expect(appHtmlElementNames).toContain("dtrSignal"); 988 | expect(appHtmlElementNames).toContain("rtsSignal"); 989 | expect(appHtmlElementNames).toContain("breakSignal"); 990 | }); 991 | }); 992 | }); 993 | 994 | describe("bindStateToDOM", () => { 995 | it("subscribes to store and binds all", () => { 996 | const store = new StateContainer({}); 997 | const el = { 998 | doc: createMockDocument(), 999 | msg: createMockDiv(), 1000 | status: createMockDiv(), 1001 | settingsBtn: createMockButton(), 1002 | settingsClose: createMockButton(), 1003 | settingsModal: createMockModal(), 1004 | styleBtn: createMockButton(), 1005 | styleClose: createMockButton(), 1006 | styleModal: createMockModal(), 1007 | aboutBtn: createMockButton(), 1008 | aboutModal: createMockModal(), 1009 | aboutClose: createMockButton(), 1010 | connectBtn: createMockButton(), 1011 | disconnectBtn: createMockButton(), 1012 | fullscreenBtn: createMockButton(), 1013 | bgColor: createMockInput(), 1014 | textColor: createMockInput(), 1015 | fontFamily: createMockInput(), 1016 | fontSize: createMockInput(), 1017 | baudRate: createMockInput(), 1018 | dataBits: createMockInput(), 1019 | parity: createMockInput(), 1020 | stopBits: createMockInput(), 1021 | encoding: createMockInput(), 1022 | dtrSignal: createMockInput(), 1023 | rtsSignal: createMockInput(), 1024 | breakSignal: createMockInput(), 1025 | }; 1026 | bindStateToDOM(el, store); 1027 | // Since subscribe is called, and render is subscribed, we can check by setting state and seeing if renderState was called indirectly 1028 | // But for simplicity, just check that no error is thrown 1029 | expect(store.getState()).toBeDefined(); 1030 | }); 1031 | }); 1032 | }); 1033 | --------------------------------------------------------------------------------