diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 62e4f91a..953d65d7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -82,7 +82,7 @@ Two classes work together: **Pooling:** By default (`pooled` not set to `false`), sockets with the same URL share one underlying `WebSocket`. Use `pooled: false` for dedicated connections. The pool key becomes `${url}::${nanoid()}` for non-pooled. -**Reconnect:** Triggered on close (unless code `4999` = intentional). Immediate first try, then `reconnectInterval` (default 5s) up to `maxReconnectTries` (default 10). +**Reconnect:** Triggered on close (unless code `4999` = intentional, or the caller closed it: a socket closed while still connecting reports 1006) and when a handshake has not opened after `handshakeTimeout` (default 15s, `REQRAFT_WS_HANDSHAKE_TIMEOUT_MS`): a hung handshake sends no event at all, and Chromium queues every other socket to the same host and port behind it, so the socket is closed, its users get error + close 1006, and it is replaced. Immediate first try, then `reconnectInterval` (default 5s) up to `maxReconnectTries` (default 10). Each try first probes `system/pid`, aborted after `probeTimeout` (default 10s); the probe never gates the try. `remove()`/`close()` call off a pending try and its probe. **Heartbeat:** Sends `"ping"` every 3s; ignores `"pong"` in message handlers. @@ -127,7 +127,7 @@ Shared form field components built on ReQore, exported from `src/components/form | Component | Field Type | |---------------|-----------------------------------| | `Boolean` | Toggle/checkbox | -| `Color` | Color picker (`react-color`) | +| `Color` | Colour picker (`@uiw/react-color-saturation`, `-hue`, `-alpha`) with a preview, validated hex/RGB/opacity inputs, help and swatches | | `Cron` | Cron expression editor | | `File` | File upload (`react-dropzone`) | | `LongString` | Multi-line text | @@ -196,6 +196,14 @@ yarn build:test # Type-check without emit - Wrap unit tests with `ReqraftProvider` (or at minimum `QueryClientProvider`) - Use `mock-socket` for WebSocket testing - Run: `yarn test` (unit) · `yarn test:stories [file-substring]` (stories) · `yarn precheck` (lint + unit + `build:test:prod`) +- **The type catalogue comes from the server.** A data type's names (its display name, a label, and its + sentence form, its name inside a sentence), descriptions and conversions (`types_accepted`, `exact_match`) are + the Qorus server's (`QorusDataTypeCatalogue::Info`, served at `/system/qorus-type-info`): `defaultQorusTypes` + (`src/helpers/qorusTypeCatalogue.ts`) is reqraft's copy until the server's list arrives, and the condition and + type messages are worded with it. `__tests__/qorusTypeCatalogue.test.ts` compares it, field for field, with + `__tests__/fixtures/qorusTypeInfo.json`, a recorded answer of the server. When the server's catalogue changes, + record its answer again and fix the copy until the test passes: + `qrest -j system/qorus-type-info | yarn -s record:type-info` ### Storybook Stories & visual testing @@ -222,6 +230,45 @@ yarn build:test # Type-check without emit - CI reaches the live hq instance with a valid token, so a story that leaks a real request can pass in CI and hang locally. When a story passes in CI but times out on your machine, look for an unmocked request before anything else. + - The guard (`src/stories/realNetworkGuard.ts`) covers what an element loads as well: + an ``, `` or `srcset`, a frame, a script, a stylesheet, a medium. Anything + else loaded from another origin, such as a CSS `url()` or a font, is caught from the + browser's resource timing. A story image is a local asset or a `data:` URI, never a + server's URL. The template fixtures keep the URL the server gives an app's logo + (`/api/public/apps//logo`) and swap in a local copy + (`__fixtures__/templateFixtures.ts`, `src/stories/Data/qorusAppLogo.svg`), so import + the fixtures from there, not from the JSON. +- **Touch stories carry `tags: ['touch']`.** A story whose play calls `withTouchScreen` + (`src/stories/touchScreen.ts`), directly or through a helper such as + `playTapIsDrawnOnAPhone`, is tagged `touch`. `yarn test:stories` runs those stories in their + own Vitest project (`storybook-touch`), whose browser context is a phone from the start + (Playwright's `isMobile` and `hasTouch`, 390×844), and before the others; an untagged one fails + with the reason. Never turn Chromium's touch emulation on or off over the DevTools protocol: + turning it off leaves the page with the machine's hover and pointer, not the mouse Playwright + launched it with, and a Linux CI runner has none, so every later story in the page finds + `(hover: none)` (it never shows on a Mac, which always reports a mouse). + `.storybook/vitest.setup.ts` checks every story's browser is its project's: + `(hover: hover) and (pointer: fine)`, or on the phone `(hover: none) and (pointer: coarse)`. +- **Story clock — every story starts at the same instant.** Components write dates + relative to the clock ("3 hours ago"), so a story dated against the real clock captured + "1 month ago" in one build and "3 months ago" a few weeks later. + - `.storybook/preview.tsx` starts every story at `STORY_NOW` (30 June 2026, 12:00 UTC, + `src/stories/storyClock.ts`). Date story data from it with `storyMinutesAgo(n)`, + never with `Date.now()`, `new Date()` or a literal date of its own. + - The clock runs on in real time from `STORY_NOW`, because a stopped clock stops lodash's + `debounce` and `throttle` (ControlGroup's resize, Slate's selection sync). A story is + captured well inside the minute the shortest "… ago" takes to change. + - `parameters.mockdate` stops the clock at an instant, for a story whose every reading + must be the same (log lines stamped to the millisecond). `parameters.live` stories keep + the real clock. + - Every story also starts with an empty `ReqraftQueryClient`, as every unit test does: a + story must pass when it is opened on its own, not only after an earlier story fetched + what it needs. With the clock it is required too: an answer carries the time it was + fetched, and the clock takes each story back to `STORY_NOW`, so the previous story's + answers would read as fetched in the future and be served instead of its own mocks. + Anything else a page keeps from story to story with a timestamp needs the same reset. + - The capture browser runs in UTC and US English (`vitest.config.ts`), as CI does, so a + date written in the viewer's zone or language captures the same on every machine. - **Visual regression via Qlip** (not Chromatic): the story run captures snapshots through the `qlip` Vitest plugin. Upload is gated on `QLIP_UPLOAD_TOKEN` (`vitest.config.ts`), so **local runs capture but never upload** — PNGs land in `qlip/screenshots//stories/auto/.png` (gitignored); read the PNG to verify a visual change. CI (`.github/workflows/tests.yml`) sets the token and uploads a build for dashboard review. The full visual-change flow + the `qlip` MCP review skill live in the imported `stacks/frontend/FRONTEND.md`. ## Code Patterns & Conventions diff --git a/.github/consumer-imports.txt b/.github/consumer-imports.txt new file mode 100644 index 00000000..bce3530d --- /dev/null +++ b/.github/consumer-imports.txt @@ -0,0 +1,39 @@ +# The reqraft modules its consumers import. check-packed-files.mjs fails CI when one of them is not in the +# npm package (package.json `files`), so a deep import cannot break in a consumer after a release. +# +# qorus-ide (wip/646-phase-b, 2026-10-10). Regenerate from a qorus-ide checkout with: +# grep -rhoE "['\"]@qoretechnologies/reqraft(/[^'\"]*)?['\"]" src __tests__ .storybook | tr -d "'\"" | sort -u +@qoretechnologies/reqraft +@qoretechnologies/reqraft/dist/components/Description/markdownRendererContext +@qoretechnologies/reqraft/dist/components/form/engine/FormEngine +@qoretechnologies/reqraft/dist/components/form/engine/FormFieldsSkeleton +@qoretechnologies/reqraft/dist/components/form/engine/_structuredData/structuredData +@qoretechnologies/reqraft/dist/components/form/engine/optionActions +@qoretechnologies/reqraft/dist/components/form/engine/rendererTypes +@qoretechnologies/reqraft/dist/components/form/engine/rowMenuContext +@qoretechnologies/reqraft/dist/components/form/expressions/useRenderExpression +@qoretechnologies/reqraft/dist/components/form/fields/allowed-values/AllowedValues +@qoretechnologies/reqraft/dist/components/form/fields/binary/Binary +@qoretechnologies/reqraft/dist/components/form/fields/emittedValues +@qoretechnologies/reqraft/dist/components/form/fields/file/File +@qoretechnologies/reqraft/dist/components/form/fields/object/Object +@qoretechnologies/reqraft/dist/components/form/fields/select/SelectCollection +@qoretechnologies/reqraft/dist/components/form/fields/template/TemplateField +@qoretechnologies/reqraft/dist/contexts/FetchContext +@qoretechnologies/reqraft/dist/contexts/StorageContext +@qoretechnologies/reqraft/dist/helpers/allowedValues +@qoretechnologies/reqraft/dist/helpers/common +@qoretechnologies/reqraft/dist/helpers/optionUiTypes +@qoretechnologies/reqraft/dist/helpers/options +@qoretechnologies/reqraft/dist/helpers/providerValue +@qoretechnologies/reqraft/dist/helpers/singleLineString +@qoretechnologies/reqraft/dist/helpers/templateItems +@qoretechnologies/reqraft/dist/helpers/templateText +@qoretechnologies/reqraft/dist/helpers/templates +@qoretechnologies/reqraft/dist/helpers/validations +@qoretechnologies/reqraft/dist/hooks/useQorusTypes +@qoretechnologies/reqraft/dist/hooks/useTemplates +@qoretechnologies/reqraft/dist/providers/ReqraftProvider +@qoretechnologies/reqraft/dist/stores/currentUser/currentUser +@qoretechnologies/reqraft/dist/utils/fetch +@qoretechnologies/reqraft/dist/utils/lspClient diff --git a/.github/scripts/check-packed-files.mjs b/.github/scripts/check-packed-files.mjs new file mode 100644 index 00000000..86c1687e --- /dev/null +++ b/.github/scripts/check-packed-files.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// Checks that the npm package ships what its consumers load: run from the package root after `yarn build`. +// +// usage: node .github/scripts/check-packed-files.mjs [consumer imports file] +// +// The package's contents are package.json's `files` allowlist; before it, everything not in .npmignore was +// published, agent notes and design docs included. An allowlist can just as easily leave something out, and a +// consumer's deep import (qorus-ide imports `@qoretechnologies/reqraft/dist/components/...` directly) would then +// fail only in the consumer. So the packed file list (`npm pack --dry-run`) must hold: +// - every entry point package.json names (main, types, module, exports, bin); +// - every file the build wrote to dist/; +// - for each import in the consumer imports file, the module (`.js`) and its types (`.d.ts`). +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, posix } from 'node:path'; + +const normalize = (path) => posix.normalize(path.replace(/^\.\//, '')); + +const targetsOf = (field) => { + if (!field) return []; + if (typeof field === 'string') return [field]; + return Object.values(field).flatMap(targetsOf); +}; + +const builtFiles = (dir) => { + let entries; + try { + entries = readdirSync(dir); + } catch { + return []; + } + return entries.flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? + builtFiles(path) + : [normalize(path.split('\\').join('/'))]; + }); +}; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); +const [pack] = JSON.parse( + execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) +); +const packed = new Set(pack.files.map(({ path }) => normalize(path))); +const problems = []; + +for (const entry of [ + pkg.main, + pkg.types, + pkg.typings, + pkg.module, + ...targetsOf(pkg.exports), + ...targetsOf(pkg.bin), +]) { + if (entry && !packed.has(normalize(entry))) { + problems.push(`${entry}: an entry point in package.json, not in the package`); + } +} + +const built = builtFiles('dist'); +if (!built.length) { + problems.push('dist/ is empty: run `yarn build` first'); +} +for (const file of built) { + if (!packed.has(file)) { + problems.push(`${file}: built, not in the package`); + } +} + +const importsFile = process.argv[2]; +const imports = + importsFile ? + readFileSync(importsFile, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + : []; +for (const specifier of imports) { + if (specifier === pkg.name) { + continue; // the package root: main and types, checked above + } + if (!specifier.startsWith(`${pkg.name}/`)) { + problems.push(`${specifier}: not an import of ${pkg.name}`); + continue; + } + const path = normalize(specifier.slice(pkg.name.length + 1)); + const has = (...candidates) => candidates.some((candidate) => packed.has(candidate)); + if (!has(path, `${path}.js`, `${path}/index.js`)) { + problems.push(`${specifier}: imported by a consumer, no module for it in the package`); + } + if ( + !has(`${path}.d.ts`, `${path}/index.d.ts`) && + !path.endsWith('.js') && + !path.endsWith('.json') + ) { + problems.push(`${specifier}: imported by a consumer, no types for it in the package`); + } +} + +console.log( + `${pkg.name}@${pkg.version}: ${pack.entryCount} files, ${pack.size} bytes packed, ${pack.unpackedSize} unpacked; ` + + `${built.length} built files and ${imports.length} consumer imports checked` +); +if (problems.length) { + console.error(problems.map((problem) => `- ${problem}`).join('\n')); + process.exit(1); +} diff --git a/.github/scripts/published-version.sh b/.github/scripts/published-version.sh new file mode 100755 index 00000000..074f1710 --- /dev/null +++ b/.github/scripts/published-version.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# What a tests run may tell people to install: the version this commit published, and only once npm has it. +# +# publish.yml publishes in parallel with the tests, and it may publish nothing (package.json's version is +# already on npm), be superseded by a newer push, or fail. So the version is worked out by the script +# publish.yml uses (release-version.sh), then named only if npm answers with it. Run #482 told people to +# `yarn add @qoretechnologies/reqraft@v0.10.63-canary.…`, a version the tests job made up and never published. +# +# usage (from the repository root): +# published-version.sh push +# published-version.sh pull_request +# +# Prints GITHUB_OUTPUT lines: +# version= (only when npm has it) +# field= +# note= +set -eu + +here=$(dirname "$0") +event=${1:?usage: published-version.sh push | pull_request } +if [ "$event" = pull_request ]; then + computed=$("$here/release-version.sh" pull_request "${2:?}" "${3:?}") + runs=${4:?the publish runs url is required} +else + computed=$("$here/release-version.sh" "$event") + runs=${2:?the publish runs url is required} +fi +value() { printf '%s\n' "$computed" | sed -n "s/^$1=//p"; } +name=$(value name) +base=$(value base) +publish=$(value publish) +version=$(value version) + +if [ "$publish" = true ] && [ "$(npm view "$name@$version" version 2>/dev/null || true)" = "$version" ]; then + echo "version=$version" + echo "field=$version" + echo "note=, install the prerelease via \`yarn add $name@$version\`." +elif [ "$publish" = true ]; then + echo "field=none on npm yet" + echo "note=. No prerelease is on npm for this commit yet: see the [Publish run]($runs)." +else + echo "field=none ($base is already on npm)" + echo "note=. No prerelease was cut: package.json is at $base, which is already on npm." +fi diff --git a/.github/scripts/qlip-build-link.sh b/.github/scripts/qlip-build-link.sh new file mode 100755 index 00000000..a1f92fc5 --- /dev/null +++ b/.github/scripts/qlip-build-link.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# The qlip build a story run uploaded, for the Discord message's "Qlip visual tests" link. +# +# usage: qlip-build-link.sh +# +# qlip prints one line after uploading, e.g. +# [qlip] uploaded build 20261010-104435: 635 entries from 54 contexts (…) → https://qlip.qoretechnologies.com +# (older qlip: `uploaded build 20260629-123456 (12 fragments, …) → …`). The id is server-assigned, so it is read +# from that line. It ends at the first space or colon: taking everything up to the space kept the colon, and the +# link went to build `20261010-104435:`, which does not exist. +# +# Prints GITHUB_OUTPUT lines `build_id=` and `url=` (the dashboard's builds are global: /builds/), or nothing +# when no build was uploaded. +set -eu + +log=${1:?usage: qlip-build-link.sh } +# ANSI colours are stripped first: one between `[qlip]` and `uploaded` would hide the line +line="" +if [ -f "$log" ]; then + line=$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | grep -aE '\[qlip\] uploaded build ' | tail -1 || true) +fi +build_id=$(printf '%s' "$line" | sed -nE 's/.*uploaded build ([^ :]+).*/\1/p') +server=$(printf '%s' "$line" | sed -nE 's/.* → (https?:[^ ]+).*/\1/p') +server=${server:-https://qlip.qoretechnologies.com} +echo "Resolved qlip build='${build_id:-}' server='${server}'" >&2 +if [ -n "$build_id" ]; then + echo "build_id=$build_id" + echo "url=$server/builds/$build_id" +fi diff --git a/.github/scripts/release-version.sh b/.github/scripts/release-version.sh new file mode 100755 index 00000000..bc2c08cc --- /dev/null +++ b/.github/scripts/release-version.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# The version a workflow run publishes, worked out in ONE place: publish.yml publishes it, and tests.yml names +# it in Discord. Two copies of this logic drifted once already: tests.yml made up a canary that nothing +# published and told people to install it. +# +# usage (from the repository root): +# release-version.sh push +# release-version.sh pull_request +# +# Prints GITHUB_OUTPUT lines: +# name= +# base= +# publish=true|false +# version= (only when publish=true) +# +# push (develop): package.json's version as it is, which publish.yml's Publish-and-release releases under `beta`. +# +# pull_request: a prerelease OF THE VERSION THIS PR IS RELEASING, the one already in package.json (every PR is +# required to bump it), under the `pr` dist-tag: -pr..g. +# - Not the next patch: a prerelease of 0.10.35 for a PR releasing 0.10.34 previews a version nobody is merging, +# and sorts ABOVE the real 0.10.34, so a consumer pinned to the preview never moves forward on upgrade. +# Semver puts a prerelease below its release and above the previous one: +# 0.10.33 < 0.10.34-pr.92.gabc123 < 0.10.34 +# - The sha is prefixed with `g` (git-describe style) so an all-digit short sha can never become an invalid +# leading-zero numeric identifier. +# - None when is already on npm: this PR has not bumped, because it need not (a CI-, docs- or test-only +# change) or because it forgot. A prerelease of a released version sorts BELOW that release and would hand +# consumers something older than they have. Skipped, not failed: a PR that forgot its bump is caught at merge, +# where the release cannot publish over an existing version. +set -eu + +event=${1:?usage: release-version.sh push | pull_request } +name=$(node -p "require('./package.json').name") +base=$(node -p "require('./package.json').version.split('-')[0]") +echo "name=$name" +echo "base=$base" + +case "$event" in +push) + echo "publish=true" + echo "version=$(node -p "require('./package.json').version")" + ;; +pull_request) + pr=${2:?the PR number is required} + sha=$(printf '%s' "${3:?the head sha is required}" | cut -c1-7) + if npm view "$name@$base" version >/dev/null 2>&1; then + echo "publish=false" + else + echo "publish=true" + echo "version=$base-pr.$pr.g$sha" + fi + ;; +*) + echo "release-version.sh: unknown event '$event'" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2c847c32..029a144f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -49,7 +49,7 @@ jobs: utcOffset: "+01:00" # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it # Checkout the repo - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} @@ -173,55 +173,29 @@ jobs: - name: Build run: yarn build - # Stamp a prerelease OF THE VERSION THIS PR IS RELEASING — the one already in - # package.json, which every PR is required to bump. - # - # This used to increment the patch first, so a PR targeting 0.10.34 published - # 0.10.35-pr.N. That is wrong twice over: the prerelease previews a version that - # is not the one being merged, and it sorts ABOVE the release it precedes, so - # the real 0.10.34 lands *below* its own preview and a consumer pinned to the - # prerelease never moves forward on `yarn upgrade`. - # - # Taking package.json as-is gives the ordering the old code was reaching for - # anyway. Semver puts a prerelease below its release and above the previous one: - # - # 0.10.33 < 0.10.34-pr.92.gabc123 < 0.10.34 - # - # so it already sorts above the current release without inventing a version - # nobody intends to ship. - # - # The sha identifier is prefixed with `g` (git-describe style) so an all-digit - # short sha can never become an invalid leading-zero numeric identifier. + # Stamp a prerelease of the version this PR is releasing, `-pr..g`, or none when + # package.json's version is already on npm. The rules and why are in release-version.sh, which tests.yml + # also uses to name this version in Discord, so the two cannot drift. # # Ported from reqore #635, which fixed the identical step there; the two repos # share this workflow shape, so they must share the correction. - name: Stamp prerelease version id: version + env: + PR: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} run: | - BASE=$(node -p "require('./package.json').version.split('-')[0]") - SHA=$(printf '%s' "${{ github.event.pull_request.head.sha }}" | cut -c1-7) - VER="${BASE}-pr.${{ github.event.pull_request.number }}.g${SHA}" + OUT=$(.github/scripts/release-version.sh pull_request "$PR" "$SHA") + echo "$OUT" >> "$GITHUB_OUTPUT" + BASE=$(printf '%s\n' "$OUT" | sed -n 's/^base=//p') + VER=$(printf '%s\n' "$OUT" | sed -n 's/^version=//p') - # Publish only when package.json names a version that is not on npm yet. - # - # If it is already published, this PR has not bumped — either because it does - # not need to (a CI-, docs- or test-only change) or because it forgot. Cutting - # a prerelease of an already-released version would sort BELOW that release - # and hand consumers something older than they have, so it is skipped rather - # than published. - # - # Skipped, not failed: a workflow-only PR has nothing to preview and should - # not go red for it. A PR that genuinely forgot its bump is still caught at - # merge, where the release job cannot publish over an existing version. - if npm view "$(node -p "require('./package.json').name")@${BASE}" version >/dev/null 2>&1; then + if [ -z "$VER" ]; then echo "::notice::package.json is at ${BASE}, which is already published — no prerelease cut. Bump the version if this PR is meant to ship one." - echo "publish=false" >> "$GITHUB_OUTPUT" exit 0 fi npm pkg set version="$VER" - echo "version=$VER" >> "$GITHUB_OUTPUT" - echo "publish=true" >> "$GITHUB_OUTPUT" echo "Publishing $VER" - name: Publish prerelease to npm (pr tag) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3cde5b55..a4dbf519 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,7 +38,7 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Get repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -76,7 +76,7 @@ jobs: exit 1 fi if [ "$DELTA" -gt 1 ]; then - echo "::error::package.json was bumped $DELTA times on this branch ($FROM -> $TO). Bump exactly once per PR — set it to $(echo $FROM | awk -F. '{print $1"."$2"."$3+1}') unless that is taken. See AGENTS.md, Versioning." + echo "::error::package.json was bumped $DELTA times on this branch ($FROM -> $TO). Bump exactly once per PR — set it to $(echo "$FROM" | awk -F. '{print $1"."$2"."$3+1}') unless that is taken. See AGENTS.md, Versioning." exit 1 fi echo "bumped ${DELTA}x — ok" @@ -92,7 +92,7 @@ jobs: msg=${msg//\"/\\\"} echo "HEAD_COMMIT_MESSAGE=$msg" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 22.19.0 @@ -107,10 +107,6 @@ jobs: with: args: "<@820316067921395752>" - - name: Save release version to outputs - id: save_release_version - run: echo ::set-output name=version::$(node -pe "require('./package.json').version") - - name: Installing modules id: install_modules run: | @@ -124,6 +120,11 @@ jobs: run: | yarn build + # The package is package.json's `files` allowlist. Fails when the packed file list is missing an entry + # point, a built file or a module a consumer imports (.github/consumer-imports.txt). + - name: Check the package ships what consumers import + run: node .github/scripts/check-packed-files.mjs .github/consumer-imports.txt + - name: Install Playwright Chromium id: install_playwright run: yarn playwright install --with-deps chromium @@ -134,52 +135,51 @@ jobs: set -o pipefail yarn test:stories 2>&1 | tee qlip-output.log - # qlip prints one line after uploading visual snapshots, e.g.: - # [qlip] uploaded build 20260629-123456 (12 fragments, …) → https://qlip.qoretechnologies.com - # The buildId is server-assigned, so we scrape it here and expose the - # dashboard link for the Discord message. Runs even on test failure (qlip - # captures-on-error, so the build still exists). + # The qlip build this run uploaded, for the Discord message's link (qlip-build-link.sh reads it from + # qlip's upload line). Runs even on test failure (qlip captures-on-error, so the build still exists). - name: Resolve qlip build link id: qlip if: always() + run: .github/scripts/qlip-build-link.sh qlip-output.log | tee -a "$GITHUB_OUTPUT" + + # The version the Discord messages name: the one this commit published, and only once npm has it. + # publish.yml publishes in parallel with this job and can publish nothing, be superseded or fail, + # so a version is never named before npm answers with it (see published-version.sh). This replaced + # a step that bumped package.json to a canary here, which nothing published, and told people to + # install it. + - name: Find the version this commit published + id: published + if: always() + env: + EVENT: ${{ github.event_name }} + PR: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} + RUNS: ${{ github.server_url }}/${{ github.repository }}/actions/workflows/publish.yml?query=branch%3A${{ github.head_ref || github.ref_name }} run: | - # Strip any ANSI colour codes so the scraped URL/id stay clean. - LINE=$(grep -aE '\[qlip\] uploaded build ' qlip-output.log | tail -1 | sed -E 's/\x1b\[[0-9;]*m//g' || true) - BUILD_ID=$(printf '%s' "$LINE" | sed -nE 's/.*uploaded build ([^ ]+) .*/\1/p') - SERVER=$(printf '%s' "$LINE" | sed -nE 's/.* → (https?:[^ ]+).*/\1/p') - SERVER=${SERVER:-https://qlip.qoretechnologies.com} - echo "Resolved qlip build='${BUILD_ID:-}' server='${SERVER}'" - if [ -n "$BUILD_ID" ]; then - echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT" - # Dashboard builds are global (no project): /builds/. - echo "url=$SERVER/builds/$BUILD_ID" >> "$GITHUB_OUTPUT" + if [ "$EVENT" = pull_request ]; then + .github/scripts/published-version.sh pull_request "$PR" "$SHA" "$RUNS" | tee -a "$GITHUB_OUTPUT" + else + .github/scripts/published-version.sh push "$RUNS" | tee -a "$GITHUB_OUTPUT" fi - - name: Bump version with timestamp and canary tag - id: bump - run: | - TIMESTAMP=$(date +"%Y%m%d%H%M%S") - NEW_VERSION=$(npm version prerelease --preid=canary.$TIMESTAMP --no-git-tag-version) - echo "New version: $NEW_VERSION" - echo "::set-output name=version::$NEW_VERSION" - # Notify Discord if Success - name: Discord notification success env: DISCORD_WEBHOOK: ${{secrets.DISCORD_WEBHOOK}} - DISCORD_EMBEDS: '[{ "author": { "icon_url": "https://avatars.githubusercontent.com/${{ github.actor }}", "name": "${{ github.actor }}", "url": "https://github.com/${{github.actor}}" },"url": "https://github.com/${{github.repository}}/commit/${{github.sha}}","fields": [{ "name": "Commit Message", "value": "${{ steps.get_head_commit_message.outputs.HEAD_COMMIT_MESSAGE }}"},{ "name": "Repository", "value": "[${{github.event.repository.name}}](${{github.event.repository.clone_url}})", "inline": true },{ "name": "Branch", "value": "[${{github.ref_name}}](https://github.com/${{github.repository}}/pull/${{github.ref_name}})", "inline": true },{ "name": "Event", "value": "${{github.event_name}}", "inline": true },{ "name": "Author", "value": "[${{github.actor}}](https://github.com/${{github.actor}})", "inline": true },{ "name": "Job", "value": "${{github.job}}", "inline": true},{ "name": "Version", "value": "v${{ steps.save_release_version.outputs.version }}", "inline": true},{ "name": "", "value": "🤖 [Github Action](https://github.com/${{ github.repository}}/actions/runs/${{ github.run_id}}) 🔍 [Qlip visual tests](${{ steps.qlip.outputs.url }})"}],"color":65280,"footer":{"text": "Published at ${{steps.current_time.outputs.readableTime}}"}}]' + DISCORD_EMBEDS: '[{ "author": { "icon_url": "https://avatars.githubusercontent.com/${{ github.actor }}", "name": "${{ github.actor }}", "url": "https://github.com/${{github.actor}}" },"url": "https://github.com/${{github.repository}}/commit/${{github.sha}}","fields": [{ "name": "Commit Message", "value": "${{ steps.get_head_commit_message.outputs.HEAD_COMMIT_MESSAGE }}"},{ "name": "Repository", "value": "[${{github.event.repository.name}}](${{github.event.repository.clone_url}})", "inline": true },{ "name": "Branch", "value": "[${{github.ref_name}}](https://github.com/${{github.repository}}/pull/${{github.ref_name}})", "inline": true },{ "name": "Event", "value": "${{github.event_name}}", "inline": true },{ "name": "Author", "value": "[${{github.actor}}](https://github.com/${{github.actor}})", "inline": true },{ "name": "Job", "value": "${{github.job}}", "inline": true},{ "name": "Version", "value": "${{ steps.published.outputs.field }}", "inline": true},{ "name": "", "value": "🤖 [Github Action](https://github.com/${{ github.repository}}/actions/runs/${{ github.run_id}}) 🔍 [Qlip visual tests](${{ steps.qlip.outputs.url }})"}],"color":65280,"footer":{"text": "Published at ${{steps.current_time.outputs.readableTime}}"}}]' DISCORD_AVATAR: "https://github.com/qoretechnologies/qorus-vscode/blob/95ad81fa8654fe83bb41f68f4de42ae088298e59/public/q-symbol-small.png?raw=true" uses: Ilshidur/action-discord@0.3.2 with: - # A push to develop has no PR to link and no canary to install. - args: "${{ github.event_name == 'pull_request' && format(':white_check_mark: Tests run #{0} for [PR]({1}) triggered by {2} *succeeded*, install the canary version via `yarn add @qoretechnologies/reqraft@{3}`.', github.run_number, github.event.pull_request.html_url, github.triggering_actor, steps.bump.outputs.version) || format(':white_check_mark: Tests run #{0} for [develop @ {1}]({2}/{3}/commit/{4}) triggered by {5} *succeeded*.', github.run_number, github.sha, github.server_url, github.repository, github.sha, github.triggering_actor) }}" + # A push to develop has no PR to link and no prerelease to install. The PR's note names the + # prerelease npm has, or says why there is none. + args: "${{ github.event_name == 'pull_request' && format(':white_check_mark: Tests run #{0} for [PR]({1}) triggered by {2} *succeeded*{3}', github.run_number, github.event.pull_request.html_url, github.triggering_actor, steps.published.outputs.note) || format(':white_check_mark: Tests run #{0} for [develop @ {1}]({2}/{3}/commit/{4}) triggered by {5} *succeeded*.', github.run_number, github.sha, github.server_url, github.repository, github.sha, github.triggering_actor) }}" if: success() # Notify discord if failure - name: Discord notification failure env: DISCORD_WEBHOOK: ${{secrets.DISCORD_WEBHOOK}} - DISCORD_EMBEDS: '[{ "author": { "icon_url": "https://avatars.githubusercontent.com/${{ github.actor }}", "name": "${{ github.actor }}", "url": "https://github.com/${{github.actor}}" },"url": "https://github.com/${{github.repository}}/commit/${{github.sha}}","fields": [{ "name": "Commit Message", "value": "${{ steps.get_head_commit_message.outputs.HEAD_COMMIT_MESSAGE }}"},{ "name": "Repository", "value": "[${{github.event.repository.name}}](${{github.event.repository.clone_url}})", "inline": true },{ "name": "Branch", "value": "[${{github.ref_name}}](https://github.com/${{github.repository}}/pull/${{github.ref_name}})", "inline": true },{ "name": "Event", "value": "${{github.event_name}}", "inline": true },{ "name": "Author", "value": "[${{github.actor}}](https://github.com/${{github.actor}})", "inline": true },{ "name": "Job", "value": "${{github.job}}", "inline": true },{ "name": "Version", "value": "v${{ steps.save_release_version.outputs.version }}", "inline": true},{ "name": "", "value": "🤖 [Github Action](https://github.com/${{ github.repository}}/actions/runs/${{ github.run_id}}) 🔍 [Qlip visual tests](${{ steps.qlip.outputs.url }})"}],"color":16711680,"footer":{"text": "Published at ${{steps.current_time.outputs.readableTime}}"}}]' + DISCORD_EMBEDS: '[{ "author": { "icon_url": "https://avatars.githubusercontent.com/${{ github.actor }}", "name": "${{ github.actor }}", "url": "https://github.com/${{github.actor}}" },"url": "https://github.com/${{github.repository}}/commit/${{github.sha}}","fields": [{ "name": "Commit Message", "value": "${{ steps.get_head_commit_message.outputs.HEAD_COMMIT_MESSAGE }}"},{ "name": "Repository", "value": "[${{github.event.repository.name}}](${{github.event.repository.clone_url}})", "inline": true },{ "name": "Branch", "value": "[${{github.ref_name}}](https://github.com/${{github.repository}}/pull/${{github.ref_name}})", "inline": true },{ "name": "Event", "value": "${{github.event_name}}", "inline": true },{ "name": "Author", "value": "[${{github.actor}}](https://github.com/${{github.actor}})", "inline": true },{ "name": "Job", "value": "${{github.job}}", "inline": true },{ "name": "Version", "value": "${{ steps.published.outputs.field }}", "inline": true},{ "name": "", "value": "🤖 [Github Action](https://github.com/${{ github.repository}}/actions/runs/${{ github.run_id}}) 🔍 [Qlip visual tests](${{ steps.qlip.outputs.url }})"}],"color":16711680,"footer":{"text": "Published at ${{steps.current_time.outputs.readableTime}}"}}]' DISCORD_AVATAR: "https://github.com/qoretechnologies/qorus-vscode/blob/95ad81fa8654fe83bb41f68f4de42ae088298e59/public/q-symbol-small.png?raw=true" uses: Ilshidur/action-discord@0.3.2 with: diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 3a4a70e7..ffcf2788 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,13 +1,15 @@ -import withMockdate from '@netsells/storybook-mockdate'; import { ReqoreContent, ReqoreLayoutContent, ReqoreModalsWrapper, ReqoreUIProvider, } from '@qoretechnologies/reqore'; -import { initializeReqraft } from '../src'; +import { initializeReqraft, ReqraftQueryClient } from '../src'; import { startDpqlMockLsp } from '../src/components/form/expressions/dpqlMockLsp'; +import { _resetServerQorusTypesForTests } from '../src/helpers/qorusTypeCatalogue'; +import { _resetExpressionTypeTransportForTests } from '../src/components/form/expressions/useExpressionType'; import { _resetRenderExpressionTransportForTests } from '../src/components/form/expressions/useRenderExpression'; +import { IStoryClockParameters, startStoryClock } from '../src/stories/storyClock'; import { GLOBAL_STORY_MOCK_DATA, STORY_QORUS_INSTANCE } from '../src/stories/storyNetwork'; import { fetchConfig } from '../src/utils/fetch'; import { _resetSharedLspConnectionsForTests } from '../src/utils/lspClient'; @@ -54,15 +56,49 @@ export const parameters = { // one without a server would show only its loading state. A story that needs // different answers starts its own server on the URL, which replaces this one; // a `live` story reaches the real server instead. -export const beforeEach = ({ parameters }: { parameters?: { live?: boolean } }) => { - _resetRenderExpressionTransportForTests(); - _resetSharedLspConnectionsForTests(); - return parameters?.live ? undefined : startDpqlMockLsp(); +// +// ...and every story starts at the same instant, `STORY_NOW`, so a date it +// shows as "3 hours ago" says so on every day it is captured (see +// src/stories/storyClock.ts; `parameters.mockdate` stops the clock, a `live` +// story keeps the real one). +// +// ...and with an empty query cache, as every unit test does. The cache lives as +// long as the page, so a story was served whatever an earlier one had fetched: +// four FormEngine plays only passed after an earlier story had loaded the type +// catalogue, and failed when the story was opened on its own. The clock makes it +// worse: an answer carries the time it was fetched, and the clock takes every +// story back to `STORY_NOW`, so an answer fetched a few seconds into the previous +// story would read as fetched in the future, fresh even to a request that never +// caches, and be served in place of this story's own mocks (useStorage's stored +// value, useFetch's 409). Emptied before anything mounts: a request the previous +// story left in flight is cancelled with nothing left waiting for it. +export const beforeEach = ({ parameters }: { parameters?: IStoryClockParameters }) => { + ReqraftQueryClient.clear(); + // and without the type catalogue an earlier story's server answered with (`getQorusTypes`) + _resetServerQorusTypesForTests(); + const restoreClock = startStoryClock(parameters); + try { + _resetRenderExpressionTransportForTests(); + // the expression type client too, and the types it cached: a story's server says what its expressions give + _resetExpressionTypeTransportForTests(); + _resetSharedLspConnectionsForTests(); + const stopLsp = parameters?.live ? undefined : startDpqlMockLsp(); + return () => { + try { + stopLsp?.(); + } finally { + restoreClock(); + } + }; + } catch (error) { + restoreClock(); + throw error; + } }; export const argTypes = { mainTheme: { - control: 'color', + control: 'color' as const, description: 'The overall theme for all ReQore components', name: 'Main Theme', defaultValue: '#333333', @@ -76,7 +112,6 @@ export const argTypes = { }; export const decorators = [ - withMockdate, (Story, context) => { // Live stories (`parameters: { live: true }`) hit this instance. Override // it to point at your own Qorus for live data, e.g. diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts index 03acd8a0..95d60629 100644 --- a/.storybook/vitest.setup.ts +++ b/.storybook/vitest.setup.ts @@ -1,6 +1,9 @@ import { setProjectAnnotations } from '@storybook/react-vite'; import { createElement } from 'react'; -import { beforeAll } from 'vitest'; +import { afterEach, beforeAll, beforeEach, expect } from 'vitest'; +import { server } from 'vitest/browser'; +import { guardRealNetwork } from '../src/stories/realNetworkGuard'; +import { TOUCH_STORY_PROJECT } from '../src/stories/touchScreen'; import * as previewAnnotations from './preview'; // storybook-addon-mock drives `parameters.mockData` through `faker`, which // patches window.fetch/XHR. Its own `withRoundTrip` decorator only (re)configures @@ -42,6 +45,18 @@ const annotations = setProjectAnnotations([ beforeAll(annotations.beforeAll); +// No story reaches a real server: what no mock answers is blocked, and the story fails naming it (see +// realNetworkGuard). Installed after `faker` above, whose fall-through for unmocked requests it wraps. +const network = guardRealNetwork(window as never); +afterEach(() => { + const blocked = network.take(); + if (blocked.length) { + throw new Error( + `The story requested what no mock answers (mock it: src/stories/storyNetwork.ts):\n${blocked.join('\n')}` + ); + } +}); + // React Query cancels in-flight (mocked) requests when a story unmounts, which // surfaces as an EMPTY unhandled rejection (no message, no stack). It's benign — // the stories pass and the app behaves correctly — but Vitest's strict detection @@ -58,10 +73,30 @@ if (typeof window !== 'undefined') { // Font parity with .storybook/preview-body.html. const style = document.createElement('style'); +// No text caret in a capture: it blinks, so a story ending with the cursor in a field was captured with it on +// or off at random, and qlip flagged the same frame as changed (Template › Template Value, qorus#646). style.textContent = ` html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; } + *, + *::before, + *::after { + caret-color: transparent !important; + } `; document.head.appendChild(style); + +/* Each story's browser is the one its project promises (vitest.config.ts): a desktop with a mouse, or, for + the stories tagged `touch`, a phone. The phone's stories run first, so every desktop story checks, after + them, that it still has its mouse: a touch screen that left a desktop page without one (qorus#646) fails + the next story here. */ +beforeEach(() => { + // a project's browser runs as a project of its own, named after both: "storybook-touch (chromium)" + const project = server.config.name ?? ''; + const phone = project === TOUCH_STORY_PROJECT || project.startsWith(`${TOUCH_STORY_PROJECT} (`); + const promised = phone ? '(hover: none) and (pointer: coarse)' : '(hover: hover) and (pointer: fine)'; + + expect(matchMedia(promised).matches, `the "${project}" project's browser matches ${promised}`).toBe(true); +}); diff --git a/README.md b/README.md index e674eb11..e991e677 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The ref exposes `format()`, `validate()`, `parse(text)`, and `serialize(expressi ### `SmartEditor` -The generic Slate-based editor primitive that `DpqlEditor` wraps. Knows nothing about any specific language — instead it takes a caller-owned LSP session (`useLspSession`) plus a set of language-flavour props (`decorate`, `customRenderLeaf`, `tagRenderer`, `triggerCharacters`, `converter`, `completionInserter`, `topActions`). The same primitive can drive a DPQL editor, a Qonsole command input, or any other LSP-backed text field. +The generic Slate-based editor primitive that `DpqlEditor` wraps. Knows nothing about any specific language — instead it takes a caller-owned LSP session (`useLspSession`) plus a set of language-flavor props (`decorate`, `customRenderLeaf`, `tagRenderer`, `triggerCharacters`, `converter`, `completionInserter`, `topActions`). The same primitive can drive a DPQL editor, a Qonsole command input, or any other LSP-backed text field. ```tsx import { SmartEditor, useLspSession } from '@qoretechnologies/reqraft'; @@ -148,6 +148,58 @@ client.onNotification('qonsole/sessionStateChanged', (params) => { /* … */ }); client.disconnect(); ``` +### `ReqraftWebSocket` + +The WebSocket under `useReqraftWebSocket` and `ReqraftLspClient`: one socket per URL on the configured instance (unless `pooled: false`), with a heartbeat and reconnects. `useReqraftWebSocket` takes the same options. + +```ts +import { ReqraftWebSocket } from '@qoretechnologies/reqraft'; + +const socket = new ReqraftWebSocket({ + url: 'log/main', // the path on the instance + onMessage: (event) => append(event.data), + onReconnectFailed: () => offerReload(), +}); + +socket.remove(); // let go of it; the last user of a shared socket closes it +``` + +| Option | Default | | +|---|---|---| +| `pooled` | `true` | Users of the same `url` share one socket; `false` gives this one its own | +| `reconnect` | `true` | Replace a socket that closes, or whose handshake does not open in time | +| `reconnectInterval` | `5000` | ms between attempts; the first attempt after a socket is lost is made at once | +| `maxReconnectTries` | `10` | Attempts before `onReconnectFailed` | +| `handshakeTimeout` | `15000` (`REQRAFT_WS_HANDSHAKE_TIMEOUT_MS`) | ms a handshake has to open; `0` sets no limit | +| `probeTimeout` | `10000` (`REQRAFT_WS_PROBE_TIMEOUT_MS`) | ms an attempt waits for its server-status probe; `0` sets no limit | +| `useHeartbeat` | `true` | Send `ping` every 3 s while open | +| `tokenOverride` | | A token for this socket instead of the instance's | +| `onOpen`, `onMessage`, `onClose`, `onError`, `onReconnecting`, `onReconnectFailed` | | Callbacks | + +- **A handshake that hangs** (the server takes the connection and never answers the upgrade) sends no event at all until the browser gives up, about four minutes later in Chromium, and Chromium holds back every other socket to the same host and port while it waits. At `handshakeTimeout` the socket is closed and replaced: its users get `onError` and then `onClose` with code 1006, as for a handshake that failed, and the replacement follows the same `reconnectInterval` and `maxReconnectTries` as after a close. An open socket has no deadline, and a connection never has more than one socket. +- **Before each attempt** it asks the server `GET system/pid`, so that a session that has expired is sent to sign in (`fetchConfig.unauthorizedRedirect`). The answer never holds the attempt back: the request is aborted after `probeTimeout`, and the attempt is made either way. +- **`remove()` and `close()`** close on purpose: the socket is not reconnected, also when it was still connecting (a browser reports that close as 1006), and a reconnect waiting to run, its probe and the handshake deadline are called off. `ReqraftWebSocketsManager.closeAll()` closes every socket the same way. + +### `ReqraftColorValue` + +A stored color, read-only: a swatch painted the color, beside the color written out (`#6495ED`, or `rgba(255, 0, 0, 0.5)` when it is see-through), on a wash of the color. A compact form row draws its colors with it, so a page and a form show a color the same way. + +```tsx +import { ReqraftColorValue } from '@qoretechnologies/reqraft'; + + // detail page + // one-line summary (row, cell) +``` + +- **Values:** `{ r, g, b }`, `{ r, g, b, a }` (as the color field stores it), `{ hex }`, or a hex code of 3, 4, 6 or 8 digits, with or without its `#`. A channel past its range is brought into it, as the color field does. +- **A stored color** is `{ r, g, b, a? }` and nothing more: `r`, `g` and `b` whole numbers from 0 to 255, `a` its opacity from 0 to 1 (none: opaque). A hex code, bare or as `{ hex }`, and a color with another field beside its own (`{ r, g, b, hex }`, which the server refuses), are drawn so a value stored that way is seen, but they are not stored colors: the `rgbcolor` validator says what is wrong (`storedColorProblem(value)`), and the color field stores the hash, and only the hash, when it edits such a value. +- **A value that is not a color** (`#zzz`, `red`, a channel that is not a number) draws no swatch: it says what the value is ("Not a color: “#zzz”"). With no value it says "No color". +- **Sizes:** `normal` (default; a 20px swatch over a checkerboard, so transparency shows; the text wraps) and `compact` (a 12px swatch; one line ending in an ellipsis). `wrap` overrides the line behavior. +- **Accessible:** the color is text (it reads, copies and is found like any text); the swatch is an image named for the color ("Color #6495ED"). +- **Words:** `labels` replaces any of `swatch(color)`, `invalid(shown)` and `empty` for a host that translates its interface. + +`describeColorValue(value)` is the reader on its own: `{ kind: 'color', text, css }`, `{ kind: 'invalid', shown }` or `{ kind: 'empty' }`. + ## Tracking, consent and A/B testing First-party analytics with a consent gate and client-side A/B testing, for any Qore web app. The core is plain TypeScript with no DOM, the browser layer attaches the listeners, and the React bindings sit on top. Everything product-specific comes in through config; the server keeps each product's data separate by `property`. diff --git a/__tests__/ mock.ts b/__tests__/ mock.ts index 4b17a4d7..8f702d10 100644 --- a/__tests__/ mock.ts +++ b/__tests__/ mock.ts @@ -1,8 +1,12 @@ import { set } from 'lodash'; +import { storyApiUrl } from '../src/stories/storyNetwork'; + +// Built from the configured instance (storyApiUrl), as every story mock is: a URL written for one host stopped +// matching when the stories ran against another, and the request went to that instance instead. export const storiesStorageMockEmpty = [ { - url: 'https://hq.qoretechnologies.com:8092/api/latest/users?action=current', + url: storyApiUrl('users?action=current'), method: 'GET', status: 200, response: {}, @@ -11,7 +15,7 @@ export const storiesStorageMockEmpty = [ export const storiesStorageMock = [ { - url: 'https://hq.qoretechnologies.com:8092/api/latest/users?action=current', + url: storyApiUrl('users?action=current'), method: 'GET', status: 200, response: { @@ -24,7 +28,7 @@ export const storiesStorageMock = [ }, }, { - url: 'https://hq.qoretechnologies.com:8092/api/latest/users/_current_/', + url: storyApiUrl('users/_current_/'), method: 'PUT', status: 200, response: (request) => { diff --git a/__tests__/allowedValueAvailability.test.tsx b/__tests__/allowedValueAvailability.test.tsx index 22dfe63b..b3cea2b7 100644 --- a/__tests__/allowedValueAvailability.test.tsx +++ b/__tests__/allowedValueAvailability.test.tsx @@ -1,3 +1,6 @@ +import { DEFAULT_THEME } from '@qoretechnologies/reqore/dist/constants/theme'; +import { buildTheme } from '@qoretechnologies/reqore/dist/helpers/colors'; +import { getReadableTextOpacity } from '../src/helpers/readableColors'; import { ReqoreUIProvider } from '@qoretechnologies/reqore'; import { fireEvent, render, renderHook, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; @@ -18,6 +21,9 @@ import { OptionsContext } from '../src/components/form/engine/optionsContext'; import { FetchContext } from '../src/contexts/FetchContext'; import { emptyFetchContext } from './support/fetchContext'; +/* A refused value's name is dimmed, as far as it still reads in the theme (qorus#646): 0.55 was 4.1:1. */ +const DIMMED = getReadableTextOpacity(buildTheme(DEFAULT_THEME as never), 0.55); + /** * An offered value that cannot be picked is disabled and says why — never * hidden, and never silent. @@ -298,13 +304,13 @@ describe('the picker row a value cannot be chosen from', () => { dimming the row would dim the reason inside it. */ expect( getComputedStyle(refused.querySelector(':scope > .reqore-panel-title') as HTMLElement).opacity - ).toBe('0.55'); + ).toBe(String(DIMMED)); const offered = await rowFor('Value: equals'); expect(offered.className).not.toContain(UNAVAILABLE_ITEM_CLASS); expect( getComputedStyle(offered.querySelector(':scope > .reqore-panel-title') as HTMLElement).opacity - ).not.toBe('0.55'); + ).not.toBe(String(DIMMED)); }); it('opens the reason from a hover anywhere on the row', async () => { @@ -405,12 +411,12 @@ describe('the checkbox group a short list renders as', () => { ReqoreCheckbox destructures `effect` out and never applies it: the row got the padlock and the cursor and no dimming at all, and nothing here noticed. It is `labelEffect` that lands, and it lands on the NAME. */ - expect(getComputedStyle(name).opacity).toBe('0.55'); + expect(getComputedStyle(name).opacity).toBe(String(DIMMED)); const explanation = refused.querySelector('.reqore-checkbox-description') as HTMLElement; expect(explanation.textContent).toContain(CHECKBOX_REASON); // The reason is the one thing in the row that has to stay readable, which // is why the row itself is never what gets dimmed. - expect(getComputedStyle(explanation).opacity).not.toBe('0.55'); + expect(getComputedStyle(explanation).opacity).not.toBe(String(DIMMED)); fireEvent.click(refused); expect(onChange).not.toHaveBeenCalled(); @@ -487,13 +493,13 @@ describe("reqraft's own FormField picker", () => { const name = await screen.findByText('Coverage'); const refused = name.closest('.reqore-checkbox') as HTMLElement; - expect(getComputedStyle(name).opacity).toBe('0.55'); + expect(getComputedStyle(name).opacity).toBe(String(DIMMED)); expect(getComputedStyle(refused).pointerEvents).toBe('auto'); expect(getComputedStyle(refused).cursor).toBe('not-allowed'); const explanation = refused.querySelector('.reqore-checkbox-description') as HTMLElement; expect(explanation.textContent).toContain(CHECKBOX_REASON); - expect(getComputedStyle(explanation).opacity).not.toBe('0.55'); + expect(getComputedStyle(explanation).opacity).not.toBe(String(DIMMED)); fireEvent.click(refused); expect(onChange).not.toHaveBeenCalled(); diff --git a/__tests__/allowedValueShapes.test.tsx b/__tests__/allowedValueShapes.test.tsx new file mode 100644 index 00000000..29b41dd3 --- /dev/null +++ b/__tests__/allowedValueShapes.test.tsx @@ -0,0 +1,219 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/** + * An `allowed_values` entry is written three ways - an envelope (`{value: {type, value}}`), a bare value + * (`{value: 'x'}`) or a name (`{name: 'x'}`), as `findAllowedValueOption` reads them. A short list's check group + * read the envelope only, so an entry written another way was a box with no name, which a click could not tick + * (qorus#646, David on FormEngine · Compact Required Field Loaded Late Opens By Itself: "an invalid empty radio box + * control with two empty choices"). + */ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { getAllowedValueAvailability } from '../src/components/form/engine/OptionFieldMessages'; +import { FieldAllowedValues } from '../src/components/form/fields/allowed-values/AllowedValues'; +import { FormField } from '../src/components/form/fields/Field'; +import { SelectFormField } from '../src/components/form/fields/select/Select'; +import { allowedValueName, allowedValueOf, heldAllowedValue } from '../src/helpers/allowedValues'; +import { validateField } from '../src/helpers/validations'; + +/* The anchored list is read where it is handed to reqore: reqore opens it through a portal that jsdom never + opens (see selectRefusedValuePaths.test.tsx). Nothing else here draws a dropdown. */ +let dropdownProps: any; +vi.mock('@qoretechnologies/reqore', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + ReqoreDropdown: (props: any) => { + dropdownProps = props; + return
; + }, + }; +}); + +describe('an allowed value, however its entry is written', () => { + it('has its value', () => { + expect(allowedValueOf({ value: { type: 'string', value: 'x' } })).toBe('x'); + expect(allowedValueOf({ value: 'x' })).toBe('x'); + expect(allowedValueOf({ name: 'x' })).toBe('x'); + expect(allowedValueOf({ value: 0 })).toBe(0); + }); + + it('has its name', () => { + expect(allowedValueName({ display_name: 'Ex', value: 'x' })).toBe('Ex'); + expect(allowedValueName({ name: 'x', value: 'x' })).toBe('x'); + expect(allowedValueName({ value: { type: 'string', value: 'x' } })).toBe('x'); + expect(allowedValueName({ value: { type: 'int', value: 3 } })).toBe('3'); + }); +}); + +describe('a short list of choices written without envelopes', () => { + it.each([ + [ + 'bare values with names', + [ + { name: 'ProcessorTypeTest', value: 'ProcessorTypeTest' }, + { name: 'qogcompatibilitytest', value: 'qogcompatibilitytest' }, + ], + ], + ['names only', [{ name: 'ProcessorTypeTest' }, { name: 'qogcompatibilitytest' }]], + ])('names each choice and ticks the one held (%s)', (_how, items) => { + const onChange = vi.fn(); + render( + + + + ); + const held = screen + .getByText('qogcompatibilitytest') + .closest('.reqore-checkbox') as HTMLElement; + const other = screen.getByText('ProcessorTypeTest').closest('.reqore-checkbox') as HTMLElement; + expect( + held.querySelector('input')?.checked ?? held.getAttribute('aria-checked') === 'true' + ).toBe(true); + // a click picks it + fireEvent.click(other); + expect(onChange).toHaveBeenCalledWith('processor', 'ProcessorTypeTest'); + }); +}); + +/** The same two choices, written each way an entry is written. */ +const WRITTEN = [ + [ + 'envelopes', + [ + { display_name: 'Workflow', value: { type: 'string', value: 'wf' } }, + { display_name: 'Service', value: { type: 'string', value: 'svc' } }, + ], + ], + [ + 'bare values', + [ + { display_name: 'Workflow', value: 'wf' }, + { display_name: 'Service', value: 'svc' }, + ], + ], +] as const; + +describe('the choice a field holds', () => { + const items = [ + { display_name: 'general', value: { type: 'richtext', value: 'C07ERBFS0P6' } }, + { display_name: 'random', value: { type: 'richtext', value: 'C07RANDOM01' } }, + ]; + + it('is the choice, however the value spells it', () => { + expect(heldAllowedValue('C07RANDOM01', items)).toBe('C07RANDOM01'); + // the document an editor wrote for it + expect( + heldAllowedValue([{ type: 'paragraph', children: [{ text: 'C07ERBFS0P6' }] }], items) + ).toBe('C07ERBFS0P6'); + // a choice written by its name only + expect(heldAllowedValue('x', [{ name: 'x' }])).toBe('x'); + }); + + it('is none when the field holds nothing, or a value no choice is', () => { + expect(heldAllowedValue('C07OTHER', items)).toBeUndefined(); + // a choice with no value is not held by a field that holds nothing + expect(heldAllowedValue(undefined, [{ name: 'x' }])).toBeUndefined(); + expect(heldAllowedValue('', [{ name: 'x' }])).toBeUndefined(); + expect(heldAllowedValue('x', [])).toBeUndefined(); + }); +}); + +describe('suggestions beside an editor that holds the value', () => { + // qorus#646, David: "if this option takes a single value, then selecting another value should replace the + // one that's there" - the list said nothing of the one held, so a pick read as adding to it + const renderSuggestions = (markedValue: unknown, onChange = vi.fn()) => { + render( + + + + ); + return onChange; + }; + + it('tick the one the field holds, and a pick of another replaces it', () => { + const onChange = renderSuggestions('C07ERBFS0P6'); + const items = dropdownProps.items as { + label: string; + selected: boolean; + onClick: () => void; + }[]; + expect(items.map(({ label, selected }) => [label, selected])).toEqual([ + ['general', true], + ['random', false], + ]); + // the trigger keeps its own name: the editor beside it draws the value + expect(dropdownProps.children).toBe('Saved & Suggested Values'); + + items[1].onClick(); + expect(onChange).toHaveBeenCalledWith('C07RANDOM01'); + }); + + it('tick none when the field holds none of them', () => { + renderSuggestions(undefined); + expect((dropdownProps.items as { selected: boolean }[]).some(({ selected }) => selected)).toBe( + false + ); + }); +}); + +describe('choices written without envelopes, wherever they are read', () => { + it.each(WRITTEN)('a short list names each and ticks the one held (%s)', (_how, items) => { + const onChange = vi.fn(); + render( + + + + ); + const held = screen.getByText('Service').closest('.reqore-checkbox') as HTMLElement; + expect( + held.querySelector('input')?.checked ?? held.getAttribute('aria-checked') === 'true' + ).toBe(true); + fireEvent.click(screen.getByText('Workflow').closest('.reqore-checkbox') as HTMLElement); + expect(onChange).toHaveBeenCalled(); + expect(JSON.stringify(onChange.mock.lastCall)).toContain('"wf"'); + }); + + it.each(WRITTEN)('a refusal names the answer it needs by its name (%s)', (_how, items) => { + const schema = { + kind: { type: 'string', display_name: 'Interface Kind', allowed_values: items }, + }; + const refused = getAllowedValueAvailability( + { depends_on: ['kind=wf'] }, + { kind: { type: 'string', value: 'svc' } } as never, + schema as never + ); + expect(refused.available).toBe(false); + expect(refused.reason).toContain('"Interface Kind" must be "Workflow"'); + }); + + it.each(WRITTEN)('a connection that is one of them is valid (%s)', (_how, items) => { + expect(validateField('connection', 'svc', { allowed_values: items as never } as never)).toBe( + true + ); + expect(validateField('connection', 'other', { allowed_values: items as never } as never)).toBe( + false + ); + }); +}); diff --git a/__tests__/allowedValuesCheckGroupKeys.test.tsx b/__tests__/allowedValuesCheckGroupKeys.test.tsx new file mode 100644 index 00000000..37afa843 --- /dev/null +++ b/__tests__/allowedValuesCheckGroupKeys.test.tsx @@ -0,0 +1,44 @@ +/** + * An allowed-values check group draws its choices with no React key warning (qorus#646). + * + * Each choice was keyed by `item.value?.toString()`: a choice whose value is not resolved yet has no key, and + * object values all key as "[object Object]". React warned for every such group (qorus-ide's Qog processor + * stories, whose Hubspot choices had not resolved), and choices that share a key can be swapped on a re-render. + */ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FieldAllowedValuesCheckGroup } from '../src/components/form/fields/allowed-values/AllowedValues'; + +const keyWarnings = (spy: ReturnType) => + spy.mock.calls + .map((args) => args.map(String).join(' ')) + .filter((text) => /unique "key" prop|same key/.test(text)); + +afterEach(() => vi.restoreAllMocks()); + +describe('an allowed-values check group', () => { + it.each([ + [ + 'choices whose values are not resolved', + [{ display_name: 'Contacts' }, { display_name: 'Deals' }], + ], + [ + 'object values', + [ + { display_name: 'First', value: { id: 1 } }, + { display_name: 'Second', value: { id: 2 } }, + ], + ], + ])('draws %s with no key warning', (_name, items) => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined); + render( + + + + ); + expect(screen.getAllByText(items[0].display_name).length).toBeGreaterThan(0); + expect(screen.getAllByText(items[1].display_name).length).toBeGreaterThan(0); + expect(keyWarnings(errors)).toEqual([]); + }); +}); diff --git a/__tests__/allowedValuesSetRowPick.test.tsx b/__tests__/allowedValuesSetRowPick.test.tsx new file mode 100644 index 00000000..9dc7b6be --- /dev/null +++ b/__tests__/allowedValuesSetRowPick.test.tsx @@ -0,0 +1,59 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { IQorusAllowedValue } from '@qoretechnologies/ts-toolkit'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { AutoFormField } from '../src/components/form/fields/auto/AutoFormField'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +/** + * A value picked from a field's allowed values is a value, not an expression (qorus#646: the Qorus IDE's guided + * check "status: One of" changed its Field, a '*string' option, to "paid" and the form got back + * `{ type: 'string', value: 'paid', is_expression: true }`, which the IDE reads as no field at all). The field said + * whether its type can be null in the argument every reader takes for the expression flag. + */ +const fetchContext = emptyFetchContext(); + +const allowedValues: IQorusAllowedValue[] = [ + { value: { type: 'string', value: 'status' }, display_name: 'status (string)' }, + { value: { type: 'string', value: 'paid' }, display_name: 'paid (bool)' }, + { value: { type: 'string', value: '__other__' }, display_name: 'Other field...' }, +]; + +const renderField = (props: Record) => { + const onChange = vi.fn(); + render( + + + + + + ); + return onChange; +}; + +describe('a value picked from the allowed values', () => { + it.each([ + ['a type that can be null', { type: '*string', defaultType: 'string', canBeNull: true }], + ['a type that cannot', { type: 'string', defaultType: 'string' }], + ['an untyped field', { type: 'any', defaultType: 'any' }], + ])('is a value, not an expression, in %s', async (_case, props) => { + const user = userEvent.setup(); + const onChange = renderField(props); + + await user.click(await screen.findByText('paid (bool)')); + + const [name, value, , isFunction] = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(name).toBe('field'); + expect(value).toBe('paid'); + expect(isFunction).toBe(false); + }); +}); diff --git a/__tests__/americanSpelling.test.ts b/__tests__/americanSpelling.test.ts new file mode 100644 index 00000000..17d501d4 --- /dev/null +++ b/__tests__/americanSpelling.test.ts @@ -0,0 +1,308 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { startCase } from 'lodash'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +/** + * qorus#646: reqraft is written in American English, as Qorus and the IDE are. The server's type catalogue says + * "Color", and reqraft said "Colour" beside it; its texts said "centred", "labelled", "summarises", "catalogue" too. + * + * What is read is every text reqraft shows or documents: the prose of its sources - string literals, template + * literals and JSX text with words in them, or one capitalized word (a label) - in its components, its stories and + * their data (a story's description is read by people, and mock data is shown); the title of each story; and the + * README. Code spans (`` `cancelled` ``), placeholders (`{count}`, `${value}`), URLs and words in capitals (a status + * such as CANCELLED) are not prose, and identifiers, import paths and keys have no spaces, so they are not read. + * Comments are not read, and nor are tests. + * + * The list of British spellings and these rules are the IDE's (qorus-ide __tests__/conventions/americanEnglish.test.ts), + * so the two check the same words the same way: a word joined to a name - `x-`, `x_`, `x/`, `x.` or `x:` before it, + * `_x`, `/x`, `.x` or `:x` after it - is part of the name, not prose. A spelling or a rule added to one is added to + * the other. + */ +const root = join(__dirname, '..'); + +/** British spellings, each with its American one, with or without a prefix (unfavourited, reauthorise). */ +const BRITISH: [RegExp, string][] = [ + [/\b(?:un|re|de|pre|dis)?colour(s|ed|ing|ful|less)?\b/i, 'color'], + [/\b(?:un|re|de|pre|dis)?behaviour(s|al)?\b/i, 'behavior'], + [/\b(?:un|re|de|pre|dis)?organis(e|es|ed|ing|er|ers|ation|ations)\b/i, 'organiz…'], + [/\b(?:un|re|de|pre|dis)?centre(s|d)?\b|\bcentring\b/i, 'center'], + [/\b(?:un|re|de|pre|dis)?licences?\b/i, 'license (the noun too)'], + [/\b(?:un|re|de|pre|dis)?favour(s|ed|ing|ite|ites|ited|iting|able)?\b/i, 'favor'], + [/\b(?:un|re|de|pre|dis)?honour(s|ed|ing)?\b/i, 'honor'], + [/\b(?:un|re|de|pre|dis)?catalogue(s|d)?\b/i, 'catalog'], + [/\b(?:un|re|de|pre|dis)?cancell(ed|ing)\b/i, 'canceled, canceling'], + [/\b(?:un|re|de|pre|dis)?(labour|neighbour|flavour|humour|harbour)(s|ed|ing|hood)?\b/i, '-or'], + [/\b(?:un|re|de|pre|dis)?analys(e|ed|ing)\b/i, 'analyz…'], + [ + /\b(?:un|re|de|pre|dis)?(normal|summar|recogn|priorit|initial|final|custom|optim|serial|author|util|visual|synchron|categor|standard|local|real|emphas|minim|maxim|capital|character|special|memor|sanit|token|parameter|material|apolog)is(e|es|ed|ing|ation|ations|able|er|ers)\b/i, + '-ize, -ization', + ], + [/\b(?:un|re|de|pre|dis)?grey(s|ed|ing)?\b/i, 'gray'], + [/\b(?:un|re|de|pre|dis)?(defence|offence)\b/i, 'defense, offense'], + [ + /\b(?:un|re|de|pre|dis)?(travell|modell|labell|signall|levell|totall|fuell|marshall|channell)(ed|ing|er|ers)\b/i, + 'one l', + ], + [/\b(?:un|re|de|pre|dis)?enrolment\b|\bfulfil(s|ment)?\b/i, 'enrollment, fulfill'], + [/\b(?:un|re|de|pre|dis)?(learnt|spelt)\b/i, 'learned, spelled'], + [/\b(?:un|re|de|pre|dis)?(metre|litre|fibre)s?\b/i, '-er'], + [/\b(?:un|re|de|pre|dis)?artefacts?\b/i, 'artifact'], + [/\b(?:un|re|de|pre|dis)?focuss(ed|ing)\b/i, 'focused'], + [/\b(?:un|re|de|pre|dis)?enquir(y|ies)\b/i, 'inquiry'], + [ + /\b(?:un|re|de|pre|dis)?(skilful|manoeuvr\w*|sceptic\w*|whilst|programmes?)\b/i, + 'skillful, maneuver, skeptic, while, program', + ], +]; + +/** Code spans, placeholders, interpolations and URLs: not prose. */ +const prose = (text: string): string => + text + .replace(/\\?`[^`]*\\?`/g, ' ') + .replace(/\$\{[^}]*\}/g, ' ') + .replace(/\{[^}]*\}/g, ' ') + .replace(/https?:\/\/\S+/g, ' '); + +/** The British spellings in `text`, as written. */ +const britishIn = (text: string): string[] => { + const words = prose(text); + return BRITISH.flatMap(([pattern]) => + [...words.matchAll(new RegExp(pattern.source, 'gi'))] + // a word in capitals is a name Qorus gives (a status such as CANCELLED), not prose + .filter(([word]) => word !== word.toUpperCase()) + // a word joined to the one before it, or to a path or key after it, is part of a name: + // `order-fulfilment:1.3`, `$sysinfo:licence.routing`, `catalogue.ts` + .filter((match) => { + const before = words.slice(Math.max(0, match.index - 2), match.index); + const after = words.slice(match.index + match[0].length, match.index + match[0].length + 2); + return !/[\w$][-_/.:]$/.test(before) && !/^[_/.:][\w$]/.test(after); + }) + .map(([word]) => word) + ); +}; + +/** Words with a space between them, or one capitalized word - a label; a key is neither. */ +const isProse = (text: string): boolean => { + const trimmed = text.trim(); + return (/\s/.test(trimmed) && /[a-z]/.test(trimmed)) || /^[A-Z][a-z]+$/.test(trimmed); +}; + +/** Every TypeScript source under a directory, relative to the root, tests left out. */ +const sourcesUnder = (dir: string): string[] => + readdirSync(join(root, dir), { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() ? sourcesUnder(join(dir, entry.name)) + : /\.(ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [join(dir, entry.name)] + : [] + ); + +const parse = (file: string): ts.SourceFile => + ts.createSourceFile( + file, + readFileSync(join(root, file), 'utf8'), + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ); + +/** Each prose text of a source - string, template and JSX text - with its line. */ +const proseOf = (file: string): { line: number; text: string }[] => { + const source = parse(file); + const found: { line: number; text: string }[] = []; + const add = (text: string, node: ts.Node) => { + if (isProse(text)) { + found.push({ + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + text, + }); + } + }; + const visit = (node: ts.Node) => { + if ( + ts.isStringLiteral(node) || + ts.isNoSubstitutionTemplateLiteral(node) || + ts.isJsxText(node) + ) { + add(node.text, node); + } else if (ts.isTemplateExpression(node)) { + add( + [node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(' ${} '), + node + ); + } + ts.forEachChild(node, visit); + }; + visit(source); + return found; +}; + +/** + * The words Storybook shows for each story a stories file exports: the export's name split into its words, as + * Storybook's `storyNameFromExport` does (lodash's `startCase`). A story that gives its own `name` is shown by it + * instead - a string, which the prose check reads - so it is left out here (`{ ... } as Story` and + * `{ ... } satisfies Story` are the story object too). Read as the IDE reads its own (qorus-ide bb11c267c). + */ +const storyNamesIn = (code: string): string[] => { + const names: string[] = []; + const source = ts.createSourceFile( + 'story.tsx', + code, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + for (const statement of source.statements) { + if ( + !ts.isVariableStatement(statement) || + !statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) { + continue; + } + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) { + continue; + } + let init = declaration.initializer; + while (init && (ts.isAsExpression(init) || ts.isSatisfiesExpression(init))) { + init = init.expression; + } + const named = + !!init && + ts.isObjectLiteralExpression(init) && + init.properties.some( + (property) => + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === 'name' + ); + if (!named) { + names.push(storyNameFromExport(declaration.name.text)); + } + } + } + return names; +}; + +const storyNamesOf = (file: string): string[] => + storyNamesIn(readFileSync(join(root, file), 'utf8')); + +/** A story export's name as Storybook shows it (`storyNameFromExport` is lodash's `startCase`): + * `UnlabeledServiceOnAPhone` is "Unlabeled Service On A Phone". */ +const storyNameFromExport = (exported: string): string => startCase(exported); + +describe('reqraft writes American English', () => { + it('in the prose of its sources, its stories and their data', () => { + const offences = sourcesUnder('src').flatMap((file) => + proseOf(file) + .filter(({ text }) => britishIn(text).length) + .map(({ line, text }) => `${file}:${line}: ${britishIn(text).join(', ')}`) + ); + expect(offences).toEqual([]); + }); + + it('in the names Storybook shows for its stories', () => { + const offences = sourcesUnder('src') + .filter((file) => /\.stories\.tsx?$/.test(file)) + .flatMap((file) => + storyNamesOf(file) + .filter((name) => britishIn(name).length) + .map((name) => `${file}: "${name}": ${britishIn(name).join(', ')}`) + ); + expect(offences).toEqual([]); + }); + + it('in its README', () => { + const offences = readFileSync(join(root, 'README.md'), 'utf8') + .split('\n') + .flatMap((line, index) => + britishIn(line).length ? [`README.md:${index + 1}: ${britishIn(line).join(', ')}`] : [] + ); + expect(offences).toEqual([]); + }); + + // the reader reads what it should: a text it skipped would pass the tests above for the wrong reason + it('reads strings, templates, JSX text and story titles', () => { + expect(proseOf('src/components/colorValue/labels.ts').map(({ text }) => text)).toContain( + 'No color' + ); + expect(storyNamesOf('src/components/colorValue/ColorValue.stories.tsx')).toContain( + 'Not A Color' + ); + // a story that gives its own name is shown by it: a string the prose check reads, not its export + expect(storyNamesOf('src/components/form/engine/FormEngine.stories.tsx')).not.toContain( + 'Compact Expression Without Catalogue' + ); + expect( + proseOf('src/components/form/engine/FormEngine.stories.tsx').map(({ text }) => text) + ).toContain('Compact Expression Without Catalog'); + }); + + it('reads a story export as the words Storybook shows for it', () => { + expect(storyNameFromExport('UnlabelledServiceOnAPhone')).toBe('Unlabelled Service On A Phone'); + expect(storyNameFromExport('FxModeRail')).toBe('Fx Mode Rail'); + expect(storyNameFromExport('Oauth2Tab')).toBe('Oauth 2 Tab'); + expect(storyNameFromExport('CsvUtf16Only')).toBe('Csv Utf 16 Only'); + expect( + storyNamesIn( + [ + 'export const LargeFileCheckCancelled: Story = { args: {} };', + 'export const AllLabelled = { args: {} } as Story;', + "export const Shown = { name: 'Shown by its name', args: {} } satisfies Story;", + 'const NotAStory = {};', + ].join('\n') + ) + ).toEqual(['Large File Check Cancelled', 'All Labelled']); + expect(britishIn(storyNameFromExport('AppNotInCatalogue'))).toEqual(['Catalogue']); + expect(britishIn(storyNameFromExport('TerminalCancelled'))).toEqual(['Cancelled']); + }); + + it('catches each spelling, and leaves code, placeholders and identifiers alone', () => { + expect(britishIn('Pick a colour, then save the favourite one')).toEqual([ + 'colour', + 'favourite', + ]); + expect(britishIn('Reauthorise it, or leave it unfavourited and unlabelled')).toEqual([ + 'unfavourited', + 'Reauthorise', + 'unlabelled', + ]); + expect(britishIn('The run was cancelled and its behaviour normalised')).toEqual([ + 'behaviour', + 'cancelled', + 'normalised', + ]); + expect(britishIn('Centred on a grey row, the catalogue summarises it')).toEqual([ + 'Centred', + 'catalogue', + 'summarises', + 'grey', + ]); + expect( + britishIn( + 'The status `cancelled` is {cancelled}, or CANCELLED: see https://example.com/catalogue' + ) + ).toEqual([]); + // a word joined into a name, a key or a path + expect( + britishIn( + 'the workflow order-fulfilment:1.3, key $sysinfo:licence.routing, file catalogue.ts' + ) + ).toEqual([]); + // joined after a name, or before a key or a path: x-, x_, x/, x., x: before it, _x, /x, .x, :x after it + expect( + britishIn( + 'data-colour, my_colour, a/colour, a.colour, ns:colour, colour_x, colour/x, colour.x, colour:x' + ) + ).toEqual([]); + // a word that ends a sentence, or has a dash after it, is still prose + expect(britishIn('Pick a colour. A colour-blind reader')).toEqual(['colour', 'colour']); + // words that only look British + expect( + britishIn('characteristics of an organism, a specialist realist, the cancellation') + ).toEqual([]); + expect(isProse('catalogue')).toBe(false); + expect(isProse('Catalogue')).toBe(true); + }); +}); diff --git a/__tests__/arrayAutoFieldEntryKey.test.tsx b/__tests__/arrayAutoFieldEntryKey.test.tsx new file mode 100644 index 00000000..7925be0e --- /dev/null +++ b/__tests__/arrayAutoFieldEntryKey.test.tsx @@ -0,0 +1,66 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { fireEvent, render } from '@testing-library/react'; +import { jsx } from 'react/jsx-runtime'; +import { describe, expect, it, vi } from 'vitest'; +import { ArrayAutoField } from '../src/components/form/fields/array/ArrayAutoField'; + +/** + * qorus#646 (React's warning, found by the story run's warning gate): a list's entry input was given the key that + * remounts it inside the props it is drawn from, and the field spreads those props into JSX - React warned "A + * props object containing a "key" prop is being spread into JSX". The key goes on an element around the entry; the + * entry is still drawn afresh, empty, once its value is added. + */ + +/** The entry, drawn the way the form's field draws it: from its props, spread. */ +const Entry = (props: { value?: unknown; onChange: (value: unknown) => void }) => ( + props.onChange(event.target.value)} + /> +); + +const renderList = () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const result = render( + + ` compiles for the automatic JSX runtime (the package's and the stories'): + // this test file's own JSX is compiled to createElement, which takes a spread key without a word + renderItem={(props) => jsx(Entry, props)} + /> + + ); + // read before the spy is restored, which clears what it recorded + const errors = consoleError.mock.calls.map((call) => call.map(String).join(' ')); + return { ...result, errors }; + } finally { + consoleError.mockRestore(); + } +}; + +describe("a list's entry input", () => { + it('is drawn with no key among its props', () => { + const { errors } = renderList(); + expect(errors.filter((error) => /props object containing a "key" prop/.test(error))).toEqual( + [] + ); + }); + + it('is drawn afresh, empty, once its value is added', () => { + const { getByTestId, container } = renderList(); + const before = getByTestId('entry'); + fireEvent.change(before, { target: { value: 'first' } }); + fireEvent.click(container.querySelector('.array-auto-compact-confirm') as HTMLElement); + const after = getByTestId('entry') as HTMLInputElement; + expect(container.querySelector('.array-auto-compact-tag')?.textContent).toContain('first'); + expect(after).not.toBe(before); + expect(after.value).toBe(''); + }); +}); diff --git a/__tests__/arrayPendingEdit.test.tsx b/__tests__/arrayPendingEdit.test.tsx new file mode 100644 index 00000000..e5eb19c1 --- /dev/null +++ b/__tests__/arrayPendingEdit.test.tsx @@ -0,0 +1,195 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A list field's edit that waits for its pause is never lost when the field goes away (qorus#646): both list +// editors keep a local copy and report it 300 ms after the last change, and a read-first row's Done closed them +// inside that pause. Done sends the edit before the row closes, Cancel and Escape drop it, and any other way out +// sends it. +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { ReactNode, useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@qoretechnologies/reqore', () => ({ + ReqoreButton: ({ children, onClick, disabled, className }: any) => ( + + ), + ReqoreControlGroup: ({ children, className, onKeyDown }: any) => ( +
+ {children} +
+ ), + ReqorePanel: ({ children, className }: any) =>
{children}
, + ReqoreTag: ({ label, className }: any) => {label}, + ReqoreTagGroup: ({ children }: any) =>
{children}
, + ReqoreVerticalSpacer: () =>
, + useReqoreProperty: () => vi.fn(), +})); + +/* Each item of the panel list is a full template field; one input stands for it here. */ +vi.mock('../src/components/form/fields/template/TemplateField', () => ({ + TemplateField: ({ name, value, onChange }: any) => ( + onChange(name, e.target.value)} + /> + ), +})); +vi.mock('../src/components/form/fields/auto/AutoFormField', () => ({ AutoFormField: () => null })); + +import { ArrayAuto } from '../src/components/form/fields/array/ArrayAuto'; +import { ArrayAutoField } from '../src/components/form/fields/array/ArrayAutoField'; +import { + createPendingEdits, + IPendingEdits, + PendingEditsContext, +} from '../src/components/form/fields/pendingEdit'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** A parent that keeps what it is told and hands it back down, as the form does. */ +const Host = ({ + scope, + initial, + onChange, + field, +}: { + scope: IPendingEdits; + initial: unknown[]; + onChange: (name: string, value: unknown) => void; + field: (value: unknown[], onChange: (name: string, value: unknown) => void) => ReactNode; +}) => { + const [value, setValue] = useState(initial); + return ( + + {field(value, (name, next) => { + onChange(name, next); + setValue(next as unknown[]); + })} + + ); +}; + +const stringItem = ({ + value, + onChange, +}: { + value: unknown; + onChange: (value: unknown) => void; +}) => ( + onChange(e.target.value)} + /> +); + +/** Adds an item through the compact list's input and its confirm button. */ +const addCompactItem = (text: string) => { + fireEvent.change(screen.getByTestId('array-input'), { target: { value: text } }); + fireEvent.click(document.querySelector('.array-auto-compact-confirm')!); +}; + +/** Changes the first item of the panel list. */ +const editPanelItem = (text: string) => + fireEvent.change(screen.getByTestId('list-0'), { target: { value: text } }); + +describe.each([ + { + name: 'ArrayAutoField', + field: (value: unknown[], onChange: (name: string, value: unknown) => void) => ( + + ), + edit: () => addCompactItem('second'), + sent: ['first', 'second'], + }, + { + name: 'ArrayAuto', + field: (value: unknown[], onChange: (name: string, value: unknown) => void) => ( + + ), + edit: () => editPanelItem('changed'), + sent: ['changed'], + }, +])('$name and an edit inside its pause', ({ field, edit, sent }) => { + /** What the field reported, after its first report: the one its pause makes of the value it mounted with. */ + const settle = async () => act(() => vi.advanceTimersByTimeAsync(500)); + + it('sends the edit when its scope flushes, before the pause, and once', async () => { + const scope = createPendingEdits(); + const onChange = vi.fn(); + render(); + await settle(); + onChange.mockClear(); + + edit(); + act(() => scope.flush()); + expect(onChange).toHaveBeenCalledWith('list', sent); + const reports = onChange.mock.calls.length; + + // the pause that follows reports the same list again at most, never anything else + await settle(); + for (const call of onChange.mock.calls.slice(reports)) { + expect(call).toEqual(['list', sent]); + } + }); + + it('sends the edit when the field goes away inside the pause', async () => { + const scope = createPendingEdits(); + const onChange = vi.fn(); + const { unmount } = render( + + ); + await settle(); + onChange.mockClear(); + + edit(); + unmount(); + + expect(onChange).toHaveBeenCalledWith('list', sent); + }); + + it('drops the edit when its scope discards, and sends nothing as the field goes away', async () => { + const scope = createPendingEdits(); + const onChange = vi.fn(); + const { unmount } = render( + + ); + await settle(); + onChange.mockClear(); + + edit(); + act(() => scope.discard()); + unmount(); + await settle(); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('sends nothing where nothing was edited', async () => { + const scope = createPendingEdits(); + const onChange = vi.fn(); + const { unmount } = render( + + ); + await settle(); + onChange.mockClear(); + + act(() => scope.flush()); + unmount(); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/autoFieldDrawnAsDeclared.test.tsx b/__tests__/autoFieldDrawnAsDeclared.test.tsx new file mode 100644 index 00000000..7cb9ed16 --- /dev/null +++ b/__tests__/autoFieldDrawnAsDeclared.test.tsx @@ -0,0 +1,156 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A field is drawn as the type it is declared from its first render. +// +// It was drawn first as the untyped `any` and given its declared type only once it had mounted. For that +// first render an untyped field's editor - text, with the field's templates as chips - was handed the +// value as text, and a rich-text value is a document: `String()` made it `[object Object]`, on screen +// until the next render. Reqraft's expression builder showed it in an `in(...)` operand holding rich +// text, in the Qorus IDE's Expression Builder stories. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/* Every render of the two editors an AutoField can draw text in, in order. */ +const drawn = vi.hoisted(() => ({ + richText: [] as Record[], + longString: [] as Record[], +})); + +vi.mock('../src/components/form/fields/rich-text/RichText', () => ({ + RichTextFormField: (props: Record) => { + drawn.richText.push(props); + return null; + }, +})); + +vi.mock('../src/components/form/fields/long-string/LongString', () => ({ + default: (props: Record) => { + drawn.longString.push(props); + return null; + }, +})); + +import { + AutoFormField, + IAutoFieldProps, + resolveAutoFieldTypes, +} from '../src/components/form/fields/auto/AutoFormField'; + +const TEMPLATES = { items: [{ label: 'Richtext Template', value: '$local:some-richtext' }] }; + +/** A rich-text value: a document holding one template chip, as the builder stores it. */ +const DOCUMENT = [ + { + type: 'paragraph', + children: [ + { + type: 'tag', + label: 'Richtext Template', + value: '$local:some-richtext', + children: [{ text: '' }], + }, + ], + }, +]; + +const field = (props: Partial) => + render( + + + + ); + +beforeEach(() => { + drawn.richText = []; + drawn.longString = []; +}); + +describe('a field declared as rich text, holding a document, with templates on offer', () => { + it('is drawn in the rich-text editor holding the document from its first render', () => { + field({ + type: 'richtext', + defaultType: 'richtext', + value: DOCUMENT, + templates: TEMPLATES, + allowTemplates: true, + }); + + expect(drawn.richText.length).toBeGreaterThan(0); + // every render: never the untyped field's text editor, never the document written as text + drawn.richText.forEach((props) => { + expect(props.valueFormat).toBeUndefined(); + expect(props.value).toBe(DOCUMENT); + }); + expect(drawn.longString).toHaveLength(0); + }); + + it('is drawn in the rich-text editor from its first render without templates too', () => { + field({ type: 'richtext', defaultType: 'richtext', value: DOCUMENT }); + + drawn.richText.forEach((props) => expect(props.value).toBe(DOCUMENT)); + expect(drawn.richText.length).toBeGreaterThan(0); + // the untyped field's editor without templates is the textarea: not drawn either + expect(drawn.longString).toHaveLength(0); + }); +}); + +describe('a field declared as long text', () => { + it('is drawn as a document from its first render, never as the one line an untyped field takes', () => { + field({ + defaultType: 'long-string', + value: 'Interface $local:id failed', + templates: TEMPLATES, + allowTemplates: true, + }); + + expect(drawn.richText.length).toBeGreaterThan(0); + drawn.richText.forEach((props) => + expect(props).toMatchObject({ valueFormat: 'text', singleLine: false }) + ); + }); +}); + +describe('the types a field mounts with', () => { + it('is the declared type, unquoted, for both', () => { + expect(resolveAutoFieldTypes('"richtext"' as any, undefined, DOCUMENT)).toEqual({ + type: 'richtext', + internalType: 'richtext', + }); + }); + + it("edits an untyped field holding a value as the value's type", () => { + expect(resolveAutoFieldTypes('any', undefined, 12)).toEqual({ + type: 'any', + internalType: 'int', + }); + expect(resolveAutoFieldTypes('auto', undefined, ['a'])).toEqual({ + type: 'auto', + internalType: 'list', + }); + }); + + it('leaves an untyped field holding nothing untyped', () => { + expect(resolveAutoFieldTypes('any', undefined, undefined)).toEqual({ + type: 'any', + internalType: 'any', + }); + }); + + it('edits as `defaultInternalType` where one is given', () => { + expect(resolveAutoFieldTypes('any', 'string', 12)).toEqual({ + type: 'any', + internalType: 'string', + }); + }); + + it("takes the value's type where no type is declared", () => { + expect(resolveAutoFieldTypes(undefined, undefined, true)).toEqual({ + type: 'bool', + internalType: 'bool', + }); + expect(resolveAutoFieldTypes(undefined, undefined, undefined)).toEqual({ + type: 'auto', + internalType: 'auto', + }); + }); +}); diff --git a/__tests__/autoFieldEditsAsBaseType.test.tsx b/__tests__/autoFieldEditsAsBaseType.test.tsx new file mode 100644 index 00000000..a62ff097 --- /dev/null +++ b/__tests__/autoFieldEditsAsBaseType.test.tsx @@ -0,0 +1,122 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A field of a type it has no editor of is edited as its base type. +// +// Qorus gives every field, option and argument the base data type of its values (`base_type`: `string` for an +// email address, `int` for a timeout). A field of a type the auto field draws no editor of - a string format +// type such as an email address, a user type, a type a newer server adds - showed "Unknown type!" in place of +// its editor, so a form could not set it (qorus#646). It is now edited as its base type; only a type with no +// base type it can edit is unknown. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/* Every render of the text editor an auto field draws text in. */ +const drawn = vi.hoisted(() => ({ longString: [] as Record[] })); + +vi.mock('../src/components/form/fields/long-string/LongString', () => ({ + default: (props: Record) => { + drawn.longString.push(props); + return null; + }, +})); + +import { AutoFormField, IAutoFieldProps } from '../src/components/form/fields/auto/AutoFormField'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +// a list's elements are template fields, which fetch the type catalogue +const field = (props: Partial) => + render( + + + + + + ); + +beforeEach(() => { + drawn.longString = []; +}); + +describe('a field of a type the auto field has no editor of', () => { + it('is edited as text where its base type is text: a string format type', () => { + field({ type: 'email', defaultType: 'email', base_type: 'string', value: 'ops@example.com' }); + + expect(screen.queryByText('Unknown type!')).toBeNull(); + expect(drawn.longString.length).toBeGreaterThan(0); + drawn.longString.forEach((props) => + expect(props).toMatchObject({ type: 'string', value: 'ops@example.com' }) + ); + }); + + it('is edited as its base type: a user type of whole numbers', () => { + const { container } = field({ + type: 'qoretechnologies/order-id' as never, + defaultType: 'qoretechnologies/order-id' as never, + base_type: 'int', + value: 7, + }); + + expect(screen.queryByText('Unknown type!')).toBeNull(); + expect(container.querySelector('input')?.value).toBe('7'); + expect(drawn.longString).toHaveLength(0); + }); + + it('is edited as its base type with a soft base type too', () => { + field({ type: 'order-ref' as never, defaultType: 'order-ref' as never, base_type: 'softstring' }); + + expect(screen.queryByText('Unknown type!')).toBeNull(); + expect(drawn.longString.length).toBeGreaterThan(0); + }); + + it('is unknown with no base type', () => { + field({ type: 'legacy-code' as never, defaultType: 'legacy-code' as never, value: 'A7' }); + + expect(screen.getByText('Unknown type!')).toBeTruthy(); + expect(drawn.longString).toHaveLength(0); + }); + + it('is unknown with a base type it has no editor of either', () => { + field({ + type: 'legacy-code' as never, + defaultType: 'legacy-code' as never, + base_type: 'mystery', + value: 'A7', + }); + + expect(screen.getByText('Unknown type!')).toBeTruthy(); + }); + + it("edits a list's elements as their base type: a list of email addresses", () => { + field({ + type: 'list', + defaultType: 'list', + ui_element_type: 'email' as never, + element_base_type: 'string', + value: ['ops@example.com', 'billing@example.com'], + }); + + expect(screen.queryByText('Unknown type!')).toBeNull(); + expect(drawn.longString.map((props) => props.value)).toEqual( + expect.arrayContaining(['ops@example.com', 'billing@example.com']) + ); + }); + + it("is unknown in a list whose elements' type has no base type", () => { + field({ + type: 'list', + defaultType: 'list', + ui_element_type: 'legacy-code' as never, + value: ['A7'], + }); + + expect(screen.getAllByText('Unknown type!').length).toBeGreaterThan(0); + }); + + it('keeps the editor of a type it has one of, whatever its base type', () => { + field({ type: 'string', defaultType: 'string', base_type: 'int', value: 'abc' }); + + expect(drawn.longString.length).toBeGreaterThan(0); + drawn.longString.forEach((props) => expect(props).toMatchObject({ value: 'abc' })); + }); +}); diff --git a/__tests__/autoFieldOffersNoSoftTypes.test.tsx b/__tests__/autoFieldOffersNoSoftTypes.test.tsx index e336e695..9d13b9ed 100644 --- a/__tests__/autoFieldOffersNoSoftTypes.test.tsx +++ b/__tests__/autoFieldOffersNoSoftTypes.test.tsx @@ -98,14 +98,14 @@ describe('the type picker of an untyped field', () => { expect( (picker.props?.items as { display_name: string }[]).map((item) => item.display_name) ).toEqual([ - 'True/False', + 'True or false', 'Date', 'Text', - 'Binary', - 'Decimal', + 'Binary data', + 'Number', 'List', 'Key/Value {}', - 'Integer', + 'Whole number', 'RGB Color', ]); // The text value resolved to `string`, which the picker shows as chosen. @@ -161,7 +161,7 @@ describe('the type picker of an untyped field', () => { expect(picker.props?.items).toEqual([ // The first entry for a base type wins; one with no name of its own takes // the base type's readable name. - { name: 'int', display_name: 'Integer' }, + { name: 'int', display_name: 'Whole number' }, { name: 'string', display_name: 'Some text' }, { name: 'list', display_name: undefined }, ]); diff --git a/__tests__/colorFieldInput.test.tsx b/__tests__/colorFieldInput.test.tsx new file mode 100644 index 00000000..54882b79 --- /dev/null +++ b/__tests__/colorFieldInput.test.tsx @@ -0,0 +1,116 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { + ColorFormField, + IColorFormFieldProps, + TColorFormFieldInput, +} from '../src/components/form/fields/color/Color'; +import type { TColorFormFieldInput as TPackageColorInput } from '../src/index'; + +/** + * qorus#646: the color field reads a value stored as a hex code, bare or as `{ hex }`, and a color with a field beside + * its own, so it can show it and store it right - but its `value` was typed as the stored hash only. A host had to + * convert the value before passing it (the IDE did), and then the field could not see the value was stored the + * wrong way: an edit that left the color as it was stored nothing, and the hex code stayed. The `value` is typed as + * what the field reads (`TColorFormFieldInput`), and a host passes the value as it is stored. + * + * The type is checked by `tsc` (the precheck), which fails on the parent's types: the field's `value` took none of + * these but the hash. + */ +describe("the color field's value, as a type", () => { + it('is what the field reads: the stored hash, a hex code, { hex }, or a hash with a field beside its own', () => { + expectTypeOf().toEqualTypeOf(); + // a host takes it from the package + expectTypeOf().toEqualTypeOf(); + expectTypeOf<'#6495ED'>().toMatchTypeOf(); + expectTypeOf<{ hex: string }>().toMatchTypeOf(); + expectTypeOf<{ hex: string; a: number }>().toMatchTypeOf(); + expectTypeOf<{ + r: number; + g: number; + b: number; + a: number; + }>().toMatchTypeOf(); + expectTypeOf<{ + r: number; + g: number; + b: number; + hex: string; + }>().toMatchTypeOf(); + // what the field stores is still only the hash + expectTypeOf>[0]>().toEqualTypeOf<{ + r: number; + g: number; + b: number; + a: number; + }>(); + // @ts-expect-error a number is not a color the field reads + const notAColor: TColorFormFieldInput = 42; + expect(notAColor).toBe(42); + }); +}); + +describe('the color field, given a value as it is stored', () => { + const renderField = (props: IColorFormFieldProps) => + render( + + + + ); + + const input = (container: HTMLElement, name: string) => + container.querySelector(`input[data-field="${name}"]`)!; + + /** The same color written another way: React gives no change event for the text an input already holds. */ + const retypeHex = (container: HTMLElement, text: string) => { + fireEvent.change(input(container, 'hex'), { target: { value: text } }); + fireEvent.blur(input(container, 'hex')); + }; + + it('stores a hex code passed straight in as the hash, when it is saved unchanged', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: '#6495ED', onChange }); + expect(input(container, 'hex').value).toBe('6495ED'); + retypeHex(container, '6495ed'); + expect(onChange).toHaveBeenCalledWith({ r: 100, g: 149, b: 237, a: 1 }); + }); + + it('stores { hex } passed straight in as the hash, its opacity kept', () => { + const onChange = vi.fn(); + const { container } = renderField({ + value: { hex: '#6495ed', a: 0.5 }, + onChange, + disableAlpha: false, + }); + expect(input(container, 'a').value).toBe('50'); + retypeHex(container, '6495ed'); + expect(onChange).toHaveBeenCalledWith({ r: 100, g: 149, b: 237, a: 0.5 }); + }); + + it('stores a hex code with its opacity as the hash', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: '#F808', onChange, disableAlpha: false }); + expect([input(container, 'hex').value, input(container, 'a').value]).toEqual(['FF8800', '53']); + retypeHex(container, 'ff8800'); + expect(onChange).toHaveBeenCalledWith({ r: 255, g: 136, b: 0, a: 0.53 }); + }); + + it('stores a hash with a field beside its own as the hash alone', () => { + const onChange = vi.fn(); + const { container } = renderField({ + value: { r: 100, g: 149, b: 237, a: 1, hex: '#6495ED' }, + onChange, + }); + retypeHex(container, '6495ed'); + expect(onChange).toHaveBeenCalledWith({ r: 100, g: 149, b: 237, a: 1 }); + }); + + it('stores nothing when a stored hash is saved unchanged', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: { r: 100, g: 149, b: 237, a: 1 }, onChange }); + retypeHex(container, '6495ed'); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/colorFormField.test.tsx b/__tests__/colorFormField.test.tsx new file mode 100644 index 00000000..50c76849 --- /dev/null +++ b/__tests__/colorFormField.test.tsx @@ -0,0 +1,425 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/** + * The `rgbcolor` field. It was react-color's Sketch picker, a function component that set its defaults with + * `defaultProps`, which React 19 no longer applies to function components; it is now drawn from the + * `@uiw` picker's parts, with the field's own preview, labelled inputs that take only what a color can + * be, help and swatches. + */ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { act, fireEvent, render } from '@testing-library/react'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ColorFormField, + colorToRgba, + IColorFormFieldProps, + IColorFormFieldValue, +} from '../src/components/form/fields/color/Color'; + +const renderField = (props: IColorFormFieldProps) => + render( + + + + ); + +/** The field as a form holds it: the value it sends comes back as its value. */ +const Controlled = ({ + initial, + onChange, + ...props +}: Omit & { + initial?: IColorFormFieldValue; + onChange: (value: IColorFormFieldValue) => void; +}) => { + const [value, setValue] = useState(initial); + return ( + { + onChange(next); + setValue(next); + }} + /> + ); +}; + +const renderControlled = (props: Parameters[0]) => + render( + + + + ); + +type TField = 'hex' | 'r' | 'g' | 'b' | 'a'; + +const input = (container: HTMLElement, name: TField) => + container.querySelector(`input[data-field="${name}"]`)!; + +const problem = (container: HTMLElement) => + container.querySelector('.reqraft-color-field-problem')!.textContent; + +/** Type into a field and leave it. */ +const typeAndLeave = (container: HTMLElement, name: TField, text: string) => { + fireEvent.change(input(container, name), { target: { value: text } }); + fireEvent.blur(input(container, name)); +}; + +/** Where the color bar's handle is, as a percentage of its width. */ +const huePosition = (container: HTMLElement) => + parseFloat( + Array.from(container.querySelectorAll('.reqraft-color-field-hue div')).find( + (element) => element.style.left + )!.style.left + ); + +const CORNFLOWER = { r: 100, g: 149, b: 237, a: 1 }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('ColorFormField', () => { + it('renders without a React warning: nothing it uses sets defaultProps on a function component', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined); + renderField({ value: CORNFLOWER }); + const said = errors.mock.calls.map((call) => call.map(String).join(' ')); + expect(said.filter((message) => message.includes('defaultProps'))).toEqual([]); + }); + + it('shows the stored color in labelled inputs and in its preview', () => { + const { container, getByLabelText, getByRole } = renderField({ value: CORNFLOWER }); + expect(getByLabelText('Hex code')).toHaveProperty('value', '6495ED'); + expect(getByLabelText('Red')).toHaveProperty('value', '100'); + expect(getByLabelText('Green')).toHaveProperty('value', '149'); + expect(getByLabelText('Blue')).toHaveProperty('value', '237'); + expect(input(container, 'a')).toBeNull(); + expect(getByRole('img', { name: 'Chosen color: #6495ED' })).toBeTruthy(); + }); + + it('says it holds no color yet when it holds none', () => { + const { getByRole } = renderField({}); + expect(getByRole('img', { name: 'No color chosen yet' })).toBeTruthy(); + }); + + it('takes its words from the host', () => { + const { getByLabelText, getByRole } = renderField({ + value: CORNFLOWER, + labels: { red: 'Rot', preview: (hex) => `Farbe ${hex}` }, + }); + expect(getByLabelText('Rot')).toHaveProperty('value', '100'); + expect(getByRole('img', { name: 'Farbe #6495ED' })).toBeTruthy(); + }); + + it('stores the color written as hex as red, green, blue and alpha', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + fireEvent.change(input(container, 'hex'), { target: { value: 'DC3545' } }); + expect(onChange).toHaveBeenLastCalledWith({ r: 220, g: 53, b: 69, a: 1 }); + }); + + it('takes a hex code pasted with its # and in lower case', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + fireEvent.change(input(container, 'hex'), { target: { value: ' #dc3545 ' } }); + expect(onChange).toHaveBeenLastCalledWith({ r: 220, g: 53, b: 69, a: 1 }); + expect(input(container, 'hex').value).toBe('DC3545'); + expect(problem(container)).toBe(''); + }); + + it('leaves out what cannot be in a hex code, and says so', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + fireEvent.change(input(container, 'hex'), { target: { value: 'DCZ35-45' } }); + expect(input(container, 'hex').value).toBe('DC3545'); + expect(onChange).toHaveBeenLastCalledWith({ r: 220, g: 53, b: 69, a: 1 }); + expect(problem(container)).toBe( + 'Z, - cannot be in a hex code: use the digits 0–9 and the letters A–F.' + ); + }); + + it('keeps a hex code to six characters, and says so', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + fireEvent.change(input(container, 'hex'), { target: { value: 'DC3545AA' } }); + expect(input(container, 'hex').value).toBe('DC3545'); + expect(problem(container)).toBe('A hex code has six characters.'); + }); + + it('stores three characters as the six they are short for, when the field is left', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + fireEvent.change(input(container, 'hex'), { target: { value: 'F80' } }); + // three may be the start of six + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(input(container, 'hex')); + expect(onChange).toHaveBeenLastCalledWith({ r: 255, g: 136, b: 0, a: 1 }); + }); + + it('stores nothing for a hex code of another length, says why and shows the color again', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + typeAndLeave(container, 'hex', '6495'); + expect(onChange).not.toHaveBeenCalled(); + expect(input(container, 'hex').value).toBe('6495ED'); + expect(problem(container)).toBe( + '6495 is not a color: a hex code has six characters (or three, short for six).' + ); + typeAndLeave(container, 'hex', ''); + expect(problem(container)).toBe('Type a hex code: six characters, each 0–9 or A–F.'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('stores a red, green or blue in its range', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + fireEvent.change(input(container, 'g'), { target: { value: '0' } }); + expect(onChange).toHaveBeenLastCalledWith({ r: 100, g: 0, b: 237, a: 1 }); + fireEvent.change(input(container, 'b'), { target: { value: '255' } }); + expect(onChange).toHaveBeenLastCalledWith({ r: 100, g: 149, b: 255, a: 1 }); + }); + + it('stores nothing past a range, marks the field and says the range', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + fireEvent.change(input(container, 'r'), { target: { value: '256' } }); + expect(onChange).not.toHaveBeenCalled(); + expect(input(container, 'r').value).toBe('256'); + expect(input(container, 'r').getAttribute('aria-invalid')).toBe('true'); + expect(problem(container)).toBe('Red goes from 0 to 255.'); + fireEvent.blur(input(container, 'r')); + expect(input(container, 'r').value).toBe('100'); + expect(input(container, 'r').hasAttribute('aria-invalid')).toBe(false); + expect(problem(container)).toBe('Red goes from 0 to 255.'); + }); + + it('leaves out what is not a digit, and says so with the range when both are wrong', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + fireEvent.change(input(container, 'g'), { target: { value: '-1.5' } }); + expect(input(container, 'g').value).toBe('15'); + expect(onChange).toHaveBeenLastCalledWith({ r: 100, g: 15, b: 237, a: 1 }); + expect(problem(container)).toBe('-, . cannot be in Green: type a whole number.'); + // what was typed, and the range, are data: drawn as code + expect( + Array.from(container.querySelectorAll('.reqraft-color-field-problem code')).map((c) => c.textContent) + ).toEqual(['-', '.']); + fireEvent.change(input(container, 'b'), { target: { value: '9e99' } }); + expect(problem(container)).toBe( + 'e cannot be in Blue: type a whole number. Blue goes from 0 to 255.' + ); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('shows a number again when its field is left empty, and says what it needs', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange }); + typeAndLeave(container, 'b', ''); + expect(input(container, 'b').value).toBe('237'); + expect(problem(container)).toBe('Blue needs a whole number from 0 to 255.'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('steps a number with the arrow keys, by ten with Shift, within its range', () => { + const onChange = vi.fn(); + const { container } = renderControlled({ initial: { r: 250, g: 5, b: 0, a: 1 }, onChange }); + fireEvent.keyDown(input(container, 'r'), { key: 'ArrowUp', shiftKey: true }); + expect(onChange).toHaveBeenLastCalledWith({ r: 255, g: 5, b: 0, a: 1 }); + fireEvent.keyDown(input(container, 'g'), { key: 'ArrowDown', shiftKey: true }); + expect(onChange).toHaveBeenLastCalledWith({ r: 255, g: 0, b: 0, a: 1 }); + fireEvent.keyDown(input(container, 'b'), { key: 'ArrowUp' }); + expect(onChange).toHaveBeenLastCalledWith({ r: 255, g: 0, b: 1, a: 1 }); + }); + + it('shows and stores the opacity as a percentage of 0 to 100', () => { + const onChange = vi.fn(); + const { container } = renderField({ + value: { ...CORNFLOWER, a: 0.5 }, + disableAlpha: false, + onChange, + }); + expect(input(container, 'a').value).toBe('50'); + fireEvent.change(input(container, 'a'), { target: { value: '25' } }); + expect(onChange).toHaveBeenLastCalledWith({ ...CORNFLOWER, a: 0.25 }); + fireEvent.change(input(container, 'a'), { target: { value: '101' } }); + expect(problem(container)).toBe('Opacity goes from 0 to 100.'); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('keeps the opacity when a hex code or a swatch picks the color', () => { + const onChange = vi.fn(); + const { container } = renderField({ + value: { ...CORNFLOWER, a: 0.5 }, + disableAlpha: false, + onChange, + }); + fireEvent.change(input(container, 'hex'), { target: { value: 'DC3545' } }); + expect(onChange).toHaveBeenLastCalledWith({ r: 220, g: 53, b: 69, a: 0.5 }); + fireEvent.click(container.querySelector('.reqraft-color-field-swatch[data-color="#417505"]')!); + expect(onChange).toHaveBeenLastCalledWith({ r: 65, g: 117, b: 5, a: 0.5 }); + }); + + it('stores the opacity bar in hundredths, as the opacity field shows it', () => { + const onChange = vi.fn(); + const { container } = renderField({ + value: { ...CORNFLOWER, a: 0.5 }, + disableAlpha: false, + onChange, + }); + const alpha = container.querySelector( + '.reqraft-color-field-alpha .w-color-interactive' + )!; + fireEvent.keyDown(alpha, { key: 'ArrowLeft' }); + expect(onChange).toHaveBeenLastCalledWith({ ...CORNFLOWER, a: 0.49 }); + }); + + it('keeps the stored transparency when the transparency slider is left out', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: { ...CORNFLOWER, a: 0.5 }, onChange }); + expect(container.querySelector('.reqraft-color-field-alpha')).toBeNull(); + fireEvent.change(input(container, 'hex'), { target: { value: 'DC3545' } }); + expect(onChange).toHaveBeenLastCalledWith({ r: 220, g: 53, b: 69, a: 0.5 }); + }); + + it('offers the transparency slider when asked to', () => { + const { container } = renderField({ value: { ...CORNFLOWER, a: 0.5 }, disableAlpha: false }); + expect(container.querySelector('.reqraft-color-field-alpha')).not.toBeNull(); + }); + + it('shows a color stored as hex, bare or as { hex }', () => { + expect(input(renderField({ value: '#dc3545' as never }).container, 'hex').value).toBe('DC3545'); + expect(input(renderField({ value: { hex: '#6495ed' } as never }).container, 'hex').value).toBe( + '6495ED' + ); + }); + + it('offers the host’s swatches, or none', () => { + const { container } = renderField({ + value: CORNFLOWER, + presetColors: [{ color: '#112233', title: 'Navy' }], + }); + const swatches = container.querySelectorAll('.reqraft-color-field-swatch'); + expect(swatches).toHaveLength(1); + expect(swatches[0].getAttribute('aria-label')).toBe('Use Navy'); + expect( + renderField({ presetColors: false }).container.querySelector('.reqraft-color-field-swatches') + ).toBeNull(); + }); + + it('goes back to its starting color when the value is cleared', () => { + const { container, rerender } = renderField({}); + const starting = input(container, 'hex').value; + rerender( + + + + ); + expect(input(container, 'hex').value).toBe('DC3545'); + rerender( + + + + ); + expect(input(container, 'hex').value).toBe(starting); + }); + + it('keeps the hue it was chosen from when the color it sent comes back as a grey', () => { + const onChange = vi.fn(); + // hue 210, saturation 2%: one step from grey on the saturation's arrow keys + const { container } = renderControlled({ initial: { r: 125, g: 127, b: 130, a: 1 }, onChange }); + const hue = huePosition(container); + expect(hue).toBeGreaterThan(50); + + const saturation = container.querySelector('.w-color-saturation')!; + for (let step = 0; step < 5; step++) { + act(() => { + fireEvent.keyDown(saturation, { key: 'ArrowLeft' }); + }); + } + + // the field now stores a grey, which has no hue of its own + const stored = onChange.mock.lastCall![0]; + expect(stored.r === stored.g && stored.g === stored.b).toBe(true); + // and the color bar stays where it was instead of jumping back to red + expect(huePosition(container)).toBeCloseTo(hue, 0); + }); + + it('keeps the hue when a grey is typed in', () => { + const onChange = vi.fn(); + const { container } = renderControlled({ initial: CORNFLOWER, onChange }); + const hue = huePosition(container); + fireEvent.change(input(container, 'hex'), { target: { value: '808080' } }); + expect(onChange).toHaveBeenLastCalledWith({ r: 128, g: 128, b: 128, a: 1 }); + expect(huePosition(container)).toBeCloseTo(hue, 0); + }); + + it('stores nothing when the hue moves on a grey: the color is the same', () => { + const onChange = vi.fn(); + const { container } = renderField({ value: { r: 128, g: 128, b: 128, a: 1 }, onChange }); + const hue = container.querySelector('.reqraft-color-field-hue .w-color-interactive')!; + fireEvent.keyDown(hue, { key: 'ArrowRight' }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('shows and hides what its controls mean', () => { + const { container, getByRole } = renderField({ value: CORNFLOWER }); + const toggle = getByRole('button', { name: /What do these mean/ }); + expect(container.querySelector('.reqraft-color-field-help')).toBeNull(); + fireEvent.click(toggle); + const help = container.querySelector('.reqraft-color-field-help')!; + expect(help.textContent).toContain('Hex code: the whole color as six characters'); + expect(help.textContent).toContain('Red, Green, Blue'); + // no opacity offered, none explained + expect(help.textContent).not.toContain('Opacity'); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + fireEvent.click(toggle); + expect(container.querySelector('.reqraft-color-field-help')).toBeNull(); + }); + + it.each([ + ['disabled', { disabled: true }], + ['read-only', { readOnly: true }], + ])('a %s field is out of reach and stores nothing', (_kind, props) => { + const onChange = vi.fn(); + const { container } = renderField({ value: CORNFLOWER, onChange, ...props }); + const field = container.querySelector('.reqraft-color-field')!; + expect(field.inert).toBe(true); + expect(field.getAttribute('aria-disabled')).toBe('true'); + fireEvent.change(input(container, 'hex'), { target: { value: 'DC3545' } }); + fireEvent.click(container.querySelector('.reqraft-color-field-swatch')!); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('an editable field is in reach', () => { + const { container } = renderField({ value: CORNFLOWER }); + const field = container.querySelector('.reqraft-color-field')!; + expect(field.inert).toBe(false); + expect(field.hasAttribute('aria-disabled')).toBe(false); + }); +}); + +describe('colorToRgba', () => { + it('reads every shape an rgbcolor value is stored in', () => { + expect(colorToRgba({ r: 1, g: 2, b: 3 })).toEqual({ r: 1, g: 2, b: 3, a: 1 }); + expect(colorToRgba({ r: 1, g: 2, b: 3, a: 0.5 })).toEqual({ r: 1, g: 2, b: 3, a: 0.5 }); + expect(colorToRgba('#0a0b0c')).toEqual({ r: 10, g: 11, b: 12, a: 1 }); + expect(colorToRgba('0a0b0c')).toEqual({ r: 10, g: 11, b: 12, a: 1 }); + expect(colorToRgba({ hex: '#fff' })).toEqual({ r: 255, g: 255, b: 255, a: 1 }); + }); + + it('brings a channel past its range into it, so it is a color', () => { + expect(colorToRgba({ r: 300, g: -4, b: 12.6, a: 2 })).toEqual({ r: 255, g: 0, b: 13, a: 1 }); + expect(colorToRgba({ r: 1, g: 2, b: 3, a: 0.456 })).toEqual({ r: 1, g: 2, b: 3, a: 0.46 }); + }); + + it('is undefined for anything that is not a color', () => { + expect(colorToRgba(undefined)).toBeUndefined(); + expect(colorToRgba(null)).toBeUndefined(); + expect(colorToRgba({})).toBeUndefined(); + expect(colorToRgba({ r: 1, g: 2 })).toBeUndefined(); + expect(colorToRgba({ r: '1', g: 2, b: 3 })).toBeUndefined(); + expect(colorToRgba('not a color')).toBeUndefined(); + expect(colorToRgba({ hex: 'zz' })).toBeUndefined(); + }); +}); diff --git a/__tests__/colorSummary.test.ts b/__tests__/colorSummary.test.ts new file mode 100644 index 00000000..9778782c --- /dev/null +++ b/__tests__/colorSummary.test.ts @@ -0,0 +1,32 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { describe, expect, it } from 'vitest'; +import { + colorToCss, + formatColorValue, + formatOptionValue, +} from '../src/components/form/engine/readFirst'; + +/** + * qorus#646: a compact row summarises a color with the reader `ReqraftColorValue` uses, so the two agree. + * That reader takes a hex code without its `#`, as the color field does, and a value that only looks like + * one is not a color: `#zzz` was summarised as "#ZZZ" beside a swatch the browser could not paint, and a + * channel that is not a finite number as "#NANNANNAN". + */ +describe("the compact row's summary of a color", () => { + it('is the color, in every shape', () => { + expect(formatColorValue('abcdef')).toBe('#ABCDEF'); + expect(colorToCss('abcdef')).toBe('rgba(171, 205, 239, 1)'); + }); + + it('is no color for a value that is not one - not a hex code and a swatch of nothing', () => { + expect(formatColorValue('#zzz')).toBeUndefined(); + expect(colorToCss('#zzz')).toBeUndefined(); + expect(formatColorValue({ r: Number.NaN, g: 0, b: 0 })).toBeUndefined(); + }); + + it('says what a value that is not a color is', () => { + expect( + formatOptionValue({ type: 'rgbcolor', value: '#zzz' } as never, { type: 'rgbcolor' } as never) + ).toBe('Not a color: “#zzz”'); + }); +}); diff --git a/__tests__/colorValue.test.tsx b/__tests__/colorValue.test.tsx new file mode 100644 index 00000000..c309dd67 --- /dev/null +++ b/__tests__/colorValue.test.tsx @@ -0,0 +1,151 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { describeColorValue, ReqraftColorValue } from '../src/components/colorValue'; +import * as reqraft from '../src/index'; + +/** + * qorus#646: a color shown read-only - a swatch beside its value - is one component, `ReqraftColorValue`, + * drawn the way a compact form row has drawn it (the row now uses it). It takes every shape a color is + * stored in, says what an invalid value is instead of drawing a swatch for it, and is readable without + * seeing the swatch. + */ + +const renderValue = (ui: React.ReactElement) => render({ui}); + +describe('a color, described', () => { + it.each([ + ['{ r, g, b }', { r: 100, g: 149, b: 237 }, '#6495ED', 'rgba(100, 149, 237, 1)'], + ['{ r, g, b, a } opaque', { r: 0, g: 0, b: 255, a: 1 }, '#0000FF', 'rgba(0, 0, 255, 1)'], + [ + '{ r, g, b, a } see-through', + { r: 255, g: 0, b: 0, a: 0.5 }, + 'rgba(255, 0, 0, 0.5)', + 'rgba(255, 0, 0, 0.5)', + ], + ['a hex code', '#abcdef', '#ABCDEF', 'rgba(171, 205, 239, 1)'], + ['a hex code without its #', 'abcdef', '#ABCDEF', 'rgba(171, 205, 239, 1)'], + ['a three-digit hex code', '#f80', '#F80', 'rgba(255, 136, 0, 1)'], + ['a hex code with its alpha', '#ff000080', '#FF000080', 'rgba(255, 0, 0, 0.5)'], + ['{ hex }', { hex: '#6495ed' }, '#6495ED', 'rgba(100, 149, 237, 1)'], + ])('%s is a color', (_shape, value, text, css) => { + expect(describeColorValue(value)).toEqual({ kind: 'color', text, css }); + }); + + it('brings a channel past its range into it, as the color field does', () => { + expect(describeColorValue({ r: 300, g: -5, b: 0 })).toMatchObject({ + kind: 'color', + text: '#FF0000', + }); + expect(describeColorValue({ r: 0, g: 0, b: 0, a: 2 })).toMatchObject({ text: '#000000' }); + expect(describeColorValue({ r: 0, g: 0, b: 0, a: -1 })).toMatchObject({ + text: 'rgba(0, 0, 0, 0)', + }); + }); + + it.each([ + ['a hex code of letters past F', '#zzz', '“#zzz”'], + ['a hex code of seven digits', '#abcdefa', '“#abcdefa”'], + ['a color name', 'red', '“red”'], + ['a channel that is not a number', { r: 'a', g: 0, b: 0 }, '{"r":"a","g":0,"b":0}'], + ['a channel that is not finite', { r: Number.NaN, g: 0, b: 0 }, '{"r":null,"g":0,"b":0}'], + ['a hash with no color in it', { name: 'blue' }, '{"name":"blue"}'], + ['a number', 42, '42'], + ])('%s is not a color, and is shown as it is', (_shape, value, shown) => { + expect(describeColorValue(value)).toEqual({ kind: 'invalid', shown }); + }); + + it('shortens a long value that is not a color', () => { + const description = describeColorValue({ note: 'x'.repeat(200) }); + expect(description.kind).toBe('invalid'); + expect((description as { shown: string }).shown.length).toBeLessThanOrEqual(61); + expect((description as { shown: string }).shown.endsWith('…')).toBe(true); + }); + + it.each([ + ['nothing', undefined], + ['null', null], + ['an empty string', ''], + ['an empty hash', {}], + ])('%s is no color', (_shape, value) => { + expect(describeColorValue(value)).toEqual({ kind: 'empty' }); + }); +}); + +describe('ReqraftColorValue', () => { + it('is exported from the package', () => { + expect(reqraft.ReqraftColorValue).toBe(ReqraftColorValue); + expect(reqraft.describeColorValue).toBe(describeColorValue); + }); + + it('shows the color as text and gives its swatch a text alternative', () => { + const { container } = renderValue(); + const swatch = screen.getByRole('img', { name: 'Color #6495ED' }); + expect(swatch?.getAttribute('title')).toBe('Color #6495ED'); + expect(container.querySelector('.reqraft-color-value-text')?.textContent).toContain('#6495ED'); + expect(container.querySelector('.reqraft-color-value')?.getAttribute('data-color')).toBe( + '#6495ED' + ); + }); + + it('paints the swatch the color it names, transparency kept', () => { + renderValue(); + const swatch = screen.getByRole('img', { name: 'Color rgba(255, 0, 0, 0.5)' }); + expect(swatch?.getAttribute('data-swatch')).toBe('rgba(255, 0, 0, 0.5)'); + }); + + it('is one line at its compact size, and wraps at its normal size', () => { + const { container } = renderValue( + <> + + + + ); + const text = (which: string) => + container.querySelector(`.${which} .reqraft-color-value-text`) as HTMLElement; + expect(container.querySelector('.compact')?.getAttribute('data-size')).toBe('compact'); + expect(container.querySelector('.normal')?.getAttribute('data-size')).toBe('normal'); + expect(text('compact').style.whiteSpace).toBe('nowrap'); + expect(text('compact').style.textOverflow).toBe('ellipsis'); + expect(text('normal').style.whiteSpace).toBe('pre-wrap'); + }); + + it('says what a value that is not a color is, and draws no swatch for it', () => { + const { container } = renderValue(); + expect(screen.queryByRole('img')).toBeNull(); + expect(container.querySelector('.reqraft-color-value')?.getAttribute('data-invalid')).toBe( + 'true' + ); + expect(container.querySelector('.reqraft-color-value')?.textContent).toBe( + 'Not a color: “#zzz”' + ); + }); + + it('says there is no color, and draws no swatch, when there is none', () => { + const { container } = renderValue(); + expect(screen.queryByRole('img')).toBeNull(); + expect(container.querySelector('.reqraft-color-value')?.textContent).toContain('No color'); + }); + + it("speaks the host's language", () => { + const { container } = renderValue( + <> + `Farbe ${color}` }} + /> + `Keine Farbe: ${shown}` }} + /> + + + ); + expect(screen.getByRole('img', { name: 'Farbe #F80' })).not.toBeNull(); + expect(container.querySelector('.invalid')?.textContent).toContain('Keine Farbe: “rot”'); + expect(container.querySelector('.empty')?.textContent).toContain('Keine Farbe'); + }); +}); diff --git a/__tests__/colorValueInAControlGroup.test.tsx b/__tests__/colorValueInAControlGroup.test.tsx new file mode 100644 index 00000000..42bdeaae --- /dev/null +++ b/__tests__/colorValueInAControlGroup.test.tsx @@ -0,0 +1,95 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreControlGroup, ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ReqraftColorValue } from '../src/components/colorValue'; + +/** + * qorus#646 (found by the IDE's React-warning guard): a control group hands its layout props (fluid, + * spaceBetween, size, ...) to each component in it, and ReqraftColorValue spread what it did not take onto its + * span - React warned "Received `true` for a non-boolean attribute `fluid`" and "React does not recognize the + * `spaceBetween` prop". It passes a DOM element only what a DOM element takes. A group's own size (small, big) + * is not one of the value's sizes, and is read as the normal one rather than drawing nothing. + */ + +/** What React wrote to console.error while `ui` rendered. */ +const renderCollectingErrors = (ui: React.ReactElement) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const result = render(ui); + return { ...result, errors: consoleError.mock.calls.map((call) => call.map(String).join(' ')) }; + } finally { + consoleError.mockRestore(); + } +}; + +/** What React says when a prop lands on a DOM element that does not take it. */ +const DOM_PROPERTY_WARNING = + /React does not recognize|non-boolean attribute|Invalid value for prop|Unknown event handler property|Invalid DOM property/; + +const GROUP_PROPS = [ + 'fluid', + 'spacebetween', + 'flat', + 'fixed', + 'fill', + 'stack', + 'intent', + 'customtheme', + 'minimal', +]; + +describe('ReqraftColorValue in a control group', () => { + it('passes its span none of the props the group hands it', () => { + const { container, errors } = renderCollectingErrors( + + + + + + + ); + expect(errors.filter((error) => DOM_PROPERTY_WARNING.test(error))).toEqual([]); + const values = Array.from(container.querySelectorAll('.reqraft-color-value')); + expect(values).toHaveLength(2); + for (const value of values) { + for (const name of GROUP_PROPS) { + expect(value.hasAttribute(name)).toBe(false); + } + } + }); + + it("reads a group's size it does not have as its normal size", () => { + const { container } = render( + + + + + + ); + const value = container.querySelector('.reqraft-color-value')!; + expect(value.getAttribute('data-size')).toBe('normal'); + expect(value.textContent).toBe('#6495ED'); + }); + + it('still passes on the DOM attributes it is given', () => { + const onClick = vi.fn(); + const { container } = render( + + ); + const value = container.querySelector('.reqraft-color-value') as HTMLElement; + expect(value.id).toBe('brand'); + expect(value.title).toBe('Brand color'); + expect(value.getAttribute('aria-describedby')).toBe('brand-help'); + expect(value.getAttribute('data-field')).toBe('brand'); + value.click(); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/colorValueInARow.test.tsx b/__tests__/colorValueInARow.test.tsx new file mode 100644 index 00000000..0d48336a --- /dev/null +++ b/__tests__/colorValueInARow.test.tsx @@ -0,0 +1,71 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/hooks/useStorage/useStorage', () => ({ + useReqraftStorage: (_k: string, d: unknown) => [d, vi.fn()], +})); + +import { FormEngine } from '../src/components/form/engine/FormEngine'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +/** + * qorus#646: a compact form row draws a color with ReqraftColorValue at its compact size - the look the row + * has always had - so a page and a row show a color the same way, and a value that is not a color says + * what it is in the row too, instead of a hex code beside a swatch of nothing. + */ +const renderRow = (field: unknown, schema: Record = {}) => + render( + + + + + + ); + +const readRow = async (container: HTMLElement): Promise => { + await waitFor(() => expect(container.querySelector('.readfirst-row')).not.toBeNull()); + return container.querySelector('.readfirst-row') as HTMLElement; +}; + +describe('a color in a compact row', () => { + it('is drawn by ReqraftColorValue at its compact size, its swatch named', async () => { + const { container } = renderRow({ type: 'rgbcolor', value: { r: 100, g: 149, b: 237, a: 1 } }); + const row = await readRow(container); + await waitFor(() => expect(row.querySelector('.reqraft-color-value')).not.toBeNull()); + const value = row.querySelector('.reqraft-color-value') as HTMLElement; + expect(value.getAttribute('data-size')).toBe('compact'); + expect(value.getAttribute('data-color')).toBe('#6495ED'); + expect(value.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe('Color #6495ED'); + }); + + it('says what a value that is not a color is, with no swatch', async () => { + const { container } = renderRow({ type: 'rgbcolor', value: '#zzz' }); + const row = await readRow(container); + await waitFor(() => expect(row.textContent).toContain('Not a color: “#zzz”')); + expect(row.querySelector('.reqraft-color-value[data-invalid="true"]')).not.toBeNull(); + expect(row.querySelector('[role="img"]')).toBeNull(); + expect(row.textContent).not.toContain('#ZZZ'); + }); + + it('shows a sensitive color as hidden, with no swatch to give it away', async () => { + const { container } = renderRow( + { type: 'rgbcolor', value: { r: 100, g: 149, b: 237, a: 1 } }, + { sensitive: true } + ); + const row = await readRow(container); + await waitFor(() => expect(row.textContent).toContain('••••••')); + expect(row.querySelector('.reqraft-color-value')).toBeNull(); + // no swatch and no wash of the color: the row drew both beside the dots + expect(row.querySelector('[style*="color-mix"]')).toBeNull(); + expect(row.textContent).not.toContain('#6495ED'); + }); +}); diff --git a/__tests__/compactNarrowRule.test.ts b/__tests__/compactNarrowRule.test.ts new file mode 100644 index 00000000..b10b3457 --- /dev/null +++ b/__tests__/compactNarrowRule.test.ts @@ -0,0 +1,47 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { describe, expect, it } from 'vitest'; +import { + COMPACT_MIN_VALUE_WIDTH, + COMPACT_ROW_GAP, + COMPACT_ROW_PAD_X, + isCompactNarrow, + LABEL_AFFORDANCE_WIDTH, + LABEL_COL_MAX, + LABEL_COLUMN_MIN, +} from '../src/components/form/engine/compactRowStyles'; + +/** + * The narrow-row rule (qorus#646, David): a compact form stacks its rows where a value would have under 320px beside + * the label column it is laid out with, not at one form width whatever its labels. The FormEngine stories (A Row + * Stacks Where Its Value Would Be Narrow, and those after it) measure the layout; these hold the rule's edges. + */ +const widestColumn = LABEL_COL_MAX + LABEL_AFFORDANCE_WIDTH; +const narrowestColumn = LABEL_COLUMN_MIN; +const besideTheLabel = (column: number) => column + COMPACT_ROW_GAP + 2 * COMPACT_ROW_PAD_X; + +describe('a compact form stacks its rows', () => { + it('where its value would have less than the least room beside its label', () => { + const edge = besideTheLabel(widestColumn) + COMPACT_MIN_VALUE_WIDTH; + + expect(isCompactNarrow(edge - 1, widestColumn)).toBe(true); + expect(isCompactNarrow(edge, widestColumn)).toBe(false); + }); + + it('by its labels: long ones under about 600px, short ones under about 500px', () => { + expect(isCompactNarrow(560, widestColumn)).toBe(true); + expect(isCompactNarrow(700, widestColumn)).toBe(false); + expect(isCompactNarrow(560, narrowestColumn)).toBe(false); + expect(isCompactNarrow(440, narrowestColumn)).toBe(true); + expect(besideTheLabel(widestColumn) + COMPACT_MIN_VALUE_WIDTH).toBeGreaterThan(580); + // the widest column holds the help button at its real width, 26px (qlip #392), which moved the edge from 606 + expect(besideTheLabel(widestColumn) + COMPACT_MIN_VALUE_WIDTH).toBeLessThan(630); + expect(besideTheLabel(narrowestColumn) + COMPACT_MIN_VALUE_WIDTH).toBeGreaterThan(480); + expect(besideTheLabel(narrowestColumn) + COMPACT_MIN_VALUE_WIDTH).toBeLessThan(520); + }); + + it('at any width a phone has', () => { + for (const width of [320, 360, 390, 430]) { + expect(isCompactNarrow(width, narrowestColumn)).toBe(true); + } + }); +}); diff --git a/__tests__/compactRowCaretFindsTheControl.test.tsx b/__tests__/compactRowCaretFindsTheControl.test.tsx index 891d20c2..822cc08a 100644 --- a/__tests__/compactRowCaretFindsTheControl.test.tsx +++ b/__tests__/compactRowCaretFindsTheControl.test.tsx @@ -53,10 +53,11 @@ describe('the caret an opened row takes', () => { await waitFor(() => expect(focusedField()).toBe('kind')); const active = document.activeElement as HTMLElement; // Not a text box — the point of the case. The row offers a pick-one, and - // the caret is on the first choice. - expect(active.tagName).not.toBe('INPUT'); + // the caret is on the first choice: a native radio, one choice of two + // (qorus#646). expect(active.tagName).not.toBe('TEXTAREA'); - expect(active.getAttribute('tabindex')).toBe('0'); + expect((active as HTMLInputElement).type).toBe('radio'); + expect(active.closest('.reqore-checkbox')?.textContent).toContain('Service'); // …and the row it belongs to is the one the engine opened, not the next // focusable field further down. expect(focusedField()).not.toBe('name'); diff --git a/__tests__/compactRowOpensAsDrawn.test.tsx b/__tests__/compactRowOpensAsDrawn.test.tsx new file mode 100644 index 00000000..39daa0ac --- /dev/null +++ b/__tests__/compactRowOpensAsDrawn.test.tsx @@ -0,0 +1,177 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { FormEngine } from '../src/components/form/engine/FormEngine'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const fetchContext = emptyFetchContext(); + +/** + * A compact row opens or closes by what the user did to the row as it was drawn (qorus#646). + * + * A click on a row was a toggle that read its direction from the latest state. The first-required scan opens its + * row with an update of its own; a click on that row, still drawn closed, landed on top of it and closed the row + * the user had just opened, in the same task (the Qorus IDE's Schema "Draft Is Saved", whose read-first form + * turns the first-required step on). Now a click on a row drawn closed opens it whatever update is pending, and + * Done, Cancel and the label close it. + */ + +const OPTIONS = { + name: { type: 'string', ui_type: 'string', display_name: 'Name', required: true, sort: 1 }, + keyName: { type: 'string', ui_type: 'string', display_name: 'Key name', required: true, sort: 2 }, + description: { + type: 'string', + ui_type: 'string', + display_name: 'Description', + preselected: true, + sort: 3, + }, +} as never; + +/** A form opened from a button: the button still holds the focus as the form mounts, so the scan waits. */ +const renderDrawer = (value: Record = {}) => { + const onChange = vi.fn(); + const view = render( + + + + + + + ); + return { onChange, ...view }; +}; + +const row = (container: HTMLElement, field: string) => + container.querySelector(`[data-field="${field}"]`); + +const openRows = (container: HTMLElement) => + Array.from(container.querySelectorAll('.readfirst-row-editing')).map( + (element) => element.dataset.field + ); + +const control = (container: HTMLElement, field: string) => + container.querySelector( + `[data-field="${field}"] input, [data-field="${field}"] textarea` + ); + +const required = (found: T | null | undefined, what: string): T => { + if (!found) { + throw new Error(`${what} is missing`); + } + return found; +}; + +describe('a click on a compact row', () => { + it('opens a row drawn closed, even when the first-required scan opens it in the same moment', async () => { + const { container } = renderDrawer(); + await waitFor(() => expect(row(container, 'name')).toBeTruthy()); + expect(openRows(container)).toEqual([]); + + act(() => { + // Tab from the button into the form: the scan opens "name", the first row that needs an answer, with an + // update of its own... + required(row(container, 'description'), 'the "description" row').focus(); + // ...and before that is drawn, the user clicks "name", which is still drawn closed + fireEvent.click(required(row(container, 'name'), 'the "name" row')); + }); + + // the row the user clicked is open, and stays open: the click did not close what the scan opened + await waitFor(() => expect(openRows(container)).toEqual(['name'])); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(openRows(container)).toEqual(['name']); + }); + + it('opens a row drawn closed, and Done closes it', async () => { + const user = userEvent.setup(); + const { container } = renderDrawer(); + await waitFor(() => expect(row(container, 'name')).toBeTruthy()); + + await user.click(required(row(container, 'name'), 'the "name" row')); + await waitFor(() => expect(openRows(container)).toEqual(['name'])); + fireEvent.change(required(control(container, 'name'), 'the "name" input'), { + target: { value: 'api_key' }, + }); + await user.click( + required(container.querySelector('.options-readfirst-done'), 'Done') + ); + + await waitFor(() => expect(openRows(container)).toEqual([])); + expect(row(container, 'name')?.textContent).toContain('api_key'); + + // and a click opens it again + await user.click(required(row(container, 'name'), 'the "name" row')); + await waitFor(() => expect(openRows(container)).toEqual(['name'])); + }); + + it('Cancel edit closes the open row and puts its value back', async () => { + const user = userEvent.setup(); + const { container } = renderDrawer({ name: { type: 'string', value: 'api_key' } }); + await waitFor(() => expect(row(container, 'name')).toBeTruthy()); + + await user.click(required(row(container, 'name'), 'the "name" row')); + await waitFor(() => expect(openRows(container)).toEqual(['name'])); + fireEvent.change(required(control(container, 'name'), 'the "name" input'), { + target: { value: 'something else' }, + }); + await user.click( + await waitFor(() => + required(container.querySelector('.options-readfirst-cancel'), 'Cancel edit') + ) + ); + + await waitFor(() => expect(openRows(container)).toEqual([])); + expect(row(container, 'name')?.textContent).toContain('api_key'); + }); + + it("a second click, on the open row's label, closes it", async () => { + const user = userEvent.setup(); + const { container } = renderDrawer(); + await waitFor(() => expect(row(container, 'name')).toBeTruthy()); + + await user.click(required(row(container, 'name'), 'the "name" row')); + await waitFor(() => expect(openRows(container)).toEqual(['name'])); + await user.click( + required( + container.querySelector('[aria-label="Collapse Name"]'), + "the row's label" + ) + ); + + await waitFor(() => expect(openRows(container)).toEqual([])); + }); + + it('the label closes the open row after the scan opened it, and the next click opens it again', async () => { + const user = userEvent.setup(); + const { container } = renderDrawer(); + await waitFor(() => expect(row(container, 'name')).toBeTruthy()); + + // the scan opens "name" for a user who comes in from the keyboard + act(() => required(row(container, 'description'), 'the "description" row').focus()); + await waitFor(() => expect(openRows(container)).toEqual(['name'])); + + await user.click( + required( + container.querySelector('[aria-label="Collapse Name"]'), + "the row's label" + ) + ); + await waitFor(() => expect(openRows(container)).toEqual([])); + await user.click(required(row(container, 'name'), 'the "name" row')); + await waitFor(() => expect(openRows(container)).toEqual(['name'])); + }); +}); diff --git a/__tests__/compactToolbarInks.test.ts b/__tests__/compactToolbarInks.test.ts new file mode 100644 index 00000000..bde1d55d --- /dev/null +++ b/__tests__/compactToolbarInks.test.ts @@ -0,0 +1,34 @@ +import { buildTheme, getMainBackgroundColor } from '@qoretechnologies/reqore/dist/helpers/colors'; +import { DEFAULT_THEME } from '@qoretechnologies/reqore/dist/constants/theme'; +import { parseToHsl } from 'polished'; +import { getCompactToolbarInks } from '../src/components/form/engine/compactToolbarColors'; +import { contrastRatio, READABLE_TEXT } from '../src/helpers/readableColors'; + +/* The read-first summary ("Incomplete · 4/6 set · 2 need attention →") drew its words in the warning and success + intents themselves: 4.0-4.1:1 on the IDE's dark page (qorus#646). In every theme its words read. */ +describe('the read-first completion summary', () => { + for (const main of ['#333333', '#0f0f0f', '#f4f4f4', '#ffffff', '#1b2f45']) { + test(`reads on a ${main} theme`, () => { + const theme = buildTheme({ ...DEFAULT_THEME, main } as never); + const page = getMainBackgroundColor(theme); + const inks = getCompactToolbarInks(theme); + + expect(contrastRatio(inks.warning, page)).toBeGreaterThanOrEqual(READABLE_TEXT); + expect(contrastRatio(inks.success, page)).toBeGreaterThanOrEqual(READABLE_TEXT); + expect(inks.countOpacity).toBeGreaterThanOrEqual(0.5); + }); + } + + test('the intents keep their hue, only lighter where they did not read', () => { + const theme = buildTheme({ ...DEFAULT_THEME, main: '#0f0f0f' } as never); + const inks = getCompactToolbarInks(theme); + + for (const [ink, intent] of [ + [inks.warning, theme.intents.warning], + [inks.success, theme.intents.success], + ]) { + expect(Math.abs(parseToHsl(ink).hue - parseToHsl(intent).hue)).toBeLessThan(15); + expect(parseToHsl(ink).lightness).toBeGreaterThanOrEqual(parseToHsl(intent).lightness); + } + }); +}); diff --git a/__tests__/debouncedFieldEcho.test.tsx b/__tests__/debouncedFieldEcho.test.tsx index 403d24b8..0e04980f 100644 --- a/__tests__/debouncedFieldEcho.test.tsx +++ b/__tests__/debouncedFieldEcho.test.tsx @@ -198,6 +198,35 @@ describe('NumberFormField and the echo of its own emit', () => { }); }); +describe('NumberFormField and an edit undone while its emit is in flight', () => { + it('reports the number it went back to', async () => { + // "50" is emitted at 100 ms and handed back at 220 ms; the "0" is deleted at 110 ms. Compared with the 5 it + // was given, the field took "5" for no change and sent nothing, and the parent kept 50 (qorus#646). + const reported: unknown[] = []; + const Parent = () => { + const [value, setValue] = useState(5); + return ( + { + reported.push(next); + setTimeout(() => setValue(next), PARENT_ECHO_MS); + }} + /> + ); + }; + render(); + fireEvent.change(screen.getByTestId('input'), { target: { value: '50' } }); + await act(() => vi.advanceTimersByTimeAsync(110)); + expect(reported.at(-1)).toBe(50); + fireEvent.change(screen.getByTestId('input'), { target: { value: '5' } }); + await act(() => vi.advanceTimersByTimeAsync(1000)); + + expect((screen.getByTestId('input') as HTMLInputElement).value).toBe('5'); + expect(reported.at(-1)).toBe(5); + }); +}); + describe('NumberFormField takes whatever is typed', () => { // A typed field is typed freely (David's review of qorus#646): a `number` input dropped every character that // is not part of a number, so a template (`$local:x`) or an expression (`@qty * 2`) could not be typed into a @@ -312,6 +341,32 @@ describe('ReqraftBinaryFormField and the echo of its own emit', () => { }); }); +describe('ReqraftBinaryFormField and an edit undone while its emit is in flight', () => { + it('reports the value it went back to', async () => { + const reported: unknown[] = []; + const Parent = () => { + const [value, setValue] = useState('AAAA'); + return ( + { + reported.push(next); + setTimeout(() => setValue(next), PARENT_ECHO_MS); + }} + /> + ); + }; + render(); + fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'AAAAB' } }); + await act(() => vi.advanceTimersByTimeAsync(110)); + expect(reported.at(-1)).toBe('AAAAB'); + fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'AAAA' } }); + await act(() => vi.advanceTimersByTimeAsync(1000)); + + expect(reported.at(-1)).toBe('AAAA'); + }); +}); + describe('EmittedValues', () => { it('treats an emit in flight as an echo, and acknowledges the ones before it', () => { const emitted = new EmittedValues(); @@ -338,6 +393,20 @@ describe('EmittedValues', () => { expect(emitted.isEcho('x')).toBe(false); }); + it('settles on the latest emit in flight, and on what the parent holds with none', () => { + const emitted = new EmittedValues(); + expect(emitted.settled('held')).toBe('held'); + emitted.record('a'); + emitted.record('ab'); + expect(emitted.settled('held')).toBe('ab'); + expect(emitted.isEcho('ab')).toBe(true); + expect(emitted.settled('ab')).toBe('ab'); + // an emit of nothing is still an emit + const empty = new EmittedValues(); + empty.record(undefined); + expect(empty.settled('held')).toBeUndefined(); + }); + it('compares structured values with the equality it is given', () => { const emitted = new EmittedValues((a, b) => JSON.stringify(a) === JSON.stringify(b)); emitted.record([1, 2]); diff --git a/__tests__/descriptionMarkdown.test.tsx b/__tests__/descriptionMarkdown.test.tsx index 78fa77d7..337091c9 100644 --- a/__tests__/descriptionMarkdown.test.tsx +++ b/__tests__/descriptionMarkdown.test.tsx @@ -22,7 +22,7 @@ describe('Description markdown', () => { ); // A backtick span marks an identifier, so it has to render as one. Mapping - // it to a ReqoreTag with an explicit near-white colour auto-contrasted the + // it to a ReqoreTag with an explicit near-white color auto-contrasted the // text to near-black: on a dark form, a description listing option keys // read as a row of glaring labels rather than as prose. const code = container.querySelectorAll('code'); diff --git a/__tests__/descriptionTooltip.test.tsx b/__tests__/descriptionTooltip.test.tsx new file mode 100644 index 00000000..4b4da2e8 --- /dev/null +++ b/__tests__/descriptionTooltip.test.tsx @@ -0,0 +1,310 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/** + * A description from the server is markdown. Its hover - a template chip's, a choice's, a schema section's - + * printed it raw (`**`, backticks) beside a template list that draws the same string through the host's + * renderer, and a finger could not open it: a hover card opens on `mouseenter` and closes on any click, and + * a tap is both. `descriptionTooltip` draws it with the host's renderer, and `useDescriptionTooltips` opens + * it on a tap where the pointer cannot hover. + */ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; +import { IReqoreTooltip } from '@qoretechnologies/reqore/dist/types/global'; +import { fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + MarkdownRendererContext, + TMarkdownRenderer, +} from '../src/components/Description/markdownRendererContext'; +import { makeDpqlTagRenderer } from '../src/components/dpqlEditor/dpqlTags'; +import { ReadOnlyTemplateTag } from '../src/components/form/fields/template/ReadOnlyTemplateTag'; +import { + DESCRIPTION_TOOLTIP_MAX_WIDTH, + descriptionTooltip, + useDescriptionTooltips, +} from '../src/helpers/descriptionTooltip'; +import { templateTooltip } from '../src/helpers/templates'; +import { COARSE_POINTER_QUERY } from '../src/hooks/useCoarsePointer'; + +const DESCRIPTION = 'The **customer’s** full name, from `customer.name`.'; +/** What it says without a host renderer: its words, none of its marks. */ +const PLAIN = 'The customer’s full name, from customer.name.'; + +/** A host renderer, as the IDE supplies one: it says what it was asked to draw. */ +const hostRender = vi.fn(({ value, compact }) => ( +
+)); + +const TEMPLATES = { + items: [ + { + label: 'Local values', + items: [ + { label: 'Customer name', value: '$local:customer', description: DESCRIPTION }, + { label: 'Order id', value: '$local:order' }, + ], + }, + ], +} as IReqoreFormTemplates; + +/** The element a card's content drew, mounted, so it can be read. */ +const drawn = (tooltip: unknown) => { + const content = (tooltip as IReqoreTooltip).content as ReactNode; + return render(<>{content}).container.firstElementChild as HTMLElement; +}; + +/** The pointer is a finger, or a mouse. */ +const pointer = (coarse: boolean) => + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query === COARSE_POINTER_QUERY ? coarse : false, + media: query, + addEventListener: () => undefined, + removeEventListener: () => undefined, + })); + +afterEach(() => { + vi.unstubAllGlobals(); + hostRender.mockClear(); +}); + +describe('descriptionTooltip', () => { + it('draws the description with the host renderer, compact, under its title', () => { + const tooltip = descriptionTooltip(DESCRIPTION, { render: hostRender }, 'Customer name'); + expect(tooltip).toMatchObject({ title: 'Customer name', maxWidth: DESCRIPTION_TOOLTIP_MAX_WIDTH }); + expect(hostRender).toHaveBeenCalledWith({ value: DESCRIPTION, compact: true }); + expect(drawn(tooltip).dataset).toMatchObject({ drawn: DESCRIPTION, compact: 'true' }); + // a mouse hovers: the card keeps the hover it always opened on + expect((tooltip as IReqoreTooltip).handler).toBeUndefined(); + }); + + it('opens on a tap where the pointer cannot hover', () => { + expect(descriptionTooltip(DESCRIPTION, { render: hostRender, tapToOpen: true })).toMatchObject({ + handler: 'click', + }); + // drawn or not: what it says is out of a finger's reach on a hover either way + expect(descriptionTooltip(DESCRIPTION, { tapToOpen: true })).toEqual({ + content: PLAIN, + maxWidth: DESCRIPTION_TOOLTIP_MAX_WIDTH, + handler: 'click', + }); + }); + + it('says the description without its marks where the host draws no markdown', () => { + expect(descriptionTooltip(DESCRIPTION, {})).toBe(PLAIN); + expect(descriptionTooltip(DESCRIPTION, {}, 'Customer name')).toEqual({ + title: 'Customer name', + content: PLAIN, + maxWidth: DESCRIPTION_TOOLTIP_MAX_WIDTH, + }); + }); + + it('keeps its paragraphs, each on its own, where it has more than one', () => { + const tooltip = descriptionTooltip('The **name**.\n\n- `first`\n- second', {}) as IReqoreTooltip; + const block = drawn(tooltip); + expect(block.textContent).toBe('The name.\n\nfirst\nsecond'); + expect(block.style.whiteSpace).toBe('pre-line'); + }); + + it('is the title alone without a description, and nothing without either', () => { + for (const none of [undefined, null, '', ' ', 42]) { + expect(descriptionTooltip(none, { render: hostRender, tapToOpen: true }, 'Order id')).toBe( + 'Order id' + ); + expect(descriptionTooltip(none, { render: hostRender })).toBeUndefined(); + } + expect(hostRender).not.toHaveBeenCalled(); + }); +}); + +describe('templateTooltip', () => { + it("draws a template's description under its name, and opens on a tap on touch", () => { + const tooltip = templateTooltip(TEMPLATES, '$local:customer', { + render: hostRender, + tapToOpen: true, + }); + expect(tooltip).toMatchObject({ title: 'Customer name', handler: 'click' }); + expect(drawn(tooltip).dataset.drawn).toBe(DESCRIPTION); + }); + + /* qorus#646, David: "in such cases there should be a reason given why there is no description in the hover" */ + it('says why there is no description for a template the catalogue does not describe', () => { + const tooltip = templateTooltip(TEMPLATES, '$local:order', { render: hostRender }); + expect(tooltip).toMatchObject({ title: 'Order id' }); + expect(drawn(tooltip).dataset.drawn).toBe('No description was given for this value.'); + }); + + it("says what a host says of a reference it names, else the host's reason, else why there is none", () => { + const described = templateTooltip( + TEMPLATES, + '$._case.attempt', + { render: hostRender }, + undefined, + { + label: 'attempt (this case)', + description: 'How many times this case has run.', + } + ); + expect(described).toMatchObject({ title: 'attempt (this case)' }); + expect(drawn(described).dataset.drawn).toBe('How many times this case has run.'); + const hostReason = templateTooltip( + TEMPLATES, + '$._case.attempt', + { render: hostRender }, + undefined, + { + label: 'attempt (this case)', + noDescription: 'The case does not describe its own counters.', + } + ); + expect(drawn(hostReason).dataset.drawn).toBe('The case does not describe its own counters.'); + // the catalogue's name and description win over the host's for a reference it lists + const listed = templateTooltip( + TEMPLATES, + '$local:customer', + { render: hostRender }, + undefined, + { + label: 'not this', + description: 'nor this', + } + ); + expect(listed).toMatchObject({ title: 'Customer name' }); + expect(drawn(listed).dataset.drawn).toBe(DESCRIPTION); + }); + + it('lists the allowed values of a template under its description, as help shows them', () => { + const withValues = { + items: [ + { + label: 'mode', + value: '$._case.mode', + badge: 'string', + description: 'How this case runs.', + allowed_values: [ + { + display_name: 'Live', + short_desc: 'Calls the real services', + value: { type: 'string', value: 'live' }, + }, + { value: { type: 'string', value: 'simulate' } }, + ], + }, + ], + } as never; + const tooltip = templateTooltip(withValues, '$._case.mode', { render: hostRender }); + expect(tooltip).toMatchObject({ title: 'mode' }); + const { container } = render( + {(tooltip as IReqoreTooltip).content as ReactNode} + ); + expect(container.querySelector('[data-drawn]')?.getAttribute('data-drawn')).toBe( + 'Text.\n\nHow this case runs.' + ); + expect(container.querySelector('.allowed-values-heading')?.textContent).toBe('Allowed values'); + const rows = Array.from(container.querySelectorAll('.allowed-values-help tbody tr')).map( + (row) => Array.from(row.querySelectorAll('td')).map((cell) => cell.textContent) + ); + // a table: the exact value, its name, its description; nothing where a value has none + expect(rows).toEqual([ + ['live', 'Live', 'Calls the real services'], + ['simulate', '', ''], + ]); + expect(container.querySelectorAll('.allowed-values-help td code')).toHaveLength(2); + }); + + /* qorus#646, David on Form Engine · Value Tab Pick Replaces A Referenced Field: the hover of a field should say + the field's type. The catalogue gives it (the entry's badge) and no hover said it. */ + it('says what kind of value a template is, in words, over its description', () => { + const typed = { + items: [ + { label: 'pos', value: '$record:{pos}', badge: 'int' }, + { + label: 'Customer name', + value: '$local:customer', + badge: 'string', + description: DESCRIPTION, + }, + ], + } as IReqoreFormTemplates; + const pos = templateTooltip(typed, '$record:{pos}', { render: hostRender }); + expect(pos).toMatchObject({ title: 'pos' }); + expect(drawn(pos).dataset.drawn).toBe( + 'A whole number.\n\nNo description was given for this value.' + ); + const customer = templateTooltip(typed, '$local:customer', { render: hostRender }); + expect(customer).toMatchObject({ title: 'Customer name' }); + expect(drawn(customer).dataset.drawn).toBe(`Text.\n\n${DESCRIPTION}`); + // in the words the server's catalogue gives the type in, where the surface has it + const said = templateTooltip(typed, '$record:{pos}', { render: hostRender }, (type) => + type === 'int' ? 'eine ganze Zahl' : undefined + ); + expect(drawn(said).dataset.drawn).toBe( + 'Eine ganze Zahl.\n\nNo description was given for this value.' + ); + }); +}); + +describe('makeDpqlTagRenderer', () => { + it("draws a field reference's type, in words, and its description under its name", () => { + const props = makeDpqlTagRenderer( + { name: { display_name: 'Name', type: { name: 'string' }, short_desc: DESCRIPTION } }, + { descriptions: { render: hostRender } } + )({ type: 'tag', value: '@name', children: [{ text: '' }] } as never); + // its type was said raw, "Name (string)" (qorus#646) + expect(props.tooltip).toMatchObject({ title: 'Name' }); + expect(drawn(props.tooltip).dataset.drawn).toBe(`Text.\n\n${DESCRIPTION}`); + }); + + it("says a field's type, and why nothing else, where the record does not describe it", () => { + const props = makeDpqlTagRenderer( + { total: { display_name: 'Total', type: { name: 'number' } } }, + { descriptions: { render: hostRender } } + )({ type: 'tag', value: '@total', children: [{ text: '' }] } as never); + expect(props.tooltip).toMatchObject({ title: 'Total' }); + // and why nothing else: the record gives no description (qorus#646) + expect(drawn(props.tooltip).dataset.drawn).toBe( + 'An exact number.\n\nNo description was given for this field.' + ); + }); +}); + +describe('useDescriptionTooltips', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + it("takes the host's renderer, and hovers with a mouse", () => { + pointer(false); + const { result } = renderHook(() => useDescriptionTooltips(), { wrapper }); + expect(result.current).toEqual({ render: hostRender, tapToOpen: false }); + }); + + it('opens on a tap with a finger', () => { + pointer(true); + const { result } = renderHook(() => useDescriptionTooltips(), { wrapper }); + expect(result.current.tapToOpen).toBe(true); + }); +}); + +describe('a template chip on a touch screen', () => { + it('opens its drawn card on a tap, and puts it away on the next', async () => { + pointer(true); + render( + + + + + + ); + const chip = screen.getByText('Customer name'); + expect(document.querySelector(`[data-drawn]`)).toBeNull(); + fireEvent.click(chip); + expect((await screen.findByText('Customer name', { selector: '.reqore-popover-content *' })).textContent).toBe( + 'Customer name' + ); + expect(document.querySelector('.reqore-popover-content [data-drawn]')).not.toBeNull(); + fireEvent.click(chip); + expect(document.querySelector('.reqore-popover-content')).toBeNull(); + }); +}); diff --git a/__tests__/dpqlEditor/accessorChains.test.ts b/__tests__/dpqlEditor/accessorChains.test.ts new file mode 100644 index 00000000..e53e7043 --- /dev/null +++ b/__tests__/dpqlEditor/accessorChains.test.ts @@ -0,0 +1,91 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A field with accessors is one chip (qorus#646, David): the server writes a reference with a selector back as +// `@create.items[*].id`, and the editor drew the chip `create.items` followed by the plain text `[*].id`. +import { + dpqlFieldName, + dpqlHoverOffsets, + endsDpqlReference, + getTagLabel, + plainTextToSlate, + slateToPlainText, +} from '../../src/components/dpqlEditor/dpqlHelpers'; +import { ISlateElement } from '../../src/components/smartEditor/types'; + +const chipsIn = (text: string) => + plainTextToSlate(text).flatMap((paragraph) => + paragraph.children + .filter((node): node is ISlateElement => (node as ISlateElement).type === 'tag') + .map(({ value, label }) => ({ value, label })) + ); + +describe('a field with accessors is one chip', () => { + const chains: [string, string][] = [ + ['@create.items[*].id', 'create.items[*].id'], + ['@orders[*]', 'orders[*]'], + ['@prices{*}', 'prices{*}'], + ['@prices{*}.amount', 'prices{*}.amount'], + ['@items[0].id', 'items[0].id'], + ['@items[-1]', 'items[-1]'], + // a key that is a name after an accessor, as the server writes it now (Qore f341ddfc0) + ['@create.items[0].id', 'create.items[0].id'], + ['@items[0].order-id', 'items[0].order-id'], + // a key that needs quoting keeps its braces, and so does a key directly after a field + ['@create.items[0]{"a b"}', 'create.items[0]{"a b"}'], + ['@headers{"content-type"}', 'headers{"content-type"}'], + ['@a{b}', 'a{b}'], + ['@h{a, "b c"}', 'h{a, "b c"}'], + // as the server wrote a key after an index before + ['@create.items[0]{"id"}', 'create.items[0]{"id"}'], + ['@orders[*].lines[*].sku', 'orders[*].lines[*].sku'], + ['@"order-lines"[*].id', 'order-lines[*].id'], + ]; + + test.each(chains)('%s, labeled %s, written back exactly', (text, label) => { + expect(chipsIn(text)).toEqual([{ value: text, label }]); + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + }); + + test('in an expression: the chain ends where the accessors do', () => { + const text = 'size(@orders[*].id) + size(@prices{*}) > @items[0]'; + expect(chipsIn(text).map(({ label }) => label)).toEqual([ + 'orders[*].id', + 'prices{*}', + 'items[0]', + ]); + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + }); + + test('what is not an accessor is not part of it', () => { + // a slice, an expression in brackets, a hash literal + expect(chipsIn('@items[1..2]')).toEqual([{ value: '@items', label: 'items' }]); + expect(chipsIn('@items[@i]').map(({ value }) => value)).toEqual(['@items', '@i']); + expect(chipsIn('@a {"x": 1}')).toEqual([{ value: '@a', label: 'a' }]); + // an unclosed key + expect(chipsIn('@h{"x')).toEqual([{ value: '@h', label: 'h' }]); + }); + + test('its name and label, for the renderer and a completion', () => { + expect(dpqlFieldName('@create.items[*].id')).toBe('create.items[*].id'); + expect(dpqlFieldName('@"order-lines"[*].id')).toBe('order-lines[*].id'); + expect(getTagLabel('@"a\\"b"{*}')).toBe('a"b{*}'); + }); + + test("names where the server says something of each part of its chip, for the chip's hover", () => { + // the field, the * of the wildcard, the name after it + expect(dpqlHoverOffsets('@create.items[*].id')).toEqual([0, 14, 17]); + // an index is a number to the server: not a part of its own + expect(dpqlHoverOffsets('@items[0].id')).toEqual([0, 10]); + expect(dpqlHoverOffsets('@prices{*}')).toEqual([0, 8]); + expect(dpqlHoverOffsets('@h{"a b"}')).toEqual([0, 3]); + expect(dpqlHoverOffsets('@h{ a, "b c"}')).toEqual([0, 4]); + expect(dpqlHoverOffsets('@"a b"[*]')).toEqual([0, 7]); + // a field alone, and a template, are one part + expect(dpqlHoverOffsets('@name')).toEqual([0]); + expect(dpqlHoverOffsets('$record:{orders[*].id}')).toEqual([0]); + }); + + test('typed, an opening bracket does not end the field: an accessor may follow', () => { + expect(endsDpqlReference('[')).toBe(false); + expect(endsDpqlReference(' ')).toBe(true); + }); +}); diff --git a/__tests__/dpqlEditor/dpqlCompletions.test.ts b/__tests__/dpqlEditor/dpqlCompletions.test.ts new file mode 100644 index 00000000..784ab526 --- /dev/null +++ b/__tests__/dpqlEditor/dpqlCompletions.test.ts @@ -0,0 +1,216 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/** + * A DPQL editor's one completion list (qorus#646, David's decision): the server's completion at the caret, with + * the field's templates where a value can be written, so that a pick is always a value where one can stand; and + * the server's diagnostics in the words the editor owes the author. The server's answers below are what + * professor's `/lsp` answered at the same carets (capture-dpql-completions.qr, checkout 35096ca2d), cut down. + */ +import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; +import { describe, expect, it } from 'vitest'; +import { + dpqlCompletionsWith, + dpqlEmptyCompletionMessage, + dpqlExpectsValue, + presentDpqlDiagnostics, + UNFINISHED_PARTS_MESSAGE, +} from '../../src/components/dpqlEditor/dpqlCompletions'; +import { ILspCompletionItem, ILspDiagnostic } from '../../src/utils/lspClient.types'; + +const TEMPLATES = { + items: [ + { + label: 'Fields of the row', + items: [ + { label: 'pos', value: '$record:{pos}', badge: 'int' }, + { label: 'bezeichnung', value: '$record:{bezeichnung}', badge: 'string' }, + ], + }, + { label: 'Settings', items: [{ label: 'Rate', value: '$config:rate', badge: 'number' }] }, + ], +} as IReqoreFormTemplates; + +/** What the server offers after an operator: the record's fields, its template contexts, functions. */ +const AFTER_AN_OPERATOR: ILspCompletionItem[] = [ + { label: 'pos', kind: 5, detail: 'Whole number' }, + { label: 'bezeichnung', kind: 5, detail: 'Text' }, + { label: '$config:', kind: 6, detail: "needs a value after ':'" }, + { label: '!', kind: 14 }, +]; +/** What it offers after a whole value (`12 `): operators only. */ +const AFTER_A_VALUE: ILspCompletionItem[] = [ + { label: '&&', kind: 14 }, + { label: '||', kind: 14 }, +]; + +const listed = (items: ILspCompletionItem[]) => items.map((item) => item.insertText ?? item.label); +const complete = (text: string, serverItems?: ILspCompletionItem[]) => { + const caret = text.indexOf('|'); + return dpqlCompletionsWith(TEMPLATES)({ + plainText: text.replace('|', ''), + offset: caret, + serverItems, + }); +}; + +describe('what the one completion list offers', () => { + it("offers the field's templates where the server offers values, each field once", () => { + expect(listed(complete('$record:{pos} + |', AFTER_AN_OPERATOR))).toEqual([ + '$record:{pos}', + '$record:{bezeichnung}', + '$config:rate', + '!', + ]); + }); + + /* qorus#646, David's review: picking `$data:` made text the server refuses (DPQL-E033, "This template isn't + finished"), and it offers nothing more after it (professor's `/lsp`, 0 items) */ + it("does not offer a template's context alone, which is no value", () => { + const contexts: ILspCompletionItem[] = [ + { label: '$data:', insertText: '$data:', kind: 6, detail: "needs a value after ':'" }, + { + label: '$qore-expr:', + insertText: '$qore-expr:', + kind: 6, + detail: "needs a value after ':'", + }, + ]; + expect(listed(complete('1 + $|', contexts))).toEqual([ + '$record:{pos}', + '$record:{bezeichnung}', + '$config:rate', + ]); + expect( + dpqlCompletionsWith(undefined)({ plainText: '1 + $', offset: 5, serverItems: contexts }) + ).toEqual([]); + // what the server offers after a context is a whole value: kept + const after: ILspCompletionItem[] = [ + { label: '$timestamp:now', insertText: '$timestamp:now', kind: 6 }, + ]; + expect( + listed( + dpqlCompletionsWith(undefined)({ plainText: '$timestamp:', offset: 11, serverItems: after }) + ) + ).toEqual(['$timestamp:now']); + }); + + it('offers no template after a whole value, where the server offers operators (no `12 pos`)', () => { + expect(listed(complete('12 |', AFTER_A_VALUE))).toEqual(['&&', '||']); + expect(complete('12|', [])).toEqual([]); + }); + + it('offers the templates in an empty text, which the server answers nothing for', () => { + expect(listed(complete('|', []))).toEqual([ + '$record:{pos}', + '$record:{bezeichnung}', + '$config:rate', + ]); + }); + + it('offers them where the text says a value can go when there is no server, and nothing where not', () => { + expect(listed(complete('1 + |'))).toHaveLength(3); + expect(listed(complete('concat(|'))).toHaveLength(3); + expect(complete('1 + 2|')).toEqual([]); + }); + + it('offers only the fields of the record after an @', () => { + expect(listed(complete('1 + @|', [{ label: 'pos', kind: 5 }]))).toEqual([ + '$record:{pos}', + '$record:{bezeichnung}', + ]); + }); + + it('lists a template as its catalogue names it, its group and type on its detail line', () => { + // the type by its name, never its code (templateTypeBadge) + expect(complete('|', [])[0]).toMatchObject({ + label: 'pos', + kind: 5, + detail: 'Fields of the row · Whole number', + }); + }); + + it('offers the server answer alone where the field has no templates', () => { + expect( + dpqlCompletionsWith(undefined)({ + plainText: '1 + ', + offset: 4, + serverItems: AFTER_AN_OPERATOR, + }) + ).toEqual(AFTER_AN_OPERATOR.filter((item) => item.label !== '$config:')); + }); +}); + +describe('where a value can be written, as the text tells', () => { + it('is an empty text, after an operator, a bracket or a comma, or where a reference is being written', () => { + for (const before of [ + '', + ' ', + '1 +', + 'a == ', + 'concat(', + 'f(1, ', + '[', + '1 + $', + '1 + @', + '1 + $rec', + ]) { + expect(dpqlExpectsValue(before), before).toBe(true); + } + }); + + it('is not after a value', () => { + for (const before of ['12', '12 ', '$record:{pos}', '"text"', 'f(1)', 'pos + 1']) { + expect(dpqlExpectsValue(before), before).toBe(false); + } + }); +}); + +describe('what the list says where it offers nothing', () => { + it('says there is no record after an @', () => { + expect(dpqlEmptyCompletionMessage('1 + @', 5)).toBe('No record fields here'); + expect(dpqlEmptyCompletionMessage('@na', 3)).toBe('No record fields here'); + }); + + it('says there are no templates after a $', () => { + expect(dpqlEmptyCompletionMessage('1 + $', 5)).toBe('No templates here'); + expect(dpqlEmptyCompletionMessage('1 + $da', 7)).toBe('No templates here'); + }); + + it('says nothing elsewhere, nor after an @ in a string', () => { + expect(dpqlEmptyCompletionMessage('12', 2)).toBeUndefined(); + expect(dpqlEmptyCompletionMessage('"mail me @', 10)).toBeUndefined(); + }); +}); + +/** The server's words for a character it cannot read (DPQL-E001, Qore batch 6). */ +const CANNOT_BE_USED_HERE = + "'…' can't be used here: remove it, or put it in quotes to make it text."; + +describe('what the editor says of what the server refuses', () => { + const at = (from: number, to: number, message: string): ILspDiagnostic => ({ + range: { start: { line: 0, character: from }, end: { line: 0, character: to } }, + severity: 1, + code: 'DPQL-E001', + source: 'dpql', + message, + }); + + it("says once what to do with an unfinished expression's placeholders, not the server's refusal of each", () => { + // the server on `… > …`: "'…' can't be used here" at 0 and at 4 (DPQL-E001) + const said = presentDpqlDiagnostics( + [at(0, 1, CANNOT_BE_USED_HERE), at(4, 5, CANNOT_BE_USED_HERE)], + '… > …' + ); + expect(said).toEqual([ + expect.objectContaining({ severity: 2, message: UNFINISHED_PARTS_MESSAGE }), + ]); + expect(UNFINISHED_PARTS_MESSAGE).toBe('Fill in each … (the parts not chosen yet).'); + }); + + it('says everything else as the server says it', () => { + const other = at(2, 2, "Something is missing at the end: a value after '+'."); + expect(presentDpqlDiagnostics([other], '1 +')).toEqual([other]); + expect( + presentDpqlDiagnostics([at(0, 1, CANNOT_BE_USED_HERE), other], '… +').map((d) => d.message) + ).toEqual([UNFINISHED_PARTS_MESSAGE, "Something is missing at the end: a value after '+'."]); + }); +}); diff --git a/__tests__/dpqlEditor/helpers.test.ts b/__tests__/dpqlEditor/helpers.test.ts index 2e945b6f..243bc6f3 100644 --- a/__tests__/dpqlEditor/helpers.test.ts +++ b/__tests__/dpqlEditor/helpers.test.ts @@ -7,9 +7,12 @@ import { getTagLabel, isTagCompletion, plainTextToSlate, + richtextResponseToSlate, slateSelectionToOffset, slateToPlainText, + withTextAroundTags, } from '../../src/components/dpqlEditor/dpqlHelpers'; +import { ISlateElement } from '../../src/components/smartEditor/types'; describe('dpqlEditor helpers', () => { describe('plainTextToSlate', () => { @@ -29,21 +32,22 @@ describe('dpqlEditor helpers', () => { const result = plainTextToSlate('@name == "Alice"'); expect(result).toHaveLength(1); const children = result[0].children; - expect(children).toHaveLength(2); - expect(children[0]).toMatchObject({ + expect(children).toHaveLength(3); + expect(children[0]).toEqual({ text: '' }); + expect(children[1]).toMatchObject({ type: 'tag', value: '@name', label: 'name', }); - expect(children[1]).toMatchObject({ text: ' == "Alice"' }); + expect(children[2]).toMatchObject({ text: ' == "Alice"' }); }); it('converts $prefix:{value} to a tag element', () => { const result = plainTextToSlate('$data:{1.field}'); expect(result).toHaveLength(1); const children = result[0].children; - expect(children).toHaveLength(1); - expect(children[0]).toMatchObject({ + expect(children).toHaveLength(3); + expect(children[1]).toMatchObject({ type: 'tag', value: '$data:{1.field}', label: 'data: 1.field', @@ -53,7 +57,7 @@ describe('dpqlEditor helpers', () => { it('converts $prefix:value (no braces) to a tag element', () => { const result = plainTextToSlate('$local:input'); expect(result).toHaveLength(1); - expect(result[0].children[0]).toMatchObject({ + expect(result[0].children[1]).toMatchObject({ type: 'tag', value: '$local:input', label: 'local: input', @@ -62,8 +66,8 @@ describe('dpqlEditor helpers', () => { it('keeps a dotted path in one reference', () => { const [paragraph] = plainTextToSlate('$local:order.id == 1'); - expect(paragraph.children[0]).toMatchObject({ type: 'tag', value: '$local:order.id' }); - expect(paragraph.children[1]).toMatchObject({ text: ' == 1' }); + expect(paragraph.children[1]).toMatchObject({ type: 'tag', value: '$local:order.id' }); + expect(paragraph.children[2]).toMatchObject({ text: ' == 1' }); }); it('ends a reference where a method is called on it', () => { @@ -71,27 +75,28 @@ describe('dpqlEditor helpers', () => { // path grammar allows dots, so `.endsWith` used to join the first chip. const [paragraph] = plainTextToSlate('$local:str.endsWith($local:order.id, true)'); expect(paragraph.children).toMatchObject([ + { text: '' }, { type: 'tag', value: '$local:str' }, { text: '.endsWith(' }, { type: 'tag', value: '$local:order.id' }, { text: ', true)' }, ]); + expect(paragraph.children).toHaveLength(5); }); it('handles mixed content with tags and text', () => { const result = plainTextToSlate('@name == $config:min AND @age > 18'); const children = result[0].children; - expect(children.length).toBeGreaterThanOrEqual(5); - - expect(children[0]).toMatchObject({ type: 'tag', value: '@name' }); - expect(children[1]).toMatchObject({ text: ' == ' }); - expect(children[2]).toMatchObject({ - type: 'tag', - value: '$config:min', - }); - expect(children[3]).toMatchObject({ text: ' AND ' }); - expect(children[4]).toMatchObject({ type: 'tag', value: '@age' }); + expect(children).toMatchObject([ + { text: '' }, + { type: 'tag', value: '@name' }, + { text: ' == ' }, + { type: 'tag', value: '$config:min' }, + { text: ' AND ' }, + { type: 'tag', value: '@age' }, + { text: ' > 18' }, + ]); }); it('consumes surrounding quotes into the tag metadata', () => { @@ -110,14 +115,59 @@ describe('dpqlEditor helpers', () => { it('handles multiline text', () => { const result = plainTextToSlate('@name\n@age'); expect(result).toHaveLength(2); - expect(result[0].children[0]).toMatchObject({ - type: 'tag', - value: '@name', - }); - expect(result[1].children[0]).toMatchObject({ - type: 'tag', - value: '@age', + expect(result[0].children).toMatchObject([{ text: '' }, { type: 'tag', value: '@name' }, { text: '' }]); + expect(result[1].children).toMatchObject([{ text: '' }, { type: 'tag', value: '@age' }, { text: '' }]); + }); + }); + + describe('a chip always has text on both sides', () => { + // Slate requires an inline void to sit between text nodes. A document + // loaded without them is never normalised: a chip that starts or ends the + // line leaves the caret nowhere to go but into the chip, and Slate drops + // what is typed there (qorus#646, "can't type" in the Text view). + const isText = (node: unknown) => typeof (node as { text?: unknown }).text === 'string'; + const wellFormed = (children: unknown[]) => + isText(children[0]) && + isText(children[children.length - 1]) && + children.every((node, i) => isText(node) || (isText(children[i - 1]) && isText(children[i + 1]))); + + it.each(['@pos', '$record:{pos}', '@a@b', '@a $local:b', 'x @a', '@a == 1', '"$local:a"', '@a\n@b'])( + 'for %s', + (text) => { + const paragraphs = plainTextToSlate(text.replace('\\n', '\n')); + paragraphs.forEach((paragraph) => expect(wellFormed(paragraph.children)).toBe(true)); + expect(slateToPlainText(paragraphs)).toBe(text.replace('\\n', '\n')); + } + ); + + it('for what the server sends', () => { + const tag = { type: 'tag', value: '$record:{pos}', label: 'record: pos', children: [{ text: '' }] }; + const result = richtextResponseToSlate({ + type: 'richtext', + value: [ + { type: 'paragraph', children: [tag] }, + { type: 'paragraph', children: [tag, tag] }, + { type: 'paragraph', children: [{ text: 'a ' }, tag, { text: ' b' }] }, + { type: 'paragraph', children: [] }, + ], }); + expect(result).not.toBeNull(); + result!.forEach((paragraph) => expect(wellFormed(paragraph.children)).toBe(true)); + expect(result![0].children).toMatchObject([{ text: '' }, { type: 'tag' }, { text: '' }]); + expect(result![1].children).toMatchObject([{ text: '' }, { type: 'tag' }, { text: '' }, { type: 'tag' }, { text: '' }]); + expect(result![2].children).toMatchObject([{ text: 'a ' }, { type: 'tag' }, { text: ' b' }]); + expect(result![3].children).toEqual([{ text: '' }]); + expect(slateToPlainText(result!)).toBe('$record:{pos}\n$record:{pos}$record:{pos}\na $record:{pos} b\n'); + }); + + it('leaves the text beside a chip as it is', () => { + const chip: ISlateElement = { type: 'tag', value: '@x', children: [{ text: '' }] }; + expect(withTextAroundTags([{ text: 'a' }, chip, { text: 'b' }])).toEqual([ + { text: 'a' }, + { type: 'tag', value: '@x', children: [{ text: '' }] }, + { text: 'b' }, + ]); + expect(withTextAroundTags([])).toEqual([{ text: '' }]); }); }); @@ -126,8 +176,8 @@ describe('dpqlEditor helpers', () => { // user's language): a letter of any script or `_`, then letters, combining marks, digits and `_` it.each(['@menge_bestätigt', '@größe', '@名前', '@Çeşit.adı', '@e\u0301tat'])('makes %s one tag', (ref) => { const children = plainTextToSlate(`${ref} > 0`)[0].children; - expect(children[0]).toMatchObject({ type: 'tag', value: ref, label: ref.slice(1) }); - expect(children[1]).toEqual({ text: ' > 0' }); + expect(children[1]).toMatchObject({ type: 'tag', value: ref, label: ref.slice(1) }); + expect(children[2]).toEqual({ text: ' > 0' }); }); it('keeps it bare through the round trip', () => { @@ -261,8 +311,9 @@ describe('dpqlEditor helpers', () => { it('returns correct offset after a tag', () => { const elements = plainTextToSlate('@name == "Alice"'); - // @name = 5 chars (child 0 tag), then offset 3 into the text (child 1) - expect(slateSelectionToOffset(elements, [0, 1], 3)).toBe(8); + // empty text (child 0), @name = 5 chars (child 1 tag), then offset 3 + // into the text (child 2) + expect(slateSelectionToOffset(elements, [0, 2], 3)).toBe(8); }); it('returns correct offset in second paragraph', () => { diff --git a/__tests__/dpqlEditor/liveDpqlHovers.json b/__tests__/dpqlEditor/liveDpqlHovers.json new file mode 100644 index 00000000..fcbe1518 --- /dev/null +++ b/__tests__/dpqlEditor/liveDpqlHovers.json @@ -0,0 +1,682 @@ +{ + "source": "textDocument/hover at every character of each text, from professor's live Qorus /lsp (qorus 8a187191e) on 2026-10-18, captured with lib646/scripts/capture-dpql-hovers.qr; `fields` is what dpql/setContext was given (none: no context).", + "captures": [ + { + "text": "size(@create.items[*].id) > size(@prices{*}) && @items[0].id != @headers{\"content-type\"} && $local:input == \"x\"", + "fields": { + "create": { + "type": "hash", + "display_name": "Create", + "short_desc": "The order the test case creates" + }, + "create.items": { + "type": "list", + "display_name": "Items", + "short_desc": "The order's line items" + }, + "prices": { + "type": "hash", + "display_name": "Prices", + "short_desc": "The price of each product" + }, + "items": { + "type": "list", + "display_name": "Items", + "short_desc": "The order's items" + }, + "headers": { + "type": "hash", + "display_name": "Headers", + "short_desc": "The response headers" + }, + "name": { + "type": "string", + "display_name": "Name" + } + }, + "hovers": [ + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + null, + "**operator** `[*]`\n\n**list wildcard ([*])**\n\nApplies the accessors after [*] to each item of a list, returning a new list\n\nReturns: `list`", + null, + null, + "**field** `id`", + "**field** `id`", + null, + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + null, + "**operator** `{*}`\n\n**hash wildcard ({*})**\n\nApplies the accessors after {*} to each value of a hash, returning a new list\n\nReturns: `list`", + null, + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + null, + "**numeric literal** `0`", + null, + null, + "**field** `id`", + "**field** `id`", + null, + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + null, + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + "**string literal** `\"x\"`" + ] + }, + { + "text": "size(@create.items[*].id) > size(@prices{*}) && @items[0].id != @headers{\"content-type\"} && $local:input == \"x\"", + "fields": null, + "hovers": [ + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + null, + "**operator** `[*]`\n\n**list wildcard ([*])**\n\nApplies the accessors after [*] to each item of a list, returning a new list\n\nReturns: `list`", + null, + null, + "**field** `id`", + "**field** `id`", + null, + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + null, + "**operator** `{*}`\n\n**hash wildcard ({*})**\n\nApplies the accessors after {*} to each value of a hash, returning a new list\n\nReturns: `list`", + null, + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@items`", + "**field** `@items`", + "**field** `@items`", + "**field** `@items`", + "**field** `@items`", + "**field** `@items`", + null, + "**numeric literal** `0`", + null, + null, + "**field** `id`", + "**field** `id`", + null, + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + null, + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + "**string literal** `\"x\"`" + ] + }, + { + "text": "@name == \"Alice\" && contains(\"$local:input\", \"es\") && @age > $config:min_age", + "fields": null, + "hovers": [ + "**field** `@name`", + "**field** `@name`", + "**field** `@name`", + "**field** `@name`", + "**field** `@name`", + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + null, + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + null, + null, + "**string literal** `\"es\"`", + "**string literal** `\"es\"`", + "**string literal** `\"es\"`", + "**string literal** `\"es\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@age`", + "**field** `@age`", + "**field** `@age`", + "**field** `@age`", + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`" + ] + }, + { + "text": "contains(@a, \"x\") || startsWith(@a, \"y\") && endsWith(@a, \"z\") && concat(@a, \"b\") == \"c\" && substr(@a, 1, 2) != \"\" && join(@l, \",\") == \"\" && regex(@a, \"x\") && boolean(@a) && length(@a) >= 1 && @n + 1 < 2 && @n - 1 <= 2 && !@b && @n * 2 / 3 > 1 && size(@l) > 0 && format_date(@d, \"YYYY\") == \"\" && format_number(@n, \".,2\") == \"\"", + "fields": null, + "hovers": [ + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + null, + null, + "**operator** `||`\n\n**Logical Or**\n\nTrue when at least one part is true; it stops at the first part that is true.\n\n`||(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is true, the whole is true.", + "**operator** `||`\n\n**Logical Or**\n\nTrue when at least one part is true; it stops at the first part that is true.\n\n`||(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is true, the whole is true.", + null, + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"y\"`", + "**string literal** `\"y\"`", + "**string literal** `\"y\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"z\"`", + "**string literal** `\"z\"`", + "**string literal** `\"z\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"b\"`", + "**string literal** `\"b\"`", + "**string literal** `\"b\"`", + null, + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"c\"`", + "**string literal** `\"c\"`", + "**string literal** `\"c\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**numeric literal** `1`", + null, + null, + "**numeric literal** `2`", + null, + null, + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"\"`", + "**string literal** `\"\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + null, + "**field** `@l`", + "**field** `@l`", + null, + null, + "**string literal** `\",\"`", + "**string literal** `\",\"`", + "**string literal** `\",\"`", + null, + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"\"`", + "**string literal** `\"\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**operator** `>=`\n\n**greater than or equals (>=)**\n\nReturns True if the first argument is greater than or equal to the second argument\n\n`@Value >= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `>=`\n\n**greater than or equals (>=)**\n\nReturns True if the first argument is greater than or equal to the second argument\n\n`@Value >= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `1`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@n`", + "**field** `@n`", + null, + "**operator** `+`\n\n**addition (+)**\n\nAdds values and returns the result\n\n`+(auto Value To Add, ...) → auto`\n\n- `Value To Add`: A value to add", + null, + "**numeric literal** `1`", + null, + "**operator** `<`\n\n**less than (<)**\n\nReturns True if the first argument is strictly less than the second argument\n\n`@Value < auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `2`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@n`", + "**field** `@n`", + null, + "**operator** `-`\n\n**subtraction (-)**\n\nSubtracts the second value from the first\n\n`-(auto Value, auto Subtrahend) → auto`\n\n- `Value`: The value to subtract from\n- `Subtrahend`: The value to subtract", + null, + "**numeric literal** `1`", + null, + "**operator** `<=`\n\n**less than or equals (<=)**\n\nReturns True if the first argument is less than or equal to the second argument\n\n`@Value <= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `<=`\n\n**less than or equals (<=)**\n\nReturns True if the first argument is less than or equal to the second argument\n\n`@Value <= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `2`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**operator** `!`\n\n**Logical Not**\n\nTrue when its condition is false, and false when it is true.\n\n`!(any Value To Invert) → bool`\n\n- `Value To Invert`: The condition to turn around.", + "**field** `@b`", + "**field** `@b`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@n`", + "**field** `@n`", + null, + "**operator** `*`\n\n**multiplication (*)**\n\nMultiplies numbers and returns the result\n\n`*(number Value To Multiply, ...) → auto`\n\n- `Value To Multiply`: A value to multiply", + null, + "**numeric literal** `2`", + null, + "**operator** `/`\n\n**division (/)**\n\nDivides the first number by the second\n\n`/(number Dividend, number Divisor) → auto`\n\n- `Dividend`: The value to divide\n- `Divisor`: The divisor", + null, + "**numeric literal** `3`", + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `1`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@l`", + "**field** `@l`", + null, + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `0`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + null, + "**field** `@d`", + "**field** `@d`", + null, + null, + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + null, + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"\"`", + "**string literal** `\"\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + null, + "**field** `@n`", + "**field** `@n`", + null, + null, + "**string literal** `\".,2\"`", + "**string literal** `\".,2\"`", + "**string literal** `\".,2\"`", + "**string literal** `\".,2\"`", + "**string literal** `\".,2\"`", + null, + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"\"`", + "**string literal** `\"\"`" + ] + } + ] +} \ No newline at end of file diff --git a/__tests__/dpqlEditor/mockHover.test.ts b/__tests__/dpqlEditor/mockHover.test.ts new file mode 100644 index 00000000..f7845672 --- /dev/null +++ b/__tests__/dpqlEditor/mockHover.test.ts @@ -0,0 +1,27 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// The stories' stand-in servers answer a hover as the live Qorus `/lsp` does (qorus#646, David: "there are no +// mouseover / hovers on the functions or on the chips"). Checked against the live server's answers at every +// character of four DPQL texts, captured from professor's `/lsp` (liveDpqlHovers.json says how). +import { mockHoverDpql } from '../../src/components/dpqlEditor/__fixtures__/dpqlMockHover'; +import LIVE from './liveDpqlHovers.json'; + +describe("the stand-in's hover", () => { + for (const { text, fields, hovers } of LIVE.captures) { + test(`is the live server's at every character of ${JSON.stringify(text.slice(0, 40))}… (${ + fields ? 'with' : 'without' + } the record's fields)`, () => { + const answered = hovers.map( + (_live, character) => + mockHoverDpql(text, { line: 0, character }, fields ?? undefined)?.contents.value ?? null + ); + // character by character, so a difference names where it is + expect(answered.map((value, character) => [character, text[character], value])).toEqual( + hovers.map((value, character) => [character, text[character], value]) + ); + }); + } + + test('has none on a line the text does not have', () => { + expect(mockHoverDpql('@a', { line: 3, character: 0 })).toBeNull(); + }); +}); diff --git a/__tests__/dpqlEditor/quotedReferences.test.ts b/__tests__/dpqlEditor/quotedReferences.test.ts new file mode 100644 index 00000000..0ff0a25d --- /dev/null +++ b/__tests__/dpqlEditor/quotedReferences.test.ts @@ -0,0 +1,200 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A reference the DPQL editor draws as a chip is the one the server's DPQL tokenizer reads (qorus#646). +// +// The server writes a record field whose name is not a bare name as a quoted field, `@"create.items[*].id"`: it is +// how the expression `$record:{create.items[*].id}` comes back from dpql/serialize, so the editor drew a reference +// it had just shown as a chip as plain text after a tab switch. And a template's bracketed value ended at its +// first `}`, so `$record:{create.a\{b\}}` was a chip for `create.a\{b\` and the text `}`. +import { + dpqlSlateConverter, + getTagLabel, + plainTextToSlate, + slateToPlainText, +} from '../../src/components/dpqlEditor/dpqlHelpers'; +import { makeDpqlTagRenderer } from '../../src/components/dpqlEditor/dpqlTags'; +import { rechipTypedReferences } from '../../src/components/smartEditor/typedReferences'; +import { ISlateElement } from '../../src/components/smartEditor/types'; + +/** The chips a line is drawn with: each one's value and label. */ +const chipsIn = (text: string) => + plainTextToSlate(text).flatMap((paragraph) => + paragraph.children + .filter((node): node is ISlateElement => (node as ISlateElement).type === 'tag') + .map(({ value, label }) => ({ value, label })) + ); + +/** A line as it is drawn: its text, with each chip as `[label]`. */ +const drawn = (text: string) => + plainTextToSlate(text) + .map((paragraph) => + paragraph.children + .map((node) => + (node as ISlateElement).type === 'tag' ? + `[${(node as ISlateElement).label}]` + : (node as { text: string }).text + ) + .join('') + ) + .join('\n'); + +describe('a quoted field is drawn as its chip', () => { + // each as dpql/serialize writes it (DpqlSerializer::serializeFieldRef): any name DPQL does not read bare is + // quoted, a backslash doubled and a quote escaped + const serialized: [string, string][] = [ + ['@"create.items[*].id"', 'create.items[*].id'], + ['@"create.items[0].id"', 'create.items[0].id'], + ['@"create.order-id"', 'create.order-id'], + ['@"c.output[0]"', 'c.output[0]'], + ['@"create.a\\\\.b"', 'create.a\\.b'], + ['@"create.a\\\\{b\\\\}"', 'create.a\\{b\\}'], + ['@"say \\"hi\\""', 'say "hi"'], + ['@"order id"', 'order id'], + ]; + + test.each(serialized)('%s is one chip, labeled with the name it stands for', (text, name) => { + expect(chipsIn(text)).toEqual([{ value: text, label: name }]); + expect(drawn(text)).toBe(`[${name}]`); + }); + + test.each(serialized)('%s is written back exactly as it was', (text) => { + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + }); + + test('in an expression, beside operators, other fields and templates', () => { + const text = '@"create.order-id" == 5 AND @name > $local:min OR @"c.output[0]" != "x"'; + + expect(drawn(text)).toBe( + '[create.order-id] == 5 AND [name] > [local: min] OR [c.output[0]] != "x"' + ); + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + }); + + test('as an argument, and with an accessor after it', () => { + expect(drawn('length(@"create.items")')).toBe('length([create.items])'); + // the accessor is part of it (accessorChains.test.ts) + expect(drawn('@"create.items"[0]')).toBe('[create.items[0]]'); + }); + + test('an escaped quote does not end it', () => { + expect(chipsIn('@"a\\"b" == 1')).toEqual([{ value: '@"a\\"b"', label: 'a"b' }]); + }); + + test('one not closed on its line is text, as the server rejects it', () => { + expect(chipsIn('@"create.items == 1')).toEqual([]); + expect(slateToPlainText(plainTextToSlate('@"create.items == 1'))).toBe('@"create.items == 1'); + }); + + test('not inside a word', () => { + expect(chipsIn('user@"x"')).toEqual([]); + }); + + test('its label, for a completion or a chip the server sends without one', () => { + expect(getTagLabel('@"create.order-id"')).toBe('create.order-id'); + expect(getTagLabel('@"a\\\\b"')).toBe('a\\b'); + expect(getTagLabel('@"tab\\tname"')).toBe('tab\tname'); + expect(getTagLabel('@name')).toBe('name'); + }); + + test('the chip is the record template it names, and the field it names describes it', () => { + const renderTag = makeDpqlTagRenderer({ + 'create.order-id': { display_name: 'Order ID', type: { name: 'string' } }, + }); + const tag = (value: string): ISlateElement => ({ + type: 'tag', + value, + children: [{ text: '' }], + }); + + // one chip for one record field (qorus#646): the template chip, `record: `, with the record's description + expect(renderTag(tag('@"create.order-id"'))).toMatchObject({ + label: 'record: create.order-id', + icon: 'ExchangeDollarLine', + intent: 'info', + }); + expect(renderTag(tag('@"create.order-id"')).tooltip).toBeTruthy(); + // its name unescaped into the path, not its quoted text; a dot of a key stays a dot of a key (qore#5480) + expect(renderTag(tag('@"say \\"hi\\""')).label).toBe('record: say "hi"'); + expect(renderTag(tag('@"create.Unit\\.Price"')).label).toBe('record: create.Unit\\\\.Price'); + expect(renderTag(tag('@name')).label).toBe('record: name'); + }); + + test('typed out, it is drawn as its chip once the character after it ends it', () => { + const text = '@"create.order-id" == 1'; + const typed: ISlateElement = { type: 'paragraph', children: [{ text }] }; + const end = '@"create.order-id"'.length; + + // still at its closing quote: left as text, the caret may go on to an accessor + expect(rechipTypedReferences(dpqlSlateConverter, typed, 0, end)).toBeUndefined(); + // the space after it typed: drawn as its chip + const rechipped = rechipTypedReferences(dpqlSlateConverter, typed, 0, end + 1); + expect(rechipped?.children).toEqual(plainTextToSlate(text)[0].children); + expect(rechipped?.children[1]).toMatchObject({ type: 'tag', label: 'create.order-id' }); + }); +}); + +describe("a template's bracketed value ends where the server's does", () => { + test('an escaped brace is part of it', () => { + expect(chipsIn('$record:{create.a\\{b\\}}')).toEqual([ + { value: '$record:{create.a\\{b\\}}', label: 'record: create.a\\{b\\}' }, + ]); + expect(drawn('$record:{create.a\\{b\\}} == 1')).toBe('[record: create.a\\{b\\}] == 1'); + }); + + test('a nested pair of braces is part of it', () => { + expect(chipsIn('$local:{x{y}} == 1')).toEqual([ + { value: '$local:{x{y}}', label: 'local: x{y}' }, + ]); + }); + + test('a string inside it is read whole, a brace in it included', () => { + expect(chipsIn('$local:{a["}"]} == 1')).toEqual([ + { value: '$local:{a["}"]}', label: 'local: a["}"]' }, + ]); + }); + + test('the ones the server reads for a record field', () => { + for (const path of [ + 'create.items[*].id', + 'create.items[0].id', + 'create.order-id', + 'create.a\\.b', + 'c.output[0]', + ]) { + const text = `$record:{${path}}`; + expect(chipsIn(text)).toEqual([{ value: text, label: `record: ${path}` }]); + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + } + }); + + test('one not closed on its line is text', () => { + expect(chipsIn('$record:{create.a\\}')).toEqual([]); + expect(chipsIn('$record:{create')).toEqual([]); + }); +}); + +describe('a field in a string literal is text', () => { + test('a string that is a field name keeps its quotes', () => { + // drawn as the field's chip, it was written back as the field `@name`: a string became a reference + expect(chipsIn('@handle == "@name"')).toEqual([{ value: '@handle', label: 'handle' }]); + expect(slateToPlainText(plainTextToSlate('@handle == "@name"'))).toBe('@handle == "@name"'); + }); + + test('in single quotes, beside other words, and over two lines', () => { + expect(chipsIn("'@name' == @x")).toEqual([{ value: '@x', label: 'x' }]); + expect(chipsIn('"hello @name" == @x')).toEqual([{ value: '@x', label: 'x' }]); + expect(chipsIn('"first line\n@name" == @x')).toEqual([{ value: '@x', label: 'x' }]); + }); + + test('an escaped quote does not end the string', () => { + expect(chipsIn('"a \\" @name" == @x')).toEqual([{ value: '@x', label: 'x' }]); + }); + + test('a template in a string is still its chip, and a string that is one keeps its quotes', () => { + expect(drawn('"Hello $local:name" == @x')).toBe('"Hello [local: name]" == [x]'); + const quoted = plainTextToSlate('contains("$local:input", "es")')[0].children; + expect(quoted[1]).toMatchObject({ value: '$local:input', metadata: { quoted: true } }); + expect(slateToPlainText(plainTextToSlate('contains("$local:input", "es")'))).toBe( + 'contains("$local:input", "es")' + ); + }); +}); diff --git a/__tests__/dpqlEditor/referenceBeingWritten.test.ts b/__tests__/dpqlEditor/referenceBeingWritten.test.ts new file mode 100644 index 00000000..daa820c9 --- /dev/null +++ b/__tests__/dpqlEditor/referenceBeingWritten.test.ts @@ -0,0 +1,56 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/** + * What a template picked in an expression's text takes the place of (qorus#646, David's review): a `$` typed to ask + * for one stayed in front of the chip, and `pos + 1 + $pos` is refused by the server. + */ +import { describe, expect, it } from 'vitest'; +import { + dpqlReferenceBeingWritten, + dpqlSlateConverter, +} from '../../src/components/dpqlEditor/dpqlHelpers'; + +describe('a reference being written, before the caret', () => { + it('is a $ typed to ask for a template', () => { + expect(dpqlReferenceBeingWritten('$')).toBe(1); + expect(dpqlReferenceBeingWritten('$record:{pos} + 1 + $')).toBe(1); + expect(dpqlReferenceBeingWritten('concat($')).toBe(1); + }); + + it('is a $ with part of a template after it', () => { + expect(dpqlReferenceBeingWritten('1 + $rec')).toBe(4); + expect(dpqlReferenceBeingWritten('1 + $record:')).toBe(8); + expect(dpqlReferenceBeingWritten('1 + $record:{po')).toBe(11); + }); + + it('is an @ with no name after it', () => { + expect(dpqlReferenceBeingWritten('1 + @')).toBe(1); + }); + + it('is a $ in a string, where a template is still one', () => { + expect(dpqlReferenceBeingWritten('concat("price: $')).toBe(1); + }); + + it('is not a whole reference, which is a value already', () => { + expect(dpqlReferenceBeingWritten('$record:{pos}')).toBe(0); + expect(dpqlReferenceBeingWritten('1 + $data:total')).toBe(0); + expect(dpqlReferenceBeingWritten('1 + @pos')).toBe(0); + }); + + it('is not an @ in a string, which is text there, even one opened on a line before', () => { + expect(dpqlReferenceBeingWritten('concat("mail me @')).toBe(0); + expect(dpqlReferenceBeingWritten("concat('first line\nmail me @")).toBe(0); + // closed before it: the @ is a field's + expect(dpqlReferenceBeingWritten('concat("a", @')).toBe(1); + }); + + it('is nothing where no $ or @ ends the text', () => { + expect(dpqlReferenceBeingWritten('')).toBe(0); + expect(dpqlReferenceBeingWritten('pos + 1')).toBe(0); + expect(dpqlReferenceBeingWritten('pos + 1 + ')).toBe(0); + expect(dpqlReferenceBeingWritten('$ ')).toBe(0); + }); + + it('is what the DPQL converter tells the editor', () => { + expect(dpqlSlateConverter.referenceBeingWritten?.('1 + $')).toBe(1); + }); +}); diff --git a/__tests__/dpqlEditor/tags.test.ts b/__tests__/dpqlEditor/tags.test.ts index 63d9d20e..1ff605ed 100644 --- a/__tests__/dpqlEditor/tags.test.ts +++ b/__tests__/dpqlEditor/tags.test.ts @@ -8,6 +8,7 @@ // back to the `$data:{dc_ai_reply.choices[0].message.content}` path. import { describe, expect, it } from 'vitest'; import { makeDpqlTagRenderer } from '../../src/components/dpqlEditor/dpqlTags'; +import { REFERENCE_CHIP_PROPS } from '../../src/helpers/templates'; import type { ISlateElement } from '../../src/components/smartEditor/types'; const TEMPLATES = { @@ -59,6 +60,7 @@ describe('makeDpqlTagRenderer template references', () => { tag('$data:{dc_ai_reply.choices[0].message.content}') ); - expect(props).toEqual({ icon: 'ExchangeDollarLine', intent: 'info' }); + // the reference chip every view draws, and nothing that names it + expect(props).toEqual({ ...REFERENCE_CHIP_PROPS, icon: 'ExchangeDollarLine', intent: 'info' }); }); }); diff --git a/__tests__/dpqlMockLanguage.test.ts b/__tests__/dpqlMockLanguage.test.ts index 8f06a6a0..00c597bc 100644 --- a/__tests__/dpqlMockLanguage.test.ts +++ b/__tests__/dpqlMockLanguage.test.ts @@ -13,7 +13,10 @@ import { mockRenderDpql, mockSerializeDpql, mockTokenizeDpql, + mockPublishedDiagnostics, + mockExpressionTypeDpql, } from '../src/components/form/expressions/dpqlMockLanguage'; +import { dpqlMockTypeName } from '../src/components/form/expressions/dpqlMockLsp'; /** Decode LSP semantic tokens into `[text, typeIndex]` pairs for one line. */ const decodeTokens = (text: string) => { @@ -36,7 +39,7 @@ describe('dpql/parse', () => { expect(result.diagnostics).toHaveLength(1); expect(result.diagnostics[0]).toMatchObject({ severity: 'error', - message: 'Unexpected end of input, expected value', + message: "Something is missing at the end: a value after '=='.", code: 'DPQL-E001', }); }); @@ -88,13 +91,25 @@ describe('dpql/parse', () => { expect(result.expression?.value).toEqual({ exp: '==', args: [ - { type: 'auto', value: { tmpl_context: 'local', tmpl_value: 'name', raw: '$local:name' } }, + { type: 'auto', value: '$local:name' }, { type: 'string', value: 'John' }, ], }); expect(result).toMatchObject({ inferred_type: 'bool', type_compatible: true }); }); + it('wraps a lone template in template(), as the template, as the server does', () => { + expect(mockParseDpql('$local:name').expression?.value).toEqual({ + exp: 'template', + args: [{ type: 'auto', value: '$local:name' }], + }); + // a string that reads like a template is a string + expect(mockParseDpql('"$local:name"').expression?.value).toEqual({ + exp: 'value', + args: [{ type: 'string', value: '$local:name' }], + }); + }); + it('wraps a lone word as value(), which fits any target', () => { expect(mockParseDpql('hello', 'bool')).toMatchObject({ success: true, @@ -108,7 +123,10 @@ describe('dpql/parse', () => { it('answers without analysis when no target was sent', () => { expect(mockParseDpql('null')).toEqual({ success: true, - expression: { is_expression: true, value: { exp: 'value', args: [{ type: 'any', value: null }] } }, + expression: { + is_expression: true, + value: { exp: 'value', args: [{ type: 'any', value: null }] }, + }, inferred_type: 'auto', diagnostics: [], }); @@ -129,7 +147,10 @@ describe('dpql/parse type analysis', () => { type_compatible: false, auto_coercible: true, coercion_may_fail: false, - suggested_fix: { text: 'toString(1 + 2)', description: 'Convert int to string using toString()' }, + suggested_fix: { + text: 'toString(1 + 2)', + description: 'Convert int to string using toString()', + }, }); }); @@ -148,7 +169,7 @@ describe('dpql/parse type analysis', () => { }); expect(result.diagnostics[0]).toMatchObject({ severity: 'warning', - message: "Expression returns 'string' but target expects 'int' (auto-coercion available)", + message: 'The expression gives text, but this field takes a whole number. Qorus converts it when it runs.', code: 'TYPE_MISMATCH', }); }); @@ -156,11 +177,15 @@ describe('dpql/parse type analysis', () => { it('refuses a conversion that does not exist, with no fix', () => { const result = mockParseDpql('1 + 2', 'bool'); - expect(result).toMatchObject({ type_compatible: false, auto_coercible: false, coercion_may_fail: false }); + expect(result).toMatchObject({ + type_compatible: false, + auto_coercible: false, + coercion_may_fail: false, + }); expect(result.suggested_fix).toBeUndefined(); expect(result.diagnostics[0]).toMatchObject({ severity: 'error', - message: "Expression returns 'int' but target expects 'bool'", + message: 'The expression gives a whole number, but this field takes true or false, and Qorus cannot convert it.', }); }); @@ -212,12 +237,12 @@ describe('dpql/serialize and dpql/renderExpression', () => { metadata: { displayName: 'Local Context' }, }; - it('serializes a stored string as a string, the reference a chip inside it', () => { + it('serializes a stored template as the template, as the UI form evaluates it (qorus#646)', () => { expect(mockSerializeDpql(STORED)).toEqual({ - dpql: '"$local:name" == "John"', + dpql: '$local:name == "John"', richtext: { type: 'richtext', - value: [{ type: 'paragraph', children: [{ text: '"' }, chip, { text: '" == "John"' }] }], + value: [{ type: 'paragraph', children: [chip, { text: ' == "John"' }] }], }, }); }); @@ -243,8 +268,27 @@ describe('dpql/serialize and dpql/renderExpression', () => { const group = { exp: '||', args: [ - { is_expression: true, value: { exp: '==', args: [{ type: 'int', value: 1 }, { type: 'int', value: 2 }] } }, - { is_expression: true, value: { exp: '&&', args: [{ type: 'bool', value: true }, { type: 'bool', value: false }, { type: 'bool', value: true }] } }, + { + is_expression: true, + value: { + exp: '==', + args: [ + { type: 'int', value: 1 }, + { type: 'int', value: 2 }, + ], + }, + }, + { + is_expression: true, + value: { + exp: '&&', + args: [ + { type: 'bool', value: true }, + { type: 'bool', value: false }, + { type: 'bool', value: true }, + ], + }, + }, { type: 'bool', value: false }, ], }; @@ -290,8 +334,8 @@ describe('expressions the catalogue spells', () => { it('serializes the same expression as DPQL', () => { expect(mockSerializeDpql(BUILDER_STORY).dpql).toBe( - '("$local:input" contains "es" || "test" startsWith "t" && "test" startsWith "t" || 23 >= "$local:id") && ' + - '"$local:str" endsWith "$local:p" && "$local:input" < "$local:p"' + '($local:input contains "es" || "test" startsWith "t" && "test" startsWith "t" || 23 >= $local:id) && ' + + '$local:str endsWith $local:p && $local:input < $local:p' ); }); @@ -300,12 +344,14 @@ describe('expressions the catalogue spells', () => { '"test".startsWith("t", true)' ); expect( - mockRenderDpql(e('starts-with', s('test'), s('t'), { type: 'bool', value: false }).value).rendered + mockRenderDpql(e('starts-with', s('test'), s('t'), { type: 'bool', value: false }).value) + .rendered ).toBe('"test".startsWith("t", false)'); }); it('reads a conditional template part from the argument, and its default only when omitted', () => { - const contains = (...extra: unknown[]) => e('contains', s('$local:input'), s('es'), ...extra).value; + const contains = (...extra: unknown[]) => + e('contains', s('$local:input'), s('es'), ...extra).value; expect(mockRenderDpql(contains()).rendered).toBe('$local:input contains "es" (ignore case)'); expect(mockRenderDpql(contains({ type: 'bool', value: true })).rendered).toBe( @@ -325,23 +371,28 @@ describe('expressions the catalogue spells', () => { }); it('calls a comparison by its symbol when it has other than two arguments', () => { - expect(mockSerializeDpql(e('contains', s('$local:input'), s('es'), { type: 'bool', value: true }).value).dpql).toBe( - 'contains("$local:input", "es", true)' - ); - expect(mockSerializeDpql(e('starts-with', s('test'), s('t'), { type: 'bool', value: false }).value).dpql).toBe( - 'startsWith("test", "t", false)' - ); + expect( + mockSerializeDpql( + e('contains', s('$local:input'), s('es'), { type: 'bool', value: true }).value + ).dpql + ).toBe('contains($local:input, "es", true)'); + expect( + mockSerializeDpql(e('starts-with', s('test'), s('t'), { type: 'bool', value: false }).value) + .dpql + ).toBe('startsWith("test", "t", false)'); }); it('calls a function by name in both', () => { const concat = e('concat', s('a'), s('$local:x')).value; expect(mockRenderDpql(concat).rendered).toBe('concat("a", $local:x)'); - expect(mockSerializeDpql(concat).dpql).toBe('concat("a", "$local:x")'); + expect(mockSerializeDpql(concat).dpql).toBe('concat("a", $local:x)'); }); it('parses a word comparison and a call by symbol back to the catalogue name', () => { - expect(mockParseDpql('"test" startsWith "t" || startsWith("test", "t", false)').expression?.value).toEqual({ + expect( + mockParseDpql('"test" startsWith "t" || startsWith("test", "t", false)').expression?.value + ).toEqual({ exp: '||', args: [ { is_expression: true, value: { exp: 'starts-with', args: [s('test'), s('t')] } }, @@ -353,14 +404,14 @@ describe('expressions the catalogue spells', () => { }); expect(mockParseDpql('concat("a", $local:x)').expression?.value).toEqual({ exp: 'concat', - args: [s('a'), { type: 'auto', value: { tmpl_context: 'local', tmpl_value: 'x', raw: '$local:x' } }], + args: [s('a'), { type: 'auto', value: '$local:x' }], }); }); }); describe('semantic tokens', () => { // Legend indices: 6 function, 11 string, 12 number, 14 operator. - it('colours an expression', () => { + it('colors an expression', () => { expect(decodeTokens('1 + 2 == "John"')).toEqual([ ['1', 12], ['+', 14], @@ -370,7 +421,7 @@ describe('semantic tokens', () => { ]); }); - it('colours a readable rendering too, as the server does', () => { + it('colors a readable rendering too, as the server does', () => { const tokens = decodeTokens('$local:input contains "es" (ignore case)'); expect(tokens).toContainEqual(['contains', 6]); @@ -379,7 +430,7 @@ describe('semantic tokens', () => { expect(tokens).toContainEqual(['case', 6]); }); - it('colours a word comparison and leaves punctuation alone', () => { + it('colors a word comparison and leaves punctuation alone', () => { expect(decodeTokens('"test" startsWith "t" || startsWith("test", "t", false)')).toEqual([ ['"test"', 11], ['startsWith', 6], @@ -394,9 +445,351 @@ describe('semantic tokens', () => { }); describe('the stand-in tokenizer reads field names in any script, as the server does', () => { - it('colours a whole non-ASCII name as one field', () => { + it('colors a whole non-ASCII name as one field', () => { const ascii = decodeTokens('@qty > 0')[0][1]; expect(decodeTokens('@menge_bestätigt > 0')[0]).toEqual(['@menge_bestätigt', ascii]); expect(decodeTokens('@名前 == "x"')[0]).toEqual(['@名前', ascii]); }); }); + +// qorus#646 (David's review: `pos + 1 + $pos` said nothing): text the server cannot read is published as the +// server publishes it. Every expected value is what Qore's DataProvider says for the same text +// (parseDpqlExpressionWithInfo, Qore batch 6 at 28a6bbcb14, installed on professor): its codes, its ranges, and +// its words, which since batch 6 say what to do, in plain words. +describe('text the server cannot read is published, in its words and where it is', () => { + const published = (text: string) => + mockPublishedDiagnostics(text).map(({ code, message, range }) => ({ + code, + message, + from: range.start.character, + to: range.end.character, + })); + const LONE_TEMPLATE_MARKER = + 'A `$` starts a template: pick one from the list, or remove the `$`.'; + const LONE_FIELD_MARKER = 'An `@` names a field: pick one from the list, or remove the `@`.'; + const UNFINISHED_TEMPLATE = + "This template isn't finished: pick one from the list, or write what it refers to after the ':'."; + + it('a $ left in front of a template', () => { + expect(published('$record:{pos} + 1 + $$record:{pos}')).toEqual([ + { + code: 'DPQL-E031', + message: "'$' can't follow a `$`: pick a template from the list, or remove the `$`.", + from: 20, + to: 21, + }, + ]); + }); + + it('a character that cannot follow a $ is named once', () => { + expect(published('$#')).toEqual([ + { + code: 'DPQL-E031', + message: "'#' can't follow a `$`: pick a template from the list, or remove the `$`.", + from: 0, + to: 1, + }, + ]); + }); + + it("a template's context with nothing after it", () => { + expect(published('$record:{pos} + $data:')).toEqual([ + { code: 'DPQL-E033', message: UNFINISHED_TEMPLATE, from: 16, to: 22 }, + ]); + expect(published('$local: ')).toEqual([ + { code: 'DPQL-E033', message: UNFINISHED_TEMPLATE, from: 0, to: 7 }, + ]); + }); + + it("a template's context without its ':'", () => { + expect(published('1 + $pos')).toEqual([ + { + code: 'DPQL-E032', + message: + "This template needs a ':' after `$pos`: pick one from the list, or remove the `$`.", + from: 4, + to: 8, + }, + ]); + }); + + it('a $ with nothing after it', () => { + expect(published('$')).toEqual([ + { code: 'DPQL-E030', message: LONE_TEMPLATE_MARKER, from: 0, to: 1 }, + ]); + expect(published('$record:{pos} + 1 + $')).toEqual([ + { code: 'DPQL-E030', message: LONE_TEMPLATE_MARKER, from: 20, to: 21 }, + ]); + // a space after it is nothing after it too, said under the code for a $ that starts no template + expect(published('$ + 1')).toEqual([ + { code: 'DPQL-E031', message: LONE_TEMPLATE_MARKER, from: 0, to: 1 }, + ]); + }); + + it('a value after a whole expression', () => { + expect(published('$record:{pos} $record:{bezeichnung}')).toEqual([ + { + code: 'DPQL-E001', + message: + 'Something follows a complete value here: join them with an operator, such as +, == or &&.', + from: 14, + to: 35, + }, + ]); + expect(published('1 + 2 )')).toEqual([ + { code: 'DPQL-E001', message: "This ')' has no '(' to close: remove it.", from: 6, to: 7 }, + ]); + }); + + it('an @ that names no field', () => { + expect(published('1 + @')).toEqual([ + { code: 'DPQL-E003', message: LONE_FIELD_MARKER, from: 4, to: 5 }, + ]); + expect(published('@ + 1')).toEqual([ + { code: 'DPQL-E003', message: LONE_FIELD_MARKER, from: 0, to: 1 }, + ]); + expect(published('@1')).toEqual([ + { + code: 'DPQL-E003', + message: "'1' can't follow an `@`: pick a field from the list, or remove the `@`.", + from: 0, + to: 1, + }, + ]); + }); + + it('each character it cannot read, reading on past it', () => { + const message = "'…' can't be used here: remove it, or put it in quotes to make it text."; + expect(published('… > …')).toEqual([ + { code: 'DPQL-E001', message, from: 0, to: 1 }, + { code: 'DPQL-E001', message, from: 4, to: 5 }, + ]); + }); + + it('a value missing, said where the token before it starts', () => { + expect(published('12 +')).toEqual([ + { + code: 'DPQL-E001', + message: "Something is missing at the end: a value after '+'.", + from: 3, + to: 3, + }, + ]); + expect(published('+ 1')).toEqual([ + { + code: 'DPQL-E001', + message: "Something is missing before '+': a value, such as a field or a template.", + from: 0, + to: 1, + }, + ]); + }); + + it("a '(' left open", () => { + expect(published('(1 + 2')).toEqual([ + { + code: 'DPQL-E001', + message: "Something is missing at the end: a ')' to close the '('.", + from: 5, + to: 5, + }, + ]); + expect(published('(1 + 2 3')).toEqual([ + { + code: 'DPQL-E001', + message: + "Something follows a complete value here: join them with an operator, or close the '(' with ')'.", + from: 7, + to: 8, + }, + ]); + expect(published('f(1 2)')).toEqual([ + { + code: 'DPQL-E001', + message: + "Something follows a complete value here: separate the values with ',', or close the '(' with ')'.", + from: 4, + to: 5, + }, + ]); + expect(published('f(1')).toEqual([ + { + code: 'DPQL-E001', + message: "Something is missing at the end: a ')' to close the '('.", + from: 2, + to: 2, + }, + ]); + }); + + it('dpql/parse says the same', () => { + expect(mockParseDpql('$record:{pos} + 1 + $$record:{pos}').diagnostics).toEqual([ + expect.objectContaining({ + severity: 'error', + code: 'DPQL-E031', + message: "'$' can't follow a `$`: pick a template from the list, or remove the `$`.", + column: 21, + end_column: 22, + }), + ]); + }); + + it('and nothing of text it reads', () => { + expect(published('$record:{pos} + 1 + $record:{pos}')).toEqual([]); + }); +}); + +// qorus#646: the server's parser refuses a `+` whose operands it cannot combine (DPQL-E050); the mock answers +// as professor's language server did for the case the stories write, a hash and a number. +describe('operands the operator cannot combine', () => { + const fields = { create: { type: 'hash' }, quantity: { type: 'int' } }; + const E050 = + "Operator '+' cannot be applied to '@create' (a hash) and a number: it adds numbers, or a number and a date; joins a string with a string, number, date or boolean; appends to a list; and merges two hashes or two binary values"; + + it('refuses the parse, with no expression, in the server words', () => { + const result = mockParseDpql('@create + 1', undefined, false, fields); + expect(result.success).toBe(false); + expect(result.expression).toBeUndefined(); + expect(result.diagnostics).toEqual([expect.objectContaining({ code: 'DPQL-E050', message: E050 })]); + }); + + it('publishes it as an error over the text', () => { + expect(mockPublishedDiagnostics('@create + 1', fields)).toEqual([ + expect.objectContaining({ severity: 1, code: 'DPQL-E050', message: E050 }), + ]); + }); + + it('reads the field as the editor writes it, a template of the record', () => { + expect(mockParseDpql('$record:{create} + 1', undefined, false, fields).success).toBe(false); + }); + + it('combines a number with a number, and a field of a type it cannot tell with anything', () => { + expect(mockParseDpql('@quantity + 1', undefined, false, fields).success).toBe(true); + expect(mockParseDpql('@create + 1', undefined, false, {}).success).toBe(true); + expect(mockPublishedDiagnostics('@quantity + 1', fields)).toEqual([]); + }); +}); + +describe('each part of && || ! is read as a condition (qorus#646)', () => { + // the server's own cases (lsp-websocket.qtest), with the record's field types + const fields = { n: { type: 'int' }, flag: { type: 'bool' }, name: { type: 'string' } }; + const codes = (text: string) => + mockParseDpql(text, undefined, true, fields).diagnostics.map((d) => [d.severity, d.code]); + + it('warns of a part read by its value', () => { + const result = mockParseDpql('@n && @flag', undefined, true, fields); + expect(result.parts).toEqual([ + { type: 'int', reading: 'truthiness' }, + { type: 'bool', reading: 'condition' }, + ]); + expect(result.condition_reading).toBe('truthiness'); + expect(codes('@n && @flag')).toEqual([['warning', 'CONDITION_BY_TRUTHINESS']]); + }); + + it('refuses a part that is text', () => { + const result = mockParseDpql('@name || @flag', undefined, true, fields); + expect(result.parts?.map((part) => part.reading)).toEqual(['not_a_condition', 'condition']); + expect(result.condition_reading).toBe('not_a_condition'); + expect(result.type_compatible).toBe(false); + expect(codes('@name || @flag')).toEqual([['error', 'NOT_A_CONDITION']]); + }); + + it('says nothing of parts that are true or false, at any depth', () => { + expect(mockParseDpql('@n > 0 && @flag', undefined, true, fields).parts).toEqual([ + { type: 'bool', reading: 'condition' }, + { type: 'bool', reading: 'condition' }, + ]); + expect(codes('@n > 0 && @flag')).toEqual([]); + expect(mockParseDpql('(@n > 0 && @flag) || !@n', undefined, true, fields).parts).toEqual([ + { type: 'bool', reading: 'condition' }, + { type: 'bool', reading: 'condition' }, + { type: 'int', reading: 'truthiness' }, + ]); + }); + + it('types a part that is an expression by its operands', () => { + expect(mockParseDpql('@n + 1 && @name + 1', undefined, true, fields).parts).toEqual([ + { type: 'int', reading: 'truthiness' }, + { type: 'string', reading: 'not_a_condition' }, + ]); + }); + + it('has no parts outside an AND, an OR or a NOT', () => { + expect(mockParseDpql('@n', undefined, true, fields).parts).toEqual([]); + }); +}); + +describe('dpql/expressionType in the mock (the server: getExpressionTypeInfo)', () => { + it('gives the type the server works out, and its UI name', () => { + expect( + mockExpressionTypeDpql({ + is_expression: true, + value: { + exp: '+', + args: [ + { type: 'int', value: 1 }, + { type: 'float', value: 2.5 }, + ], + }, + } as never) + ).toEqual({ inferred_type: 'float', ui_type: 'number', known: true }); + expect( + mockExpressionTypeDpql({ exp: 'concat', args: [{ type: 'string', value: 'a' }] } as never) + ).toEqual({ inferred_type: 'string', ui_type: 'richtext', known: true }); + expect( + mockExpressionTypeDpql({ + exp: '==', + args: [ + { type: 'int', value: 1 }, + { type: 'int', value: 1 }, + ], + } as never) + ).toEqual({ inferred_type: 'bool', ui_type: 'bool', known: true }); + }); + + it('says a type is not known when it is known only when the expression runs', () => { + expect( + mockExpressionTypeDpql({ + exp: '+', + args: [ + { type: 'auto', value: '$local:x' }, + { type: 'int', value: 1 }, + ], + } as never) + ).toEqual({ inferred_type: 'auto', ui_type: 'any', known: false }); + }); +}); + +// Qore batch 6: a completion's detail names a type as DataProvider's DataTypeCatalogue does, and a template +// context that needs a value says so. Each name is what DataTypeCatalogue::lookupType() gives for the type. +describe("the stand-in's completion details are the server's plain words", () => { + it('names a type as the catalogue does, by its base for an optional, soft or complex form', () => { + expect( + [ + 'int', + '*string', + 'softint', + 'hash', + 'number', + 'float', + 'bool', + 'date', + 'list', + ].map(dpqlMockTypeName) + ).toEqual([ + 'Whole number', + 'Text', + 'Whole number', + 'Key-value pairs (hash)', + 'Exact number', + 'Number', + 'True or false', + 'Date', + 'List', + ]); + }); + + it('keeps the name of a type the catalogue does not name', () => { + expect(dpqlMockTypeName('qoretechnologies/order')).toBe('qoretechnologies/order'); + }); +}); diff --git a/__tests__/dpqlMockRecordPath.test.ts b/__tests__/dpqlMockRecordPath.test.ts new file mode 100644 index 00000000..f55de85c --- /dev/null +++ b/__tests__/dpqlMockRecordPath.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + mockParseDpql, + mockRenderDpql, + mockSerializeDpql, +} from '../src/components/form/expressions/dpqlMockLanguage'; +import { + readRecordReference, + recordReferenceDpql, +} from '../src/components/form/expressions/dpqlMockRecordPath'; + +/** + * The stories' stand-in for the Qorus language server writes a record field as the server does (qorus#646): Qorus + * reads `$record:{}` as a test path and gives it as a field reference, or the accessors over one + * (`QorusRecordPath::reference()`), and the DPQL serializer writes those with its accessor sugar and qore#5480's path + * rule: dots separate the parts of a name, quoted or bare, and a dot that is part of a key is `\.`. It wrote the + * template (`$record:{pos} + 1`), which is not what a user sees on the Expression tab. + */ +const WRITTEN: Array<[string, string]> = [ + ['$record:{pos}', '@pos'], + ['$record:{create.status}', '@create.status'], + ['$record:{menge_bestätigt}', '@menge_bestätigt'], + // a part that is not a name: the path is one quoted name, its dots still separating its parts + ['$record:{create.order-id}', '@"create.order-id"'], + ['$record:{a b}', '@"a b"'], + // a dot that is part of a key (qore#5480), in a template as the Qorus IDE writes it: the path create.Unit\.Price + // with its backslash escaped for the template + ['$record:{create.Unit\\\\.Price}', '@"create.Unit\\.Price"'], + // the accessors, as the serializer writes them + ['$record:{orders[*].id}', '@orders[*].id'], + ['$record:{create.items[*].id}', '@create.items[*].id'], + ['$record:{orders[*]}', '@orders[*]'], + ['$record:{orders[*].order-id}', '@orders[*].order-id'], + ['$record:{orders[*].a b}', '@orders[*]{"a b"}'], + ['$record:{prices{*}}', '@prices{*}'], + ['$record:{prices{*}.amount}', '@prices{*}.amount'], + ['$record:{items[0].sku}', '@items[0].sku'], + ['$record:{items[0].a b}', '@items[0]{"a b"}'], + ['$record:{items[0][1]}', '@items[0][1]'], +]; + +describe('a record field written by the stories’ server', () => { + it.each(WRITTEN)('%s is written %s', (template, dpql) => { + expect(recordReferenceDpql(template)).toBe(dpql); + }); + + it('reads the template escapes first', () => { + expect(recordReferenceDpql('$record:{prices\\{*\\}.amount}')).toBe('@prices{*}.amount'); + // a backslash that escapes nothing of the template's is the path's own, as Qorus reads it + expect(recordReferenceDpql('$record:{Unit\\.Price}')).toBe('@"Unit\\.Price"'); + }); + + it('leaves what is not a record field, and a path the accessors cannot give', () => { + expect(recordReferenceDpql('$local:name')).toBeUndefined(); + expect(recordReferenceDpql('$data:{create.status}')).toBeUndefined(); + expect(recordReferenceDpql('plain text')).toBeUndefined(); + expect(recordReferenceDpql(12)).toBeUndefined(); + // it does not start with a field, or a list selector is neither * nor an index + expect(recordReferenceDpql('$record:{[0]}')).toBeUndefined(); + expect(recordReferenceDpql('$record:{orders[x]}')).toBeUndefined(); + }); +}); + +describe('a record field read by the stories’ server', () => { + it.each(WRITTEN)('%s is read back from %s', (template, dpql) => { + expect(readRecordReference(`${dpql} == 1`)).toEqual({ template, length: dpql.length }); + }); + + it('stops where the reference ends', () => { + expect(readRecordReference('@pos + 1')).toEqual({ template: '$record:{pos}', length: 4 }); + expect(readRecordReference('@items[0].sku, 2')).toEqual({ + template: '$record:{items[0].sku}', + length: '@items[0].sku'.length, + }); + }); + + it('reads a key given in braces as a part of the path', () => { + expect(readRecordReference('@headers{"content-type"}')?.template).toBe( + '$record:{headers.content-type}' + ); + expect(readRecordReference('@headers{"a.b"}')?.template).toBe('$record:{headers.a\\\\.b}'); + }); + + it('is nothing where the text does not start with a field', () => { + expect(readRecordReference('pos')).toBeUndefined(); + expect(readRecordReference('@')).toBeUndefined(); + }); +}); + +describe('an expression holding a record field', () => { + const POS_PLUS_ONE = { + exp: '+', + args: [ + { type: 'int', value: '$record:{pos}' }, + { type: 'int', value: 1 }, + ], + }; + + it('is written with the field reference, and read back to the same expression', () => { + const { dpql } = mockSerializeDpql(POS_PLUS_ONE); + + expect(dpql).toBe('@pos + 1'); + expect(mockSerializeDpql(mockParseDpql(dpql).expression).dpql).toBe('@pos + 1'); + }); + + it('round-trips a quoted path and an accessor chain', () => { + for (const text of [ + '@"create.order-id" == "A-1"', + 'size(@orders[*].id) > 0', + '@"Unit\\.Price" > 10', + ]) { + expect(mockSerializeDpql(mockParseDpql(text).expression).dpql).toBe(text); + } + }); + + it('is rendered with the field as its chip', () => { + expect(mockRenderDpql(POS_PLUS_ONE).rendered).toBe('$record:{pos} + 1'); + }); +}); diff --git a/__tests__/dpqlRendering.test.tsx b/__tests__/dpqlRendering.test.tsx index 9b2dad03..f8fb2ab3 100644 --- a/__tests__/dpqlRendering.test.tsx +++ b/__tests__/dpqlRendering.test.tsx @@ -45,7 +45,7 @@ beforeEach(() => { }); describe('DpqlRendering', () => { - it('shows the text through a read-only DPQL editor, without diagnostics, hover or overlay', () => { + it('shows the text through a read-only DPQL editor, without diagnostics or overlay', () => { const { container } = mount(); expect(container.querySelector('[data-testid="shown"]')?.textContent).toBe('1 + 2 > 3'); @@ -53,11 +53,16 @@ describe('DpqlRendering', () => { value: '1 + 2 > 3', readOnly: true, showDiagnostics: false, - enableHover: false, // The text is known up front; no "Connecting…" overlay may hide it. loadingIndicator: null, }); }); + + it('says what a word is on hover, as the editor does (qorus#646, David: "trim" said nothing when read)', () => { + mount(); + + expect(editorProps[0].enableHover).not.toBe(false); + }); }); describe('ExpressionRendering', () => { diff --git a/__tests__/dpqlSessionSerializeRefused.test.tsx b/__tests__/dpqlSessionSerializeRefused.test.tsx new file mode 100644 index 00000000..4efcf1f8 --- /dev/null +++ b/__tests__/dpqlSessionSerializeRefused.test.tsx @@ -0,0 +1,37 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A value the server refuses to write as DPQL is said to be refused, not answered with no text (qorus#646). +// +// `dpql/serialize` raises DPQL-SERIALIZE-ERROR for an expression it cannot write - a two-argument operator +// stored with more arguments. The session answered '' for that, as it does while it has no client, and the +// Text view opened empty for the author to type over: what was typed then replaced the value. +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +const client = { + customRequest: async (method: string) => { + if (method === 'dpql/serialize') { + throw Object.assign( + new Error('DPQL-SERIALIZE-ERROR: "-" takes two arguments; this one has 3'), + { + code: -32603, + } + ); + } + return {}; + }, +}; + +vi.mock('../src/components/smartEditor/useLspSession', () => ({ + useLspSession: () => ({ client, isReady: true, uri: 'inmemory://dpql/1', diagnostics: [] }), +})); + +import { useDpqlSession } from '../src/components/dpqlEditor/useDpqlSession'; + +describe('a DPQL session asked to write a value it cannot', () => { + it('says the server refused, with its reason', async () => { + const { result } = renderHook(() => useDpqlSession({})); + await expect(result.current.serialize({ exp: '-', args: [1, 2, 3] })).rejects.toThrow( + /DPQL-SERIALIZE-ERROR/ + ); + }); +}); diff --git a/__tests__/dpqlTypedTextDetection.test.tsx b/__tests__/dpqlTypedTextDetection.test.tsx index eaa5caa1..94655132 100644 --- a/__tests__/dpqlTypedTextDetection.test.tsx +++ b/__tests__/dpqlTypedTextDetection.test.tsx @@ -178,10 +178,12 @@ describe('DPQL typed into a plain field', () => { await userEvent.click(offer() as HTMLElement); // Only now is the expression stored, and with the expression flag set. - await waitFor(() => expect(storedExpression(onChange)).toEqual({ - exp: '+', - args: [{ type: 'int', value: 5 }], - })); + await waitFor(() => + expect(storedExpression(onChange)).toEqual({ + exp: '+', + args: [{ type: 'int', value: 5 }], + }) + ); }); it('keeps the literal when the offer is dismissed, and does not ask again', async () => { @@ -199,41 +201,42 @@ describe('DPQL typed into a plain field', () => { expect(storedExpression(onChange)).toBeUndefined(); }); - it('SWITCHES when the text cannot be a literal of the field type', async () => { + /* qorus#646, David's review: "@pos + @bezeichnung" became an expression without asking. An expression detected + is offered, never switched to - text that cannot be a value of the field's type too: the field says it refuses + it, beside the offer, and the author chooses. */ + it('OFFERS, and stores nothing, where the text cannot be a value of the field type either', async () => { probe.mockResolvedValue(expressionResult('>')); - // A name field: its editor takes any text, but `@a > 5` is not a legal - // identifier, so it was already an invalid value the moment it landed. + // A name field: its editor takes any text, but `@a > 5` is not a legal identifier const onChange = await renderTypedField('@a > 5', { has_to_be_valid_identifier: true }); + await findOffer(); + expect(storedExpression(onChange)).toBeUndefined(); + // accepted, it is the expression + await userEvent.click(offer() as HTMLElement); await waitFor(() => expect(storedExpression(onChange)).toEqual({ exp: '>', args: [{ type: 'int', value: 5 }], }) ); - // Nothing to accept — it already happened. - expect(offer()).toBeNull(); }); - it('SWITCHES on a typed field whose editor takes free text', async () => { + it('OFFERS on a typed field whose editor takes free text', async () => { probe.mockResolvedValue(expressionResult('>')); const onChange = await renderTypedField('@a > 5', { type: 'binary' }); - await waitFor(() => - expect(storedExpression(onChange)).toEqual({ - exp: '>', - args: [{ type: 'int', value: 5 }], - }) - ); + await findOffer(); + expect(storedExpression(onChange)).toBeUndefined(); }); - it('makes the switch undoable, restoring the exact text', async () => { + it('makes an accepted offer undoable, restoring the exact text', async () => { probe.mockResolvedValue(expressionResult('>')); const onChange = await renderTypedField('@a > 5', { has_to_be_valid_identifier: true }); + await userEvent.click((await findOffer()) as HTMLElement); await waitFor(() => expect(storedExpression(onChange)).toBeTruthy()); await userEvent.click(document.querySelector('.dpql-detected-undo') as HTMLElement); @@ -245,16 +248,50 @@ describe('DPQL typed into a plain field', () => { }); }); + it('offers the undone text again as an expression where it cannot be a value of the field type', async () => { + // `@a > 5` is no legal identifier: put back by the undo, it is a value the field refuses, so the way back + // to the expression is offered beside it (David on FormEngine · Expression Typed On Value Switches With + // Undo, qorus#646). Accepted, it is the expression again. + probe.mockResolvedValue(expressionResult('>')); + + const onChange = await renderTypedField('@a > 5', { has_to_be_valid_identifier: true }); + await userEvent.click((await findOffer()) as HTMLElement); + await waitFor(() => expect(storedExpression(onChange)).toBeTruthy()); + + await userEvent.click(document.querySelector('.dpql-detected-undo') as HTMLElement); + await waitFor(() => expect(offer()).not.toBeNull()); + + onChange.mockClear(); + await userEvent.click(offer() as HTMLElement); + await waitFor(() => + expect(storedExpression(onChange)).toEqual({ exp: '>', args: [{ type: 'int', value: 5 }] }) + ); + }); + + it('does not offer the undone text again where it can be a value of the field type', async () => { + // a plain text field keeps `@a > 5` as the text it is: the undo said so, and it is not asked again + probe.mockResolvedValue(expressionResult('>')); + + const onChange = await renderTypedField('@a > 5'); + await waitFor(() => expect(offer()).not.toBeNull()); + await userEvent.click(offer() as HTMLElement); + await waitFor(() => expect(storedExpression(onChange)).toBeTruthy()); + + await userEvent.click(document.querySelector('.dpql-detected-undo') as HTMLElement); + await waitFor(() => expect(onChange.mock.calls.slice(-1)[0][3]).toBe(false)); + expect(offer()).toBeNull(); + }); + it('takes expression text typed into a numeric field as an expression', async () => { // A numeric field is typed freely now (David's review of qorus#646): it used to render // `input[type=number]`, which dropped `@`, `>` and spaces, so detection never saw the text and only - // the digits survived. Text that cannot be a number here was an error the moment it was typed, so it - // switches to the expression, as it does on any field whose type the text cannot be. + // the digits survived. The text is offered as the expression it is, and taken as one when accepted. probe.mockResolvedValue(expressionResult('>')); const onChange = await renderTypedField('@a > 5', { type: 'int' }); await waitFor(() => expect(probe).toHaveBeenCalledWith('@a > 5')); + await userEvent.click((await findOffer()) as HTMLElement); await waitFor(() => expect(storedExpression(onChange)).toBeDefined()); }); @@ -280,10 +317,12 @@ describe('DPQL typed into a plain field', () => { await userEvent.click(offer() as HTMLElement); - await waitFor(() => expect(storedExpression(onChange)).toEqual({ - exp: '+', - args: [{ type: 'int', value: 5 }], - })); + await waitFor(() => + expect(storedExpression(onChange)).toEqual({ + exp: '+', + args: [{ type: 'int', value: 5 }], + }) + ); }); }); @@ -342,7 +381,10 @@ describe('a value that is a richtext document', () => { name='opt' aria-label='Option' value={[ - { type: 'paragraph', children: [{ type: 'tag', value: '$.result', children: [{ text: '' }] }] }, + { + type: 'paragraph', + children: [{ type: 'tag', value: '$.result', children: [{ text: '' }] }], + }, ]} type='string' defaultType='string' diff --git a/__tests__/emittedValuesExport.test.ts b/__tests__/emittedValuesExport.test.ts new file mode 100644 index 00000000..ec0c32de --- /dev/null +++ b/__tests__/emittedValuesExport.test.ts @@ -0,0 +1,90 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// What a debounced field has reported and not yet heard back is public: a host's own field tracks its reports +// as reqraft's own fields do (the Qorus IDE's rich text, which lost what was typed to the echo of a report). +// Imported from the package root, as a host imports it - a deep import into `dist/` is a dependency on the +// file layout, not on the API. +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { EmittedValues, TEmittedValuesEquals, useEmittedValues } from '../src'; + +const document = (text: string) => [{ type: 'paragraph', children: [{ text }] }]; + +/** As the IDE's rich text reads values: '', undefined, null and an empty document are one empty value. */ +const sameText: TEmittedValuesEquals = (a, b) => { + const text = (value: unknown) => + Array.isArray(value) ? + value + .map((paragraph: any) => paragraph.children.map((leaf: any) => leaf.text).join('')) + .join('\n') + : value === undefined || value === null ? '' + : String(value); + return text(a) === text(b); +}; + +describe('the package root', () => { + it('exports EmittedValues and useEmittedValues', () => { + expect(typeof EmittedValues).toBe('function'); + expect(typeof useEmittedValues).toBe('function'); + }); +}); + +describe('EmittedValues', () => { + it('takes the echo of a report for the parent catching up, and acknowledges it and the reports before it', () => { + const emitted = new EmittedValues(); + emitted.record('s'); + emitted.record('sh'); + expect(emitted.settled('')).toBe('sh'); + + // the parent hands back the first report while the second is still on its way + expect(emitted.isEcho('s')).toBe(true); + expect(emitted.includes('sh')).toBe(true); + expect(emitted.isEcho('sh')).toBe(true); + // nothing left in flight: what the parent holds is what it holds + expect(emitted.settled('sh')).toBe('sh'); + }); + + it('takes anything else for a change from outside, and drops what was in flight', () => { + const emitted = new EmittedValues(); + emitted.record('ship'); + expect(emitted.isEcho('other')).toBe(false); + expect(emitted.includes('ship')).toBe(false); + }); + + it("reads values as the same only as its equals says: by default '', undefined, null and an empty document differ", () => { + const emitted = new EmittedValues(); + emitted.record(''); + expect(emitted.includes(undefined)).toBe(false); + expect(emitted.includes(null)).toBe(false); + expect(emitted.includes(document(''))).toBe(false); + // a copy of a reported document is not the document that was reported + const report = document('1'); + emitted.record(report); + expect(emitted.includes(document('1'))).toBe(false); + expect(emitted.includes(report)).toBe(true); + }); + + it('reads them as the same where the caller says they are', () => { + const emitted = new EmittedValues(sameText); + emitted.record(''); + expect(emitted.includes(undefined)).toBe(true); + expect(emitted.includes(null)).toBe(true); + expect(emitted.includes(document(''))).toBe(true); + expect(emitted.isEcho(undefined)).toBe(true); + }); +}); + +describe('useEmittedValues', () => { + it('is one EmittedValues for the life of the component, comparing as its first equals says', () => { + const { result, rerender } = renderHook( + ({ equals }: { equals?: TEmittedValuesEquals }) => useEmittedValues(equals), + { initialProps: { equals: sameText } } + ); + const first = result.current; + act(() => first.record('')); + + rerender({ equals: undefined }); + expect(result.current).toBe(first); + // still the first render's equals: undefined is the echo of '' + expect(result.current.isEcho(undefined)).toBe(true); + }); +}); diff --git a/__tests__/emptyOptionalList.test.ts b/__tests__/emptyOptionalList.test.ts new file mode 100644 index 00000000..00a435d8 --- /dev/null +++ b/__tests__/emptyOptionalList.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + describeBlockedSave, + isOptionValueEmpty, +} from '../src/components/form/engine/readFirst'; +import { validateField, validateFieldWithResult } from '../src/helpers/validations'; + +/** + * An optional list field holding an empty list is not set - and so not invalid. + * + * The reported case: an API schema opened for editing held `author: []` in an optional `select-array` field. + * The form filed the field under Optional (an empty list is "not set" to its status), while validation checked + * `[]` as a value - "At least one value is required" - so the whole form was invalid and Submit refused, with + * nothing on screen saying why. One rule now says what is set, and both read it. + */ +describe('an optional list field holding an empty list', () => { + const schema = { + display_name: { type: 'string', display_name: 'Name', required: true }, + author: { type: 'select-array', display_name: 'Authors' }, + } as any; + + it('does not make the form invalid', () => { + const options = { + display_name: { type: 'string', value: 'Orders' }, + author: { type: 'select-array', value: [] }, + }; + expect(validateField('options', options, { optionSchema: schema })).toBe(true); + // nor does a list type the form knows under another name + for (const type of ['multi-select', 'array', 'file-tree', 'list']) { + expect( + validateField( + 'options', + { display_name: options.display_name, tags: { type, value: [] } }, + { optionSchema: { ...schema, tags: { type } } } + ) + ).toBe(true); + } + }); + + it('still makes it invalid when the field is required', () => { + const result = validateFieldWithResult( + 'options', + { + display_name: { type: 'string', value: 'Orders' }, + author: { type: 'select-array', value: [] }, + }, + { optionSchema: { ...schema, author: { ...schema.author, required: true } } } + ); + expect(result.isValid).toBe(false); + }); + + it('is judged by its values once it has some', () => { + expect( + validateField( + 'options', + { + display_name: { type: 'string', value: 'Orders' }, + author: { type: 'select-array', value: ['Ann'] }, + }, + { optionSchema: schema } + ) + ).toBe(true); + }); + + it('is what the form calls not set, as are no value, null and empty text', () => { + expect([undefined, null, '', []].map(isOptionValueEmpty)).toEqual([true, true, true, true]); + expect([0, false, 'x', ['x'], {}].map(isOptionValueEmpty)).toEqual([ + false, + false, + false, + false, + false, + ]); + }); +}); + +describe('why a form cannot be saved', () => { + const schema = { description: { display_name: 'Description' } }; + + it('names the first field that blocks it by the name the form shows, and says what is wrong', () => { + expect( + describeBlockedSave( + [{ fieldName: 'description', validation: { reason: 'Value is required', reasons: [] } }], + schema + ) + ).toBe('Before saving, fix Description: Value is required'); + }); + + it('counts the others', () => { + expect( + describeBlockedSave( + [ + { fieldName: 'description', validation: { reasons: ['Value is required'] } }, + { fieldName: 'port', validation: { reasons: ['Value must be a number'] } }, + { fieldName: 'host', validation: { reasons: [] } }, + ], + schema + ) + ).toBe('Before saving, fix Description: Value is required (and 2 more fields)'); + }); + + it('falls back to the internal name, and says nothing when nothing blocks it', () => { + expect(describeBlockedSave([{ fieldName: 'port', validation: { reasons: [] } }])).toBe( + 'Before saving, fix port' + ); + expect(describeBlockedSave([], schema)).toBeUndefined(); + }); +}); diff --git a/__tests__/expressionAddedValueTakesTheFocus.test.tsx b/__tests__/expressionAddedValueTakesTheFocus.test.tsx new file mode 100644 index 00000000..91532cc0 --- /dev/null +++ b/__tests__/expressionAddedValueTakesTheFocus.test.tsx @@ -0,0 +1,80 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/hooks/useStorage/useStorage', () => ({ + useReqraftStorage: (_k: string, d: unknown) => [d, vi.fn()], +})); + +import { Expression } from '../src/components/form/expressions/builder'; +import { mockExpressions } from '../src/components/form/expressions/mockExpressions'; +import { IExpression } from '../src/components/form/expressions/types'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const fetchContext = emptyFetchContext(); + +/** + * A value added with the "Add value" slot takes the focus, and opens nothing (qorus#646). + * + * A new operand has no type yet, so it has no text box: its first control is its template picker, a list that + * opens when it is focused. The builder gave that picker the focus, and since reqore 0.78 a list focused as it + * mounts opens at once, so adding a value opened the list of templates over the form. David: adding a value is + * not asking for that list. The operand itself takes the focus; its picker opens when the user goes to it. + */ +const Host = ({ initial, type }: { initial: IExpression; type: string }) => { + const [value, setValue] = useState(initial); + + return ( + setValue(next)) as never} + expressions={mockExpressions as never} + type={type as never} + returnType={type as never} + level={0} + /> + ); +}; + +const operands = () => document.querySelectorAll('.expression-arg'); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('a value added with the "Add value" slot', () => { + it.each([ + ['concat', 'string', 'first'], + ['+', 'int', 1], + ['&&', 'bool', true], + ])('in %s takes the focus itself, not its template picker', async (exp, type, value) => { + render( + + + + + + ); + const slot = await waitFor(() => { + const found = document.querySelector('.expression-add-arg'); + expect(found).toBeTruthy(); + return found as HTMLElement; + }); + + fireEvent.click(slot); + + await waitFor(() => expect(operands()).toHaveLength(2)); + const added = operands()[1]; + // a new value has no type yet, so nothing to type into: only its template picker + expect(added.querySelector('textarea, input')).toBeNull(); + expect(added.querySelector('.template-selector')).toBeTruthy(); + await waitFor(() => expect(document.activeElement).toBe(added)); + expect(document.querySelector('.reqore-popover-content')).toBeNull(); + }); +}); diff --git a/__tests__/expressionArgumentRendering.test.tsx b/__tests__/expressionArgumentRendering.test.tsx index d1bec0b0..6f760339 100644 --- a/__tests__/expressionArgumentRendering.test.tsx +++ b/__tests__/expressionArgumentRendering.test.tsx @@ -78,7 +78,7 @@ describe('drawing an argument that is the null literal', () => { await settled(); expect(document.body.textContent).not.toContain('Something went wrong'); - expect(document.body.textContent).toContain('Missing value'); + expect(document.body.textContent).toContain('Enter a value for'); }); }); diff --git a/__tests__/expressionClearValue.test.tsx b/__tests__/expressionClearValue.test.tsx index cca53394..d4c705d3 100644 --- a/__tests__/expressionClearValue.test.tsx +++ b/__tests__/expressionClearValue.test.tsx @@ -1,6 +1,6 @@ import { ReqoreUIProvider } from '@qoretechnologies/reqore'; import { render, screen, waitFor } from '@testing-library/react'; -import { forwardRef, useImperativeHandle, useState } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; /** @@ -13,7 +13,11 @@ import { describe, expect, it, vi } from 'vitest'; * the clear having silently failed. */ vi.mock('../src/components/dpqlEditor', () => ({ - DpqlEditor: forwardRef(({ value, onChange }, ref) => { + DpqlEditor: forwardRef(({ value, onChange, onReady }, ref) => { + // a session that is up: the field reads and writes its text through it from the start + useEffect(() => { + onReady?.(); + }, []); useImperativeHandle(ref, () => ({ serialize: async () => '1 + 2' }), []); return ( ').querySelector('textarea'))).toBe(true); + expect(isTypingControl(cell('
').querySelector('div'))).toBe( + true + ); + }); + + it('does not read a checkbox, a switch, a radio or a button as typed into', () => { + expect(isTypingControl(cell('').querySelector('input'))).toBe(false); + expect( + isTypingControl(cell('').querySelector('input')) + ).toBe(false); + expect(isTypingControl(cell('').querySelector('input'))).toBe(false); + expect(isTypingControl(cell('').querySelector('button'))).toBe(false); + expect(isTypingControl(null)).toBe(false); + }); +}); diff --git a/__tests__/rowMarksExpressions.test.tsx b/__tests__/rowMarksExpressions.test.tsx index c3a7b412..5f087af8 100644 --- a/__tests__/rowMarksExpressions.test.tsx +++ b/__tests__/rowMarksExpressions.test.tsx @@ -70,7 +70,7 @@ describe('a compact row carrying an expression', () => { it('shows the expression as DPQL, through the one read-only rendering', async () => { /* Every read-only expression surface — Explain, Preview, a suggested conversion, this row — draws through `DpqlRendering`, so the row and the - editor it opens read the same: monospace, server-coloured, references as + editor it opens read the same: monospace, server-colored, references as chips. Proportional text here was the one place that differed. */ renderRow({ type: 'auto', diff --git a/__tests__/rowMenuOffersTheWayOutOfTemplateMode.test.tsx b/__tests__/rowMenuOffersTheWayOutOfTemplateMode.test.tsx index 4b193604..091d31b8 100644 --- a/__tests__/rowMenuOffersTheWayOutOfTemplateMode.test.tsx +++ b/__tests__/rowMenuOffersTheWayOutOfTemplateMode.test.tsx @@ -14,6 +14,7 @@ import { RowMenuContext } from '../src/components/form/engine/rowMenuContext'; import { TemplateField, ITemplateFieldProps, + NO_TEMPLATES_ON_OFFER, } from '../src/components/form/fields/template/TemplateField'; import { FetchContext } from '../src/contexts/FetchContext'; import { emptyFetchContext } from './support/fetchContext'; @@ -86,3 +87,31 @@ describe("what a field publishes into its row's menu", () => { expect(container.querySelector('.template-more')).toBeNull(); }); }); + +describe('a field that takes templates where none is on offer', () => { + it('shows the way in, not to be taken, and says why', async () => { + // a context whose catalogue has none: "Use Template" led to an empty, disabled list (qorus#646) + fieldInARow({ value: 'already typed', templates: { items: [] } as never }); + + await waitFor(() => expect(labels()).toContain('Use Template')); + const item = Object.values(published) + .flat() + .find( + (entry) => (entry as { label?: unknown }).label === 'Use Template' + ) as IReqoreDropdownItem; + expect(item.disabled).toBe(true); + expect(item.description).toBe(NO_TEMPLATES_ON_OFFER); + }); + + it('takes the way in where one is on offer', async () => { + fieldInARow({ value: 'already typed' }); + + await waitFor(() => expect(labels()).toContain('Use Template')); + const item = Object.values(published) + .flat() + .find( + (entry) => (entry as { label?: unknown }).label === 'Use Template' + ) as IReqoreDropdownItem; + expect(item.disabled).toBeFalsy(); + }); +}); diff --git a/__tests__/savedValuesAndTemplateBadge.test.tsx b/__tests__/savedValuesAndTemplateBadge.test.tsx new file mode 100644 index 00000000..4042413a --- /dev/null +++ b/__tests__/savedValuesAndTemplateBadge.test.tsx @@ -0,0 +1,324 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// Saved values and a template's badge (qorus#646): host seams of the template field - values the host keeps for +// reuse, offered in the field's ⋮, and where a template comes from, shown in the template picker. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { IReqoreDropdownItem } from '@qoretechnologies/reqore/dist/components/Dropdown/list'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/hooks/useStorage/useStorage', () => ({ + useReqraftStorage: (_k: string, d: unknown) => [d, vi.fn()], +})); + +/** How the fake language server answers: up, or not there at all. */ +const lsp = { available: true }; + +/* The Text view's editor, standing in for the DPQL editor: it shows its text, parses a lone template as the + server does (the `template` operation), and reports when there is no language server. */ +vi.mock('../src/components/dpqlEditor', () => ({ + DpqlEditor: forwardRef(({ value, onChange, onUnavailable }, ref) => { + useImperativeHandle( + ref, + () => ({ + parse: async (text: string) => + /^\$[a-z]+:\{[^}]*\}$/.test(text) ? + { + success: true, + expression: { + is_expression: true, + value: { + exp: 'template', + args: [ + { + type: 'auto', + value: { tmpl_context: 'record', tmpl_value: 'pos', raw: text }, + }, + ], + }, + }, + } + : /\+/.test(text) ? + { + success: true, + expression: { + is_expression: true, + value: { + exp: '+', + args: text + .split('+') + .map((part) => ({ type: 'int', value: Number(part.trim()) })), + }, + }, + } + : { + success: true, + expression: { + is_expression: true, + value: { exp: 'value', args: [{ type: 'int', value: Number(text) }] }, + }, + }, + serialize: async () => '', + }), + [] + ); + useEffect(() => { + if (!lsp.available) onUnavailable?.(); + }, []); + return ( +