diff --git a/.agents/context/project.md b/.agents/context/project.md index fe88cd910..01c4862b3 100644 --- a/.agents/context/project.md +++ b/.agents/context/project.md @@ -11,7 +11,8 @@ Read them before making changes; if they disagree with this file, they win. - Framework: Lit 3 custom elements with Shadow DOM, `@lit/context` for shared state - Positioning: `@floating-ui/dom`; localization: `igniteui-i18n-core` - Styles: SCSS compiled to generated `.css.ts` files, themes from `igniteui-theming` -- Tests: Web Test Runner + Playwright, `@open-wc/testing`, mandatory a11y audits +- Tests: Web Test Runner + Playwright, `@open-wc/testing`, mandatory a11y audits, fast-check + property-based (fuzz) tests in `*.property.spec.ts` - Docs and demos: Storybook, Custom Elements Manifest, TypeDoc - Tooling: oxlint, oxfmt, stylelint, lit-analyzer, dependency-cruiser diff --git a/.agents/skills/review-component-pr/SKILL.md b/.agents/skills/review-component-pr/SKILL.md index bf3dbb0aa..7aeca78f6 100644 --- a/.agents/skills/review-component-pr/SKILL.md +++ b/.agents/skills/review-component-pr/SKILL.md @@ -107,6 +107,8 @@ Map each change to a spec section with - [ ] Interaction uses `#internals/testing/simulate.spec.js`. Forms use `createFormAssociatedTestBed` and the validity helpers. - [ ] No spec imports another component's spec. Shared helpers are in `src/internals/testing/`. +- [ ] A new or changed parser, converter or serializer has property-based tests in + `[module].property.spec.ts` - [ ] The story's `// region default` block was regenerated (`cem` + `build:meta`), not edited - [ ] CHANGELOG updated diff --git a/.github/CODING_GUIDELINES.md b/.github/CODING_GUIDELINES.md index 1b028c6ef..c9b96e937 100644 --- a/.github/CODING_GUIDELINES.md +++ b/.github/CODING_GUIDELINES.md @@ -536,9 +536,11 @@ Each component has tests in `[component].spec.ts`. They cover: | -------------------------- | ------------------------------------------------------------------------------ | | `simulate.spec.js` | `simulateClick`, `simulateKeyboard`, `simulatePointerDown`, `simulateInput`, … | | `form-testbed.spec.js` | `createFormAssociatedTestBed` and the shared label and ARIA projection suites | - | `validity-helpers.spec.js` | Validity assertions and `runValidationContainerTests` | + | `validity-helpers.spec.js` | Validity assertions and `runValidationContainerTests`. Await it. | | `invoker-commands.spec.js` | `runInvokerCommandsTests` | | `helpers.spec.js` | Animation, focus, scroll and style helpers, and `axeReflectedRelationsOptions` | + | `fast-check-setup.spec.js` | `fc` with the seed, `SLOW_PROPERTY_RUNS`, `orderedPair` and `withFixture` | + | `date-arbitraries.spec.js` | Date arbitraries, wall clocks and `assumeWallClock` for date properties | Use the simulated events, not `element.click()` or a hand-built `KeyboardEvent`. They send the full event sequence of a real user interaction. @@ -548,6 +550,24 @@ Each component has tests in `[component].spec.ts`. They cover: - The `## Test scenarios` section of the [specification](#specifications) mirrors the suite. When you add or remove a test, update that section in the same change. +### Property-based tests + +A parser, converter or serializer that takes user or stored input also gets property-based +(fuzz) tests with [fast-check](https://fast-check.dev/): + +- Put them in `[module].property.spec.ts` next to the module. Import `fc` from + `#internals/testing/fast-check-setup.spec.js`, which sets the seed. +- Test properties that hold for all input: round-trips, invariants, and no throws. Where + possible, use an oracle that does not use the code under test. +- Generate Unicode input (`fc.string({ unit: 'grapheme' })`), not only ASCII. +- Keep each test well below the 3000 ms timeout. A property that renders components uses + `{ numRuns: SLOW_PROPERTY_RUNS }` and `withFixture`, which gives each run a new fixture. +- Use `Object.is` for values that can be `NaN`. Chai `equal` uses `===`. +- A date property with defaulted parts uses `assumeWallClock`. It skips a wall clock in a + daylight saving gap. CI runs in UTC and does not show these cases. +- When a property fails, fix the code and add the counterexample to the example-based suite. + See [CONTRIBUTING.md](CONTRIBUTING.md#property-based-tests) to replay it. + ## Properties and Attributes - Property names are camelCase. Attribute names are kebab-case. Properties that copy standard diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7b3e8022d..fe3fc83d3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -72,6 +72,24 @@ To run the tests in watch mode, run: npm run test:watch ``` +### Property-based tests + +Parsers, converters and serializers that take user or stored input also have property-based (fuzz) tests with [fast-check](https://fast-check.dev/), in `[module].property.spec.ts`. They run with the rest of the suite. fast-check shrinks a failure to a minimal counterexample. + +`npm run test` uses a fixed seed, so a run fails only for a counterexample that your change causes. Each week, the [Fuzz workflow](workflows/fuzz.yml) uses a random seed and more runs. To do the same locally, set: + +| Variable | Effect | +| -------------- | ----------------------------------------------------------- | +| `FC_SEED` | The seed. `random` picks a new one. | +| `FC_NUM_RUNS` | The number of runs for each property. The default is 100. | +| `TEST_TIMEOUT` | The test timeout in milliseconds, for a high `FC_NUM_RUNS`. | + +```sh +FC_SEED=random FC_NUM_RUNS=1000 TEST_TIMEOUT=120000 npx wtr --files "src/**/*.property.spec.ts" +``` + +A failed property shows its seed and counterexample. To replay it, use the same seed and run count: `FC_SEED= FC_NUM_RUNS= npx wtr --files `. The weekly run uses 1000. Fix the code, and add the counterexample to the example-based suite. Change a property only if the property is wrong. + ### Demoing with Storybook To start a local instance of Storybook for your component, run: diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml new file mode 100644 index 000000000..ab0fd383c --- /dev/null +++ b/.github/actions/setup-playwright/action.yml @@ -0,0 +1,28 @@ +name: Set up Playwright +description: Restores or installs the Playwright Chromium browser and its system dependencies. Run it after `npm ci`. + +runs: + using: composite + steps: + - name: Resolve the Playwright version + id: playwright + shell: bash + run: echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Restore the Playwright browsers + id: playwright-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }} + + # The cache holds the browser binaries, but not the system libraries in the + # image, so `install-deps` runs in both cases. + - name: Install the Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + shell: bash + run: npx playwright install --with-deps --only-shell chromium + - name: Install the Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + shell: bash + run: npx playwright install-deps chromium diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fb390394a..0225bd1de 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -67,6 +67,8 @@ structural reference. - Write tests with `@open-wc/testing` in `[name].spec.ts`. The a11y audit is mandatory. - Use the shared helpers in `src/internals/testing/`. +- A parser, converter or serializer also gets property-based tests with fast-check in + `[name].property.spec.ts`. - Run `npm run check`, `npm run lint` and `npm run test` before you open a PR. ## Resources diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 000000000..7ee59abb2 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,45 @@ +# Runs the property-based tests with a random seed and more runs, to find new +# counterexamples. See CONTRIBUTING.md to replay a failure. +name: Fuzz + +permissions: read-all + +on: + schedule: + # Sundays at 02:30 UTC. + - cron: '30 2 * * 0' + workflow_dispatch: + inputs: + seed: + description: 'fast-check seed, or "random"' + default: 'random' + num-runs: + description: 'Runs for each property' + default: '1000' + +jobs: + fuzz: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: 'npm' + - run: npm ci + + - uses: ./.github/actions/setup-playwright + + # The tile-manager suite needs the compiled styles. + - run: npm run build:styles + + - name: Run the property-based tests + env: + FC_SEED: ${{ inputs.seed || 'random' }} + FC_NUM_RUNS: ${{ inputs.num-runs || '1000' }} + TEST_TIMEOUT: '120000' + run: npx wtr --files "src/**/*.property.spec.ts" diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index a7378ac79..f73b4f471 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -56,26 +56,7 @@ jobs: cache: 'npm' - run: npm ci - - name: Resolve the Playwright version - id: playwright - run: echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT" - - - name: Restore the Playwright browsers - id: playwright-cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }} - - # The browser binaries live in the cache, but the system libraries they link - # against are installed into the image and cannot be, so `install-deps` runs - # either way. - - name: Install the Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install --with-deps --only-shell chromium - - name: Install the Playwright system dependencies - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium + - uses: ./.github/actions/setup-playwright - run: npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md index 6059ac714..f1ae676c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] +### Security +- #### Tile manager + - `loadLayout` now copies only the serialized tile properties: the spans, the positions, the flags and `id`. A layout from storage or a server can no longer set `innerHTML` on a tile or replace its prototype through `__proto__`. A value that is not an array, and an entry that is not an object, are ignored. + +### Fixed +- #### QR code + - Versions 30 to 40 at the `M` error correction level now use the data codeword counts of ISO/IEC 18004. Before, these codes had the wrong block structure. +- #### Mask input, Date time input, Date range picker + - A mask position holds one UTF-16 code unit, so an astral character, such as an emoji, is now rejected as input and as a prompt. Before, it shifted the positions after it or split into two halves. A mask or input format with an astral literal now edits at the correct positions. +- #### Mask input + - An empty optional control is no longer a bad input. A value that fits no position of a letter mask, such as `12` for `LLL`, is now a bad input. +- #### Date time input, Date range picker + - A mask flag in the input format, such as `A` or `0`, now stays literal. The letters of a date range separator, such as the `t` of `' to '`, also stay literal. + - A `yyyy` year of three or four typed digits is now kept, also in the calendar: `0049` is year 49, and `02/29/0000` is valid. One or two typed digits still resolve to the 1950 to 2049 range. + - A `y` or `yyy` year format now widens to four characters in the mask and in the default placeholder, and keeps its case. +- #### Date range picker + - Setting `min` or `max` with a value before the first render no longer throws a `TypeError`. +- #### Color picker + - The HSL saturation no longer becomes infinite for a very small saturation at full value. + ## [7.4.1] - 2026-09-25 ### Added - #### AI-Assisted Development diff --git a/README.md b/README.md index 34b5955c3..fc7e341f6 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ Read [ACCESSIBILITY.md][Accessibility] for the conformance target, the verificat Security fixes are released for the latest major version, and critical fixes are backported to the previous major. Report vulnerabilities privately through [GitHub private vulnerability reporting](https://github.com/IgniteUI/igniteui-webcomponents/security/advisories/new), never in a public issue. -Every release ships with supply-chain evidence attached to the [GitHub release](https://github.com/IgniteUI/igniteui-webcomponents/releases): the published tarball with its digests, a CycloneDX SBOM, and signed provenance and SBOM attestations that you can check with `gh attestation verify`. GitHub's CodeQL default setup scans every push and pull request, the OpenSSF Scorecard runs weekly, and Dependabot keeps dependencies and actions patched. +Every release ships with supply-chain evidence attached to the [GitHub release](https://github.com/IgniteUI/igniteui-webcomponents/releases): the published tarball with its digests, a CycloneDX SBOM, and signed provenance and SBOM attestations that you can check with `gh attestation verify`. GitHub's CodeQL default setup scans every push and pull request, the OpenSSF Scorecard runs weekly, and Dependabot keeps dependencies and actions patched. Property-based (fuzz) tests with [fast-check](https://fast-check.dev/) check the mask, date, color, QR code and layout parsers on every push and pull request, and weekly with random seeds. Read [SECURITY.md][Security] for the support policy, the reporting process, response targets and verification steps, and [THREAT-MODEL.md][Threat model] for the trust boundaries and what the host application remains responsible for. diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 3e1e7bf57..e3144fefc 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -40,6 +40,10 @@ The components make no network requests of their own. `registerIcon` fetches the The icon registry opens the `BroadcastChannel` `ignite-ui-icon-channel` and publishes the icons the application registers to other browsing contexts of the same origin. It never applies state it receives, so another context cannot inject an icon. Nothing leaves the origin. +### Untrusted serialized layouts + +`loadLayout` on the tile manager treats a layout as untrusted, because applications keep it in web storage or on a server. It copies only the serialized tile properties (spans, positions, flags and `id`) to the tiles with a matching `id`. It ignores all other keys, such as `innerHTML` or an own `__proto__`, a value that is not an array, and an entry that is not an object. Invalid JSON throws a `SyntaxError`. Property-based tests send generated and hostile layouts to it. + ### Clipboard The color picker and the chat message actions write to the clipboard only when the user activates a copy control. The components never read the clipboard, so pasted content reaches them only as ordinary user input. diff --git a/package-lock.json b/package-lock.json index dabc50a5b..f383695db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "custom-element-vs-code-integration": "^1.5.0", "custom-elements-manifest": "^2.1.0", "dependency-cruiser": "^18.4.0", + "fast-check": "^4.10.2", "husky": "^9.1.7", "ig-typedoc-theme": "^7.0.1", "igniteui-i18n-resources": "^1.0.5", @@ -7717,6 +7718,29 @@ "license": "MIT", "optional": true }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12332,6 +12356,23 @@ } } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", diff --git a/package.json b/package.json index dc80389e2..367652e6c 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "custom-element-vs-code-integration": "^1.5.0", "custom-elements-manifest": "^2.1.0", "dependency-cruiser": "^18.4.0", + "fast-check": "^4.10.2", "husky": "^9.1.7", "ig-typedoc-theme": "^7.0.1", "igniteui-i18n-resources": "^1.0.5", diff --git a/src/components/checkbox/checkbox.spec.ts b/src/components/checkbox/checkbox.spec.ts index 34191c41e..4f4733328 100644 --- a/src/components/checkbox/checkbox.spec.ts +++ b/src/components/checkbox/checkbox.spec.ts @@ -403,7 +403,7 @@ describe('Checkbox', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcCheckboxComponent, testParameters); + await runValidationContainerTests(IgcCheckboxComponent, testParameters); }); }); diff --git a/src/components/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts index 8ee7d544d..96fb9ea0b 100644 --- a/src/components/color-picker/color-picker.spec.ts +++ b/src/components/color-picker/color-picker.spec.ts @@ -1336,7 +1336,7 @@ describe('Color picker', () => { }); describe('Validation message slots', () => { - it('renders validation message slots', () => { + it('renders validation message slots', async () => { const testParameters: ValidationContainerTestsParams[] = [ { slots: ['valueMissing'], props: { required: true } }, @@ -1344,7 +1344,10 @@ describe('Color picker', () => { { slots: ['invalid'], props: { required: true } }, ]; - runValidationContainerTests(IgcColorPickerComponent, testParameters); + await runValidationContainerTests( + IgcColorPickerComponent, + testParameters + ); }); }); diff --git a/src/components/color-picker/color.property.spec.ts b/src/components/color-picker/color.property.spec.ts new file mode 100644 index 000000000..e7c5aac24 --- /dev/null +++ b/src/components/color-picker/color.property.spec.ts @@ -0,0 +1,195 @@ +import { expect } from '@open-wc/testing'; + +import { fc } from '#internals/testing/fast-check-setup.spec.js'; +import { expectCloseTo } from '#internals/testing/helpers.spec.js'; +import type { ColorFormat } from '../types.js'; +import { isValidColor, normalizeColor, parseColor } from './common.js'; +import { converter, type RGB } from './converters.js'; +import { ColorModel, getContext } from './model.js'; + +const EPSILON = 1e-6; + +const channel = fc.double({ min: 0, max: 255, noNaN: true }); +const byte = fc.integer({ min: 0, max: 255 }); +const alpha = fc.double({ min: 0, max: 1, noNaN: true }); +const rgb = fc.tuple(channel, channel, channel); +const hsl = fc.tuple( + fc.double({ min: 0, max: 360, maxExcluded: true, noNaN: true }), + fc.double({ min: 0, max: 100, noNaN: true }), + fc.double({ min: 0, max: 100, noNaN: true }) +); + +const number = fc.double({ min: -1000, max: 1000, noNaN: true }); + +/** Strings shaped like the CSS color syntaxes, and arbitrary strings. */ +const colorString = fc.oneof( + fc.string({ unit: 'grapheme', maxLength: 30 }), + fc.stringMatching(/^#?[0-9a-fA-F]{0,9}$/), + fc + .tuple( + fc.constantFrom('rgb', 'rgba', 'hsl', 'hsla'), + fc.array(number, { maxLength: 5 }), + fc.constantFrom(', ', ' ', ' / ') + ) + .map(([fn, args, sep]) => `${fn}(${args.join(sep)})`), + fc.constantFrom('red', 'transparent', 'currentcolor', 'rebeccapurple', 'none') +); + +const operation = fc.oneof( + fc.record({ + key: fc.constantFrom('r', 'g', 'b', 'h', 's', 'l', 'v', 'alpha'), + value: fc.oneof(number, fc.constantFrom(Infinity, -Infinity)), + }), + fc.record({ key: fc.constant('sv'), value: fc.tuple(number, number) }) +); + +function expectInRange(values: number[], max: number[]) { + values.forEach((value, i) => { + expect(Number.isNaN(value), `${values}`).to.be.false; + expect(value, `${values}`).to.be.within(-EPSILON, max[i] + EPSILON); + }); +} + +describe('Color properties', () => { + const ctx = getContext(); + + describe('Converters', () => { + it('round-trips integer channels through hex exactly', () => { + fc.assert( + fc.property(fc.tuple(byte, byte, byte), (value) => { + const hex = converter.rgb.hex(value); + + expect(hex).to.match(/^[0-9a-f]{6}$/); + expect(parseColor(`#${hex}`, ctx).value).to.deep.equal(value); + }) + ); + }); + + it('round-trips through HSL and HSV', () => { + fc.assert( + fc.property(rgb, (value) => { + expectCloseTo( + converter.hsl.rgb(converter.rgb.hsl(value)), + value, + EPSILON + ); + expectCloseTo( + converter.hsv.rgb(converter.rgb.hsv(value)), + value, + EPSILON + ); + }) + ); + }); + + it('converts HSL to the same color directly and through HSV', () => { + fc.assert( + fc.property(hsl, (value) => { + expectCloseTo( + converter.hsv.rgb(converter.hsl.hsv(value)), + converter.hsl.rgb(value), + EPSILON + ); + }) + ); + }); + + it('keeps each converted channel in range', () => { + fc.assert( + fc.property(rgb, hsl, (rgbValue, hslValue) => { + expectInRange(converter.rgb.hsl(rgbValue), [360, 100, 100]); + expectInRange(converter.rgb.hsv(rgbValue), [360, 100, 100]); + expectInRange(converter.hsl.rgb(hslValue), [255, 255, 255]); + expectInRange(converter.hsl.hsv(hslValue), [360, 100, 100]); + }) + ); + }); + }); + + describe('Parsing', () => { + it('never throws and returns channels in range for any string', () => { + fc.assert( + fc.property(colorString, (value) => { + const { value: channels, alpha: a } = parseColor(value, ctx); + + expectInRange(channels, [255, 255, 255]); + expectInRange([a], [1]); + }) + ); + }); + + it('returns opaque black for a string that is not a color', () => { + fc.assert( + fc.property(colorString, (value) => { + fc.pre(!isValidColor(normalizeColor(value), ctx)); + + expect(parseColor(value, ctx)).to.deep.equal({ + value: [0, 0, 0], + alpha: 1, + }); + expect(ColorModel.parse(value).isEmpty).to.be.true; + }) + ); + }); + + it('parses the string of a color model back to the same color', () => { + const tolerance: Record = { + hex: 0.5, + rgb: 0.5, + hsl: 5, + }; + + fc.assert( + fc.property( + rgb, + alpha, + fc.constantFrom('hex', 'rgb', 'hsl'), + fc.boolean(), + (value, a, format, forceAlpha) => { + const model = new ColorModel(value as RGB, a); + const parsed = ColorModel.parse(model.asString(format, forceAlpha)); + + expect(parsed.isEmpty).to.be.false; + expectCloseTo(parsed.toRGB(), model.toRGB(), tolerance[format]); + expect(parsed.alpha).to.be.closeTo(model.alpha, 1 / 255 + EPSILON); + } + ) + ); + }); + }); + + describe('Model', () => { + it('keeps every color space in range for any sequence of writes', () => { + fc.assert( + fc.property(fc.array(operation, { maxLength: 10 }), (operations) => { + const model = ColorModel.default(); + + for (const { key, value } of operations) { + if (key === 'sv') { + const [s, v] = value as [number, number]; + model.setSaturationAndValue(s, v); + } else { + model[key as 'r'] = value as number; + } + + expectInRange(model.toRGB(), [255, 255, 255]); + expectInRange(model.toHSL(), [360, 100, 100]); + expectInRange(model.toHSV(), [360, 100, 100]); + expectInRange([model.alpha], [1]); + } + }) + ); + }); + + it('keeps a clone equal to its source', () => { + fc.assert( + fc.property(rgb, alpha, (value, a) => { + const model = new ColorModel(value as RGB, a); + + expect(model.clone().equals(model)).to.be.true; + expect(model.equals(model.clone())).to.be.true; + }) + ); + }); + }); +}); diff --git a/src/components/color-picker/converters.spec.ts b/src/components/color-picker/converters.spec.ts index a1b9f00f6..08347b3ab 100644 --- a/src/components/color-picker/converters.spec.ts +++ b/src/components/color-picker/converters.spec.ts @@ -1,19 +1,16 @@ import { expect } from '@open-wc/testing'; +import { expectCloseTo } from '#internals/testing/helpers.spec.js'; import { converter, type HSL, type HSV, type RGB } from './converters.js'; /** Tolerance for the accumulated float error of a conversion round-trip. */ const EPSILON = 0.5; -function expectTupleCloseTo( +const expectTupleCloseTo = ( actual: RGB | HSL | HSV, expected: RGB | HSL | HSV, epsilon = EPSILON -): void { - for (const [index, value] of expected.entries()) { - expect(actual[index], `component ${index}`).to.be.closeTo(value, epsilon); - } -} +) => expectCloseTo(actual, expected, epsilon); /** A hue sweep dense enough to cross every branch of the conversions. */ const hues = Array.from({ length: 24 }, (_, i) => i * 15); @@ -108,6 +105,14 @@ describe('Color converters', () => { expectTupleCloseTo(converter.hsv.hsl([0, 50, 100]), [0, 100, 75]); expectTupleCloseTo(converter.hsv.hsl([120, 100, 50]), [120, 100, 25]); }); + + it('keeps the saturation finite for a tiny saturation at full value', () => { + const [, s, l] = converter.hsv.hsl([0, 1e-17, 100]); + + expect(Number.isFinite(s)).to.be.true; + expect(s).to.be.within(0, 100); + expect(l).to.be.closeTo(100, EPSILON); + }); }); describe('hsl -> rgb', () => { diff --git a/src/components/color-picker/converters.ts b/src/components/color-picker/converters.ts index d77c6ec31..086e8b3a5 100644 --- a/src/components/color-picker/converters.ts +++ b/src/components/color-picker/converters.ts @@ -185,7 +185,9 @@ export const converter = Object.freeze({ l = (2 - s) * v; const lMin = (2 - s) * vMin; sl = s * vMin; - sl /= lMin <= 1 ? lMin : 2 - lMin; + // Expanded `2 - lMin`. The short form cancels to 0 for a value near 100 + // and a tiny saturation. + sl /= lMin <= 1 ? lMin : 2 * (1 - vMin) + s * vMin; sl = sl || 0; l /= 2; diff --git a/src/components/color-picker/spec.md b/src/components/color-picker/spec.md index c1625b9fe..3f7392d6c 100644 --- a/src/components/color-picker/spec.md +++ b/src/components/color-picker/spec.md @@ -48,6 +48,7 @@ | 1 | 2026-09-21 | Initial specification | | 2 | 2026-09-24 | Describe the naming order and the host ARIA naming | | 3 | 2026-09-24 | Keep `aria-expanded` off the input mode text input | +| 4 | 2026-09-28 | Add the property-based color suite | ## Overview @@ -371,7 +372,7 @@ All of the above are skipped while the component is `disabled`. ## Test scenarios -The component is covered by five suites in this directory, all running in a real browser through +The component is covered by six suites in this directory, all running in a real browser through `@web/test-runner` with `@open-wc/testing` fixtures and assertions: | Suite | Scope | @@ -381,6 +382,7 @@ The component is covered by five suites in this directory, all running in a real | [`model.spec.ts`](./model.spec.ts) | The color model. | | [`common.spec.ts`](./common.spec.ts) | Color string parsing and validation. | | [`converters.spec.ts`](./converters.spec.ts) | The color space converters. | +| [`color.property.spec.ts`](./color.property.spec.ts) | Property-based (fuzz) tests for the converters, the parser and the model. | The component suite reuses `createFormAssociatedTestBed`, `runValidationContainerTests`, `runExternalLabelAssociationTests`, `runAriaProjectionTests` and the `simulate*` helpers from @@ -421,7 +423,16 @@ The component suite reuses `createFormAssociatedTestBed`, `runValidationContaine ### Converters -23. The conversions between RGB, HSL, HSV and hex, in both directions, including round-trips. +23. The conversions between RGB, HSL, HSV and hex, in both directions, including round-trips. The saturation stays + finite for a tiny saturation at full value. + +### Color properties + +24. Converters - the exact hex round-trip, the HSL and HSV round-trips, HSL to RGB directly and through HSV, and + every converted channel in range. +25. Parsing - `parseColor` never throws and returns channels in range. A string that is not a color gives opaque black + and an empty model. A model string in each format parses back to the same color. +26. Model - every color space stays in range for any sequence of channel writes, and a clone equals its source. ## Assumptions and limitations diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 10c00b58e..4d102af65 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -2041,7 +2041,7 @@ describe('Combo', () => { }); describe('Validation message slots', () => { - it('', () => { + it('', async () => { const testParameters: ValidationContainerTestsParams[] = [ { slots: ['valueMissing'], props: { required: true } }, // value-missing slot @@ -2049,7 +2049,7 @@ describe('Combo', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcComboComponent, testParameters); + await runValidationContainerTests(IgcComboComponent, testParameters); }); }); diff --git a/src/components/date-picker/date-picker-form.spec.ts b/src/components/date-picker/date-picker-form.spec.ts index 3d35efb4e..73fa38d5b 100644 --- a/src/components/date-picker/date-picker-form.spec.ts +++ b/src/components/date-picker/date-picker-form.spec.ts @@ -379,7 +379,7 @@ describe('igc-datepicker form integration', () => { }); describe('Validation message slots', () => { - it('', () => { + it('', async () => { const now = CalendarDay.today; const tomorrow = now.add('day', 1); const yesterday = now.add('day', -1); @@ -411,7 +411,7 @@ describe('igc-datepicker form integration', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcDatePickerComponent, testParameters); + await runValidationContainerTests(IgcDatePickerComponent, testParameters); }); it('renders the projected messages on the first failed submission', async () => { diff --git a/src/components/date-range-picker/date-range-mask-parser.property.spec.ts b/src/components/date-range-picker/date-range-mask-parser.property.spec.ts new file mode 100644 index 000000000..39bf56d18 --- /dev/null +++ b/src/components/date-range-picker/date-range-mask-parser.property.spec.ts @@ -0,0 +1,137 @@ +import { expect } from '@open-wc/testing'; + +import { isValidDate } from '#internals/date/converters.js'; +import { + assumeWallClock, + fourDigitYearDate as date, +} from '#internals/testing/date-arbitraries.spec.js'; +import { fc } from '#internals/testing/fast-check-setup.spec.js'; +import { DatePartType } from '../date-time-input/date-part.js'; +import { DateTimeMaskParser } from '../date-time-input/datetime-mask-parser.js'; +import { + DateRangeMaskParser, + DateRangePosition, +} from './date-range-mask-parser.js'; + +const FORMATS = [ + 'MM/dd/yyyy', + 'dd.MM.yyyy', + 'yyyy-MM-dd', + 'MM/dd/yy', + 'MM/dd/yyyy HH:mm', + 'dd/MM/yyyy hh:mm tt', +]; + +/** Formats with single-position and widened year parts. */ +const LOOSE_FORMATS = [...FORMATS, 'M/d/yy', 'd.M.y', 'yyy-M-d', 'H:m:s']; + +/** Separators, a few with date format letters and mask flags. */ +const SEPARATORS = [ + ' - ', + ' – ', + ' ~ ', + '/', + ' 📅 ', + ' to ', + ' y ', + ' and ', + ' 0 ', +]; + +const optionsOf = (formats: string[]) => + fc.record({ + format: fc.constantFrom(...formats), + separator: fc.constantFrom(...SEPARATORS), + }); + +const options = optionsOf(FORMATS); +const looseOptions = optionsOf(LOOSE_FORMATS); + +describe('Date range mask parser properties', () => { + it('round-trips a range through formatDateRange and parseDateRange', () => { + fc.assert( + fc.property(options, date, date, (opts, start, end) => { + assumeWallClock(opts.format, start); + assumeWallClock(opts.format, end); + + const parser = new DateRangeMaskParser(opts); + const masked = parser.formatDateRange({ start, end }); + const parsed = parser.parseDateRange(masked); + + expect(masked).to.have.lengthOf(parser.emptyMask.length); + expect(parsed?.start, masked).to.be.an.instanceOf(Date); + expect(parsed?.end, masked).to.be.an.instanceOf(Date); + expect(parser.formatDateRange(parsed)).to.equal(masked); + }) + ); + }); + + it('parses each side like a single date parser', () => { + fc.assert( + fc.property(options, date, date, (opts, start, end) => { + const parser = new DateRangeMaskParser(opts); + const single = new DateTimeMaskParser({ format: opts.format }); + const parsed = parser.parseDateRange( + parser.formatDateRange({ start, end }) + ); + + expect(parsed!.start!.getTime()).to.equal( + single.parseDate(single.formatDate(start))!.getTime() + ); + expect(parsed!.end!.getTime()).to.equal( + single.parseDate(single.formatDate(end))!.getTime() + ); + }) + ); + }); + + it('lays out the two dates around the separator', () => { + fc.assert( + fc.property(looseOptions, looseOptions, (initial, next) => { + const parser = new DateRangeMaskParser(initial); + parser.mask = next.format; + + const single = new DateTimeMaskParser({ format: next.format }); + const separatorStart = single.emptyMask.length; + const separatorEnd = separatorStart + parser.separator.length; + + expect(parser.emptyMask).to.equal( + single.emptyMask + parser.separator + single.emptyMask + ); + + for (const part of parser.parts) { + if (part.position === DateRangePosition.Start) { + expect(part.end).to.be.at.most(separatorStart); + } else { + expect(part.start).to.be.at.least(separatorEnd); + expect(part.end).to.be.at.most(parser.emptyMask.length); + } + } + + const dateParts = parser.parts.filter( + (part) => part.type !== DatePartType.Literal + ); + expect(dateParts).to.have.lengthOf( + 2 * + single.parts.filter((part) => part.type !== DatePartType.Literal) + .length + ); + }) + ); + }); + + it('never throws and returns valid dates for any string', () => { + const masked = fc.string({ unit: 'grapheme', maxLength: 50 }); + + fc.assert( + fc.property(looseOptions, masked, (opts, value) => { + const parser = new DateRangeMaskParser(opts); + const parsed = parser.parseDateRange(parser.apply(value)); + + for (const side of [parsed?.start, parsed?.end]) { + expect(!side || isValidDate(side)).to.be.true; + } + }) + ); + }); +}); diff --git a/src/components/date-range-picker/date-range-mask-parser.spec.ts b/src/components/date-range-picker/date-range-mask-parser.spec.ts index b35b2f787..61a7d48ff 100644 --- a/src/components/date-range-picker/date-range-mask-parser.spec.ts +++ b/src/components/date-range-picker/date-range-mask-parser.spec.ts @@ -34,6 +34,48 @@ describe('DateRangeMaskParser', () => { expect(parser.mask).to.equal('MM/dd/yyyy to MM/dd/yyyy'); }); + it('keeps the letters and mask flags of a separator literal', () => { + const parser = new DateRangeMaskParser({ + format: 'MM/dd/yyyy', + separator: ' to ', + }); + + // The separator stays literal, so the `t` is not an AM/PM position. + expect(parser.emptyMask).to.equal('__/__/____ to __/__/____'); + expect(parser.apply('1225202512312025')).to.equal( + '12/25/2025 to 12/31/2025' + ); + + const range = parser.parseDateRange('12/25/2025 to 12/31/2025')!; + expect(range.start!.getDate()).to.equal(25); + expect(range.end!.getDate()).to.equal(31); + + const flags = new DateRangeMaskParser({ separator: ' a 0 ' }); + expect(flags.emptyMask).to.equal('__/__/____ a 0 __/__/____'); + + flags.mask = 'dd.MM.yyyy'; + expect(flags.emptyMask).to.equal('__.__.____ a 0 __.__.____'); + }); + + it('widens the year of the date format but not a y in the separator', () => { + const parser = new DateRangeMaskParser({ + format: 'M/d/y', + separator: ' y ', + }); + + expect(parser.mask).to.equal('M/d/yyyy y M/d/yyyy'); + expect(parser.emptyMask).to.equal('_/_/____ y _/_/____'); + + const endMonth = parser.getPartByTypeAndPosition( + DatePartType.Month, + DateRangePosition.End + ); + expect(endMonth!.start).to.equal(11); + + parser.mask = 'MM/dd/yyy'; + expect(parser.mask).to.equal('MM/dd/yyyy y MM/dd/yyyy'); + }); + it('creates parser with custom prompt character', () => { const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy', diff --git a/src/components/date-range-picker/date-range-mask-parser.ts b/src/components/date-range-picker/date-range-mask-parser.ts index 81feb6f9c..ed4ad2f95 100644 --- a/src/components/date-range-picker/date-range-mask-parser.ts +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -8,9 +8,11 @@ import { import { DateFormatMaskParser, DateTimeMaskParser, - DEFAULT_DATETIME_FORMAT, } from '../date-time-input/datetime-mask-parser.js'; -import type { MaskOptions } from '../mask-input/mask-parser.js'; +import { + escapeMaskFlags, + type MaskOptions, +} from '../mask-input/mask-parser.js'; import type { DateRangeValue } from '../types.js'; //#region Types and Enums @@ -100,10 +102,10 @@ export class DateRangeMaskParser extends DateFormatMaskParser { private _separator: string; /** Start position of the separator in the mask */ - private _separatorStart: number; + private _separatorStart!: number; /** End position of the separator in the mask */ - private _separatorEnd: number; + private _separatorEnd!: number; /** * Gets the separator string used between start and end dates. @@ -113,17 +115,44 @@ export class DateRangeMaskParser extends DateFormatMaskParser { } constructor(options?: DateRangeMaskOptions) { - const format = options?.format || DEFAULT_DATETIME_FORMAT; const separator = options?.separator || DEFAULT_SEPARATOR; const promptCharacter = options?.promptCharacter; + const startParser = new DateTimeMaskParser({ + format: options?.format, + promptCharacter, + }); + const format = startParser.mask; super({ format: `${format}${separator}${format}`, promptCharacter }); - this._startParser = new DateTimeMaskParser({ format, promptCharacter }); + this._startParser = startParser; this._endParser = new DateTimeMaskParser({ format, promptCharacter }); this._separator = separator; + this._setSeparatorBounds(); + + // Parse again. The base constructor ran before the separator was set. + this._parseMaskLiterals(); + } + + private _setSeparatorBounds(): void { this._separatorStart = this._startParser.mask.length; - this._separatorEnd = this._separatorStart + separator.length; + this._separatorEnd = this._separatorStart + this._separator.length; + } + + /** + * Converts each date like a single date format, and escapes the separator, so that its + * letters stay literal. Returns an empty pattern while the base constructor runs. + */ + protected override _toMaskFormat(format: string): string { + if (this._separator === undefined) { + return ''; + } + + return ( + super._toMaskFormat(format.slice(0, this._separatorStart)) + + escapeMaskFlags(this._separator) + + super._toMaskFormat(format.slice(this._separatorEnd)) + ); } protected override _buildParts(): IDateRangePart[] { @@ -146,11 +175,10 @@ export class DateRangeMaskParser extends DateFormatMaskParser { public override set mask(value: string) { this._startParser.mask = value; this._endParser.mask = value; + this._setSeparatorBounds(); - this._separatorStart = this._startParser.mask.length; - this._separatorEnd = this._separatorStart + this._separator.length; - - super.mask = `${value}${this._separator}${value}`; + const format = this._startParser.mask; + super.mask = `${format}${this._separator}${format}`; } public override get mask(): string { diff --git a/src/components/date-range-picker/date-range-picker-single.form.spec.ts b/src/components/date-range-picker/date-range-picker-single.form.spec.ts index 4eb5fa9dd..beea94ab5 100644 --- a/src/components/date-range-picker/date-range-picker-single.form.spec.ts +++ b/src/components/date-range-picker/date-range-picker-single.form.spec.ts @@ -512,7 +512,7 @@ describe('Date Range Picker Single Input - Form integration', () => { }); }); describe('Validation message slots', () => { - it('', () => { + it('', async () => { const now = CalendarDay.today; const tomorrow = now.add('day', 1); const yesterday = now.add('day', -1); @@ -550,7 +550,10 @@ describe('Date Range Picker Single Input - Form integration', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcDateRangePickerComponent, testParameters); + await runValidationContainerTests( + IgcDateRangePickerComponent, + testParameters + ); }); }); }); diff --git a/src/components/date-range-picker/date-range-picker-two-inputs.form.spec.ts b/src/components/date-range-picker/date-range-picker-two-inputs.form.spec.ts index 63d671efe..a08150745 100644 --- a/src/components/date-range-picker/date-range-picker-two-inputs.form.spec.ts +++ b/src/components/date-range-picker/date-range-picker-two-inputs.form.spec.ts @@ -458,7 +458,7 @@ describe('Date Range Picker Two Inputs - Form integration', () => { }); }); describe('Validation message slots', () => { - it('', () => { + it('', async () => { const now = CalendarDay.today; const testParameters: ValidationContainerTestsParams[] = @@ -494,7 +494,10 @@ describe('Date Range Picker Two Inputs - Form integration', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcDateRangePickerComponent, testParameters); + await runValidationContainerTests( + IgcDateRangePickerComponent, + testParameters + ); }); }); it('is correctly validated on switching between two and single inputs', async () => { diff --git a/src/components/date-range-picker/date-range-picker.common.spec.ts b/src/components/date-range-picker/date-range-picker.common.spec.ts index 41b326b31..94a69f4c2 100644 --- a/src/components/date-range-picker/date-range-picker.common.spec.ts +++ b/src/components/date-range-picker/date-range-picker.common.spec.ts @@ -55,6 +55,32 @@ describe('Date range picker - common tests for single and two inputs mode', () = }); describe('Rendering and initialization', () => { + it('accepts a value and bounds set before the first render', async () => { + const today = CalendarDay.today; + + for (const useTwoInputs of [false, true]) { + const element = document.createElement( + IgcDateRangePickerComponent.tagName + ) as IgcDateRangePickerComponent; + + // Validation runs on each set, before any editor exists. + expect(() => + Object.assign(element, { + useTwoInputs, + value: { start: today.native, end: today.add('day', 1).native }, + max: today.add('day', -1).native, + min: today.add('day', -5).native, + }) + ).not.to.throw(); + + document.body.append(element); + await elementUpdated(element); + + expect(element.validity.rangeOverflow).to.be.true; + element.remove(); + } + }); + it('should be successfully initialized in open state in dropdown mode', async () => { picker = await fixture( html`` diff --git a/src/components/date-range-picker/date-range-picker.ts b/src/components/date-range-picker/date-range-picker.ts index 26523534f..4a4cbf183 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -30,6 +30,10 @@ import { pickerDependencies, } from '../date-picker/date-picker.base.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; +import { + formatHasDateParts, + formatHasTimeParts, +} from '../date-time-input/datetime-mask-parser.js'; import type { DateRangeValue } from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; import IgcDateRangeInputComponent from './date-range-input.js'; @@ -510,13 +514,13 @@ export default class IgcDateRangePickerComponent extends EventEmitterMixin< /* blazorSuppress */ /** @internal */ public hasDateParts(): boolean { - return this._startEditor.hasDateParts(); + return formatHasDateParts(this.inputFormat); } /* blazorSuppress */ /** @internal */ public hasTimeParts(): boolean { - return this._startEditor.hasTimeParts(); + return formatHasTimeParts(this.inputFormat); } /** Selects a date range value in the picker */ diff --git a/src/components/date-range-picker/spec.md b/src/components/date-range-picker/spec.md index 71b4cae12..24dccf00e 100644 --- a/src/components/date-range-picker/spec.md +++ b/src/components/date-range-picker/spec.md @@ -52,6 +52,8 @@ | 1 | 2026-09-21 | Initial specification | | 2 | 2026-09-23 | Expose the `ranges` part and add it to the test scenarios | | 3 | 2026-09-24 | Describe the naming order and the host ARIA naming | +| 4 | 2026-09-28 | Add the property-based range mask parser suite | +| 5 | 2026-09-28 | Validate a value and bounds set before the first render | ## Overview @@ -515,7 +517,7 @@ On top of the input and calendar parts it re-exports, the picker exposes: ## Test scenarios -The component is covered by seven suites in this directory, all running in a real browser through +The component is covered by eight suites in this directory, all running in a real browser through `@web/test-runner` with `@open-wc/testing` fixtures and assertions: | Suite | Scope | @@ -527,6 +529,7 @@ The component is covered by seven suites in this directory, all running in a rea | [`date-range-picker-two-inputs.form.spec.ts`](./date-range-picker-two-inputs.form.spec.ts) | Form integration for the two inputs mode. | | [`predefined-ranges-area.spec.ts`](./predefined-ranges-area.spec.ts) | The internal range chips component. | | [`date-range-mask-parser.spec.ts`](./date-range-mask-parser.spec.ts) | The range mask parser on its own. | +| [`date-range-mask-parser.property.spec.ts`](./date-range-mask-parser.property.spec.ts) | Property-based (fuzz) tests for the range mask parser. | The suites reuse `createFormAssociatedTestBed`, `runValidationContainerTests`, `runExternalLabelAssociationTests` and the `simulate*` helpers from @@ -534,7 +537,8 @@ The suites reuse `createFormAssociatedTestBed`, `runValidationContainerTests`, ### Common suite -1. Rendering and initialization of the shared structure, including the exposed `ranges` part. +1. Rendering and initialization of the shared structure, including the exposed `ranges` part, and a value and bounds + set before the first render. 2. Properties, including localization - formats, resource strings and the separator. 3. Methods - `show`, `hide`, `toggle`, `select` and `clear`. 4. Interactions - selection through the calendar, keyboard navigation, and interactions with the show icon. @@ -577,7 +581,13 @@ Each display mode has its own form suite, with the same groups. ### Range mask parser -25. Initialization, range parsing, range formatting, part queries, spinning, prompt updates and mask updates. +25. Initialization (a literal separator and a widened year format), range parsing, range formatting, part queries, + spinning, prompt updates and mask updates. + +### Range mask parser properties + +26. For generated formats, separators and dates: the round-trip, each side parsed like a single date, the layout + around the separator after a mask change, and no throw or invalid date for any string. ## Assumptions and limitations diff --git a/src/components/date-time-input/date-part.ts b/src/components/date-time-input/date-part.ts index 877059db9..a35ab6cd0 100644 --- a/src/components/date-time-input/date-part.ts +++ b/src/components/date-time-input/date-part.ts @@ -8,6 +8,7 @@ * Classes are private to this module - only types and factory function are exported. */ +import { daysInMonth } from '#internals/date/model.js'; import { clamp, modulo } from '#internals/utils/math.js'; //#region Types and Enums @@ -199,13 +200,6 @@ const DATE_BOUNDS = { //#region Helper Functions -/** - * Gets the number of days in a specific month/year. - */ -function daysInMonth(year: number, month: number): number { - return new Date(year, month + 1, 0).getDate(); -} - /** * Pads a value with zeros to the specified length. */ diff --git a/src/components/date-time-input/date-time-input.spec.ts b/src/components/date-time-input/date-time-input.spec.ts index 866a6b21e..8f7efdc1c 100644 --- a/src/components/date-time-input/date-time-input.spec.ts +++ b/src/components/date-time-input/date-time-input.spec.ts @@ -1523,7 +1523,10 @@ describe('Date Time Input component', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcDateTimeInputComponent, testParameters); + await runValidationContainerTests( + IgcDateTimeInputComponent, + testParameters + ); }); }); diff --git a/src/components/date-time-input/datetime-mask-parser.property.spec.ts b/src/components/date-time-input/datetime-mask-parser.property.spec.ts new file mode 100644 index 000000000..ef3abf663 --- /dev/null +++ b/src/components/date-time-input/datetime-mask-parser.property.spec.ts @@ -0,0 +1,142 @@ +import { expect } from '@open-wc/testing'; + +import { isValidDate } from '#internals/date/converters.js'; +import { + assumeWallClock, + fourDigitYearDate as date, + pivotTwoDigitYear, + toWallClock, +} from '#internals/testing/date-arbitraries.spec.js'; +import { fc } from '#internals/testing/fast-check-setup.spec.js'; +import { DatePartType } from './date-part.js'; +import { + DateTimeMaskParser, + formatHasDateParts, + formatHasTimeParts, +} from './datetime-mask-parser.js'; + +/** Separators, a few with mask flags (`A`, `#`, `0`) that must stay literal. */ +const SEPARATORS = ['/', '-', '.', ':', ' ', ', ', ' 📅 ', ' A ', '#', ' 0 ']; + +/** Part variants with a fixed width. */ +const PADDED_PARTS = [ + ['yyyy', 'yy'], + ['MM'], + ['dd'], + ['HH', 'hh'], + ['mm'], + ['ss'], +]; + +/** All documented part variants. */ +const ALL_PARTS = [ + ['yyyy', 'yyy', 'yy', 'y'], + ['MM', 'M'], + ['dd', 'd'], + ['HH', 'H', 'hh', 'h'], + ['mm', 'm'], + ['ss', 's'], + ['tt', 't'], +]; + +function formatOf(groups: string[][]) { + return fc + .shuffledSubarray(groups, { minLength: 1 }) + .chain((picked) => + fc.tuple( + fc.tuple(...picked.map((variants) => fc.constantFrom(...variants))), + fc.array(fc.constantFrom(...SEPARATORS), { + minLength: picked.length, + maxLength: picked.length, + }) + ) + ) + .map(([parts, separators]) => + parts.map((part, i) => (i ? separators[i] : '') + part).join('') + ); +} + +/** A padded format. An `hh` format gets a `tt` part, because a 12-hour value needs it. */ +const paddedFormat = formatOf(PADDED_PARTS).map((format) => + format.includes('hh') ? `${format} tt` : format +); + +const anyFormat = formatOf(ALL_PARTS); + +const anyString = fc.string({ unit: 'grapheme', maxLength: 30 }); + +describe('Date-time mask parser properties', () => { + it('round-trips a date through formatDate and parseDate', () => { + fc.assert( + fc.property(paddedFormat, date, (format, value) => { + const clock = assumeWallClock(format, value); + const parser = new DateTimeMaskParser({ format }); + const masked = parser.formatDate(value); + const parsed = parser.parseDate(masked); + + expect(masked).to.have.lengthOf(parser.emptyMask.length); + expect(parsed, masked).to.be.an.instanceOf(Date); + expect(parser.formatDate(parsed)).to.equal(masked); + expect(toWallClock(parsed!), masked).to.deep.equal(clock); + }) + ); + }); + + it('resolves a two-digit year to the 1950 to 2049 range', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 99 }), (year) => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yy' }); + const typed = String(year).padStart(2, '0'); + + expect(parser.parseDate(`01/01/${typed}`)!.getFullYear()).to.equal( + pivotTwoDigitYear(year) + ); + }) + ); + }); + + it('never throws, and parses any string to null or a valid date', () => { + fc.assert( + fc.property(anyFormat, anyString, (format, value) => { + const parser = new DateTimeMaskParser({ format }); + const masked = parser.apply(value); + + expect(masked).to.have.lengthOf(parser.emptyMask.length); + parser.isBlank(masked); + + for (const parsed of [ + parser.parseDate(value), + parser.parseDate(masked), + ]) { + expect(parsed === null || isValidDate(parsed)).to.be.true; + } + }) + ); + }); + + it('keeps each part inside the mask', () => { + fc.assert( + fc.property(anyFormat, (format) => { + const parser = new DateTimeMaskParser({ format }); + let previousEnd = 0; + + for (const part of parser.parts) { + expect(part.start).to.equal(previousEnd); + expect(part.end).to.be.above(part.start); + previousEnd = part.end; + } + + expect(previousEnd).to.equal(parser.emptyMask.length); + expect(parser.hasDateParts() || parser.hasTimeParts()).to.equal( + parser.parts.some( + (part) => + part.type !== DatePartType.Literal && + part.type !== DatePartType.AmPm + ) + ); + expect(formatHasDateParts(format)).to.equal(parser.hasDateParts()); + expect(formatHasTimeParts(format)).to.equal(parser.hasTimeParts()); + }) + ); + }); +}); diff --git a/src/components/date-time-input/datetime-mask-parser.spec.ts b/src/components/date-time-input/datetime-mask-parser.spec.ts index 868287642..7d138f6d7 100644 --- a/src/components/date-time-input/datetime-mask-parser.spec.ts +++ b/src/components/date-time-input/datetime-mask-parser.spec.ts @@ -52,6 +52,42 @@ describe('DateTimeMaskParser', () => { expect(yearPart!.start).to.equal(6); expect(yearPart!.end).to.equal(10); }); + + it('widens a year format other than yy to yyyy in the mask and the parts', () => { + const parser = new DateTimeMaskParser({ format: 'd.M.yyy' }); + + expect(parser.mask).to.equal('d.M.yyyy'); + expect(parser.emptyMask).to.equal('_._.____'); + + parser.mask = 'y/MM'; + expect(parser.mask).to.equal('yyyy/MM'); + + const month = parser.parts.find((p) => p.type === DatePartType.Month); + expect(month!.start).to.equal(5); + expect(month!.end).to.equal(7); + + parser.mask = 'DD.MM.YYYY'; + expect(parser.mask).to.equal('DD.MM.YYYY'); + parser.mask = 'yyY'; + expect(parser.mask).to.equal('yyYYYY'); + }); + + it('keeps a literal that is a mask flag literal', () => { + const parser = new DateTimeMaskParser({ format: 'dd.MM.yyyy A #0' }); + + expect(parser.emptyMask).to.equal('__.__.____ A #0'); + expect(parser.apply('25122025')).to.equal('25.12.2025 A #0'); + expect(parser.parseDate('25.12.2025 A #0')!.getFullYear()).to.equal(2025); + }); + + it('keeps UTF-16 part positions after an astral literal', () => { + const parser = new DateTimeMaskParser({ format: '📅 MM/dd' }); + + const month = parser.parts.find((p) => p.type === DatePartType.Month); + expect(month!.start).to.equal(3); + expect(month!.end).to.equal(5); + expect(parser.parseDate('📅 12/25')!.getMonth()).to.equal(11); + }); }); describe('Mask Application', () => { @@ -172,6 +208,37 @@ describe('DateTimeMaskParser', () => { const date2 = parser.parseDate('12/25/99'); expect(date2!.getFullYear()).to.equal(1999); }); + + it('keeps a four-digit year below 100 as typed', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + + expect(parser.parseDate('01/01/0049')!.getFullYear()).to.equal(49); + expect(parser.parseDate('01/01/0075')!.getFullYear()).to.equal(75); + }); + + it('uses the calendar of a year below 100 for the days of the month', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + + // Year 0 is a leap year, and 1900 is not. + expect(parser.parseDate('02/29/0000')!.getFullYear()).to.equal(0); + expect(parser.parseDate('02/29/0004')!.getFullYear()).to.equal(4); + expect(parser.parseDate('02/29/0001')).to.be.null; + }); + + it('applies the century threshold to each year part by its own digits', () => { + const parser = new DateTimeMaskParser({ format: 'yyyy (yy)' }); + + // The last year part sets the year. The threshold applies to its two digits. + expect(parser.parseDate('2024 (24)')!.getFullYear()).to.equal(2024); + expect(parser.parseDate('24__ (99)')!.getFullYear()).to.equal(1999); + }); + + it('applies the century threshold to a partially typed yyyy year', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + + expect(parser.parseDate('01/01/24__')!.getFullYear()).to.equal(2024); + expect(parser.parseDate('01/01/75__')!.getFullYear()).to.equal(1975); + }); }); describe('Part Queries', () => { diff --git a/src/components/date-time-input/datetime-mask-parser.ts b/src/components/date-time-input/datetime-mask-parser.ts index 72338b0c0..d9559fbc3 100644 --- a/src/components/date-time-input/datetime-mask-parser.ts +++ b/src/components/date-time-input/datetime-mask-parser.ts @@ -1,5 +1,10 @@ +import { createDate } from '#internals/date/model.js'; import { asNumber, clamp } from '#internals/utils/math.js'; -import { type MaskOptions, MaskParser } from '../mask-input/mask-parser.js'; +import { + escapeMaskFlags, + type MaskOptions, + MaskParser, +} from '../mask-input/mask-parser.js'; import { createDatePart, DATE_PART_TYPES, @@ -31,6 +36,18 @@ const FORMAT_CHAR_TO_DATE_PART = new Map([ const CENTURY_THRESHOLD = 50; const CENTURY_BASE = 2000; +/** + * Applies the century threshold to a year of one or two typed digits. Keeps a year of + * three or four typed digits. + */ +function resolveYear(year: number, typed: string): number { + if (typed.length > 2) { + return year; + } + + return year + (year < CENTURY_THRESHOLD ? CENTURY_BASE : CENTURY_BASE - 100); +} + /** Default values for missing date parts */ const DEFAULT_DATE_VALUES = { year: 2000, @@ -53,7 +70,7 @@ type PartBuilder = DatePartOptions & { type: DatePartType }; /** * Converts a date format string into a mask pattern. Date characters become `0`, or `L` - * for the alphabetic AM/PM marker; everything else is carried over as a literal. + * for the alphabetic AM/PM marker. Other characters stay literal. * * @example * ```ts @@ -65,23 +82,42 @@ function toMaskFormat(dateFormat: string): string { for (const char of dateFormat) { const type = FORMAT_CHAR_TO_DATE_PART.get(char); - result += type ? (type === DatePartType.AmPm ? 'L' : '0') : char; + + if (!type) { + result += escapeMaskFlags(char); + } else { + result += type === DatePartType.AmPm ? 'L' : '0'; + } } return result; } -/** - * Widens a short year format to `yyyy` for editing purposes, `yy` excluded - a two digit - * year is edited as two digits. - */ -function normalizeYearFormat(builders: PartBuilder[]): void { - const year = builders.find((part) => part.type === DatePartType.Year); +/** Widens each year run, except `yy`, to four characters. Keeps the case of the run. */ +function normalizeYearFormat(format: string): string { + return format.replace(/y+|Y+/g, (run) => + run.length === 2 ? run : run[0].repeat(4) + ); +} - if (year && year.format.length !== 2) { - year.end += 4 - year.format.length; - year.format = 'yyyy'; +function hasPartOf(format: string, types: ReadonlySet): boolean { + for (const char of format) { + const type = FORMAT_CHAR_TO_DATE_PART.get(char); + if (type && types.has(type)) { + return true; + } } + return false; +} + +/** Returns whether a date format has a day, month or year part. */ +export function formatHasDateParts(format: string): boolean { + return hasPartOf(format, DATE_PART_TYPES); +} + +/** Returns whether a date format has an hours, minutes or seconds part. */ +export function formatHasTimeParts(format: string): boolean { + return hasPartOf(format, TIME_PART_TYPES); } //#endregion @@ -180,7 +216,19 @@ export abstract class DateFormatMaskParser< */ export class DateTimeMaskParser extends DateFormatMaskParser { constructor(options?: MaskOptions) { - super({ ...options, format: options?.format || DEFAULT_DATETIME_FORMAT }); + super({ + ...options, + format: normalizeYearFormat(options?.format || DEFAULT_DATETIME_FORMAT), + }); + } + + public override get mask(): string { + return super.mask; + } + + /** Sets the date format. Widens each year format, except `yy`, to four characters. */ + public override set mask(value: string) { + super.mask = value && normalizeYearFormat(value); } //#region Date Format Parsing @@ -191,7 +239,8 @@ export class DateTimeMaskParser extends DateFormatMaskParser { let run: PartBuilder | null = null; let position = 0; - for (const char of this.mask) { + // Iterate by UTF-16 code unit, like the mask positions. + for (const char of this.mask.split('')) { const type = FORMAT_CHAR_TO_DATE_PART.get(char); // A part runs only while the same format character repeats - 'MM' is one part, @@ -226,8 +275,6 @@ export class DateTimeMaskParser extends DateFormatMaskParser { builders.push(run); } - normalizeYearFormat(builders); - return builders.map(({ type, ...options }) => createDatePart(type, options) ); @@ -249,14 +296,6 @@ export class DateTimeMaskParser extends DateFormatMaskParser { parts[DatePartType.Month]! -= 1; } - // Apply century threshold for two-digit years (only if year is in format) - if ( - parts[DatePartType.Year] !== undefined && - parts[DatePartType.Year]! < CENTURY_THRESHOLD - ) { - parts[DatePartType.Year]! += CENTURY_BASE; - } - if (!this._validateDateParts(parts)) { return null; } @@ -281,11 +320,15 @@ export class DateTimeMaskParser extends DateFormatMaskParser { datePart.type === DatePartType.Date || datePart.type === DatePartType.Month; - parts[datePart.type] = clamp( - asNumber(this._typedPart(masked, datePart)), + const typed = this._typedPart(masked, datePart); + const value = clamp( + asNumber(typed), isMonthOrDate ? 1 : 0, Number.MAX_SAFE_INTEGER ); + + parts[datePart.type] = + datePart.type === DatePartType.Year ? resolveYear(value, typed) : value; } return parts; @@ -341,7 +384,7 @@ export class DateTimeMaskParser extends DateFormatMaskParser { parts: Partial> ): Date { const d = DEFAULT_DATE_VALUES; - return new Date( + return createDate( parts[DatePartType.Year] ?? d.year, parts[DatePartType.Month] ?? d.month, parts[DatePartType.Date] ?? d.date, diff --git a/src/components/date-time-input/spec.md b/src/components/date-time-input/spec.md index 20066c964..6a0ffa5f2 100644 --- a/src/components/date-time-input/spec.md +++ b/src/components/date-time-input/spec.md @@ -52,6 +52,7 @@ | ------: | ---------- | -------------------------------------------------- | | 1 | 2026-09-21 | Initial specification | | 2 | 2026-09-24 | Describe the naming order and the host ARIA naming | +| 3 | 2026-09-28 | Describe the year parsing; add the property suite | ## Overview @@ -220,7 +221,8 @@ Add content around the text through the `prefix`, `suffix` and `helper-text` slo #### Input format The `inputFormat` property is the mask the end-user edits. Every supported format character contributes an editable -position; anything else is treated as a literal and skipped over while typing. +position; anything else is treated as a literal and skipped over while typing. Mask flags such as `A` or `0` are +literals too. | Format | Description | | :----: | :------------------------------------------------------------------------- | @@ -229,7 +231,7 @@ position; anything else is treated as a literal and skipped over while typing. | `M` | Month, single position. | | `MM` | Month with an explicitly set leading zero. | | `yy` | Short (two digit) year format. Values below `50` resolve to the 2000s. | -| `yyyy` | Full year format. Any year format other than `yy` is normalized to `yyyy`. | +| `yyyy` | Full year format. A year format other than `yy` widens to four characters. | | `h` | Hours in 12-hour format, single position. | | `hh` | Hours in 12-hour format with an explicitly set leading zero. | | `H` | Hours in 24-hour format, single position. | @@ -241,6 +243,9 @@ position; anything else is treated as a literal and skipped over while typing. | `t` | AM/PM section for 12-hour format. | | `tt` | AM/PM section for 12-hour format. | +In a `yyyy` part, one or two typed digits resolve like `yy`. Three or four typed digits are kept, so `0049` is year +49. The default `placeholder` shows the widened year format. + ```html @@ -610,7 +615,7 @@ enum DatePart { ## Test scenarios -The component is covered by three suites in this directory, all running in a real browser through +The component is covered by four suites in this directory, all running in a real browser through `@web/test-runner` with `@open-wc/testing` fixtures and assertions: | Suite | Scope | @@ -618,6 +623,7 @@ The component is covered by three suites in this directory, all running in a rea | [`date-time-input.spec.ts`](./date-time-input.spec.ts) | The component: formats, editing, spinning, validation and form integration. | | [`date-part.spec.ts`](./date-part.spec.ts) | The date part classes on their own. | | [`datetime-mask-parser.spec.ts`](./datetime-mask-parser.spec.ts) | The date-time mask parser on its own. | +| [`datetime-mask-parser.property.spec.ts`](./datetime-mask-parser.property.spec.ts) | Property-based (fuzz) tests for the parser, with generated formats, dates and input. | The component suite additionally reuses `createFormAssociatedTestBed`, `runValidationContainerTests`, `runExternalLabelAssociationTests`, the `simulate*` helpers and `ValidityHelpers` from @@ -711,7 +717,11 @@ Generated by `runExternalLabelAssociationTests`. formatting per token, `validate` ranges - including the day validated against its month and year context - and `spin` for every part, with leap year adjustment, looping and clamping. 44. [`datetime-mask-parser.spec.ts`](./datetime-mask-parser.spec.ts) covers the parser that turns a format into a - mask and back. + mask and back, including the year format normalization with its case kept, a literal that is a mask flag, the + century threshold, the calendar of years below 100, and UTF-16 part positions after an astral literal. +45. [`datetime-mask-parser.property.spec.ts`](./datetime-mask-parser.property.spec.ts) checks, for generated formats: + the `formatDate` / `parseDate` round-trip for dates from year 0 to 9999, the two-digit year range, no throw or + invalid date for any string, and the parts that tile the mask and agree with the format-level part queries. ## Assumptions and limitations diff --git a/src/components/file-input/file-input.spec.ts b/src/components/file-input/file-input.spec.ts index 2a9b3bd42..3e83a5d01 100644 --- a/src/components/file-input/file-input.spec.ts +++ b/src/components/file-input/file-input.spec.ts @@ -280,6 +280,6 @@ describe('Validation message slots', () => { { slots: ['customError'] }, ]; - runValidationContainerTests(IgcFileInputComponent, testParameters); + await runValidationContainerTests(IgcFileInputComponent, testParameters); }); }); diff --git a/src/components/input/input.spec.ts b/src/components/input/input.spec.ts index 188da6e78..02d7e1a55 100644 --- a/src/components/input/input.spec.ts +++ b/src/components/input/input.spec.ts @@ -770,7 +770,7 @@ describe('Input component', () => { }, ]; - runValidationContainerTests(IgcInputComponent, testParameters); + await runValidationContainerTests(IgcInputComponent, testParameters); }); }); diff --git a/src/components/mask-input/mask-input.spec.ts b/src/components/mask-input/mask-input.spec.ts index 079e83dcc..ded90752b 100644 --- a/src/components/mask-input/mask-input.spec.ts +++ b/src/components/mask-input/mask-input.spec.ts @@ -1141,6 +1141,20 @@ describe('Masked input', () => { spec.assertSubmitPasses(); }); + it('treats an empty optional control as valid for any mask', () => { + for (const mask of ['000', '##-##', 'LLL', 'AA', '&&']) { + spec.setProperties({ mask, value: '' }); + spec.assertSubmitPasses(); + expect(spec.element.validity.badInput, mask).to.be.false; + } + }); + + it('reports a value that fits no position of a letter mask as bad input', () => { + spec.setProperties({ mask: 'LLL', value: '12' }); + spec.assertSubmitFails(); + expect(spec.element.validity.badInput).to.be.true; + }); + it('fulfils custom constraint', () => { spec.element.setCustomValidity('invalid'); spec.assertSubmitFails(); @@ -1231,12 +1245,12 @@ describe('Masked input', () => { const testParameters: ValidationContainerTestsParams[] = [ { slots: ['valueMissing'], props: { required: true } }, // value-missing slot - { slots: ['badInput'], props: { mask: '00-00' } }, // bad-input slot + { slots: ['badInput'], props: { mask: '00-00', value: '1' } }, // bad-input slot { slots: ['customError'] }, // custom-error slot { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcMaskInputComponent, testParameters); + await runValidationContainerTests(IgcMaskInputComponent, testParameters); }); }); diff --git a/src/components/mask-input/mask-input.ts b/src/components/mask-input/mask-input.ts index fcf4978c7..205436ff5 100644 --- a/src/components/mask-input/mask-input.ts +++ b/src/components/mask-input/mask-input.ts @@ -256,9 +256,14 @@ export default class IgcMaskInputComponent extends MaskBehaviorMixin( //#region Public methods /* blazorSuppress */ - /** Returns whether the current masked input is valid according to the mask pattern. */ + /** + * Returns whether the current masked input is valid according to the mask pattern. + * An empty control is valid. The `required` validator checks it. + */ public isValidMaskPattern(): boolean { - return this._parser.isValidString(this._maskedValue); + return ( + !this._formValue.value || this._parser.isValidString(this._maskedValue) + ); } //#endregion diff --git a/src/components/mask-input/mask-parser.property.spec.ts b/src/components/mask-input/mask-parser.property.spec.ts new file mode 100644 index 000000000..0f2fae727 --- /dev/null +++ b/src/components/mask-input/mask-parser.property.spec.ts @@ -0,0 +1,285 @@ +import { expect } from '@open-wc/testing'; + +import { fc, orderedPair } from '#internals/testing/fast-check-setup.spec.js'; +import { escapeMaskFlags, MaskParser } from './mask-parser.js'; + +/** The characters each flag accepts. The oracle for the parser output. */ +const FLAG_PATTERNS: Record = { + C: /^[\s\S]$/u, + '&': /^[^\p{Separator}]$/u, + a: /^[\p{Letter}\p{Number}\p{Separator}]$/u, + A: /^[\p{Letter}\p{Number}]$/u, + '?': /^[\p{Letter}\p{Separator}]$/u, + L: /^\p{Letter}$/u, + '0': /^\p{Number}$/u, + '9': /^[\p{Number}\p{Separator}]$/u, + '#': /^[\p{Number}\-+]$/u, +}; + +const FLAGS = Object.keys(FLAG_PATTERNS).join(''); + +/** Single code unit characters of each class that the flags check. */ +const POOL = [...'abcxyzABCXYZéßжλ0123456789 -+!.@/']; + +const VALID_CHARS = Object.fromEntries( + Object.entries(FLAG_PATTERNS).map(([flag, pattern]) => [ + flag, + POOL.filter((char) => pattern.test(char)), + ]) +); + +/** Prompts that are not in the {@link POOL}. */ +const SAFE_PROMPTS = ['_', '*', '•']; + +/** Zero digits of some of the numbering systems the parser normalizes. */ +const UNICODE_ZEROS = [0x0660, 0x06f0, 0x0966, 0x0e50, 0xff10]; + +type Token = { kind: 'flag' | 'escaped' | 'literal'; char: string }; + +const flag = fc.constantFrom(...FLAGS); + +/** A grapheme with no flag and no escape character in it. */ +const literalChar = fc + .string({ unit: 'grapheme', minLength: 1, maxLength: 1 }) + .filter((s) => !s.split('').some((c) => FLAGS.includes(c) || c === '\\')); + +const tokenOf = (kind: Token['kind'], chars: fc.Arbitrary) => + chars.map((char): Token => ({ kind, char })); + +const token = fc.oneof( + { weight: 4, arbitrary: tokenOf('flag', flag) }, + { weight: 1, arbitrary: tokenOf('escaped', flag) }, + { weight: 2, arbitrary: tokenOf('literal', literalChar) } +); + +const tokens = fc.array(token, { minLength: 1, maxLength: 20 }); + +const anyInput = fc.string({ unit: 'grapheme', maxLength: 40 }); +const anyPrompt = fc.string({ unit: 'grapheme', maxLength: 3 }); + +/** A parser for `parts`, and the escaped mask and literal positions it must have. */ +function setup(parts: Token[], prompt?: string) { + const format = parts + .map(({ kind, char }) => + kind === 'escaped' ? escapeMaskFlags(char) : char + ) + .join(''); + const literals = new Set(); + let escaped = ''; + + for (const { kind, char } of parts) { + for (const unit of char.split('')) { + if (kind !== 'flag') { + literals.add(escaped.length); + } + escaped += unit; + } + } + + const parser = new MaskParser({ format, promptCharacter: prompt }); + return { parser, escaped, literals }; +} + +/** A mask, a prompt and the characters that fill each input position. */ +const filledMask = tokens.chain((parts) => + fc + .tuple( + fc.constantFrom(...SAFE_PROMPTS), + fc.tuple( + ...parts + .filter((part) => part.kind === 'flag') + .map((part) => fc.constantFrom(...VALID_CHARS[part.char])) + ) + ) + .map(([prompt, fill]) => ({ parts, prompt, input: fill.join('') })) +); + +describe('Mask parser properties', () => { + it('builds the escaped mask and the literal positions from the format', () => { + fc.assert( + fc.property(tokens, (parts) => { + const { parser, escaped, literals } = setup(parts); + + expect(parser.escapedMask).to.equal(escaped); + expect([...parser.literalPositions]).to.have.members([...literals]); + }) + ); + }); + + it('never throws for any format, prompt and input', () => { + fc.assert( + fc.property(anyInput, anyPrompt, anyInput, (format, prompt, input) => { + const parser = new MaskParser({ format, promptCharacter: prompt }); + const masked = parser.apply(input); + + expect(masked).to.have.lengthOf(parser.escapedMask.length); + parser.parse(masked); + parser.isValidString(masked); + }) + ); + }); + + it('keeps the mask length, the literals and the flag rules when it applies input', () => { + fc.assert( + fc.property(tokens, anyPrompt, anyInput, (parts, prompt, input) => { + const { parser, escaped, literals } = setup(parts, prompt); + const masked = parser.apply(input); + + expect(masked).to.have.lengthOf(escaped.length); + expect(masked.isWellFormed(), masked).to.be.true; + + for (let i = 0; i < escaped.length; i++) { + if (literals.has(i)) { + expect(masked[i]).to.equal(escaped[i]); + } else if (masked[i] !== parser.prompt) { + expect(masked[i]).to.match(FLAG_PATTERNS[escaped[i]]); + } + } + }) + ); + }); + + it('parses the empty mask to an empty string', () => { + fc.assert( + fc.property(tokens, anyPrompt, (parts, prompt) => { + const { parser } = setup(parts, prompt); + + expect(parser.parse(parser.emptyMask)).to.equal(''); + }) + ); + }); + + it('round-trips valid input through apply and parse', () => { + fc.assert( + fc.property(filledMask, ({ parts, prompt, input }) => { + const { parser } = setup(parts, prompt); + const masked = parser.apply(input); + + expect(parser.parse(masked)).to.equal(input); + expect(parser.apply(parser.parse(masked))).to.equal(masked); + expect(parser.isValidString(masked)).to.be.true; + }) + ); + }); + + it('places each valid character of a replace at the next input position', () => { + fc.assert( + fc.property(filledMask, ({ parts, prompt, input }) => { + const { parser } = setup(parts, prompt); + + expect(parser.replace(parser.emptyMask, input, 0, 0).value).to.equal( + parser.apply(input) + ); + }) + ); + }); + + it('keeps the mask length and the literals when it replaces a range', () => { + const replaceCase = fc + .tuple(tokens, anyPrompt, anyInput, anyInput) + .chain(([parts, prompt, initial, value]) => + orderedPair(fc.nat(setup(parts).escaped.length)).map((range) => ({ + parts, + prompt, + initial, + value, + range, + })) + ); + + fc.assert( + fc.property( + replaceCase, + ({ parts, prompt, initial, value, range: [start, end] }) => { + const { parser, escaped, literals } = setup(parts, prompt); + const result = parser.replace( + parser.apply(initial), + value, + start, + end + ); + + expect(result.value).to.have.lengthOf(escaped.length); + expect(result.value.isWellFormed(), result.value).to.be.true; + expect(result.end).to.be.within(start, escaped.length); + + for (const i of literals) { + expect(result.value[i]).to.equal(escaped[i]); + } + } + ) + ); + }); + + it('normalizes Unicode digits to ASCII', () => { + const digits = fc.array(fc.integer({ min: 0, max: 9 }), { + minLength: 1, + maxLength: 12, + }); + + fc.assert( + fc.property( + digits, + fc.constantFrom(...UNICODE_ZEROS), + fc.constantFrom('0', '9', '#'), + (values, zero, digitFlag) => { + const parser = new MaskParser({ + format: digitFlag.repeat(values.length), + }); + const ascii = parser.apply(values.join('')); + const unicode = String.fromCharCode(...values.map((v) => zero + v)); + + expect(parser.apply(unicode)).to.equal(ascii); + expect(parser.replace('', unicode, 0, 0).value).to.equal(ascii); + } + ) + ); + }); + + it('finds the nearest non-literal positions', () => { + fc.assert( + fc.property(tokens, fc.integer({ min: -5, max: 45 }), (parts, start) => { + const { parser, escaped, literals } = setup(parts); + const length = escaped.length; + + const next = parser.getNextNonLiteralPosition(start); + expect(next).to.be.within(Math.min(Math.max(0, start), length), length); + if (next < length) { + expect(literals.has(next)).to.be.false; + } + for (let i = Math.max(0, start); i < Math.min(next, length); i++) { + expect(literals.has(i)).to.be.true; + } + + const previous = parser.getPreviousNonLiteralPosition(start); + expect(previous).to.be.at.least(0); + if (previous > 0) { + expect(previous).to.be.below(start); + expect(literals.has(previous)).to.be.false; + for (let i = previous + 1; i < Math.min(start, length); i++) { + expect(literals.has(i)).to.be.true; + } + } + }) + ); + }); + + it('keeps a single non-flag prompt character', () => { + fc.assert( + fc.property(fc.array(anyPrompt, { maxLength: 5 }), (prompts) => { + const parser = new MaskParser(); + + for (const prompt of prompts) { + const previous = parser.prompt; + parser.prompt = prompt; + + const first = prompt[0] ?? ''; + const accepted = + first !== '' && !FLAGS.includes(first) && first.isWellFormed(); + + expect(parser.prompt).to.equal(accepted ? first : previous); + } + }) + ); + }); +}); diff --git a/src/components/mask-input/mask-parser.spec.ts b/src/components/mask-input/mask-parser.spec.ts index cf9cbec76..7432b12a5 100644 --- a/src/components/mask-input/mask-parser.spec.ts +++ b/src/components/mask-input/mask-parser.spec.ts @@ -482,6 +482,16 @@ describe('Mask parser', () => { expect(parser.isValidString('123-__5')).to.be.false; }); + it('isValidString treats a missing required position as unfilled', () => { + // A position past the end of the string is not filled. + parser.mask = 'LLL'; + expect(parser.isValidString('ab')).to.be.false; + expect(parser.isValidString('')).to.be.false; + + parser.mask = '&&'; + expect(parser.isValidString('')).to.be.false; + }); + it('isValidString with invalid characters', () => { parser.mask = '0000'; expect(parser.isValidString('12ab')).to.be.false; @@ -699,5 +709,32 @@ describe('Mask parser', () => { const result = parser.replace('1234-5678', 'XX', 2, 4); expect(result.value).to.equal('12__-5678'); }); + + it('replace keeps UTF-16 positions after an astral literal', () => { + parser.mask = '📞 000'; + const result = parser.replace(parser.apply(), '12', 3, 3); + expect(result.value).to.equal('📞 12_'); + expect(result.end).to.equal(5); + }); + + it('rejects an astral character whole instead of splitting it', () => { + parser.mask = 'C-C'; + expect(parser.replace(parser.apply(), '😀', 0, 0).value).to.equal('_-_'); + expect(parser.apply('😀')).to.equal('_-_'); + + parser.mask = 'CCC'; + expect(parser.replace(parser.apply(), 'a😀b', 0, 0).value).to.equal( + 'ab_' + ); + // `apply` uses one position for each invalid character. + expect(parser.apply('a😀b')).to.equal('a_b'); + }); + + it('rejects an astral prompt, whose first code unit is half a character', () => { + parser.prompt = '*'; + parser.prompt = '😀'; + expect(parser.prompt).to.equal('*'); + expect(new MaskParser({ promptCharacter: '😀' }).prompt).to.equal('_'); + }); }); }); diff --git a/src/components/mask-input/mask-parser.ts b/src/components/mask-input/mask-parser.ts index d200dc7b0..8a99c5dda 100644 --- a/src/components/mask-input/mask-parser.ts +++ b/src/components/mask-input/mask-parser.ts @@ -82,10 +82,16 @@ const UNICODE_DIGIT_TO_ASCII = new Map( * * Falls back to `current` when the prompt is empty or collides with a mask flag - a flag * standing in for an unfilled position could not be told apart from one the user typed. + * Also falls back when the prompt starts with an astral character. */ function normalizePrompt(value: string | undefined, current: string): string { const char = value ? value.substring(0, 1) : current; - return MASK_FLAGS.has(char) ? current : char; + return MASK_FLAGS.has(char) || isSurrogate(char) ? current : char; +} + +/** Returns whether `char` starts with a UTF-16 surrogate, half of an astral character. */ +function isSurrogate(char: string): boolean { + return (char.charCodeAt(0) & 0xf800) === 0xd800; } function replaceUnicodeNumbers(text: string): string { @@ -108,8 +114,34 @@ const MASK_PATTERNS = new Map([ ['#', /[\p{Number}\-+]/u], // Numeric and sign characters (+, -) ]); -function validate(char: string, flag: string): boolean { - return MASK_PATTERNS.get(flag)?.test(char) ?? false; +/** + * Returns whether `char` fits the mask position of `flag`. A position holds one UTF-16 code + * unit, so an astral character never fits. A missing position fits no flag. + */ +function validate(char: string | undefined, flag: string): boolean { + return ( + char !== undefined && + !isSurrogate(char) && + (MASK_PATTERNS.get(flag)?.test(char) ?? false) + ); +} + +/** + * Escapes each mask flag in `text`, so that a mask pattern reads all of it as literal text. + * + * @example + * ```ts + * escapeMaskFlags(' at '); // ' \\at ' + * ``` + */ +export function escapeMaskFlags(text: string): string { + let result = ''; + + for (const char of text) { + result += MASK_FLAGS.has(char) ? `${ESCAPE_CHAR}${char}` : char; + } + + return result; } /** @@ -351,8 +383,9 @@ export class MaskParser { const prompt = this.prompt; const endBoundary = Math.min(end, length); - // Initialize the array for the masked string or get a fresh mask with prompts and/or literals - const maskedChars = maskString ? [...maskString] : [...this.emptyMask]; + // Split the masked string by UTF-16 code unit, like the DOM selection. Split the input + // by code point, so that an astral character is rejected whole. + const maskedChars = (maskString || this.emptyMask).split(''); const inputChars = Array.from(replaceUnicodeNumbers(value)); const inputLength = inputChars.length; @@ -461,8 +494,9 @@ export class MaskParser { return result.join(''); } - // Normalize Unicode digits to ASCII - const normalizedInput = replaceUnicodeNumbers(input); + // Normalize Unicode digits to ASCII. Split by code point, so that an astral character + // is one invalid character. + const normalizedInput = Array.from(replaceUnicodeNumbers(input)); const inputLength = normalizedInput.length; let inputIndex = 0; diff --git a/src/components/mask-input/spec.md b/src/components/mask-input/spec.md index d3cffce7b..6e70109f5 100644 --- a/src/components/mask-input/spec.md +++ b/src/components/mask-input/spec.md @@ -47,6 +47,8 @@ | ------: | ---------- | -------------------------------------------------- | | 1 | 2026-09-21 | Initial specification | | 2 | 2026-09-24 | Describe the naming order and the host ARIA naming | +| 3 | 2026-09-28 | Add the property-based parser suite | +| 4 | 2026-09-28 | An empty control is no bad input | ## Overview @@ -177,6 +179,9 @@ By default the mask is `CCCCCCCCCC` - ten optional positions that accept any cha Digits typed in a digit position are normalized to ASCII, so input from Arabic-Indic, Devanagari, Thai, full-width and the other supported numbering systems is accepted. +Each position holds one UTF-16 code unit. No position accepts an astral character, such as most emoji, because it +takes two code units. + #### Literals and escaping Any character in the pattern that is not a flag is a literal and is rendered as-is. A flag character can be turned @@ -206,7 +211,8 @@ An empty control returns an empty string in both modes. ``` -Only the first character of the assigned string is used, and a mask flag is rejected in favor of the default `_`. +Only the first character of the assigned string is used. A mask flag, or an astral character such as an emoji, is +rejected in favor of the current prompt, which starts as `_`. #### Labeling from the light DOM @@ -248,7 +254,7 @@ The component applies two validators: | Validator | Validity flag | Fails when | | ---------- | -------------- | ----------------------------------------------------------------------- | | `required` | `valueMissing` | The control is required and the value is empty. | -| mask | `badInput` | Some positions are filled but not all required positions are satisfied. | +| mask | `badInput` | The value is not empty, and not all required positions are satisfied. | `isValidMaskPattern()` exposes the same check programmatically. See the [validation container specification](../validation-container/spec.md) for the message slot mechanism. @@ -371,6 +377,7 @@ The suite lives in [`mask-input.spec.ts`](./mask-input.spec.ts). It runs in a re | `simulateInput / simulateClick / simulateKeyboard` | User interaction driven without real device events. | | `ValidityHelpers` | Assertions for validity, invalid styling and validation slot presence and content. | | `mask-parser.spec.ts` | A unit suite for the pattern parser itself, independent of the component. | +| `mask-parser.property.spec.ts` | Property-based (fuzz) tests for the pattern parser, with generated masks, prompts and input. | | `mask-history.spec.ts` | A unit suite for the undo and redo history, independent of the component. | The groups below mirror the `describe` blocks of the suite. @@ -423,37 +430,45 @@ Driven by `createFormAssociatedTestBed`. 32. Reflects the disabled state of an ancestor `fieldset`. 33. Fulfils the required constraint, including with value formatting, the mask pattern constraint and a custom constraint. +34. An empty optional control is valid for any mask, and a value that fits no position of a letter mask is a bad + input. ### defaultValue -34. Form integration - correct initial state, correct submission, correct reset, and dropping the undo history on +35. Form integration - correct initial state, correct submission, correct reset, and dropping the undo history on reset. -35. Validation - fails the initial validation, and passes once `defaultValue` is updated. +36. Validation - fails the initial validation, and passes once `defaultValue` is updated. ### Validation message slots Generated by `runValidationContainerTests`. -36. `value-missing` with `required`. -37. `bad-input` with an unsatisfied mask pattern. -38. `custom-error` after `setCustomValidity`. -39. `invalid` with `required`. +37. `value-missing` with `required`. +38. `bad-input` with an unsatisfied mask pattern. +39. `custom-error` after `setCustomValidity`. +40. `invalid` with `required`. ### External label association Generated by `runExternalLabelAssociationTests`. -40. An external `label` bound through `for`, and a `label` wrapping the host, are projected onto the native input as +41. An external `label` bound through `for`, and a `label` wrapping the host, are projected onto the native input as `ariaLabelledByElements`, and clicking it focuses the control. A `label` added after the first render names the control from the first focus, an axe audit passes with only an external `label`, and the host `aria-labelledby` and `aria-label` follow the [naming order](../input/spec.md#naming-order). ### Parser and history unit suites -41. [`mask-parser.spec.ts`](./mask-parser.spec.ts) covers the parser on its own: every flag, literals and escaping, - applying and parsing values, Unicode digit normalization, and edge and boundary conditions. -42. [`mask-history.spec.ts`](./mask-history.spec.ts) covers the history on its own: recording, coalescing, traversal +42. [`mask-parser.spec.ts`](./mask-parser.spec.ts) covers the parser on its own: every flag, literals and escaping, + applying and parsing values, Unicode digit normalization, and edge and boundary conditions, including UTF-16 + positions after an astral literal, and astral input and prompts rejected whole. +43. [`mask-history.spec.ts`](./mask-history.spec.ts) covers the history on its own: recording, coalescing, traversal and invalidation of the steps. +44. [`mask-parser.property.spec.ts`](./mask-parser.property.spec.ts) checks, for generated masks, prompts and input: + the escaped mask and literal positions; `apply` and `replace` never throw, keep the mask length, literals and + flag rules, and never split an astral character; the `apply` / `parse` round-trip; digit normalization; the + non-literal position lookups; and prompt normalization. + ## Assumptions and limitations - The mask input does not expose a `type` attribute, since it is always an input of type `text`. diff --git a/src/components/qr-code/model/encode.ts b/src/components/qr-code/model/encode.ts index 24a28b215..11746addb 100644 --- a/src/components/qr-code/model/encode.ts +++ b/src/components/qr-code/model/encode.ts @@ -1,7 +1,7 @@ import type { QrEncodingMode, QrErrorCorrectionLevel } from '../types.js'; import { getDataCodewordsCount, interleaveBlocks } from './error-correction.js'; -const EC_LEVEL_INDEX = { L: 0, M: 1, Q: 2, H: 3 } as const; +export const EC_LEVEL_INDEX = { L: 0, M: 1, Q: 2, H: 3 } as const; const ALPHANUMERIC_MAP = new Map( [...'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'].map((char, index) => [ char, diff --git a/src/components/qr-code/model/error-correction.ts b/src/components/qr-code/model/error-correction.ts index ccefd889d..9266d776d 100644 --- a/src/components/qr-code/model/error-correction.ts +++ b/src/components/qr-code/model/error-correction.ts @@ -804,8 +804,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 19, dataCW: 45 }, - { numBlocks: 10, dataCW: 46 }, + { numBlocks: 19, dataCW: 47 }, + { numBlocks: 10, dataCW: 48 }, ], }, { @@ -835,8 +835,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 2, dataCW: 45 }, - { numBlocks: 29, dataCW: 46 }, + { numBlocks: 2, dataCW: 46 }, + { numBlocks: 29, dataCW: 47 }, ], }, { @@ -860,8 +860,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 10, dataCW: 45 }, - { numBlocks: 23, dataCW: 46 }, + { numBlocks: 10, dataCW: 46 }, + { numBlocks: 23, dataCW: 47 }, ], }, { @@ -891,8 +891,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 14, dataCW: 45 }, - { numBlocks: 21, dataCW: 46 }, + { numBlocks: 14, dataCW: 46 }, + { numBlocks: 21, dataCW: 47 }, ], }, { @@ -922,8 +922,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 14, dataCW: 45 }, - { numBlocks: 23, dataCW: 46 }, + { numBlocks: 14, dataCW: 46 }, + { numBlocks: 23, dataCW: 47 }, ], }, { @@ -953,8 +953,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 12, dataCW: 45 }, - { numBlocks: 26, dataCW: 46 }, + { numBlocks: 12, dataCW: 47 }, + { numBlocks: 26, dataCW: 48 }, ], }, { @@ -984,8 +984,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 6, dataCW: 45 }, - { numBlocks: 34, dataCW: 46 }, + { numBlocks: 6, dataCW: 47 }, + { numBlocks: 34, dataCW: 48 }, ], }, { @@ -1015,8 +1015,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 29, dataCW: 45 }, - { numBlocks: 14, dataCW: 46 }, + { numBlocks: 29, dataCW: 46 }, + { numBlocks: 14, dataCW: 47 }, ], }, { @@ -1046,8 +1046,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 13, dataCW: 45 }, - { numBlocks: 32, dataCW: 46 }, + { numBlocks: 13, dataCW: 46 }, + { numBlocks: 32, dataCW: 47 }, ], }, { @@ -1077,8 +1077,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 40, dataCW: 45 }, - { numBlocks: 7, dataCW: 46 }, + { numBlocks: 40, dataCW: 47 }, + { numBlocks: 7, dataCW: 48 }, ], }, { @@ -1108,8 +1108,8 @@ export const EC_BLOCKS_TABLE: ECBlock[][] = [ { ecPerBlock: 28, groups: [ - { numBlocks: 18, dataCW: 45 }, - { numBlocks: 31, dataCW: 46 }, + { numBlocks: 18, dataCW: 47 }, + { numBlocks: 31, dataCW: 48 }, ], }, { diff --git a/src/components/qr-code/model/qr-model.property.spec.ts b/src/components/qr-code/model/qr-model.property.spec.ts new file mode 100644 index 000000000..f11e0758c --- /dev/null +++ b/src/components/qr-code/model/qr-model.property.spec.ts @@ -0,0 +1,132 @@ +import { expect } from '@open-wc/testing'; + +import { + fc, + SLOW_PROPERTY_RUNS, +} from '#internals/testing/fast-check-setup.spec.js'; +import type { QrErrorCorrectionLevel } from '../types.js'; +import { EC_LEVEL_INDEX, encodeQR } from './encode.js'; +import { EC_BLOCKS_TABLE, getDataCodewordsCount } from './error-correction.js'; +import { generateQRCodeMatrix } from './matrix.js'; + +const EC_LEVELS = Object.keys(EC_LEVEL_INDEX) as QrErrorCorrectionLevel[]; +const ALPHANUMERIC = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'; + +/** + * Returns the total codewords of a version, from its module count (ISO/IEC 18004). The + * oracle does not use `EC_BLOCKS_TABLE`. + */ +function totalCodewords(version: number): number { + let modules = (16 * version + 128) * version + 64; + + if (version >= 2) { + const alignments = Math.floor(version / 7) + 2; + modules -= (25 * alignments - 10) * alignments - 55; + if (version >= 7) { + modules -= 36; + } + } + + return Math.floor(modules / 8); +} + +const ecLevel = fc.constantFrom(...EC_LEVELS); +const version = fc.integer({ min: 1, max: 40 }); + +const data = fc.oneof( + fc.stringMatching(/^\d{1,200}$/), + fc + .array(fc.constantFrom(...ALPHANUMERIC), { minLength: 1, maxLength: 200 }) + .map((chars) => chars.join('')), + fc.string({ unit: 'grapheme', minLength: 1, maxLength: 100 }), + fc.string({ unit: 'binary', minLength: 1, maxLength: 100 }) +); + +describe('QR model properties', () => { + it('holds as many codewords per version as the symbol has room for', () => { + fc.assert( + fc.property(version, fc.nat(3), (v, ecIndex) => { + const { ecPerBlock, groups } = EC_BLOCKS_TABLE[v - 1][ecIndex]; + const blocks = groups.reduce((sum, g) => sum + g.numBlocks, 0); + + expect( + getDataCodewordsCount(v, ecIndex) + blocks * ecPerBlock + ).to.equal(totalCodewords(v)); + }) + ); + }); + + it('encodes any data that fits into the smallest version that holds it', () => { + fc.assert( + fc.property(data, ecLevel, (value, level) => { + const result = encodeQR(value, level); + + expect(result.version).to.be.within(1, 40); + expect(result.codewords).to.have.lengthOf( + totalCodewords(result.version) + ); + expect(result.codewords.every((c) => c >= 0 && c <= 255)).to.be.true; + + if (result.version > 1) { + expect(() => encodeQR(value, level, result.version - 1)).to.throw( + /Data too long/ + ); + } + expect(encodeQR(value, level, result.version)).to.deep.equal(result); + }) + ); + }); + + it('picks the most compact mode for the characters of the data', () => { + fc.assert( + fc.property(data, (value) => { + const { mode } = encodeQR(value, 'L'); + + if (/^\d+$/.test(value)) { + expect(mode).to.equal('numeric'); + } else if ([...value].every((c) => ALPHANUMERIC.includes(c))) { + expect(mode).to.equal('alphanumeric'); + } else { + expect(mode).to.equal('byte'); + } + }) + ); + }); + + it('builds a square matrix of the version size', () => { + fc.assert( + fc.property(data, ecLevel, (value, level) => { + const { matrix, size, version: v } = generateQRCodeMatrix(value, level); + + expect(size).to.equal(17 + 4 * v); + expect(matrix).to.have.lengthOf(size); + expect( + matrix.every( + (row) => + row.length === size && + row.every((module) => typeof module === 'boolean') + ) + ).to.be.true; + }), + { numRuns: SLOW_PROPERTY_RUNS } + ); + }); + + it('throws the capacity error, and no other error, for data that does not fit', () => { + const oversized = fc + .tuple(version, ecLevel, fc.integer({ min: 1, max: 50 })) + .map(([v, level, extra]) => ({ + v, + level, + value: 'a'.repeat( + getDataCodewordsCount(v, EC_LEVEL_INDEX[level]) + extra + ), + })); + + fc.assert( + fc.property(oversized, ({ v, level, value }) => { + expect(() => encodeQR(value, level, v)).to.throw(/Data too long/); + }) + ); + }); +}); diff --git a/src/components/qr-code/model/qr-model.spec.ts b/src/components/qr-code/model/qr-model.spec.ts index 4b4a35013..c9ab3e492 100644 --- a/src/components/qr-code/model/qr-model.spec.ts +++ b/src/components/qr-code/model/qr-model.spec.ts @@ -143,6 +143,12 @@ describe('QR model - error correction', () => { it('returns 9 for V1/H', () => { expect(getDataCodewordsCount(1, 3)).to.equal(9); }); + + it('returns the ISO/IEC 18004 capacities for V30-V40/M', () => { + expect(getDataCodewordsCount(30, 1)).to.equal(1373); + expect(getDataCodewordsCount(31, 1)).to.equal(1455); + expect(getDataCodewordsCount(40, 1)).to.equal(2334); + }); }); describe('calculateECC', () => { diff --git a/src/components/qr-code/spec.md b/src/components/qr-code/spec.md index 578ff46fe..618a5a22a 100644 --- a/src/components/qr-code/spec.md +++ b/src/components/qr-code/spec.md @@ -46,9 +46,10 @@ ## Revision history -| Version | Date | Notes | -| ------: | ---------- | --------------------- | -| 1 | 2026-09-21 | Initial specification | +| Version | Date | Notes | +| ------: | ---------- | -------------------------------------------------------- | +| 1 | 2026-09-21 | Initial specification | +| 2 | 2026-09-28 | Add the property-based model suite; V30-V40/M table fix | ## Overview @@ -286,9 +287,10 @@ None. The SVG is rendered entirely from the properties of the component. ## Test scenarios -| Suite | File | -| ---------------------- | ----------------- | -| `IgcQrCodeComponent` | `qr-code.spec.ts` | +| Suite | File | +| -------------------- | ---------------------------------------------------------------------- | +| `IgcQrCodeComponent` | `qr-code.spec.ts` | +| QR model properties | [`model/qr-model.property.spec.ts`](./model/qr-model.property.spec.ts) | ### Accessibility tests @@ -345,10 +347,17 @@ None. The SVG is rendered entirely from the properties of the component. 22. An existing matching extension in the file name is kept, and the download dialog opens only when requested. 23. Both methods reject without a value, and `toImage()` rejects invalid options. +### QR model properties + +24. Each version and error correction level holds as many codewords as the symbol has room for, checked against the + module count of ISO/IEC 18004. +25. Generated numeric, alphanumeric and byte data encodes into the smallest version that holds it, with the + codeword count of that version, and the encoding mode matches the characters of the data. +26. The matrix is square, with the side length of its version, and data over the capacity of a version throws the + capacity error. + ### Not covered by the suite -- The encoding mode selection and the automatic version choice are covered indirectly, through the rendering and - the error correction tests, rather than asserted per mode. - `margin` is not asserted on its own. ## Assumptions and limitations diff --git a/src/components/radio/radio.spec.ts b/src/components/radio/radio.spec.ts index de64d2047..2fae68d0f 100644 --- a/src/components/radio/radio.spec.ts +++ b/src/components/radio/radio.spec.ts @@ -652,7 +652,7 @@ describe('Radio Component', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcRadioComponent, testParameters); + await runValidationContainerTests(IgcRadioComponent, testParameters); }); }); diff --git a/src/components/select/select.spec.ts b/src/components/select/select.spec.ts index 3d6185158..985a182f9 100644 --- a/src/components/select/select.spec.ts +++ b/src/components/select/select.spec.ts @@ -1842,7 +1842,7 @@ describe('Select', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcSelectComponent, testParameters); + await runValidationContainerTests(IgcSelectComponent, testParameters); }); }); diff --git a/src/components/textarea/textarea.spec.ts b/src/components/textarea/textarea.spec.ts index 84f890700..e4420d9ee 100644 --- a/src/components/textarea/textarea.spec.ts +++ b/src/components/textarea/textarea.spec.ts @@ -494,7 +494,7 @@ describe('Textarea component', () => { { slots: ['invalid'], props: { required: true } }, // invalid slot ]; - runValidationContainerTests(IgcTextareaComponent, testParameters); + await runValidationContainerTests(IgcTextareaComponent, testParameters); }); }); diff --git a/src/components/tile-manager/serializer.property.spec.ts b/src/components/tile-manager/serializer.property.spec.ts new file mode 100644 index 000000000..be137938c --- /dev/null +++ b/src/components/tile-manager/serializer.property.spec.ts @@ -0,0 +1,130 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; + +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + fc, + SLOW_PROPERTY_RUNS, + withFixture, +} from '#internals/testing/fast-check-setup.spec.js'; +import type { SerializedTile } from './serializer.js'; +import IgcTileManagerComponent from './tile-manager.js'; +import IgcTileComponent from './tile.js'; + +const IDS = ['tile-1', 'tile-2', 'tile-3']; +const CONTENT = 'Tile content'; + +/** Keys a hostile or corrupted layout could carry. */ +const HOSTILE_KEYS = [ + '__proto__', + 'constructor', + 'innerHTML', + 'outerHTML', + 'textContent', + 'className', + 'style', + 'onclick', + 'slot', +]; + +const tileEntry = fc.record( + { + id: fc.oneof(fc.constantFrom(...IDS), fc.string(), fc.constant(null)), + colSpan: fc.integer({ min: 1, max: 10 }), + rowSpan: fc.integer({ min: 1, max: 10 }), + colStart: fc.option(fc.integer({ min: 1, max: 10 })), + rowStart: fc.option(fc.integer({ min: 1, max: 10 })), + position: fc.integer({ min: 0, max: 5 }), + disableFullscreen: fc.boolean(), + disableMaximize: fc.boolean(), + disableResize: fc.boolean(), + maximized: fc.boolean(), + }, + { requiredKeys: ['id'] } +); + +const hostileEntry = fc + .tuple( + fc.constantFrom(...IDS), + fc.dictionary(fc.constantFrom(...HOSTILE_KEYS), fc.jsonValue(), { + minKeys: 1, + }) + ) + .map(([id, extra]) => ({ ...extra, id })); + +/** A layout as JSON text. `__proto__` stays an own key, so `JSON.parse` gets it. */ +const layout = fc + .array(fc.oneof(tileEntry, hostileEntry, fc.jsonValue()), { maxLength: 6 }) + .map((entries) => JSON.stringify(entries)); + +const template = html` + + ${IDS.map((id) => html`${CONTENT}`)} + +`; + +const withTileManager = (run: (el: IgcTileManagerComponent) => Promise) => + withFixture(template, run); + +describe('Tile manager serialization properties', () => { + before(() => { + defineComponents(IgcTileManagerComponent); + }); + + it('restores the layout it saved', async () => { + await fc.assert( + fc.asyncProperty(fc.array(tileEntry, { maxLength: 6 }), (tiles) => + withTileManager(async (tileManager) => { + tileManager.loadLayout(JSON.stringify(tiles)); + await elementUpdated(tileManager); + + const saved = tileManager.saveLayout(); + tileManager.loadLayout(saved); + await elementUpdated(tileManager); + + expect(tileManager.saveLayout()).to.equal(saved); + }) + ), + { numRuns: SLOW_PROPERTY_RUNS } + ); + }); + + it('applies only the serialized properties of any layout', async () => { + await fc.assert( + fc.asyncProperty(layout, (json) => + withTileManager(async (tileManager) => { + tileManager.loadLayout(json); + await elementUpdated(tileManager); + + for (const tile of tileManager.tiles) { + expect(Object.getPrototypeOf(tile)).to.equal( + IgcTileComponent.prototype + ); + expect(tile.textContent).to.equal(CONTENT); + expect(tile.onclick).to.be.null; + expect(tile.slot).to.equal(''); + expect(IDS).to.include(tile.id); + } + + const saved: SerializedTile[] = JSON.parse(tileManager.saveLayout()); + expect(saved.map((tile) => tile.id)).to.have.members(IDS); + }) + ), + { numRuns: SLOW_PROPERTY_RUNS } + ); + }); + + it('ignores any JSON value that is not a layout', async () => { + const value = fc.jsonValue().filter((v) => !Array.isArray(v)); + const tileManager = await fixture(template); + const before = tileManager.saveLayout(); + + await fc.assert( + fc.asyncProperty(value, async (v) => { + expect(() => tileManager.loadLayout(JSON.stringify(v))).not.to.throw(); + await elementUpdated(tileManager); + expect(tileManager.saveLayout()).to.equal(before); + }), + { numRuns: SLOW_PROPERTY_RUNS } + ); + }); +}); diff --git a/src/components/tile-manager/serializer.ts b/src/components/tile-manager/serializer.ts index 52a5a7c74..a6fe4763c 100644 --- a/src/components/tile-manager/serializer.ts +++ b/src/components/tile-manager/serializer.ts @@ -1,3 +1,4 @@ +import { isPlainObject } from '#internals/utils/types.js'; import type IgcTileManagerComponent from './tile-manager.js'; export interface SerializedTile { @@ -13,6 +14,22 @@ export interface SerializedTile { id: string | null; } +/** The tile properties of a layout. The type makes the list complete. */ +const SERIALIZED: Record = { + colSpan: true, + colStart: true, + disableFullscreen: true, + disableMaximize: true, + disableResize: true, + maximized: true, + position: true, + rowSpan: true, + rowStart: true, + id: true, +}; + +const SERIALIZED_KEYS = Object.keys(SERIALIZED) as Array; + class TileManagerSerializer { private readonly _tileManager: IgcTileManagerComponent; @@ -22,18 +39,13 @@ class TileManagerSerializer { public save(): SerializedTile[] { return this._tileManager.tiles.map((tile) => { - return { - colSpan: tile.colSpan, - colStart: tile.colStart, - disableFullscreen: tile.disableFullscreen, - disableMaximize: tile.disableMaximize, - disableResize: tile.disableResize, - maximized: tile.maximized, - position: tile.position, - rowSpan: tile.rowSpan, - rowStart: tile.rowStart, - id: tile.id, - }; + const saved = {} as Record; + + for (const key of SERIALIZED_KEYS) { + saved[key] = tile[key]; + } + + return saved as SerializedTile; }); } @@ -41,14 +53,28 @@ class TileManagerSerializer { return JSON.stringify(this.save()); } + /** + * Applies a layout to the tiles with the same `id`. The layout is not trusted. Copies + * only the serialized properties, and ignores values that are not tile objects. + */ public load(tiles: SerializedTile[]): void { - const mapped = new Map(tiles.map((tile) => [tile.id, tile])); + if (!Array.isArray(tiles)) { + return; + } + + const mapped = new Map( + tiles.filter(isPlainObject).map((tile) => [tile.id, tile]) + ); for (const tile of this._tileManager.tiles) { const serialized = mapped.get(tile.id); if (serialized) { - Object.assign(tile, serialized); + for (const key of SERIALIZED_KEYS) { + if (Object.hasOwn(serialized, key)) { + Reflect.set(tile, key, serialized[key]); + } + } } } } diff --git a/src/components/tile-manager/spec.md b/src/components/tile-manager/spec.md index 082bbc5be..82f82aac8 100644 --- a/src/components/tile-manager/spec.md +++ b/src/components/tile-manager/spec.md @@ -44,9 +44,10 @@ This directory hosts two public components: [`igc-tile-manager`](#igc-tile-manag ## Revision history -| Version | Date | Notes | -| ------: | ---------- | --------------------- | -| 1 | 2026-09-21 | Initial specification | +| Version | Date | Notes | +| ------: | ---------- | ------------------------------------------------------------ | +| 1 | 2026-09-21 | Initial specification | +| 2 | 2026-09-28 | `loadLayout` copies only the tile properties; property suite | ## Overview @@ -192,6 +193,10 @@ localStorage.setItem('dashboard', layout); manager.loadLayout(localStorage.getItem('dashboard')!); ``` +`loadLayout` treats the layout as untrusted. It copies only the serialized tile properties (spans, positions, flags +and `id`) to the tiles with a matching `id`. It ignores other keys, a value that is not an array, and an entry that is +not an object. Invalid JSON throws a `SyntaxError`. + ### Localization The components render no strings of their own; the titles and the content come from the application. The default @@ -311,7 +316,7 @@ A container within the tile manager for displaying various types of information. ## Test scenarios -The component is covered by three suites in this directory, all running in a real browser through +The component is covered by four suites in this directory, all running in a real browser through `@web/test-runner` with `@open-wc/testing` fixtures and assertions: | Suite | Scope | @@ -319,6 +324,7 @@ The component is covered by three suites in this directory, all running in a rea | [`tile-manager.spec.ts`](./tile-manager.spec.ts) | The manager: layout, spans, maximize, slots, serialization and API. | | [`tile-dnd.spec.ts`](./tile-dnd.spec.ts) | Drag and drop of the tiles. | | [`tile-resize.spec.ts`](./tile-resize.spec.ts) | Resizing of the tiles. | +| [`serializer.property.spec.ts`](./serializer.property.spec.ts) | Property-based (fuzz) tests for the layout serialization. | The groups below mirror the `describe` blocks. @@ -353,26 +359,33 @@ The groups below mirror the `describe` blocks. 12. `saveLayout` returns a JSON payload describing the current tiles. 13. `loadLayout` restores a previously saved arrangement. +14. `loadLayout` copies only the serialized tile properties, and ignores a value that is not an array of tiles. ### API tests -14. `tiles` returns the tiles sorted by their position. +15. `tiles` returns the tiles sorted by their position. ### Positioning -15. `position`, `colStart` and `rowStart` place the tiles at the expected coordinates. +16. `position`, `colStart` and `rowStart` place the tiles at the expected coordinates. ### Drag and drop tests -16. A tile drag reorders the tiles, in the tile and the header drag modes. -17. `igcTileDragStart` is cancelable, and `igcTileDragEnd` and `igcTileDragCancel` report the outcome. -18. Special scenarios - dragging over a maximized tile, dragging outside the manager - settle consistently. +17. A tile drag reorders the tiles, in the tile and the header drag modes. +18. `igcTileDragStart` is cancelable, and `igcTileDragEnd` and `igcTileDragCancel` report the outcome. +19. Special scenarios - dragging over a maximized tile, dragging outside the manager - settle consistently. ### Resize tests -19. Dragging the side, bottom and corner adorners changes the span of the tile. -20. `igcTileResizeStart` is cancelable, and `igcTileResizeEnd` and `igcTileResizeCancel` report the outcome. -21. `disableResize` on a tile prevents resizing regardless of the manager mode. +20. Dragging the side, bottom and corner adorners changes the span of the tile. +21. `igcTileResizeStart` is cancelable, and `igcTileResizeEnd` and `igcTileResizeCancel` report the outcome. +22. `disableResize` on a tile prevents resizing regardless of the manager mode. + +### Serialization properties + +23. For generated layouts, `loadLayout` restores what `saveLayout` returned. For any layout, with keys such as + `innerHTML` and `__proto__`, it applies only the serialized properties, and each tile keeps its class and + content. A JSON value that is not a layout changes nothing. ## Assumptions and limitations diff --git a/src/components/tile-manager/tile-manager.spec.ts b/src/components/tile-manager/tile-manager.spec.ts index 147a13416..cbb3088ba 100644 --- a/src/components/tile-manager/tile-manager.spec.ts +++ b/src/components/tile-manager/tile-manager.spec.ts @@ -942,6 +942,32 @@ describe('Tile Manager component', () => { tileManager.loadLayout(JSON.stringify(tilesData)) ).not.to.throw(); }); + + it('should copy only the serialized tile properties from a layout', async () => { + const [tile] = tileManager.tiles; + const content = tile.innerHTML; + + tileManager.loadLayout( + '[{"id":"custom-id1","colSpan":4,"innerHTML":"","__proto__":{},"slot":"x"}]' + ); + await elementUpdated(tileManager); + + expect(tile.colSpan).to.equal(4); + expect(tile.innerHTML).to.equal(content); + expect(tile.slot).to.equal(''); + expect(Object.getPrototypeOf(tile)).to.equal(IgcTileComponent.prototype); + }); + + it('should ignore a layout that is not an array of tiles', async () => { + const layout = tileManager.saveLayout(); + + for (const data of ['{}', '"text"', '5', 'null', '[null, 1, "x"]']) { + expect(() => tileManager.loadLayout(data)).not.to.throw(); + } + await elementUpdated(tileManager); + + expect(tileManager.saveLayout()).to.equal(layout); + }); }); describe('API', () => { diff --git a/src/internals/date/model.ts b/src/internals/date/model.ts index c04f8db88..ccd3c03c1 100644 --- a/src/internals/date/model.ts +++ b/src/internals/date/model.ts @@ -20,6 +20,28 @@ const MILLISECONDS_PER_DAY = 86400000; const WEEKDAY_MIN = 1; // Monday const WEEKDAY_MAX = 5; // Friday +/** + * Returns a local date. Unlike the `Date` constructor, it keeps the years 0 to 99. + */ +export function createDate( + year: number, + month = 0, + date = 1, + hours = 0, + minutes = 0, + seconds = 0 +): Date { + const result = new Date(2000, 0, 1); + result.setFullYear(year, month, date); + result.setHours(hours, minutes, seconds, 0); + return result; +} + +/** Returns the number of days in `month` (zero-based) of `year`. */ +export function daysInMonth(year: number, month: number): number { + return createDate(year, month + 1, 0).getDate(); +} + export function toCalendarDay(date: DayParameter): CalendarDay { return date instanceof Date ? CalendarDay.from(date) : date; } @@ -34,7 +56,11 @@ export function toCalendarDay(date: DayParameter): CalendarDay { */ function timestampOf(value: DayParameter): number { return value instanceof Date - ? new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime() + ? createDate( + value.getFullYear(), + value.getMonth(), + value.getDate() + ).getTime() : value.timestamp; } @@ -128,7 +154,7 @@ export class CalendarDay { } constructor(args: CalendarDayParams) { - this._date = new Date(args.year, args.month, args.date ?? 1); + this._date = createDate(args.year, args.month, args.date); } public clone(): CalendarDay { @@ -143,10 +169,9 @@ export class CalendarDay { // Clamp to the last day of the month when the date overflows it. if (date > 0) { - const temp = new Date(year, month, date); + const temp = createDate(year, month, date); if (temp.getMonth() !== month) { - const lastDayOfMonth = new Date(year, month + 1, 0).getDate(); - return new CalendarDay({ year, month, date: lastDayOfMonth }); + return new CalendarDay({ year, month, date: daysInMonth(year, month) }); } } @@ -212,7 +237,7 @@ export class CalendarDay { const dayNum = target.getDay() || 7; target.setDate(target.getDate() + 4 - dayNum); - const yearStart = new Date(target.getFullYear(), 0, 1); + const yearStart = createDate(target.getFullYear()); // Full weeks up to the nearest Thursday. const weekNo = Math.ceil( diff --git a/src/internals/testing/date-arbitraries.spec.ts b/src/internals/testing/date-arbitraries.spec.ts new file mode 100644 index 000000000..6b762abf0 --- /dev/null +++ b/src/internals/testing/date-arbitraries.spec.ts @@ -0,0 +1,78 @@ +import { createDate } from '../date/model.js'; +import { fc } from './fast-check-setup.spec.js'; + +/** Dates whose year fits the four positions of a `yyyy` part: 0 to 9999. */ +export const fourDigitYearDate = fc.date({ + min: createDate(0), + max: new Date(createDate(10000).getTime() - 1), + noInvalidDate: true, +}); + +/** The local date and time fields of a wall clock, with a zero-based month. */ +export type WallClock = { + year: number; + month: number; + day: number; + hours: number; + minutes: number; + seconds: number; +}; + +/** Returns the year of two digits `year`: 0-49 in the 2000s, 50-99 in the 1900s. */ +export function pivotTwoDigitYear(year: number): number { + return year + (year < 50 ? 2000 : 1900); +} + +/** Returns the local wall clock of `date`. */ +export function toWallClock(date: Date): WallClock { + return { + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + }; +} + +function yearOf(format: string, date: Date): number { + if (format.includes('yyyy')) { + return date.getFullYear(); + } + if (format.includes('yy')) { + return pivotTwoDigitYear(date.getFullYear() % 100); + } + return 2000; +} + +/** + * Returns the wall clock that parsing `format` gives for `date`. A field that is not in + * the format gets the parser default (January 1, 2000, 00:00:00). + */ +export function wallClockOf(format: string, date: Date): WallClock { + return { + year: yearOf(format, date), + month: format.includes('MM') ? date.getMonth() : 0, + day: format.includes('dd') ? date.getDate() : 1, + hours: /HH|hh/.test(format) ? date.getHours() : 0, + minutes: format.includes('mm') ? date.getMinutes() : 0, + seconds: format.includes('ss') ? date.getSeconds() : 0, + }; +} + +/** + * Returns the {@link wallClockOf} `format` and `date`. Skips the run when that wall clock + * is in a daylight saving gap of the local time zone, because it cannot round-trip. + */ +export function assumeWallClock(format: string, date: Date): WallClock { + const clock = wallClockOf(format, date); + const { year, month, day, hours, minutes, seconds } = clock; + const actual = toWallClock( + createDate(year, month, day, hours, minutes, seconds) + ); + + const keys = Object.keys(clock) as Array; + + fc.pre(keys.every((key) => actual[key] === clock[key])); + return clock; +} diff --git a/src/internals/testing/fast-check-setup.spec.ts b/src/internals/testing/fast-check-setup.spec.ts new file mode 100644 index 000000000..11b09315f --- /dev/null +++ b/src/internals/testing/fast-check-setup.spec.ts @@ -0,0 +1,48 @@ +import { fixture, fixtureCleanup } from '@open-wc/testing'; +import fc from 'fast-check'; +import type { TemplateResult } from 'lit'; + +/** + * Sets the global fast-check settings for the `*.property.spec.ts` tests. + * Import `fc` from this module, so that the settings apply. The runner config + * injects `__FAST_CHECK__` from `FC_SEED` and `FC_NUM_RUNS`. Without them, the + * seed is fixed. + */ + +type FastCheckSettings = { seed?: number; numRuns?: number }; + +const settings: FastCheckSettings = + (globalThis as { __FAST_CHECK__?: FastCheckSettings }).__FAST_CHECK__ ?? {}; + +const numRuns = settings.numRuns ?? 100; + +fc.configureGlobal({ seed: settings.seed ?? 0x16e17e, numRuns }); + +export { fc }; + +/** The run count for a slow property, such as one that renders components. */ +export const SLOW_PROPERTY_RUNS = Math.max(1, Math.ceil(numRuns / 4)); + +/** An arbitrary of two values of `arbitrary`, in ascending order. */ +export function orderedPair( + arbitrary: fc.Arbitrary +): fc.Arbitrary { + return fc + .tuple(arbitrary, arbitrary) + .map(([a, b]) => [Math.min(a, b), Math.max(a, b)] as const); +} + +/** + * Renders `template`, runs `run` on the element, and then removes it. Each run of a + * property starts from the same state, so that a counterexample replays. + */ +export async function withFixture( + template: TemplateResult, + run: (element: T) => Promise +): Promise { + try { + await run(await fixture(template)); + } finally { + fixtureCleanup(); + } +} diff --git a/src/internals/testing/helpers.spec.ts b/src/internals/testing/helpers.spec.ts index f58f040ee..631999e95 100644 --- a/src/internals/testing/helpers.spec.ts +++ b/src/internals/testing/helpers.spec.ts @@ -68,6 +68,17 @@ export function compareStyles( /** * Compares two date values */ +/** Asserts that each number of `actual` is within `delta` of the same number of `expected`. */ +export function expectCloseTo( + actual: ArrayLike, + expected: ArrayLike, + delta: number +): void { + for (let i = 0; i < expected.length; i++) { + expect(actual[i], `index ${i}`).to.be.closeTo(expected[i], delta); + } +} + export function checkDatesEqual(a: CalendarDay | Date, b: CalendarDay | Date) { expect(toCalendarDay(a).equalTo(toCalendarDay(b))).to.be.true; } diff --git a/src/internals/testing/validity-helpers.spec.ts b/src/internals/testing/validity-helpers.spec.ts index c7d7ba713..1557273db 100644 --- a/src/internals/testing/validity-helpers.spec.ts +++ b/src/internals/testing/validity-helpers.spec.ts @@ -81,15 +81,16 @@ export const ValidityHelpers = { }, } as const; -export function runValidationContainerTests( +/** + * Checks that a new `element` renders the validation slots of each case in `testParams`. + * The cases run in sequence. Await the result. A failure names the slots of its case. + */ +export async function runValidationContainerTests( element: Constructor & IgniteComponent, testParams: ValidationContainerTestsParams[] -): void { - const runner = async ({ - slots, - props, - }: ValidationContainerTestsParams) => { - if (isEmpty(slots)) return; +): Promise { + for (const { slots, props } of testParams) { + if (isEmpty(slots)) continue; const instance = document.createElement(element.tagName) as T; instance.append( @@ -101,18 +102,23 @@ export function runValidationContainerTests( ); Object.assign(instance, props); document.body.append(instance); - await elementUpdated(instance); - if (slots.includes('customError')) { - instance.setCustomValidity('invalid'); - } + try { + await elementUpdated(instance); - await ValidityHelpers.checkValidationSlots(instance, ...slots); - instance.remove(); - }; + if (slots.includes('customError')) { + instance.setCustomValidity('invalid'); + } - for (const each of testParams) { - runner(each); + await ValidityHelpers.checkValidationSlots(instance, ...slots); + } catch (error) { + if (error instanceof Error) { + error.message = `[${slots.join(', ')}] ${error.message}`; + } + throw error; + } finally { + instance.remove(); + } } } diff --git a/src/internals/utils/arrays.property.spec.ts b/src/internals/utils/arrays.property.spec.ts new file mode 100644 index 000000000..9073b73c7 --- /dev/null +++ b/src/internals/utils/arrays.property.spec.ts @@ -0,0 +1,91 @@ +import { expect } from '@open-wc/testing'; + +import { fc } from '#internals/testing/fast-check-setup.spec.js'; + +import { asArray, chunk, isEmpty, partition, sameItems } from './arrays.js'; + +const items = fc.array(fc.anything(), { maxLength: 30 }); + +describe('Array utilities properties', () => { + it('chunk splits an array into full chunks and one shorter last chunk', () => { + fc.assert( + fc.property(items, fc.integer({ min: 1, max: 40 }), (array, size) => { + const chunks = [...chunk(array, size)]; + + expect(sameItems(chunks.flat(1), array)).to.be.true; + expect(chunks).to.have.lengthOf(Math.ceil(array.length / size)); + chunks.forEach((part, i) => { + if (i < chunks.length - 1) { + expect(part).to.have.lengthOf(size); + } else { + expect(part.length).to.be.within(1, size); + } + }); + }) + ); + }); + + it('chunk throws for a size that is not a positive safe integer', () => { + const size = fc.oneof( + fc.integer({ max: 0 }), + fc.double().filter((value) => !Number.isSafeInteger(value)) + ); + + fc.assert( + fc.property(items, size, (array, value) => { + expect(() => [...chunk(array, value)]).to.throw(); + }) + ); + }); + + it('partition keeps each item once, in order, on the side of its predicate', () => { + const predicate = fc.func(fc.boolean()); + + fc.assert( + fc.property(items, predicate, (array, fn) => { + // Pass one argument. `fc.func` gives an answer for each argument list, and + // `filter` also passes the index and the array. + const isTruthy = (item: unknown) => fn(item); + const [truthy, falsy] = partition(array, isTruthy); + + expect(sameItems(truthy, array.filter(isTruthy))).to.be.true; + expect( + sameItems( + falsy, + array.filter((x) => !isTruthy(x)) + ) + ).to.be.true; + }) + ); + }); + + it('sameItems is symmetric and holds for a copy', () => { + const nullable = fc.option(items, { nil: undefined }); + + fc.assert( + fc.property(items, nullable, nullable, (array, a, b) => { + expect(sameItems(array, array.slice())).to.be.true; + expect(sameItems(a, b)).to.equal(sameItems(b, a)); + }) + ); + }); + + it('asArray wraps a single value and passes an array through', () => { + fc.assert( + fc.property(fc.anything(), (value) => { + const result = asArray(value); + + if (value == null) { + expect(result).to.deep.equal([]); + } else if (Array.isArray(value)) { + expect(result).to.equal(value); + } else { + expect(result).to.have.lengthOf(1); + // `Object.is`, not chai's `===`, which fails for `NaN`. + expect(Object.is(result[0], value)).to.be.true; + } + expect(isEmpty(result)).to.equal(result.length === 0); + }) + ); + }); +}); diff --git a/src/internals/utils/arrays.spec.ts b/src/internals/utils/arrays.spec.ts index d555251a1..01a6aa029 100644 --- a/src/internals/utils/arrays.spec.ts +++ b/src/internals/utils/arrays.spec.ts @@ -6,6 +6,7 @@ import { isEmpty, lastOf, partition, + sameItems, } from './arrays.js'; describe('Array utilities', () => { @@ -76,4 +77,21 @@ describe('Array utilities', () => { expect(partition([1, 3], (x) => x % 2 === 0)).to.eql([[], [1, 3]]); }); }); + + describe('sameItems', () => { + it('compares items by identity and order', () => { + const [a, b] = [{}, {}]; + + expect(sameItems([a, b], [a, b])).to.be.true; + expect(sameItems([a, b], [b, a])).to.be.false; + expect(sameItems([a], [{}])).to.be.false; + expect(sameItems(null, undefined)).to.be.true; + expect(sameItems([], null)).to.be.false; + }); + + it('matches NaN to itself, like Object.is', () => { + expect(sameItems([Number.NaN], [Number.NaN])).to.be.true; + expect(sameItems([0], [-0])).to.be.false; + }); + }); }); diff --git a/src/internals/utils/arrays.ts b/src/internals/utils/arrays.ts index 5320f2f5b..32c15c2e3 100644 --- a/src/internals/utils/arrays.ts +++ b/src/internals/utils/arrays.ts @@ -53,8 +53,8 @@ export function asArray(value?: T | T[]): T[] { /** * Returns whether two collections hold the same items, in the same order and - * by identity. Two empty values match; an empty value differs from a - * collection. + * by identity (`Object.is`). Two empty values match; an empty value differs + * from a collection. * * @example * ```typescript @@ -76,7 +76,7 @@ export function sameItems( } for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { + if (!Object.is(a[i], b[i])) { return false; } } diff --git a/src/internals/utils/math.property.spec.ts b/src/internals/utils/math.property.spec.ts new file mode 100644 index 000000000..1e2b052ba --- /dev/null +++ b/src/internals/utils/math.property.spec.ts @@ -0,0 +1,84 @@ +import { expect } from '@open-wc/testing'; + +import { fc, orderedPair } from '#internals/testing/fast-check-setup.spec.js'; + +import { + asNumber, + clamp, + modulo, + numberOfDecimals, + roundPrecise, + wrap, +} from './math.js'; + +const number = fc.double({ noNaN: true }); +const finite = fc.double({ noNaN: true, noDefaultInfinity: true }); +const bounds = orderedPair(finite); + +describe('Math utilities properties', () => { + it('clamp returns a value in range, and is idempotent', () => { + fc.assert( + fc.property(number, bounds, (value, [min, max]) => { + const result = clamp(value, min, max); + + expect(result).to.be.within(min, max); + expect(clamp(result, min, max)).to.equal(result); + if (value >= min && value <= max) { + expect(result).to.equal(value); + } + }) + ); + }); + + it('wrap returns a value in range', () => { + fc.assert( + fc.property(number, bounds, (value, [min, max]) => { + expect(wrap(min, max, value)).to.be.within(min, max); + }) + ); + }); + + it('modulo has the sign of the divisor and a smaller magnitude', () => { + const divisor = fc + .double({ min: -1e6, max: 1e6, noNaN: true }) + .filter((d) => Math.abs(d) > 1e-6); + const dividend = fc.double({ min: -1e9, max: 1e9, noNaN: true }); + + fc.assert( + fc.property(dividend, divisor, (n, d) => { + const result = modulo(n, d); + + if (d > 0) { + expect(result).to.be.at.least(0).and.below(d); + } else { + expect(result).to.be.at.most(0).and.above(d); + } + }) + ); + }); + + it('roundPrecise is idempotent and keeps at most the given decimals', () => { + const value = fc.double({ min: -1e6, max: 1e6, noNaN: true }); + + fc.assert( + fc.property(value, fc.integer({ min: 0, max: 6 }), (x, magnitude) => { + const rounded = roundPrecise(x, magnitude); + + expect(roundPrecise(rounded, magnitude)).to.equal(rounded); + expect(numberOfDecimals(rounded)).to.be.at.most(magnitude); + expect(Math.abs(rounded - x)).to.be.at.most( + 0.5 * 10 ** -magnitude + 1e-9 + ); + }) + ); + }); + + it('asNumber always returns a finite number, and reads back a number string', () => { + fc.assert( + fc.property(fc.anything(), finite, (value, x) => { + expect(Number.isFinite(asNumber(value))).to.be.true; + expect(asNumber(String(x))).to.equal(x === 0 ? 0 : x); + }) + ); + }); +}); diff --git a/src/internals/utils/math.spec.ts b/src/internals/utils/math.spec.ts index 8404336b8..55fa23c8a 100644 --- a/src/internals/utils/math.spec.ts +++ b/src/internals/utils/math.spec.ts @@ -83,6 +83,12 @@ describe('Math utilities', () => { expect(asNumber(2.71)).to.equal(2.71); }); + it('should return the fallback for values without a string conversion', () => { + expect(asNumber(Symbol('x'), 3)).to.equal(3); + expect(asNumber(Object.create(null), 3)).to.equal(3); + expect(asNumber({ toString: '' }, 3)).to.equal(3); + }); + it('should return the fallback for non-parseable input', () => { expect(asNumber('five')).to.equal(0); expect(asNumber('five', 5)).to.equal(5); diff --git a/src/internals/utils/math.ts b/src/internals/utils/math.ts index 9175af192..341c3a935 100644 --- a/src/internals/utils/math.ts +++ b/src/internals/utils/math.ts @@ -61,7 +61,15 @@ export function numberInRangeInclusive( * ``` */ export function asNumber(value: unknown, fallback = 0): number { - const parsed = Number.parseFloat(value as string); + let parsed: number; + + try { + parsed = Number.parseFloat(value as string); + } catch { + // A symbol, or an object with no string conversion. + return fallback; + } + return Number.isFinite(parsed) ? parsed : fallback; } diff --git a/src/internals/utils/objects.property.spec.ts b/src/internals/utils/objects.property.spec.ts new file mode 100644 index 000000000..8721640f5 --- /dev/null +++ b/src/internals/utils/objects.property.spec.ts @@ -0,0 +1,92 @@ +import { expect } from '@open-wc/testing'; + +import { fc } from '#internals/testing/fast-check-setup.spec.js'; + +import { equal } from './objects.js'; + +const settings = { + withDate: true, + withMap: true, + withSet: true, + withTypedArray: true, + withSparseArray: true, + maxDepth: 3, +} satisfies fc.ObjectConstraints; + +const value = fc.anything({ ...settings, withNullPrototype: true }); + +/** Values that `structuredClone` copies with the same prototypes. */ +const cloneable = fc.anything(settings); + +describe('equal properties', () => { + it('is reflexive', () => { + fc.assert( + fc.property(value, (x) => { + expect(equal(x, x)).to.be.true; + }) + ); + }); + + it('holds between a value and its structured clone', () => { + fc.assert( + fc.property(cloneable, (x) => { + const copy = structuredClone(x); + + expect(equal(x, copy)).to.be.true; + expect(equal(copy, x)).to.be.true; + }) + ); + }); + + it('is symmetric', () => { + const pair = fc.oneof( + fc.tuple(value, value), + cloneable.map((x) => [x, structuredClone(x)] as const) + ); + + fc.assert( + fc.property(pair, ([a, b]) => { + expect(equal(a, b)).to.equal(equal(b, a)); + }) + ); + }); + + it('is transitive over clones', () => { + fc.assert( + fc.property(cloneable, value, (x, y) => { + const copy = structuredClone(x); + + if (equal(x, y)) { + expect(equal(copy, y)).to.be.true; + } + }) + ); + }); + + it('tells a value from the same value with one more key', () => { + const record = fc.dictionary(fc.string(), cloneable, { maxKeys: 5 }); + + fc.assert( + fc.property(record, fc.string(), cloneable, (x, key, extra) => { + fc.pre(!Object.hasOwn(x, key) && key !== '__proto__'); + const larger = { ...x, [key]: extra }; + + expect(equal(x, larger)).to.be.false; + expect(equal(larger, x)).to.be.false; + }) + ); + }); + + it('terminates on circular structures', () => { + fc.assert( + fc.property(cloneable, (x) => { + const a: Record = { x }; + const b: Record = { x: structuredClone(x) }; + a.self = a; + b.self = b; + + expect(equal(a, b)).to.be.true; + }) + ); + }); +}); diff --git a/src/internals/utils/objects.spec.ts b/src/internals/utils/objects.spec.ts index d1aa52df2..72dcdeb0f 100644 --- a/src/internals/utils/objects.spec.ts +++ b/src/internals/utils/objects.spec.ts @@ -129,6 +129,11 @@ describe('equal', () => { expect(equal(date1, date2)).to.be.true; }); + it('should return true for two invalid Dates', () => { + expect(equal(new Date(Number.NaN), new Date('invalid'))).to.be.true; + expect(equal(new Date(Number.NaN), new Date(0))).to.be.false; + }); + it('should return false for Dates with different time values', () => { const date1 = new Date('2025-04-22T12:00:00.000Z'); const date2 = new Date('2025-04-22T12:01:00.000Z'); diff --git a/src/internals/utils/objects.ts b/src/internals/utils/objects.ts index f1269e5ac..d9f973cca 100644 --- a/src/internals/utils/objects.ts +++ b/src/internals/utils/objects.ts @@ -55,11 +55,12 @@ function compare(a: object, b: object, visited: Visited): boolean { const left = customConversion(a, method); const right = customConversion(b, method); + // `Object.is`, so that an invalid Date (`NaN`) equals its copy. if (left || right) return ( !!left && !!right && - Reflect.apply(left, a, []) === Reflect.apply(right, b, []) + Object.is(Reflect.apply(left, a, []), Reflect.apply(right, b, [])) ); } diff --git a/src/internals/utils/strings.property.spec.ts b/src/internals/utils/strings.property.spec.ts new file mode 100644 index 000000000..bc152cbbd --- /dev/null +++ b/src/internals/utils/strings.property.spec.ts @@ -0,0 +1,99 @@ +import { expect } from '@open-wc/testing'; + +import { fc } from '#internals/testing/fast-check-setup.spec.js'; + +import { + createIdGenerator, + escapeRegex, + formatString, + nanoid, + toKebabCase, +} from './strings.js'; + +const text = fc.string({ unit: 'grapheme', maxLength: 40 }); + +describe('String utilities properties', () => { + it('escapeRegex makes a pattern that matches only the string itself', () => { + fc.assert( + fc.property(text, text, (value, other) => { + for (const flags of ['', 'u']) { + const pattern = new RegExp(`^${escapeRegex(value)}$`, flags); + + expect(pattern.test(value)).to.be.true; + expect(pattern.test(other)).to.equal(other === value); + } + }) + ); + }); + + it('toKebabCase is idempotent and gives lowercase words', () => { + fc.assert( + fc.property(text, (value) => { + const kebab = toKebabCase(value); + + expect(kebab).to.match(/^[a-z0-9-]*$/); + expect(toKebabCase(kebab)).to.equal(kebab); + }) + ); + }); + + it('formatString leaves a template without specifiers unchanged', () => { + const template = text.filter((value) => !/{\d+}/.test(value)); + + fc.assert( + fc.property(template, fc.array(fc.anything()), (value, params) => { + expect(formatString(value, ...params)).to.equal(value); + }) + ); + }); + + it('formatString replaces each specifier that has a parameter', () => { + const pieces = fc.array( + fc.oneof( + text.filter((value) => !/[{}]/.test(value)), + fc.nat(5).map((index) => `{${index}}`) + ), + { maxLength: 10 } + ); + const params = fc.array(fc.string({ maxLength: 5 }), { maxLength: 4 }); + + fc.assert( + fc.property(pieces, params, (parts, values) => { + const expected = parts + .map((part) => { + const index = /^{(\d+)}$/.exec(part)?.[1]; + return index !== undefined && Number(index) < values.length + ? values[Number(index)] + : part; + }) + .join(''); + + expect(formatString(parts.join(''), ...values)).to.equal(expected); + }) + ); + }); + + it('nanoid returns ids of the given size from its alphabet', () => { + fc.assert( + fc.property(fc.array(fc.nat(100), { maxLength: 20 }), (sizes) => { + for (const size of sizes) { + expect(nanoid(size)).to.match(new RegExp(`^[A-Za-z0-9_-]{${size}}$`)); + } + }) + ); + }); + + it('createIdGenerator never repeats an id', () => { + fc.assert( + fc.property(text, fc.nat(200), (prefix, count) => { + const next = createIdGenerator(prefix); + const ids = new Set(Array.from({ length: count }, next)); + + expect(ids.size).to.equal(count); + for (const id of ids) { + expect(id.startsWith(`${prefix}-`)).to.be.true; + } + }) + ); + }); +}); diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index ea7762f9d..912ed2be9 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -26,24 +26,68 @@ function filterBenignBrowserLogs() { }; } +/** + * Reads an integer of at least `min` from the environment variable `name`. Returns + * `fallback` when the variable is unset or empty. Throws for other values. + */ +function readInteger(name, { min = 1, fallback } = {}) { + const raw = process.env[name]?.trim(); + + if (!raw) { + return fallback; + } + + const value = Number(raw); + + if (!Number.isSafeInteger(value)) { + throw new Error(`${name} must be an integer, got "${raw}".`); + } + + if (value < min) { + throw new Error(`${name} must be at least ${min}, got "${raw}".`); + } + + return value; +} + +/** + * The fast-check settings from `FC_SEED` and `FC_NUM_RUNS`. `FC_SEED=random` picks a new + * seed. See CONTRIBUTING.md. + */ +function fastCheckSettings() { + return { + seed: + process.env.FC_SEED?.trim() === 'random' + ? Math.floor(Math.random() * 0x7fffffff) + : readInteger('FC_SEED', { min: -Infinity }), + numRuns: readInteger('FC_NUM_RUNS'), + }; +} + +const fastCheck = JSON.stringify(fastCheckSettings()); + /** * Loads `assertion-errors.spec.ts` ahead of the test framework so that every * test file gets the chai patch that keeps failed assertions on non-cloneable * subjects (sinon spies, DOM nodes) reportable. See the module itself for the * details. + * + * Also sets the {@link fastCheckSettings} for `fast-check-setup.spec.ts`. */ function testRunnerHtml(testFramework) { return ` + `; } export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ - files: ['src/**/*.spec.ts'], + // The helpers in `src/internals/testing` are not test files. + files: ['src/**/*.spec.ts', '!src/internals/testing/**'], browsers: [playwrightLauncher({ product: 'chromium', headless: true })], testRunnerHtml, @@ -62,7 +106,7 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ testFramework: { config: { - timeout: 3000, + timeout: readInteger('TEST_TIMEOUT', { fallback: 3000 }), }, },