diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 763fb2ae..e3977c51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,9 +201,11 @@ jobs: with: node-version: 24 cache: 'npm' - cache-dependency-path: ./tests/end-to-end/package-lock.json + cache-dependency-path: ./package-lock.json - run: sudo apt-get update && sudo apt-get install -y unixodbc-dev - - run: npm ci && npx playwright install --with-deps --only-shell chromium + - run: npm ci + working-directory: . + - run: npx playwright install --with-deps --only-shell chromium # The browser tests exercise the official site, but they do not need a # separate Rust build. Reuse the binary compiled and tested above. - name: Download Linux binary diff --git a/.gitignore b/.gitignore index 0902ee96..99445994 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ xbed.sql node_modules/ sqlpage/sqlpage.db tests_uploads/ +/test-results/ +/playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2584a569..b7a5e5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - `sqlpage.send_mail` now supports rich email bodies. Use `body_html` for a caller-provided HTML alternative, or `body_md` to render Markdown as HTML. Messages retain a plain-text alternative; `body` may be omitted when `body_md` is used, and `body_md` and `body_html` cannot be combined. - Form `options_source` URLs now preserve existing query parameters when adding the dynamic `search` parameter. - Map coordinates that are not a pair of numbers, like a latitude with no longitude, are now reported in the browser console and skipped, instead of breaking the whole map. + - Stacked charts now stack their series by `x` value instead of by point order, which used to give wrong totals when a series was missing a point. + - `line`, `area`, `scatter`, `bubble` and `heatmap` charts with text labels on the x axis now line their series up by label, leaving a gap where a series skips one. - `column` charts now display vertical bars instead of nothing at all. - `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart. - Screen readers now announce the title of the modal component instead of an unnamed dialog. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47f9ff29..f2d86528 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,8 +109,8 @@ cargo run In a separate terminal, run the tests: ```bash -cd tests/end-to-end npm install +cd tests/end-to-end npx playwright install chromium npm run test ``` diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 93f03219..9e4ce254 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -664,7 +664,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('marker', 'Marker size', 'REAL', TRUE, TRUE), ('labels', 'Whether to show the data labels on the chart or not.', 'BOOLEAN', TRUE, TRUE), ('color', 'The name of a color in which to display the chart. If there are multiple series in the chart, this parameter can be repeated multiple times.', 'COLOR', TRUE, TRUE), - ('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others.', 'BOOLEAN', TRUE, TRUE), + ('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others. Series are aligned on their x values, and a series that has no value for a given x counts as zero there.', 'BOOLEAN', TRUE, TRUE), ('toolbar', 'Whether to display a toolbar at the top right of the chart, that offers downloading the data as CSV.', 'BOOLEAN', TRUE, TRUE), ('show_legend', 'Whether to display the legend listing all chart series. Defaults to true.', 'BOOLEAN', TRUE, TRUE), ('logarithmic', 'Display the y-axis in logarithmic scale.', 'BOOLEAN', TRUE, TRUE), @@ -717,6 +717,20 @@ INSERT INTO example(component, description, properties) VALUES '{"series": "Marketing", "x": 2022, "value": 15}, '|| '{"series": "Human resources", "x": 2021, "value": 30}, '|| '{"series": "Human resources", "x": 2022, "value": 55}]')), + ('chart', 'A stacked area chart, showing how each series contributes to a total. +The `stacked` property also works with the `line` and `bar` chart types. + +Series are aligned on their `x` values, and a series that has no value for a given `x` counts as zero there: +below, the graphics card draws no power outside of the render. +If a missing value does not mean zero in your data, make all the series share the same `x` values, +for instance by rounding timestamps to a common interval.', + json('[{"component":"chart", "title": "Power draw", "type": "area", "stacked": true, "time": true, "ytitle": "watts", "color": ["blue", "teal"], "marker": 4}, '|| + '{"series": "CPU", "x": "2024-03-01T10:00:00Z", "value": 45}, '|| + '{"series": "CPU", "x": "2024-03-01T10:15:00Z", "value": 52}, '|| + '{"series": "CPU", "x": "2024-03-01T10:30:00Z", "value": 48}, '|| + '{"series": "CPU", "x": "2024-03-01T10:45:00Z", "value": 44}, '|| + '{"series": "GPU", "x": "2024-03-01T10:15:00Z", "value": 120}, '|| + '{"series": "GPU", "x": "2024-03-01T10:30:00Z", "value": 140}]')), ('chart', 'A line chart with multiple series. One of the most common types of charts, often used to show trends over time. Also demonstrates the use of the `toolbar` attribute to allow the user to download the graph as an image or the data as a CSV file.', json('[{"component":"chart", "title": "Revenue", "ymin": 0, "toolbar": true}, diff --git a/package-lock.json b/package-lock.json index 194cd958..7d97b7b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,13 @@ "name": "sqlpage", "version": "1.0.0", "license": "MIT", + "workspaces": [ + "tests/end-to-end" + ], "devDependencies": { - "@biomejs/biome": "^2.5.4" + "@biomejs/biome": "^2.5.4", + "@types/node": "^26.2.0", + "typescript": "^7.0.2" } }, "node_modules/@biomejs/biome": { @@ -186,6 +191,472 @@ "engines": { "node": ">=14.21.3" } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/end-to-end": { + "resolved": "tests/end-to-end", + "link": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "tests/end-to-end": { + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.61.1" + } + }, + "tests/end-to-end/node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "tests/end-to-end/node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "tests/end-to-end/node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } } } } diff --git a/package.json b/package.json index 924c4e4d..79f9d613 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "sqlpage", "version": "1.0.0", "scripts": { - "test": "biome check .", + "test": "biome check . && npm run typecheck && node --test \"tests/js/**/*.spec.ts\"", + "typecheck": "tsc && tsc -p tests/js && tsc -p tests/end-to-end", "format": "biome format --write .", "fix": "biome check --fix --unsafe ." }, @@ -12,6 +13,11 @@ }, "license": "MIT", "devDependencies": { - "@biomejs/biome": "^2.5.4" - } + "@biomejs/biome": "^2.5.4", + "@types/node": "^26.2.0", + "typescript": "^7.0.2" + }, + "workspaces": [ + "tests/end-to-end" + ] } diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index e5ee4efe..fecc626f 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -2,7 +2,9 @@ sqlpage_chart = (() => { function sqlpage_chart() { - for (const c of document.querySelectorAll("[data-pre-init=chart]")) { + /** @type {NodeListOf} */ + const charts = document.querySelectorAll("[data-pre-init=chart]"); + for (const c of charts) { try { build_sqlpage_chart(c); } catch (e) { @@ -39,66 +41,66 @@ sqlpage_chart = (() => { const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; const APEXCHARTS_TYPE_ALIASES = { column: "bar" }; - /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */ + /** @typedef {number|string|Date} XValue */ + /** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */ + /** @typedef { { [name:string]: ChartSeries } } Series */ + + /** @param {XValue} x @returns {number|string} equal x values share a key */ + const x_key = (x) => (x instanceof Date ? x.getTime() : x); /** - * Aligns series data points by their x-axis categories, ensuring all series have data points - * for each unique category. Missing values are filled with zeros. - * Categories are ordered by their name. - * - * @example - * // Input series: - * const series = [ - * { name: "A", data: [{x: "X2", y: 10}, {x: "X3", y: 30}] }, - * { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}] } - * ]; - * - * // Output after align_categories (orderedCategories will be ["X1","X2", "X3"]): - * // [ - * // { name: "A", data: [{x: "X1", y: 0}, {x: "X2", y: 10}, {x: "X3", y: 30}] }, - * // { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}, {x: "X3", y: 0}] } - * // ] - * - * @param {(Series[string])[]} series - Array of series objects, each containing name and data points - * @returns {Series[string][]} Aligned series with consistent categories across all series + * @param {ChartSeries[]} series + * @returns {XValue[]} every x the series hold, in their own order where they + * agree and in ascending order where they diverge */ - function align_categories(series) { - const categoriesSet = new Set(); - const pointers = series.map((_) => 0); // Index of current data point in each series - const x_at = (series_idx) => - series[series_idx].data[pointers[series_idx]].x; - const series_idxs = series.flatMap((s, i) => (s.data.length ? i : [])); - while (series_idxs.length > 0) { - let idx_of_xmin = series_idxs[0]; - for (const series_idx of series_idxs) { - if (x_at(series_idx) < x_at(idx_of_xmin)) idx_of_xmin = series_idx; - } - - const new_category = x_at(idx_of_xmin); - if (!categoriesSet.has(new_category)) categoriesSet.add(new_category); - pointers[idx_of_xmin]++; - if (pointers[idx_of_xmin] >= series[idx_of_xmin].data.length) { - series_idxs.splice(series_idxs.indexOf(idx_of_xmin), 1); - } + function merged_x_values(series) { + const unread = series.map(({ data }) => data.map(({ x }) => x)); + const merged = new Map(); + while (unread.some((xs) => xs.length > 0)) { + const with_lowest_x = unread + .filter((xs) => xs.length > 0) + .reduce((a, b) => (b[0] < a[0] ? b : a)); + const x = with_lowest_x.shift(); + merged.set(x_key(x), x); } - // Create a map of category -> value for each series and rebuild - return series.map((s) => { - const valueMap = new Map(s.data.map((point) => [point.x, point.y])); + return [...merged.values()]; + } + + /** + * ApexCharts pairs points across series by index rather than by x, so a + * series that skips an x lands on the wrong one. Give every series the same + * amount of x values. + * + * @param {ChartSeries[]} series + * @param {number|null} y_when_missing what a series with no value at an x is + * worth there: zero to add nothing to a stack, null to leave a gap. + * @returns {ChartSeries[]} + */ + function align_series(series, y_when_missing) { + const all_x = merged_x_values(series); + return series.map(({ name, data }) => { + const by_x = new Map(data.map((point) => [x_key(point.x), point])); return { - name: s.name, - data: Array.from(categoriesSet, (category) => ({ - x: category, - y: valueMap.get(category) || 0, - })), + name, + data: all_x.map((x) => { + const point = by_x.get(x_key(x)); + return { ...point, x, y: point?.y ?? y_when_missing }; + }), }; }); } + // The unit tests load this file as a CommonJS module; browsers have no `module`. + if (typeof module !== "undefined") + module.exports = { align_series, merged_x_values }; + /** @param {HTMLElement} c */ function build_sqlpage_chart(c) { const [data_element] = c.getElementsByTagName("data"); const data = JSON.parse(data_element.textContent); - const chartContainer = c.querySelector(".chart"); + const chartContainer = /** @type {HTMLElement} */ ( + c.querySelector(".chart") + ); chartContainer.innerHTML = ""; const is_timeseries = !!data.time; const chart_type = @@ -135,11 +137,15 @@ sqlpage_chart = (() => { let labels; const categories = series.length > 0 && typeof series[0].data[0].x === "string"; + const y_is_a_range = chart_type === "rangeBar"; + const apexcharts_pairs_points_by_index = + is_stacked || (categories && !y_is_a_range); + const counts_a_skipped_x_as_zero = is_stacked || chart_type === "bar"; if (chart_type === "pie") { labels = data.points.map(([name, x, _y]) => x || name); series = data.points.map(([_name, _x, y]) => Number.parseFloat(y)); - } else if (categories && chart_type === "bar" && series.length > 1) - series = align_categories(series); + } else if (series.length > 1 && apexcharts_pairs_points_by_index) + series = align_series(series, counts_a_skipped_x_as_zero ? 0 : null); const options = { chart: { diff --git a/sqlpage/globals.d.ts b/sqlpage/globals.d.ts new file mode 100644 index 00000000..ca1c0593 --- /dev/null +++ b/sqlpage/globals.d.ts @@ -0,0 +1,41 @@ +// Names the browser bundle relies on at runtime rather than through an import: +// libraries build.rs inlines ahead of our own code, libraries loaded on demand, +// and the objects SQLPage's scripts hang off the window for each other. + +/** + * A library this project ships no type definitions for. Saying `unknown` + * instead would only move the guesswork to a cast at every call site. + */ +// biome-ignore lint/suspicious/noExplicitAny: that is what an untyped library is +type Untyped = any; + +/** Leaflet, loaded from a CDN by sqlpage_map when a page holds a map. */ +declare const L: Untyped; + +/** ApexCharts, inlined ahead of apexcharts.js by build.rs. */ +declare const ApexCharts: Untyped; + +/** chart_series.js, inlined ahead of apexcharts.js by build.rs. */ +declare const chart_series: typeof import("./chart_series"); + +/** Tom Select, inlined ahead of tomselect.js by build.rs. */ +declare const TomSelect: Untyped; + +/** apexcharts.js publishes its initialiser under this name. */ +declare var sqlpage_chart: () => void; + +/** + * Tabler's bundled Bootstrap, inlined ahead of sqlpage.js by build.rs. Its + * widgets are untyped: naming a few of them here would only claim more than + * this file knows. + */ +interface TablerBootstrap { + [widget: string]: Untyped; +} + +interface Window { + /** Every chart rendered on the page, in the order they were built. */ + charts?: unknown[]; + tabler: { bootstrap: TablerBootstrap }; + bootstrap?: TablerBootstrap; +} diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js index 6f2b0d43..e32667b8 100644 --- a/sqlpage/sqlpage.js +++ b/sqlpage/sqlpage.js @@ -1,20 +1,21 @@ /* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js */ -const nonce = document.currentScript.nonce; +const nonce = /** @type {HTMLScriptElement} */ (document.currentScript).nonce; function sqlpage_card() { - for (const c of document.querySelectorAll("[data-pre-init=card]")) { + /** @type {NodeListOf} */ + const cards = document.querySelectorAll("[data-pre-init=card]"); + for (const c of cards) { c.removeAttribute("data-pre-init"); + if (!c.dataset.embed) continue; const url = new URL(c.dataset.embed, window.location.href); url.searchParams.set("_sqlpage_embed", "1"); fetch(url) .then((res) => res.text()) .then((html) => { const body = c.querySelector(".card-content"); - body.innerHTML = html; + if (body) body.innerHTML = html; const spinner = c.querySelector(".card-loading-placeholder"); - if (spinner) { - spinner.parentNode.removeChild(spinner); - } + spinner?.remove(); const fragLoadedEvt = new CustomEvent("fragment-loaded", { bubbles: true, }); @@ -28,14 +29,18 @@ function setup_table(root_el) { /** @type {HTMLInputElement | null} */ const search_input = root_el.querySelector("input.search"); const table_el = root_el.querySelector("table"); - const sort_buttons = [...table_el.querySelectorAll("button.sort[data-sort]")]; + if (!table_el) return; + /** @type {NodeListOf} */ + const sort_button_els = table_el.querySelectorAll("button.sort[data-sort]"); + const sort_buttons = [...sort_button_els]; const item_parent = table_el.querySelector("tbody"); const has_sort = sort_buttons.length > 0; if (search_input || has_sort) { const items = table_parse_data(table_el, sort_buttons); if (search_input) setup_table_search_behavior(search_input, items); - if (has_sort) setup_sort_behavior(sort_buttons, items, item_parent); + if (has_sort && item_parent) + setup_sort_behavior(sort_buttons, items, item_parent); } // Change number format AFTER parsing and storing the sort keys @@ -44,7 +49,7 @@ function setup_table(root_el) { /** * @param {HTMLInputElement} search_input - * @param {Array<{el: HTMLElement, sort_keys: Array<{num: number, str: string}>}>} items + * @param {TableRow[]} items */ function setup_table_search_behavior(search_input, items) { function onSearch() { @@ -66,12 +71,15 @@ function setup_table_search_behavior(search_input, items) { /**@param {HTMLElement} table_el */ function apply_number_formatting(table_el) { + /** @type {NodeListOf} */ const header_els = table_el.querySelectorAll("thead > tr > th"); const col_types = [...header_els].map((el) => el.dataset.column_type); const col_rawnums = [...header_els].map((el) => !!el.dataset.raw_number); const col_money = [...header_els].map((el) => !!el.dataset.money); const number_format_locale = table_el.dataset.number_format_locale; - const number_format_digits = table_el.dataset.number_format_digits; + const number_format_digits = table_el.dataset.number_format_digits + ? Number(table_el.dataset.number_format_digits) + : undefined; const currency = table_el.dataset.currency; for (const tr_el of table_el.querySelectorAll("tbody tr, tfoot tr")) { @@ -95,20 +103,25 @@ function apply_number_formatting(table_el) { } } +/** @typedef { {el: HTMLElement, sort_keys: {num: number, str: string}[]} } TableRow */ + /** Prepare the table rows for sorting. * @param {HTMLElement} table_el * @param {HTMLElement[]} sort_buttons + * @returns {TableRow[]} */ function table_parse_data(table_el, sort_buttons) { const is_num = [...sort_buttons].map( - (btn_el) => btn_el.parentElement.dataset.column_type === "number", + (btn_el) => btn_el.parentElement?.dataset.column_type === "number", ); - return [...table_el.querySelectorAll("tbody tr")].map((tr_el) => { + /** @type {NodeListOf} */ + const row_els = table_el.querySelectorAll("tbody tr"); + return [...row_els].map((tr_el) => { const cells = tr_el.getElementsByTagName("td"); return { el: tr_el, sort_keys: sort_buttons.map((_btn_el, idx) => { - const str = cells[idx]?.textContent; + const str = cells[idx]?.textContent ?? ""; const num = is_num[idx] ? Number.parseFloat(str) : Number.NaN; return { num, str }; }), @@ -119,7 +132,7 @@ function table_parse_data(table_el, sort_buttons) { /** * Adds event listeners to the sort buttons to sort the table rows. * @param {HTMLElement[]} sort_buttons - * @param {HTMLElement[]} items + * @param {TableRow[]} items * @param {HTMLElement} item_parent */ function setup_sort_behavior(sort_buttons, items, item_parent) { @@ -147,7 +160,9 @@ function setup_sort_behavior(sort_buttons, items, item_parent) { } function sqlpage_table() { - for (const r of document.querySelectorAll("[data-pre-init=table]")) { + /** @type {NodeListOf} */ + const tables = document.querySelectorAll("[data-pre-init=table]"); + for (const r of tables) { r.removeAttribute("data-pre-init"); setup_table(r); } @@ -198,13 +213,14 @@ function sqlpage_map() { } function onLeafletLoad() { is_leaflet_loaded = true; + /** @type {NodeListOf} */ const maps = document.querySelectorAll("[data-pre-init=map]"); for (const m of maps) { const tile_source = m.dataset.tile_source; - const maxZoom = +m.dataset.max_zoom; + const maxZoom = Number(m.dataset.max_zoom); const attribution = m.dataset.attribution; const map = L.map(m, { attributionControl: !!attribution }); - const zoom = m.dataset.zoom; + const zoom = Number(m.dataset.zoom); const center = parseCoords(m.dataset.center); if (tile_source) L.tileLayer(tile_source, { attribution, maxZoom }).addTo(map); @@ -213,14 +229,14 @@ function sqlpage_map() { setTimeout(addMarker, 0, marker_elem, map); } setTimeout(() => { - if (center) map.setView(center, +zoom); + if (center) map.setView(center, zoom); else { const markerBounds = (m) => m.getLatLng ? m.getLatLng() : m.getBounds(); const bounds = map._sqlpage_markers.map(markerBounds); if (bounds.length > 0) map.fitBounds(bounds); - else map.setView([51.505, 10], +zoom); - if (zoom != null) map.setZoom(+zoom); + else map.setView([51.505, 10], zoom); + if (!Number.isNaN(zoom)) map.setZoom(zoom); } }, 100); m.removeAttribute("data-pre-init"); @@ -282,15 +298,16 @@ function sqlpage_map() { } function sqlpage_form() { + /** @type {NodeListOf} */ const file_inputs = document.querySelectorAll( "input[type=file][data-max-size]", ); for (const input of file_inputs) { - const max_size = +input.dataset.maxSize; - input.addEventListener("change", function () { + const max_size = Number(input.dataset.maxSize); + input.addEventListener("change", () => { input.classList.remove("is-invalid"); input.setCustomValidity(""); - for (const { size } of this.files) { + for (const { size } of input.files ?? []) { if (size > max_size) { input.classList.add("is-invalid"); return input.setCustomValidity( @@ -301,6 +318,7 @@ function sqlpage_form() { }); } + /** @type {NodeListOf} */ const auto_submit_forms = document.querySelectorAll("form[data-auto-submit]"); for (const form of auto_submit_forms) { form.addEventListener("change", () => form.submit()); @@ -314,11 +332,13 @@ function get_tabler_color(name) { } function load_scripts() { + /** @type {NodeListOf} */ const addjs = document.querySelectorAll("[data-sqlpage-js]"); const existing_scripts = new Set( [...document.querySelectorAll("script")].map((s) => s.src), ); for (const el of addjs) { + if (!el.dataset.sqlpageJs) continue; const js = new URL(el.dataset.sqlpageJs, window.location.href).href; if (existing_scripts.has(js)) continue; existing_scripts.add(js); @@ -375,11 +395,16 @@ function sqlpage_toast() { if (!Toast) return; const initialized_toasts = []; - for (const toast of document.querySelectorAll('[data-pre-init="toast"]')) { + /** @type {NodeListOf} */ + const toasts = document.querySelectorAll('[data-pre-init="toast"]'); + for (const toast of toasts) { const source_container = toast.parentElement; + if (!source_container) continue; const position = source_container.dataset.sqlpageToastPosition; - let container = document.querySelector( - `.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`, + let container = /** @type {HTMLElement | null} */ ( + document.querySelector( + `.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`, + ) ); if (!container) { container = source_container; diff --git a/sqlpage/tomselect.js b/sqlpage/tomselect.js index 1e0f40e1..9460bf3e 100644 --- a/sqlpage/tomselect.js +++ b/sqlpage/tomselect.js @@ -1,9 +1,9 @@ /* !include https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/js/tom-select.popular.min.js */ function sqlpage_select_dropdown() { - for (const s of document.querySelectorAll( - "[data-pre-init=select-dropdown]", - )) { + /** @type {NodeListOf} */ + const selects = document.querySelectorAll("[data-pre-init=select-dropdown]"); + for (const s of selects) { try { sqlpage_select_dropdown_individual(s); } catch (e) { diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/chart-component.spec.ts index 55724888..59d774a8 100644 --- a/tests/end-to-end/chart-component.spec.ts +++ b/tests/end-to-end/chart-component.spec.ts @@ -2,10 +2,17 @@ import { expect, type Page, test } from "@playwright/test"; const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; +type ChartPoint = { x: string | number | Date; y: number | null }; + declare global { interface Window { charts?: { - w: { config: { chart: { type: string; stacked: boolean } } }; + w: { + config: { + chart: { type: string; stacked: boolean }; + series: { name: string; data: ChartPoint[] }[]; + }; + }; }[]; } function sqlpage_chart(): void; @@ -24,6 +31,46 @@ const TASKS_OVER_TIME: Row[] = [ ["Build", "Bob", ["2024-03-04", "2024-03-09"]], ]; +const CPU_AT_EVERY_MINUTE: Row[] = [ + ["CPU", "2024-01-01T00:00:00Z", 10], + ["CPU", "2024-01-01T00:01:00Z", 20], + ["CPU", "2024-01-01T00:02:00Z", 30], + ["CPU", "2024-01-01T00:03:00Z", 40], +]; + +const GPU_ONLY_ONCE_THE_RENDER_STARTED: Row[] = [ + ["GPU", "2024-01-01T00:01:00Z", 50], + ["GPU", "2024-01-01T00:02:00Z", 50], + ["GPU", "2024-01-01T00:03:00Z", 50], +]; + +const A_IN_EVERY_QUARTER: Row[] = [ + ["A", "Q1", 1], + ["A", "Q2", 2], + ["A", "Q3", 3], +]; + +const B_MISSING_THE_FIRST_QUARTER: Row[] = [ + ["B", "Q2", 20], + ["B", "Q3", 30], +]; + +const A_QUARTERS_OUT_OF_ORDER: Row[] = [ + ["A", "Q3", 3], + ["A", "Q1", 1], + ["A", "Q2", 2], +]; + +const A_FROM_THE_SECOND_CATEGORY: Row[] = [ + ["A", "X2", 10], + ["A", "X3", 30], +]; + +const B_UNTIL_THE_SECOND_CATEGORY: Row[] = [ + ["B", "X1", 25], + ["B", "X2", 20], +]; + async function renderChart( page: Page, chart: Record, @@ -52,6 +99,25 @@ async function renderChart( console.error = reportError; const rendered = window.charts?.[before]; + const series = (rendered?.w.config.series ?? []).map((s) => ({ + name: s.name, + points: s.data.map((p) => [ + p.x instanceof Date ? p.x.toISOString() : p.x, + p.y, + ]), + })); + const drawnPerSeries = series.map(({ name }) => { + const markers = [ + ...container.querySelectorAll( + `.apexcharts-series[seriesName='${name}'] .apexcharts-series-markers > .apexcharts-marker`, + ), + ].map((m) => m.getBBox()); + return { + name, + lefts: markers.map((b) => Math.round(b.x)), + heights: markers.map((b) => Math.round(b.y)), + }; + }); const shapes = [ ...container.querySelectorAll( ".apexcharts-bar-area, .apexcharts-rangebar-area", @@ -65,6 +131,8 @@ async function renderChart( failures, type: rendered?.w.config.chart.type ?? null, stacked: rendered?.w.config.chart.stacked ?? null, + series, + drawnPerSeries, shapes, }; }, @@ -88,6 +156,189 @@ test("draws a column chart as a vertical bar chart", async ({ page }) => { expect(new Set(chart.shapes.map((s) => s.height)).size).toBe(3); }); +test("gives a stacked series a zero at every x it did not measure", async ({ + page, +}) => { + const chart = await renderChart( + page, + { type: "area", stacked: true, time: true }, + [...CPU_AT_EVERY_MINUTE, ...GPU_ONLY_ONCE_THE_RENDER_STARTED], + ); + + expect(chart.failures).toEqual([]); + expect(chart.series.map((s) => s.name)).toEqual(["CPU", "GPU"]); + expect(chart.series[1].points).toEqual([ + ["2024-01-01T00:00:00.000Z", 0], + ["2024-01-01T00:01:00.000Z", 50], + ["2024-01-01T00:02:00.000Z", 50], + ["2024-01-01T00:03:00.000Z", 50], + ]); +}); + +test("stacks a series above the one it shares an x with", async ({ page }) => { + const chart = await renderChart( + page, + { type: "area", stacked: true, time: true }, + [...CPU_AT_EVERY_MINUTE, ...GPU_ONLY_ONCE_THE_RENDER_STARTED], + ); + const [cpu, gpu] = chart.drawnPerSeries; + + expect(gpu.heights).toHaveLength(4); + expect(gpu.heights[0]).toBe(cpu.heights[0]); + expect(gpu.heights[1]).toBeLessThan(cpu.heights[1]); +}); + +test("keeps a lone series in the order the query returned it (#930)", async ({ + page, +}) => { + const chart = await renderChart( + page, + { type: "bar" }, + A_QUARTERS_OUT_OF_ORDER, + ); + + expect(chart.failures).toEqual([]); + expect(chart.series[0].points).toEqual([ + ["Q3", 3], + ["Q1", 1], + ["Q2", 2], + ]); +}); + +test("orders by name the categories two bar series do not share (#951)", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "bar" }, [ + ...A_FROM_THE_SECOND_CATEGORY, + ...B_UNTIL_THE_SECOND_CATEGORY, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[0].points).toEqual([ + ["X1", 0], + ["X2", 10], + ["X3", 30], + ]); + expect(chart.series[1].points).toEqual([ + ["X1", 25], + ["X2", 20], + ["X3", 0], + ]); +}); + +test("leaves the points of a chart that does not stack alone", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "area", time: true }, [ + ...CPU_AT_EVERY_MINUTE, + ...GPU_ONLY_ONCE_THE_RENDER_STARTED, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["2024-01-01T00:01:00.000Z", 50], + ["2024-01-01T00:02:00.000Z", 50], + ["2024-01-01T00:03:00.000Z", 50], + ]); +}); + +test("stacks a bar series on the categories it skipped", async ({ page }) => { + const chart = await renderChart(page, { type: "bar", stacked: true }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", 0], + ["Q2", 20], + ["Q3", 30], + ]); +}); + +test("lines an unstacked series up with the categories it skipped", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 20], + ["Q3", 30], + ]); +}); + +test("draws nothing where an unstacked series has no value", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + const [a, b] = chart.drawnPerSeries; + + expect(a.lefts).toHaveLength(3); + expect(b.lefts).toEqual(a.lefts.slice(1)); +}); + +test("keeps a measured zero apart from a missing value", async ({ page }) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ["B", "Q2", 0], + ["B", "Q3", 30], + ]); + const [a, b] = chart.drawnPerSeries; + + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 0], + ["Q3", 30], + ]); + expect(b.lefts).toEqual(a.lefts.slice(1)); +}); + +for (const type of ["area", "scatter", "heatmap"]) { + test(`lines up the series of a ${type} chart on a category axis`, async ({ + page, + }) => { + const chart = await renderChart(page, { type }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points.map((p) => p[0])).toEqual(["Q1", "Q2", "Q3"]); + }); +} + +test("keeps the bubble size of the points it lined up", async ({ page }) => { + const chart = await renderChart(page, { type: "bubble" }, [ + ["A", "Q1", 1, 30], + ["A", "Q2", 2, 30], + ["B", "Q2", 5, 70], + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 5], + ]); +}); + +test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { + const chart = await renderChart(page, { type: "rangeBar", time: true }, [ + ["Design", "Alice", ["2024-03-01", "2024-03-05"]], + ["Build", "Bob", ["2024-03-04", "2024-03-09"]], + ]); + + expect(chart.failures).toEqual([]); + expect(chart.shapes).toHaveLength(2); +}); + test("draws a rangeBar chart that asks to be stacked", async ({ page }) => { const chart = await renderChart( page, diff --git a/tests/end-to-end/globals.d.ts b/tests/end-to-end/globals.d.ts new file mode 100644 index 00000000..47ba8d7f --- /dev/null +++ b/tests/end-to-end/globals.d.ts @@ -0,0 +1,21 @@ +// What the browser tests reach for on the page: widgets that the scripts under +// test attach to elements at runtime, and libraries the page loads itself. + +interface TomSelectInstance { + getValue(): string | string[]; + setTextboxValue(value: string): void; + focus(): void; + options: Record; +} + +interface HTMLElement { + /** Attached by sqlpage_select_dropdown to every select it takes over. */ + tomselect?: TomSelectInstance; +} + +interface Window { + /** Tabler's bundled Bootstrap, inlined ahead of sqlpage.js by build.rs. */ + tabler?: { + bootstrap: { Toast: { getInstance(element: Element): unknown } }; + }; +} diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index d3a14de3..7c9325b4 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -1,14 +1,12 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Locator, type Page, test } from "@playwright/test"; const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; test("Open documentation", async ({ page }) => { await page.goto(BASE); - // Expect a title "to contain" a substring. await expect(page).toHaveTitle(/SQLPage.*/); - // open the submenu await page.getByText("Documentation", { exact: true }).first().click(); const components = ["form", "map", "chart", "button"]; for (const component of components) { @@ -35,6 +33,49 @@ test("chart supports hiding legend", async ({ page }) => { await expect(expensesChart.locator(".apexcharts-legend")).toBeHidden(); }); +const DATA_POINT_MARKERS = ".apexcharts-series-markers > .apexcharts-marker"; + +const chartCard = (page: Page, title: string) => + page.locator(".card", { has: page.getByRole("heading", { name: title }) }); + +const drawnPoints = (card: Locator, series: string) => + card + .locator(`.apexcharts-series[seriesName='${series}'] ${DATA_POINT_MARKERS}`) + .evaluateAll((markers) => + markers.map((m) => ({ + x: m.getAttribute("cx"), + y: m.getAttribute("cy"), + })), + ); + +test("stacked chart draws every series at every x of the chart", async ({ + page, +}) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + const powerChart = chartCard(page, "Power draw"); + await expect(powerChart.locator(".apexcharts-canvas")).toBeVisible(); + + const cpu = await drawnPoints(powerChart, "CPU"); + const gpu = await drawnPoints(powerChart, "GPU"); + + expect(cpu).toHaveLength(4); + expect(gpu.map((p) => p.x)).toEqual(cpu.map((p) => p.x)); +}); + +test("stacked chart raises a series only where it has a value", async ({ + page, +}) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + const powerChart = chartCard(page, "Power draw"); + await expect(powerChart.locator(".apexcharts-canvas")).toBeVisible(); + + const cpu = await drawnPoints(powerChart, "CPU"); + const gpu = await drawnPoints(powerChart, "GPU"); + + expect([gpu[0], gpu[3]]).toEqual([cpu[0], cpu[3]]); + expect(Number(gpu[1].y)).toBeLessThan(Number(cpu[1].y)); +}); + test("map", async ({ page }) => { await page.goto(`${BASE}/documentation.sql?component=map#component`); await expect(page.getByText("Loading...")).not.toBeVisible(); @@ -297,34 +338,46 @@ test("table filtering", async ({ page }) => { ).not.toBeVisible(); }); -test("table sorting", async ({ page }) => { +const sortableTable = async (page: Page) => { await page.goto(`${BASE}/documentation.sql?component=table`); - const tableSection = page.locator(".table-responsive", { + return page.locator(".table-responsive", { has: page.getByRole("cell", { name: "31456" }), }); +}; - // Test numeric sorting on id column - await tableSection.getByRole("button", { name: "id" }).click(); - let ids = await tableSection.locator("td.id").allInnerTexts(); - let numericIds = ids.map((id) => Number.parseInt(id, 10)); - const sortedIds = [...numericIds].sort((a, b) => a - b); - expect(numericIds).toEqual(sortedIds); - - // Test reverse sorting - await tableSection.getByRole("button", { name: "id" }).click(); - ids = await tableSection.locator("td.id").allInnerTexts(); - numericIds = ids.map((id) => Number.parseInt(id, 10)); - const reverseSortedIds = [...numericIds].sort((a, b) => b - a); - expect(numericIds).toEqual(reverseSortedIds); - - // Test amount in stock column sorting - await tableSection.getByRole("button", { name: "Amount in stock" }).click(); - const amounts = await tableSection.locator("td.Amount").allInnerTexts(); - const numericAmounts = amounts.map((amount) => - Number.parseInt(amount.replace(/[^0-9]/g, ""), 10), - ); - const sortedAmounts = [...numericAmounts].sort((a, b) => a - b); - expect(numericAmounts).toEqual(sortedAmounts); +const numbersInColumn = async (table: Locator, cells: string) => { + const texts = await table.locator(cells).allInnerTexts(); + expect(texts.length).toBeGreaterThan(1); + return texts.map((text) => Number.parseInt(text.replace(/[^0-9]/g, ""), 10)); +}; + +const ascending = (values: number[]) => [...values].sort((a, b) => a - b); + +test("table sorts a column when its header is clicked", async ({ page }) => { + const table = await sortableTable(page); + await table.getByRole("button", { name: "id" }).click(); + + const ids = await numbersInColumn(table, "td._col_id"); + expect(ids).toEqual(ascending(ids)); +}); + +test("table reverses the sort when the header is clicked again", async ({ + page, +}) => { + const table = await sortableTable(page); + await table.getByRole("button", { name: "id" }).click(); + await table.getByRole("button", { name: "id" }).click(); + + const ids = await numbersInColumn(table, "td._col_id"); + expect(ids).toEqual(ascending(ids).reverse()); +}); + +test("table sorts a column of formatted numbers by value", async ({ page }) => { + const table = await sortableTable(page); + await table.getByRole("button", { name: "Amount in stock" }).click(); + + const amounts = await numbersInColumn(table, "td._col_Amount_in_stock"); + expect(amounts).toEqual(ascending(amounts)); }); async function checkNoConsoleErrors(page: Page, component: string) { @@ -358,10 +411,10 @@ test("no console errors on card page", async ({ page }) => { }); test("CSP issues unique nonces per request", async ({ page }) => { - const csp1 = await (await page.goto(BASE)).headerValue( + const csp1 = await (await page.goto(BASE))?.headerValue( "content-security-policy", ); - const csp2 = await (await page.reload()).headerValue( + const csp2 = await (await page.reload())?.headerValue( "content-security-policy", ); @@ -371,24 +424,19 @@ test("CSP issues unique nonces per request", async ({ page }) => { test("form component documentation", async ({ page }) => { await page.goto(`${BASE}/component.sql?component=form`); - // Find the form that contains radio buttons for component selection const componentForm = page.locator("form", { has: page.getByRole("radio", { name: "Chart" }), }); - // the form should be visible await expect(componentForm).toBeVisible(); - // Check that "form" is the first and default selected option const mapRadio = componentForm.getByRole("radio", { name: "Map" }); await expect(mapRadio).toHaveValue("map"); await expect(mapRadio).toBeChecked(); - // Select "Chart" option and submit await componentForm.getByLabel("Chart").click({ force: true }); await componentForm.getByRole("button", { name: "Submit" }).click(); - // Verify we're on the chart documentation page await expect( page.getByRole("heading", { name: /chart/i, level: 1 }), ).toBeVisible(); @@ -412,14 +460,14 @@ test("form select combines initial options with remote search results", async ({ )?.tomselect, ); - const initialState = await select.evaluate((element) => { + const initialState = await select.evaluate((element: HTMLSelectElement) => { const tomselect = element.tomselect; return { - value: tomselect.getValue(), + value: tomselect?.getValue(), labels: Object.fromEntries( - Object.entries(tomselect.options).map(([value, option]) => [ + Object.entries(tomselect?.options ?? {}).map(([value, option]) => [ value, - option.label, + option?.label, ]), ), }; @@ -429,7 +477,9 @@ test("form select combines initial options with remote search results", async ({ labels: { form: "Form" }, }); - await select.evaluate((element) => element.tomselect.focus()); + await select.evaluate((element: HTMLSelectElement) => + element.tomselect?.focus(), + ); await page.keyboard.type("form"); await page.waitForResponse((response) => response @@ -440,9 +490,9 @@ test("form select combines initial options with remote search results", async ({ ); await expect .poll(async () => - select.evaluate((element) => ({ - value: element.tomselect.getValue(), - formLabel: element.tomselect.options.form?.label, + select.evaluate((element: HTMLSelectElement) => ({ + value: element.tomselect?.getValue(), + formLabel: element.tomselect?.options.form?.label, })), ) .toEqual({ @@ -450,7 +500,9 @@ test("form select combines initial options with remote search results", async ({ formLabel: "form", }); - await select.evaluate((element) => element.tomselect.setTextboxValue("")); + await select.evaluate((element: HTMLSelectElement) => + element.tomselect?.setTextboxValue(""), + ); await page.keyboard.type("map"); await page.waitForResponse((response) => response @@ -461,10 +513,10 @@ test("form select combines initial options with remote search results", async ({ ); await expect .poll(async () => - select.evaluate((element) => ({ - value: element.tomselect.getValue(), - formLabel: element.tomselect.options.form?.label, - mapLabel: element.tomselect.options.map?.label, + select.evaluate((element: HTMLSelectElement) => ({ + value: element.tomselect?.getValue(), + formLabel: element.tomselect?.options.form?.label, + mapLabel: element.tomselect?.options.map?.label, })), ) .toEqual({ @@ -476,14 +528,12 @@ test("form select combines initial options with remote search results", async ({ test("modal", async ({ page }) => { await page.goto(`${BASE}/documentation.sql?component=modal#component`); - // get the button that opens the modal const openButton = page.getByRole("button", { name: "Open a simple modal" }); await openButton.click(); const modal = page.getByRole("dialog", { name: "A modal box" }); await expect(modal).toBeVisible(); - // close the modal await page.keyboard.press("Escape"); await expect(modal).not.toBeVisible(); diff --git a/tests/end-to-end/package-lock.json b/tests/end-to-end/package-lock.json deleted file mode 100644 index 9e74e2c3..00000000 --- a/tests/end-to-end/package-lock.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "name": "end-to-end", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "end-to-end", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "@playwright/test": "^1.61.1", - "@types/node": "^24.13.3" - } - }, - "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/tests/end-to-end/package.json b/tests/end-to-end/package.json index 8d884e2b..4f8f47dd 100644 --- a/tests/end-to-end/package.json +++ b/tests/end-to-end/package.json @@ -11,7 +11,6 @@ "author": "", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.61.1", - "@types/node": "^24.13.3" + "@playwright/test": "^1.61.1" } } diff --git a/tests/end-to-end/tsconfig.json b/tests/end-to-end/tsconfig.json new file mode 100644 index 00000000..0a126078 --- /dev/null +++ b/tests/end-to-end/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"] + }, + "include": ["*.spec.ts", "*.config.ts", "globals.d.ts"] +} diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts new file mode 100644 index 00000000..ece9ca35 --- /dev/null +++ b/tests/js/chart_series.spec.ts @@ -0,0 +1,226 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const browser_globals_apexcharts_reads_when_it_loads = { + document: { body: null }, + add_init_fn: () => {}, +}; +Object.assign(globalThis, browser_globals_apexcharts_reads_when_it_loads); + +const require = createRequire(import.meta.url); +const { + align_series, + merged_x_values, +} = require("../../sqlpage/apexcharts.js"); + +const ADDS_NOTHING_TO_THE_STACK = 0; +const LEAVES_A_GAP = null; + +type XValue = number | string | Date; +type Point = { x: XValue; y: number | string | null; z?: number }; +type Series = { name: string; data: Point[] }; + +const series = (name: string, ...data: Point[]): Series => ({ name, data }); +const xs = (s: Series) => s.data.map((p) => p.x); + +test("merged_x_values keeps the order the series agree on", () => { + const merged = merged_x_values([ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }, { x: "Q3", y: 3 }), + series("b", { x: "Q1", y: 4 }, { x: "Q2", y: 5 }, { x: "Q3", y: 6 }), + ]); + + assert.deepEqual(merged, ["Q1", "Q2", "Q3"]); +}); + +test("merged_x_values orders by name the x values the series do not share (#951)", () => { + const merged = merged_x_values([ + series("a", { x: "X2", y: 10 }, { x: "X3", y: 30 }), + series("b", { x: "X1", y: 25 }, { x: "X2", y: 20 }), + ]); + + assert.deepEqual(merged, ["X1", "X2", "X3"]); +}); + +test("merged_x_values compares numbers as numbers, not as text", () => { + const merged = merged_x_values([ + series("a", { x: 2, y: 1 }, { x: 10, y: 1 }), + series("b", { x: 9, y: 1 }), + ]); + + assert.deepEqual(merged, [2, 9, 10]); +}); + +test("merged_x_values matches equal dates written as different objects", () => { + const merged = merged_x_values([ + series("a", { x: new Date("2024-03-01"), y: 1 }), + series("b", { x: new Date("2024-03-01"), y: 2 }), + ]); + + assert.equal(merged.length, 1); +}); + +test("merged_x_values ignores series that hold no points", () => { + const merged = merged_x_values([ + series("empty"), + series("a", { x: 1, y: 1 }), + ]); + + assert.deepEqual(merged, [1]); +}); + +test("align_series gives every series a point at every x (#727)", () => { + const [a, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(xs(a), ["Q1", "Q2"]); + assert.deepEqual(xs(b), ["Q1", "Q2"]); +}); + +test("align_series counts an x a stacked series skipped as zero (#727)", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: 0 }, + { x: "Q2", y: 3 }, + ]); +}); + +test("align_series leaves a gap where an unstacked series has no value", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + LEAVES_A_GAP, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: null }, + { x: "Q2", y: 3 }, + ]); +}); + +test("align_series keeps a measured zero apart from a missing value", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 0 }), + ], + LEAVES_A_GAP, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: null }, + { x: "Q2", y: 0 }, + ]); +}); + +test("align_series counts a null value as missing", () => { + const [, b] = align_series( + [series("a", { x: "Q1", y: 1 }), series("b", { x: "Q1", y: null })], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(b.data, [{ x: "Q1", y: 0 }]); +}); + +test("align_series keeps a blank value the series wrote", () => { + const [, b] = align_series( + [series("a", { x: "Q1", y: 1 }), series("b", { x: "Q1", y: "" })], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(b.data, [{ x: "Q1", y: "" }]); +}); + +test("align_series keeps a value the series wrote as text", () => { + const [, b] = align_series( + [series("a", { x: "Q1", y: 1 }), series("b", { x: "Q1", y: "7" })], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(b.data, [{ x: "Q1", y: "7" }]); +}); + +test("align_series keeps the third dimension of points it did not fill in", () => { + const [a] = align_series( + [series("a", { x: "Q1", y: 1, z: 42 }), series("b", { x: "Q2", y: 2 })], + LEAVES_A_GAP, + ); + + assert.equal(a.data[0].z, 42); +}); + +test("align_series matches dates by value rather than by identity", () => { + const [a, b] = align_series( + [ + series("a", { x: new Date("2024-03-01"), y: 1 }), + series("b", { x: new Date("2024-03-01"), y: 2 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.equal(a.data.length, 1); + assert.equal(b.data.length, 1); + assert.equal(b.data[0].y, 2); +}); + +test("align_series leaves a lone series in the order it arrived (#930)", () => { + const [only] = align_series( + [series("a", { x: "Q2", y: 1 }, { x: "Q1", y: 2 })], + LEAVES_A_GAP, + ); + + assert.deepEqual(only.data, [ + { x: "Q2", y: 1 }, + { x: "Q1", y: 2 }, + ]); +}); + +test("align_series returns series that already share every x unchanged", () => { + const given = [ + series("a", { x: "Q3", y: 1 }, { x: "Q1", y: 2 }, { x: "Q2", y: 3 }), + series("b", { x: "Q3", y: 4 }, { x: "Q1", y: 5 }, { x: "Q2", y: 6 }), + ]; + + assert.deepEqual(align_series(given, ADDS_NOTHING_TO_THE_STACK), given); +}); + +test("align_series does not mutate the series it is given", () => { + const given = [ + series("a", { x: "Q1", y: 1 }), + series("b", { x: "Q2", y: 2 }), + ]; + const before = JSON.stringify(given); + + align_series(given, LEAVES_A_GAP); + + assert.equal(JSON.stringify(given), before); +}); + +test("align_series keeps the last of duplicated x values", () => { + const [a] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q1", y: 9 }), + series("b", { x: "Q2", y: 2 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(a.data, [ + { x: "Q1", y: 9 }, + { x: "Q2", y: 0 }, + ]); +}); diff --git a/tests/js/package.json b/tests/js/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/tests/js/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/js/tsconfig.json b/tests/js/tsconfig.json new file mode 100644 index 00000000..752910d9 --- /dev/null +++ b/tests/js/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["es2022"], + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"] + }, + "include": ["*.spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..936b4362 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022", "dom", "dom.iterable"], + "module": "preserve", + "moduleResolution": "bundler", + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "noImplicitAny": false, + "types": [] + }, + "include": ["sqlpage/*.js", "sqlpage/globals.d.ts"] +}