diff --git a/.eslintignore b/.eslintignore index 9385391f..d6b66876 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ # compiled output /dist/ +/playground-dist/ # misc /coverage/ diff --git a/.eslintrc.js b/.eslintrc.js index 4c82aca1..22db5fe1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -42,6 +42,8 @@ module.exports = { 'autoprefixer', 'tailwindcss', '@tailwindcss/forms', + // Build-time only: required from index.js when COVERAGE=true. + 'ember-cli-code-coverage', ], }, ], @@ -53,12 +55,15 @@ module.exports = { './.eslintrc.js', './.prettierrc.js', './.stylelintrc.js', - './.template-lintrc.js', './ember-cli-build.js', './index.js', './testem.js', './blueprints/*/index.js', './config/**/*.js', + './scripts/build-playground.js', + './scripts/check-coverage.js', + './scripts/check-coverage-test.js', + './scripts/stamp-coverage-run.js', './tests/dummy/config/**/*.js', ], parserOptions: { @@ -70,5 +75,18 @@ module.exports = { }, extends: ['plugin:n/recommended'], }, + // QUnit test files. `no-hooks-from-ancestor-modules` is the one that matters most here: + // a nested `module('…', function (hooks) { … })` shadows the outer hooks, which QUnit 3 + // turns into a hard error. It is easy to reintroduce and the suite stays green when you do. + { + files: ['tests/**/*-test.js'], + plugins: ['qunit'], + extends: ['plugin:qunit/recommended'], + rules: { + // Opinionated and noisy for this suite: most tests assert an obvious, fixed number + // of things, and an expect() count that drifts is worse than none. + 'qunit/require-expect': 'off', + }, + }, ], }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 505fae67..930174f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,25 +1,23 @@ name: Ember CI -# Pushes run only for main and tags (tags publish). A release or dev branch always -# has a pull request, and pull_request already runs on every push to it; triggering -# on push too ran the whole suite twice for each commit. +# Validate branches through pull requests so updates run CI only once. +# Keep tag pushes for publishing after the required checks pass. on: push: - branches: - - main tags: - 'v*' pull_request: + # These filters match the target branch, including release integration branches. branches: - main - 'release/v*' - 'dev-v*' -# A newer push to the same pull request or branch makes the running check stale. -# Every tag is its own ref, so a publish run is never cancelled by another push. +# Cancel stale runs for the same PR. +# Allow tag publishing runs to finish. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} env: NODE_VERSION: 22.x @@ -28,110 +26,174 @@ permissions: contents: read jobs: - build: + lint: + name: Lint runs-on: ubuntu-latest + # The suite takes about three minutes. A hang (a test run that never exits) should + # fail fast, not run until someone cancels it. + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + build: + name: Build + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build - - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} + - name: Build the playground for GitHub Pages + run: pnpm run build:playground + + - name: Verify the Pages artifact + run: | + set -euo pipefail - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: latest + test -f playground-dist/index.html || { echo 'playground-dist/index.html is missing'; exit 1; } + test -f playground-dist/.nojekyll || { echo 'playground-dist/.nojekyll is missing'; exit 1; } + test -d playground-dist/assets || { echo 'playground-dist/assets is missing'; exit 1; } - - name: Install Dependencies - run: pnpm install + # Assets must resolve under the sub-path, or every request 404s on Pages. + grep -q '/ember-ui/assets/' playground-dist/index.html \ + || { echo 'index.html does not reference assets under /ember-ui/'; exit 1; } - - name: Lint - run: pnpm run lint + # Nothing from the test or coverage lifecycle may reach a published artifact. + for unwanted in tests tests.html testem.js coverage; do + if [ -e "playground-dist/$unwanted" ]; then + echo "playground-dist/$unwanted must not be published" + exit 1 + fi + done - - name: Build - run: pnpm run build + if find playground-dist -name 'lcov.info' -o -name 'coverage-*.json' | grep -q .; then + echo 'coverage artefacts leaked into the Pages build' + exit 1 + fi test: - needs: build + name: Test with coverage runs-on: ubuntu-latest - # The suite takes about three minutes. A hang (a test run that never exits) should - # fail fast, not run until someone cancels it. - timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} + - name: Setup pnpm + uses: pnpm/action-setup@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: latest + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm - - name: Install Dependencies - run: pnpm install + - name: Install dependencies + run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm run lint + - name: Coverage gate self-test + run: pnpm run coverage:selftest - - name: Test - run: pnpm run test:ember --filter='!Integration' + - name: Run full test suite with coverage + run: pnpm run test:coverage + + - name: Enforce 100% coverage gate + run: pnpm run coverage:check + + - name: Verify LCOV report exists + run: test -s coverage/lcov.info || (echo 'coverage/lcov.info missing or empty' && exit 1) + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: coverage/lcov.info + flags: ember-ui + fail_ci_if_error: true + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} npm_publish: - needs: test + name: Publish to npm + needs: [lint, test] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} + - name: Setup pnpm + uses: pnpm/action-setup@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: latest + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm - - name: Install Dependencies - run: pnpm install + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build - - name: Build - run: pnpm run build + - name: Set up npm + run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_AUTH_TOKEN }}" > ~/.npmrc - - name: Set up npm - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_AUTH_TOKEN }}" > ~/.npmrc + - name: Publish + run: npm publish --access public - - name: Publish - run: npm publish --access public - github_publish: - needs: build + name: Publish to GitHub registry + needs: [lint, test] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') permissions: contents: read packages: write - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + cache: pnpm - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: latest - - - name: Install Dependencies - run: pnpm install + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build diff --git a/.github/workflows/playground-pages.yml b/.github/workflows/playground-pages.yml new file mode 100644 index 00000000..99489048 --- /dev/null +++ b/.github/workflows/playground-pages.yml @@ -0,0 +1,105 @@ +name: Playground (GitHub Pages) + +# Deploys the interactive component playground to GitHub Pages. +# +# Trigger scope is deliberately narrow: +# - Only pushes to `main`, so the published site tracks the default branch and one branch owns +# the Pages site. Pull request heads are NEVER deployed — a PR from a fork would otherwise be +# able to publish arbitrary content to the project's Pages site. +# - `workflow_dispatch` allows a manual deploy from any branch, which is how to publish the +# playground before a release branch has merged. +# +# Release branches (`dev-v*`) are deliberately NOT listed: letting a release branch and `main` +# both deploy would let them overwrite the same site unpredictably. See PLAYGROUND.md. + +on: + push: + branches: [main] + paths: + - 'addon/**' + - 'app/**' + - 'tests/dummy/**' + - 'scripts/build-playground.js' + - 'package.json' + - 'pnpm-lock.yaml' + - 'ember-cli-build.js' + - 'tailwind.config.js' + - '.github/workflows/playground-pages.yml' + workflow_dispatch: + +env: + NODE_VERSION: 22.x + +permissions: + contents: read + pages: write + id-token: write + +# One deploy at a time. In-progress deploys are allowed to finish rather than being cancelled, +# so the published site is never left half-written. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build playground + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Build the playground + # `base_path` follows whatever Pages reports, so moving to a custom domain later needs no + # code change. It falls back to the project-page sub-path. + # Invoked directly rather than through `pnpm run`, so the flag reaches the script instead + # of being consumed by the package manager. + run: node scripts/build-playground.js --base-path "${{ steps.pages.outputs.base_path || '/ember-ui/' }}" + + - name: Verify the artifact + run: | + set -euo pipefail + + test -f playground-dist/index.html || { echo 'index.html is missing'; exit 1; } + test -f playground-dist/.nojekyll || { echo '.nojekyll is missing'; exit 1; } + test -d playground-dist/assets || { echo 'assets/ is missing'; exit 1; } + + for unwanted in tests tests.html testem.js coverage; do + if [ -e "playground-dist/$unwanted" ]; then + echo "playground-dist/$unwanted must not be published" + exit 1 + fi + done + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: playground-dist + + deploy: + name: Deploy to Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 71ad79d0..4411c937 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,15 @@ # broccoli-debug /DEBUG/ + +# Generated coverage output. `/coverage/` above covers the normal case; these two guard the +# reports istanbul writes for workspace-linked siblings, whose relative paths resolve out of +# the coverage folder and into the package root (see config/coverage.js). +/ember-core/ +/*/addon/**/*.js.html + +# Written by scripts/stamp-coverage-run.js; proves the coverage artifacts belong to the last run +.coverage-run-stamp.json + +# Static playground build output (GitHub Pages artifact) +/playground-dist/ diff --git a/.npmignore b/.npmignore index 69beb28f..054e91f2 100644 --- a/.npmignore +++ b/.npmignore @@ -33,3 +33,5 @@ /package.json.ember-try /package-lock.json.ember-try /yarn.lock.ember-try +playground-dist/ +docs/ diff --git a/.prettierignore b/.prettierignore index 9385391f..d6b66876 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,7 @@ # compiled output /dist/ +/playground-dist/ # misc /coverage/ diff --git a/.stylelintignore b/.stylelintignore index a0cf71cb..1f90b012 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -3,6 +3,8 @@ # compiled output /dist/ +/playground-dist/ +/coverage/ # addons /.node_modules.ember-try/ diff --git a/.template-lintrc.js b/.template-lintrc.js deleted file mode 100644 index c7795679..00000000 --- a/.template-lintrc.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -module.exports = { - extends: 'recommended', - rules: { - 'no-invalid-interactive': 'off', - 'no-yield-only': 'off', - 'no-pointer-down-event-binding': 'off', - 'table-groups': 'off', - 'link-href-attributes': 'off', - 'require-input-label': 'off', - 'no-array-prototype-extensions': 'off', - 'no-unsupported-role-attributes': 'off', - }, -}; diff --git a/.template-lintrc.mjs b/.template-lintrc.mjs new file mode 100644 index 00000000..dc564205 --- /dev/null +++ b/.template-lintrc.mjs @@ -0,0 +1,52 @@ +import NoUnguardedHandlerArgument from './lint/no-unguarded-handler-argument.mjs'; + +export default { + plugins: [ + { + name: 'fleetbase-ember-ui', + rules: { + 'no-unguarded-handler-argument': NoUnguardedHandlerArgument, + }, + }, + ], + extends: 'recommended', + rules: { + // `{{on "click" @arg}}` and `{{fn @arg …}}` THROW while rendering when the argument is + // absent — they are not no-ops. Thirteen bindings across five components were fixed for + // this; the rule keeps them from coming back. + 'no-unguarded-handler-argument': true, + 'no-invalid-interactive': 'off', + 'no-yield-only': 'off', + 'no-pointer-down-event-binding': 'off', + 'table-groups': 'off', + 'link-href-attributes': 'off', + 'require-input-label': 'off', + 'no-array-prototype-extensions': 'off', + 'no-unsupported-role-attributes': 'off', + // These are addon helpers, not components. The rule cannot tell them + // apart on its own and its own error message asks for this list. + 'no-curly-component-invocation': { + allow: ['avatar-url', 'format-currency', 'format-milliseconds', 'get-default-value', 'is-bool-value', 'is-dark-mode', 'json-hash', 'string-starts-with', 'ui-is-array'], + }, + }, + overrides: [ + { + // Test fixtures always supply the handlers they bind — the template is written + // alongside the arguments it needs. The rule exists to protect the addon's shipped + // components, where the caller is someone else. + files: ['tests/**/*-test.js'], + rules: { + 'no-unguarded-handler-argument': false, + }, + }, + { + // Modifier tests set inline styles on their fixtures because the + // element's own style is exactly what the modifier under test reads + // and writes. The rule stays fully enforced for every addon template. + files: ['tests/integration/modifiers/**/*-test.js'], + rules: { + 'no-inline-styles': 'off', + }, + }, + ], +}; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb3ae186..7917e78b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,25 +1,73 @@ -# How To Contribute +# Contributing -## Installation +Thanks for helping improve Fleetbase Ember UI. -* `git clone ` -* `cd ember-ui` -* `yarn install` +## Getting set up -## Linting +```bash +git clone https://github.com/fleetbase/ember-ui.git +cd ember-ui +pnpm install --frozen-lockfile +``` + +Node 18 or newer is required. This repository uses **pnpm**; the lockfile is committed and CI +installs with `--frozen-lockfile`, so please do not introduce another package manager. + +## Running the playground + +The playground doubles as the development application — it renders the real components through +normal Ember resolution: + +```bash +pnpm start +``` + +Then visit . See [PLAYGROUND.md](PLAYGROUND.md) for its architecture and for +how to add an example when you add a component. + +## Tests + +```bash +pnpm run test:ember # the full suite in headless Chrome +pnpm test # lint and the full suite +``` -* `yarn lint` -* `yarn lint:fix` +Run a subset while working on one component: -## Running tests +```bash +pnpm exec ember test --filter="Integration | Component | button" +pnpm exec ember test --server # watch mode +``` + +Compatibility across supported Ember versions: + +```bash +pnpm exec ember try:each +``` + +## Coverage + +Coverage is gated at 100% of first-party `addon/` source and enforced in CI: + +```bash +pnpm run test:coverage +pnpm run coverage:check +``` + +A pull request that adds behaviour is expected to add the tests that cover it. + +## Linting -* `ember test` – Runs the test suite on the current Ember version -* `ember test --server` – Runs the test suite in "watch mode" -* `ember try:each` – Runs the test suite against multiple Ember versions +```bash +pnpm run lint # eslint, ember-template-lint and stylelint +pnpm run lint:fix +``` -## Running the dummy application +## Opening a pull request -* `ember serve` -* Visit the dummy application at [http://localhost:4200](http://localhost:4200). +- Keep the change focused, and describe what it does and why. +- Add or update tests alongside the change. +- Make sure `pnpm test` passes locally before pushing; CI runs lint, the full suite with coverage, + and a production build. -For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/). +For more on ember-cli itself, see the [ember-cli documentation](https://cli.emberjs.com/release/). diff --git a/PLAYGROUND.md b/PLAYGROUND.md new file mode 100644 index 00000000..e7e39cc3 --- /dev/null +++ b/PLAYGROUND.md @@ -0,0 +1,520 @@ +# Component Playground + +An interactive companion to the official component documentation at +. Every documented component gets a route where you can change its +arguments and watch the real addon component react, plus a minimal embeddable view the +documentation site can put in an iframe. + +The playground is the addon's existing Ember dummy application. It is not a second workspace, not +Storybook, and not a copy of any component — it renders the real components through normal Ember +resolution, with the real addon styles. + +--- + +## Scope is documentation-driven + +**The scope authority is , not `app/components`.** + +The addon exports **275** public components. The official documentation covers **63** of them. +Only those 63 get playground pages. + +This matters because it is the difference between a useful complement to the documentation and a +second, exhaustive API browser that nobody maintains. An existing-but-undocumented public +component resolves to the playground's deliberate not-found page rather than being exposed +automatically. + +The allowlist lives in [`tests/dummy/app/playground/allowlist.js`](tests/dummy/app/playground/allowlist.js). +Each entry records the Ember resolution path, the display name, the documentation category, and +the official documentation URL. + +`path` is the **resolution path**, not a lowercased display name: + +| Display name | Resolution path | +| --------------------------- | -------------------------- | +| `Layout::Resource::Tabular` | `layout/resource/tabular` | +| `Attach::Tooltip` | `attach/tooltip` | +| `Modal::Layouts::Confirm` | `modal/layouts/confirm` | +| `RegistryYield` | `registry-yield` | + +### The 63 documented components + +| Category | Components | +| --- | --- | +| Layout & Structure (17) | `Layout::Container`, `Layout::Header`, `Layout::Sidebar`, `Layout::Main`, `Layout::Section`, `Layout::MobileNavbar`, `ContentPanel`, `Overlay`, `Drawer`, `Layout::Resource::Tabular`, `Layout::Resource::Card`, `Layout::Resource::CardsGrid`, `Layout::Resource::Panel`, `Floating`, `Attach::Tooltip`, `Attach::Popover`, `Spacer` | +| Navigation (3) | `TabNavigation`, `Tabs`, `DropdownButton` | +| Buttons & Actions (3) | `Button`, `ClickToCopy`, `ClickToReveal` | +| Forms & Inputs (15) | `InputGroup`, `Checkbox`, `Toggle`, `Select`, `MultiSelect`, `ComboBox`, `ModelSelect`, `DatePicker`, `DateTimeInput`, `PhoneInput`, `CoordinatesInput`, `ModelCoordinatesInput`, `UnitInput`, `MoneyInput`, `FileUpload` | +| Data Display (9) | `Table`, `Badge`, `Pill`, `ProgressBar`, `Spinner`, `Timeline`, `ActivityLog`, `File`, `CommentThread` | +| Calendars & Boards (3) | `FullCalendar`, `EventCalendar`, `Kanban` | +| Modals (9) | `Modal::Default`, `Modal::Layouts::Confirm`, `::Alert`, `::Prompt`, `::BulkAction`, `::Progress`, `::Process`, `::Loading`, `::OptionPrompt` | +| Dashboard (1) | `Dashboard` | +| Builders (2) | `ReportBuilder`, `TemplateBuilder` | +| Registry & Slots (1) | `RegistryYield` | + +Several components share a documentation page — the six `Layout::*` scaffolding components are +documented on `/layout/overview`, the four resource layouts on `/layout/resource-tabular`, and the +eight modal layouts on `/modals/modal-layouts`. Repeated `docsUrl` values are expected. + +### Explicitly out of scope + +Undocumented public components, internal implementation components, table cells, modal internals, +chat components, template-builder and report-builder sub-components, widget internals, helpers, +modifiers, services, initializers, and utility functions. Services may back an example — the +`modals-manager` service is how the modal layouts are demonstrated — but they get no page. + +### Updating the allowlist when the documentation changes + +1. Re-read the navigation at . +2. Add, remove or re-categorise entries in `allowlist.js`, including the `docsUrl`. +3. Update the expected count in `tests/unit/playground/allowlist-test.js`. +4. Add a registry entry and an example adapter for anything new (see *Adding an example*). +5. Run `pnpm run test:ember` — the scope-completeness tests fail in both directions until the + allowlist, the registry and the adapters agree. + +--- + +## Documentation / runtime discrepancies + +### ScheduleCalendar (documentation is stale) + +`/docs/ui/scheduling/event-calendar` is titled **"EventCalendar / ScheduleCalendar"**, but +`ScheduleCalendar` and `ScheduleItemCard` no longer exist in this addon — they were removed as +dead code. + +The playground therefore: + +- includes **`EventCalendar`**, which exists; +- does **not** restore `ScheduleCalendar` or `ScheduleItemCard`, and does not invent replacements; +- does **not** create a broken route to match stale prose. + +The discrepancy is recorded in `allowlist.js` as `REMOVED_FROM_ADDON_STILL_IN_DOCS` and asserted in +`tests/unit/playground/allowlist-test.js`, so it stays visible rather than being quietly forgotten. + +**Recommendation:** correct the official documentation separately — the page should describe +`EventCalendar` only. That change belongs to the documentation site, whose source is not in this +repository. + +### Argument surfaces were read from the implementation + +Registry controls were derived from the current `addon/components/*.hbs` and `*.js`, not from the +documentation prose. Where the two disagree, the implementation wins, because it is what actually +runs. Two consequences worth stating: + +- **`Layout::Resource::Panel` has no save button unless you pass `@saveTask`.** This is + deliberate: wiring a default would force a save button onto read-only panels. The playground + passes a local no-op task so the button is demonstrable. +- **`Spinner` takes `@message`, `@size`, `@width`/`@height`.** Several arguments named in passing + in the prose are not read by the component. +- **`InputGroup`'s label comes from `@name`, not `@labelText`.** `@labelText` is not read by the + component at all — `input-group.hbs:3` passes `@name` through to ``, and + `@placeholder` falls back to `@name` as well. The existing integration tests use `@name` + throughout. The playground control is therefore labelled `Label (@name)` and bound to `@name`; + binding `@labelText` would have produced a control that silently did nothing. + +--- + +## Architecture + +Everything lives under `tests/dummy/`. No playground runtime code was added to `addon/`, because +none of it is useful to addon consumers. + +``` +tests/dummy/app/ + playground/ + allowlist.js the 63 documented components — the scope authority + registry.js one entry per documented component, built from the allowlist + controls.js control types, coercion and validation + state-codec.js encode/decode the shareable `state` query parameter + event-sanitizer.js turn callback arguments into something safe to display + controlled.js base class for controlled input adapters + host-stubs.js host-app dependencies the console layout components need + fixtures/index.js deterministic fixtures — no clock, no network + components/playground/ + host.{js,hbs} preview + controls + event log; shared by both routes + catalog.{js,hbs} searchable, categorized catalog + control.{js,hbs} renders one control by type + examples/ 63 example adapters, one per documented component + widgets/ small safe components for the Dashboard and RegistryYield examples + modifiers/playground/ + resize-reporter.js one-way postMessage height reporting for iframes +``` + +### Routes + +| Route | Purpose | +| --- | --- | +| `/` | Redirects to the catalog | +| `/components` | Searchable, categorized catalog (`q` and `category` in the URL) | +| `/components/:slug` | Full interactive component page | +| `/embed/:slug` | Minimal iframe view | +| anything else | The playground's deliberate not-found page | + +Catalog and detail are **sibling** routes rather than parent/child, so each owns its own model and +query parameters without the catalog rendering behind every component page. + +In production the app uses **hash routing**, because GitHub Pages cannot rewrite deep links onto +`index.html`: + +``` +https://fleetbase.github.io/ember-ui/#/components/button +https://fleetbase.github.io/ember-ui/#/embed/button?state=… +``` + +--- + +## What the host application has to supply + +The addon ships component styles, not a document. A consuming application supplies the layer +underneath them, and the playground — being that application here — has to supply it too. Three +rules in `tests/dummy/app/styles/app.css` do that, and each is load-bearing. + +**1. Element normalisation.** `addon/styles/addon.css` contains no `@tailwind base`, by design: an +addon that emitted preflight would clobber the styles of every application consuming it. Its +component rules are `@apply`-generated and assume the elements underneath are already normalised. +Without that layer a ` + + `); + + // The canvas does not yield, so append a child directly to exercise the bubbling case. + const canvas = find('.tb-canvas'); + const inner = document.createElement('span'); + canvas.appendChild(inner); + + await click(inner); + + assert.strictEqual(deselects, 0, 'a click that merely bubbles through does not clear the selection'); + }); + + test('clicking the background without a handler does not throw', async function (assert) { + await render(hbs``); + await click('.tb-canvas'); + + assert.dom('.tb-canvas').exists(); + }); + + test('it forwards splattributes', async function (assert) { + await render(hbs``); + + assert.dom('.tb-canvas').hasAttribute('data-test-canvas', 'yes'); + assert.dom('.tb-canvas').hasClass('extra'); + }); + + test('dimensions update when the template changes', async function (assert) { + this.set('template', { width: 100, height: 100, unit: 'px' }); + + await render(hbs``); + assert.strictEqual(styleValue(find('.tb-canvas').getAttribute('style'), 'width'), '100px'); + + this.set('template', { width: 300, height: 100, unit: 'px' }); + assert.strictEqual(styleValue(find('.tb-canvas').getAttribute('style'), 'width'), '300px'); + }); +}); diff --git a/tests/integration/components/template-builder/element-renderer-test.js b/tests/integration/components/template-builder/element-renderer-test.js new file mode 100644 index 00000000..6fecb556 --- /dev/null +++ b/tests/integration/components/template-builder/element-renderer-test.js @@ -0,0 +1,714 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, settled, findAll, find } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +function element(overrides = {}) { + return { uuid: 'el_1', type: 'text', x: 10, y: 20, width: 200, height: 40, ...overrides }; +} + +function wrapper() { + return find('.tb-element'); +} + +function styleOf(selector) { + return find(selector).getAttribute('style') ?? ''; +} + +module('Integration | Component | template-builder/element-renderer', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set('templateElement', element()); + }); + + const TEMPLATE = hbs` + + `; + + module('the wrapper', function () { + test('it renders an absolutely positioned box carrying the element identity', async function (assert) { + await render(TEMPLATE); + + assert.dom('.tb-element').exists(); + assert.dom(wrapper()).hasAttribute('data-element-type', 'text'); + assert.dom(wrapper()).hasAttribute('data-element-uuid', 'el_1'); + + const style = styleOf('.tb-element'); + assert.true(style.includes('position: absolute')); + assert.true(style.includes('left: 0')); + assert.true(style.includes('top: 0')); + assert.true(style.includes('width: 200px')); + assert.true(style.includes('height: 40px')); + assert.true(style.includes('z-index: 1'), 'a default z-index is applied'); + // wrapperStyle itself omits the transform; did-insert writes it onto the + // element's inline style afterwards, which is why it shows up here. + assert.true(style.includes('cursor: move')); + }); + + test('width, height and z-index fall back to defaults', async function (assert) { + this.set('templateElement', { uuid: 'el_1', type: 'text' }); + + await render(TEMPLATE); + + const style = styleOf('.tb-element'); + assert.true(style.includes('width: 100px')); + assert.true(style.includes('height: 30px')); + assert.true(style.includes('z-index: 1')); + }); + + test('an explicit z-index and opacity are applied', async function (assert) { + this.set('templateElement', element({ z_index: 7, opacity: 0.5 })); + + await render(TEMPLATE); + + const style = styleOf('.tb-element'); + assert.true(style.includes('z-index: 7')); + assert.true(style.includes('opacity: 0.5')); + }); + + test('an opacity of zero is still applied', async function (assert) { + this.set('templateElement', element({ opacity: 0 })); + + await render(TEMPLATE); + + assert.true(styleOf('.tb-element').includes('opacity: 0'), 'zero is a real opacity, not a missing one'); + }); + + test('no opacity is emitted when none is set', async function (assert) { + await render(TEMPLATE); + + assert.false(styleOf('.tb-element').includes('opacity')); + }); + + test('the initial position is applied as a transform', async function (assert) { + await render(TEMPLATE); + + assert.strictEqual(wrapper().dataset.x, '10'); + assert.strictEqual(wrapper().dataset.y, '20'); + assert.strictEqual(wrapper().style.transform, 'translate(10px, 20px)'); + }); + + test('a rotated element carries the rotation in its transform', async function (assert) { + this.set('templateElement', element({ rotation: 45 })); + + await render(TEMPLATE); + + assert.strictEqual(wrapper().style.transform, 'translate(10px, 20px) rotate(45deg)'); + assert.strictEqual(wrapper().dataset.rotation, '45'); + }); + + test('an element with no position starts at the origin', async function (assert) { + this.set('templateElement', { uuid: 'el_1', type: 'text' }); + + await render(TEMPLATE); + + assert.strictEqual(wrapper().style.transform, 'translate(0px, 0px)'); + }); + + test('the transform survives a re-render of the element', async function (assert) { + await render(TEMPLATE); + assert.strictEqual(wrapper().style.transform, 'translate(10px, 20px)'); + + this.set('templateElement', element({ z_index: 9, rotation: 90 })); + await settled(); + + assert.strictEqual(wrapper().style.transform, 'translate(10px, 20px) rotate(90deg)', 'position is preserved and the new rotation applied'); + assert.true(styleOf('.tb-element').includes('z-index: 9')); + }); + }); + + module('selection', function () { + test('an unselected element offers a hover affordance and no handles', async function (assert) { + await render(TEMPLATE); + + assert.dom(wrapper()).hasClass('hover:ring-1'); + assert.strictEqual(findAll('.tb-element-handle').length, 0); + }); + + test('a selected element is ringed and grows four resize handles', async function (assert) { + this.set('isSelected', true); + + await render(TEMPLATE); + + assert.dom(wrapper()).hasClass('ring-2'); + assert.dom(wrapper()).hasClass('ring-blue-500'); + assert.deepEqual( + findAll('.tb-element-handle').map((handle) => handle.className.split(' ')[1]), + ['tb-handle-nw', 'tb-handle-ne', 'tb-handle-sw', 'tb-handle-se'] + ); + }); + }); + + module('text elements', function () { + test('the content is rendered', async function (assert) { + this.set('templateElement', element({ content: 'Invoice total' })); + + await render(TEMPLATE); + + assert.dom('.tb-element-text').containsText('Invoice total'); + }); + + test('an element with no content renders an empty box', async function (assert) { + await render(TEMPLATE); + + assert.dom('.tb-element-text').exists(); + assert.dom('.tb-element-text').hasText(''); + }); + + test('every typographic property reaches the style', async function (assert) { + this.set( + 'templateElement', + element({ + content: 'Styled', + font_size: 18, + font_family: 'Inter', + font_weight: 'bold', + font_style: 'italic', + text_align: 'center', + text_decoration: 'underline', + line_height: 1.4, + letter_spacing: 2, + color: 'rgb(1, 2, 3)', + background_color: 'rgb(4, 5, 6)', + padding: 8, + }) + ); + + await render(TEMPLATE); + + const style = styleOf('.tb-element-text'); + for (const fragment of [ + 'font-size: 18px', + 'font-family: Inter', + 'font-weight: bold', + 'font-style: italic', + 'text-align: center', + 'text-decoration: underline', + 'line-height: 1.4', + 'letter-spacing: 2px', + 'color: rgb(1, 2, 3)', + 'background-color: rgb(4, 5, 6)', + 'padding: 8px', + ]) { + assert.true(style.includes(fragment), `${fragment} is applied`); + } + }); + + test('borders are applied with their defaults', async function (assert) { + this.set('templateElement', element({ content: 'Bordered', border_width: 2, border_radius: 4 })); + + await render(TEMPLATE); + + const style = styleOf('.tb-element-text'); + assert.true(style.includes('border: 2px solid #000000'), `default style and colour are used (got ${style})`); + assert.true(style.includes('border-radius: 4px')); + }); + + test('an explicit border style and colour win', async function (assert) { + this.set('templateElement', element({ content: 'Bordered', border_width: 3, border_style: 'dashed', border_color: 'rgb(9, 9, 9)' })); + + await render(TEMPLATE); + + assert.true(styleOf('.tb-element-text').includes('dashed')); + }); + + test('an unstyled text element emits no typographic declarations', async function (assert) { + await render(TEMPLATE); + + const style = styleOf('.tb-element-text'); + assert.false(style.includes('font-size')); + assert.false(style.includes('color:')); + assert.true(style.includes('overflow: hidden'), 'but the structural styles are always present'); + }); + }); + + module('image elements', function () { + test('an image with a source is rendered', async function (assert) { + this.set('templateElement', element({ type: 'image', src: '/logo.png', alt: 'Company logo' })); + + await render(TEMPLATE); + + assert.dom('img').hasAttribute('src', '/logo.png'); + assert.dom('img').hasAttribute('alt', 'Company logo'); + }); + + test('object fit and radius are applied', async function (assert) { + this.set('templateElement', element({ type: 'image', src: '/logo.png', object_fit: 'contain', border_radius: 6 })); + + await render(TEMPLATE); + + const style = find('img').getAttribute('style'); + assert.true(style.includes('object-fit: contain')); + assert.true(style.includes('border-radius: 6px')); + }); + + test('an image with no source shows a placeholder instead', async function (assert) { + this.set('templateElement', element({ type: 'image' })); + + await render(TEMPLATE); + + assert.dom('img').doesNotExist(); + assert.dom('.tb-element svg').exists('a placeholder icon is shown'); + }); + + test('an image with no alt text still renders', async function (assert) { + this.set('templateElement', element({ type: 'image', src: '/logo.png' })); + + await render(TEMPLATE); + + assert.dom('img').hasAttribute('alt', ''); + }); + }); + + module('table elements', function () { + test('columns and rows are rendered', async function (assert) { + this.set( + 'templateElement', + element({ + type: 'table', + columns: [{ label: 'Item' }, { label: 'Qty' }], + rows: [['Widget', '2']], + }) + ); + + await render(TEMPLATE); + + assert.deepEqual( + findAll('th').map((cell) => cell.textContent.trim()), + ['Item', 'Qty'] + ); + assert.strictEqual(findAll('tbody tr').length, 1); + }); + + test('with columns but no rows a placeholder grid is shown', async function (assert) { + this.set('templateElement', element({ type: 'table', columns: [{ label: 'Item' }] })); + + await render(TEMPLATE); + + assert.strictEqual(findAll('tbody tr').length, 3, 'three placeholder rows'); + assert.dom('tbody td').hasText('—'); + }); + + test('with no columns at all it says so', async function (assert) { + this.set('templateElement', element({ type: 'table' })); + + await render(TEMPLATE); + + assert.dom('.tb-element').containsText('No columns defined'); + }); + + // Nothing here resolves a data source — the builder stores intent and + // something downstream renders it — so the caption is what tells a + // variable- or query-backed table apart from an empty one. + test('a manual table shows no source caption', async function (assert) { + this.set('templateElement', element({ type: 'table', columns: [{ label: 'Item' }] })); + + await render(TEMPLATE); + + assert.strictEqual(findAll('tbody tr').length, 3, 'only the placeholder rows'); + assert.dom('.tb-table-source-caption').doesNotExist(); + }); + + test('a variable-backed table names its variable', async function (assert) { + this.set('templateElement', element({ type: 'table', columns: [{ label: 'Item' }], data_source_mode: 'variable', data_source: '{order.items}' })); + + await render(TEMPLATE); + + assert.dom('.tb-table-source-caption').hasText('Rows from {order.items}'); + }); + + test('a variable-backed table with no variable yet still says where its rows come from', async function (assert) { + this.set('templateElement', element({ type: 'table', columns: [{ label: 'Item' }], data_source_mode: 'variable' })); + + await render(TEMPLATE); + + assert.dom('.tb-table-source-caption').hasText('Rows from a variable'); + }); + + test('a query-backed table names its endpoint', async function (assert) { + this.set('templateElement', element({ type: 'table', columns: [{ label: 'Item' }], data_source_mode: 'query', query_endpoint: 'int/v1/orders' })); + + await render(TEMPLATE); + + assert.dom('.tb-table-source-caption').hasText('Rows from int/v1/orders'); + assert.dom('.tb-table-source-caption').hasAttribute('colspan', '1'); + }); + + test('a query-backed table with no endpoint yet still says where its rows come from', async function (assert) { + this.set('templateElement', element({ type: 'table', columns: [{ label: 'Item' }, { label: 'Qty' }], data_source_mode: 'query' })); + + await render(TEMPLATE); + + assert.dom('.tb-table-source-caption').hasText('Rows from a query'); + assert.dom('.tb-table-source-caption').hasAttribute('colspan', '2', 'the caption spans every column'); + }); + + test('a query-backed table with rows of its own renders them instead of the caption', async function (assert) { + this.set( + 'templateElement', + element({ + type: 'table', + columns: [{ label: 'Item', key: 'name' }], + rows: [{ name: 'Widget' }], + data_source_mode: 'query', + query_endpoint: 'int/v1/orders', + }) + ); + + await render(TEMPLATE); + + assert.dom('.tb-table-source-caption').doesNotExist(); + assert.dom('tbody td').hasText('Widget'); + }); + + test('header, cell and border styling is applied', async function (assert) { + this.set( + 'templateElement', + element({ + type: 'table', + columns: [{ label: 'Item' }], + rows: [['Widget']], + border_color: 'rgb(1, 1, 1)', + header_background: 'rgb(2, 2, 2)', + header_color: 'rgb(3, 3, 3)', + header_font_size: 14, + header_font_weight: 'bold', + cell_padding: 5, + cell_font_size: 11, + }) + ); + + await render(TEMPLATE); + + assert.true(find('table').getAttribute('style').includes('border-color: rgb(1, 1, 1)')); + + const header = find('th').getAttribute('style'); + assert.true(header.includes('background-color: rgb(2, 2, 2)')); + assert.true(header.includes('color: rgb(3, 3, 3)')); + assert.true(header.includes('font-size: 14px')); + assert.true(header.includes('font-weight: bold')); + + const cell = find('tbody td').getAttribute('style'); + assert.true(cell.includes('padding: 5px')); + assert.true(cell.includes('font-size: 11px')); + assert.true(cell.includes('border-color: rgb(1, 1, 1)')); + }); + + test('an unstyled table emits no border colour', async function (assert) { + this.set('templateElement', element({ type: 'table', columns: [{ label: 'Item' }] })); + + await render(TEMPLATE); + + assert.strictEqual(find('table').getAttribute('style'), ''); + }); + }); + + module('line elements', function () { + test('a line uses its defaults', async function (assert) { + this.set('templateElement', element({ type: 'line' })); + + await render(TEMPLATE); + + const inner = find('.tb-element-line div').getAttribute('style'); + assert.true(inner.includes('border-top: 1px solid'), 'a one-pixel solid line by default'); + }); + + test('line width, style and colour are applied', async function (assert) { + this.set('templateElement', element({ type: 'line', line_width: 3, line_style: 'dotted', color: 'rgb(7, 7, 7)' })); + + await render(TEMPLATE); + + const inner = find('.tb-element-line div').getAttribute('style'); + assert.true(inner.includes('border-top: 3px dotted rgb(7, 7, 7)')); + }); + }); + + module('shape elements', function () { + test('a bare shape fills its box', async function (assert) { + this.set('templateElement', element({ type: 'shape' })); + + await render(TEMPLATE); + + assert.dom('.tb-element-shape').exists(); + assert.true(styleOf('.tb-element-shape').includes('width: 100%')); + }); + + test('background, border and radius are applied', async function (assert) { + this.set('templateElement', element({ type: 'shape', background_color: 'rgb(8, 8, 8)', border_width: 2, border_radius: 10 })); + + await render(TEMPLATE); + + const style = styleOf('.tb-element-shape'); + assert.true(style.includes('background-color: rgb(8, 8, 8)')); + assert.true(style.includes('border: 2px solid')); + assert.true(style.includes('border-radius: 10px')); + }); + + test('a circle shape is fully rounded regardless of radius', async function (assert) { + this.set('templateElement', element({ type: 'shape', shape: 'circle', border_radius: 4 })); + + await render(TEMPLATE); + + const style = styleOf('.tb-element-shape'); + assert.true(style.includes('border-radius: 50%'), 'the circle rule is appended last so it wins'); + }); + }); + + module('code elements', function () { + test('a qr code without a value is labelled generically', async function (assert) { + this.set('templateElement', element({ type: 'qr_code' })); + + await render(TEMPLATE); + + assert.dom('.tb-element').containsText('QR Code'); + }); + + test('a barcode without a value is labelled generically', async function (assert) { + this.set('templateElement', element({ type: 'barcode' })); + + await render(TEMPLATE); + + assert.dom('.tb-element').containsText('Barcode'); + }); + + test('a value replaces the generic label', async function (assert) { + this.set('templateElement', element({ type: 'qr_code', value: '{order.tracking}' })); + + await render(TEMPLATE); + + assert.dom('.tb-element').containsText('{order.tracking}'); + assert.dom('.tb-element').doesNotContainText('QR Code'); + }); + }); + + module('unknown types', function () { + test('an element with no type is treated as text', async function (assert) { + this.set('templateElement', { uuid: 'el_1', content: 'Untyped' }); + + await render(TEMPLATE); + + assert.dom(wrapper()).hasAttribute('data-element-type', 'text'); + assert.dom('.tb-element-text').containsText('Untyped'); + }); + + test('an unrecognised type renders the wrapper but no body', async function (assert) { + this.set('templateElement', element({ type: 'hologram' })); + + await render(TEMPLATE); + + assert.dom(wrapper()).hasAttribute('data-element-type', 'hologram'); + assert.dom('.tb-element-text').doesNotExist(); + assert.dom('.tb-element-shape').doesNotExist(); + }); + }); + + test('it renders and tears down cleanly without any callbacks', async function (assert) { + this.set('show', true); + + await render(hbs`{{#if this.show}}{{/if}}`); + assert.dom('.tb-element').exists(); + + this.set('show', false); + await settled(); + + assert.dom('.tb-element').doesNotExist('the interact.js instance is torn down without error'); + }); + // interact.js listens for real pointer events on the document, so the drag, resize and tap + // handlers can be driven the same way a browser would drive them. Nothing here is simulated + // at the component level — these dispatch PointerEvents and let interact.js do its own + // gesture detection. + module('pointer interaction', function () { + function pointer(type, x, y, target) { + (target ?? document).dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX: x, + clientY: y, + }) + ); + } + + async function drag(target, from, to) { + pointer('pointerdown', from.x, from.y, target); + pointer('pointermove', from.x + (to.x - from.x) / 2, from.y + (to.y - from.y) / 2); + pointer('pointermove', to.x, to.y); + pointer('pointerup', to.x, to.y); + await settled(); + } + + function corner(node) { + const box = node.getBoundingClientRect(); + return { x: box.left + box.width / 2, y: box.top + box.height / 2 }; + } + + test('a tap selects the element', async function (assert) { + const selected = []; + this.set('onSelect', (el) => selected.push(el)); + await render(TEMPLATE); + + const at = corner(wrapper()); + pointer('pointerdown', at.x, at.y, wrapper()); + pointer('pointerup', at.x, at.y, wrapper()); + await settled(); + + assert.deepEqual( + selected.map((el) => el.uuid), + ['el_1'], + 'the element data model is handed back' + ); + }); + + test('a tap on an element with no onSelect is harmless', async function (assert) { + await render(TEMPLATE); + + const at = corner(wrapper()); + pointer('pointerdown', at.x, at.y, wrapper()); + pointer('pointerup', at.x, at.y, wrapper()); + await settled(); + + assert.dom('.tb-element').exists('the element is still there'); + }); + + test('dragging moves the element and reports the new position', async function (assert) { + const moves = []; + this.set('onMove', (uuid, position) => moves.push({ uuid, position })); + await render(TEMPLATE); + + const from = corner(wrapper()); + await drag(wrapper(), from, { x: from.x + 40, y: from.y + 30 }); + + assert.strictEqual(moves.length, 1, 'the move is reported once, at the end of the drag'); + assert.deepEqual(moves[0], { uuid: 'el_1', position: { x: 50, y: 50 } }, 'the deltas are added to the starting position'); + assert.strictEqual(wrapper().style.transform, 'translate(50px, 50px)', 'and the transform followed the pointer'); + }); + + test('dragging without an onMove handler still moves the element', async function (assert) { + await render(TEMPLATE); + + const from = corner(wrapper()); + await drag(wrapper(), from, { x: from.x + 40, y: from.y + 30 }); + + assert.strictEqual(wrapper().style.transform, 'translate(50px, 50px)', 'the transform is applied regardless'); + }); + + test('zoom scales the pointer deltas down to template pixels', async function (assert) { + const moves = []; + this.set('zoom', 2); + this.set('onMove', (uuid, position) => moves.push(position)); + await render(TEMPLATE); + + const from = corner(wrapper()); + await drag(wrapper(), from, { x: from.x + 40, y: from.y + 30 }); + + assert.deepEqual(moves[0], { x: 30, y: 35 }, 'a 40x30 pointer move is a 20x15 move at 2x zoom'); + }); + + test('an element cannot be dragged off the top-left of the canvas', async function (assert) { + const moves = []; + this.set('canvasWidth', 800); + this.set('canvasHeight', 600); + this.set('onMove', (uuid, position) => moves.push(position)); + await render(TEMPLATE); + + const from = corner(wrapper()); + await drag(wrapper(), from, { x: from.x - 500, y: from.y - 500 }); + + assert.deepEqual(moves[0], { x: 0, y: 0 }, 'it stops at the origin'); + }); + + test('an element cannot be dragged off the bottom-right of the canvas', async function (assert) { + const moves = []; + this.set('canvasWidth', 800); + this.set('canvasHeight', 600); + this.set('onMove', (uuid, position) => moves.push(position)); + await render(TEMPLATE); + + const from = corner(wrapper()); + await drag(wrapper(), from, { x: from.x + 5000, y: from.y + 5000 }); + + assert.deepEqual(moves[0], { x: 600, y: 560 }, 'it stops with its own width and height inside the canvas'); + }); + + test('an element with no size of its own is clamped by its rendered default box', async function (assert) { + const moves = []; + this.set('templateElement', { uuid: 'el_1', type: 'text' }); + this.set('canvasWidth', 800); + this.set('canvasHeight', 600); + this.set('onMove', (uuid, position) => moves.push(position)); + await render(TEMPLATE); + + const from = corner(wrapper()); + await drag(wrapper(), from, { x: from.x + 5000, y: from.y + 5000 }); + + assert.deepEqual(moves[0], { x: 700, y: 570 }, 'the default 100x30 box is what gets clamped'); + }); + + test('dragging a rotated element keeps its rotation', async function (assert) { + this.set('templateElement', element({ rotation: 45 })); + await render(TEMPLATE); + + const from = corner(wrapper()); + await drag(wrapper(), from, { x: from.x + 40, y: from.y + 30 }); + + assert.strictEqual(wrapper().style.transform, 'translate(50px, 50px) rotate(45deg)', 'the rotation survives the move'); + }); + + test('dragging a resize handle resizes the element and reports it', async function (assert) { + const resizes = []; + this.set('isSelected', true); + this.set('onResize', (uuid, box) => resizes.push({ uuid, box })); + await render(TEMPLATE); + + const handle = find('.tb-handle-se'); + const at = corner(handle); + await drag(handle, at, { x: at.x + 50, y: at.y + 20 }); + + assert.strictEqual(resizes.length, 1, 'the resize is reported once'); + assert.strictEqual(resizes[0].uuid, 'el_1'); + assert.strictEqual(resizes[0].box.width, 150, 'the width grew by the pointer delta'); + assert.strictEqual(resizes[0].box.height, 40, 'and so did the height'); + }); + + test('an element cannot be resized below its minimum', async function (assert) { + const resizes = []; + this.set('isSelected', true); + this.set('onResize', (uuid, box) => resizes.push(box)); + await render(TEMPLATE); + + const handle = find('.tb-handle-se'); + const at = corner(handle); + await drag(handle, at, { x: at.x - 500, y: at.y - 500 }); + + assert.strictEqual(resizes[0].width, 20, 'the width floor is 20'); + assert.strictEqual(resizes[0].height, 10, 'and the height floor is 10'); + }); + + test('resizing without an onResize handler still resizes the element', async function (assert) { + this.set('isSelected', true); + await render(TEMPLATE); + + const handle = find('.tb-handle-se'); + const at = corner(handle); + await drag(handle, at, { x: at.x + 50, y: at.y + 20 }); + + assert.strictEqual(wrapper().style.width, '150px', 'the element took the new width'); + }); + }); +}); diff --git a/tests/integration/components/template-builder/layers-panel-test.js b/tests/integration/components/template-builder/layers-panel-test.js new file mode 100644 index 00000000..9f803753 --- /dev/null +++ b/tests/integration/components/template-builder/layers-panel-test.js @@ -0,0 +1,333 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, doubleClick, fillIn, blur, triggerKeyEvent, find, findAll, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +function elements() { + return [ + { uuid: 'el_1', type: 'text', label: 'Heading', z_index: 1 }, + { uuid: 'el_2', type: 'image', z_index: 3 }, + { uuid: 'el_3', type: 'qr_code', z_index: 2 }, + ]; +} + +function rows() { + return findAll('.tb-layer-row'); +} + +function labels() { + return findAll('.tb-layer-row span[title="Double-click to rename"]').map((span) => span.textContent.trim()); +} + +function rowLabel(index) { + return rows()[index].querySelector('span[title="Double-click to rename"]'); +} + +function renameField() { + return find('input[type="text"]'); +} + +function rowActions(index, title) { + return rows()[index].querySelector(`button[title="${title}"]`); +} + +module('Integration | Component | template-builder/layers-panel', function (hooks) { + setupRenderingTest(hooks); + + let selected; + let updated; + let deleted; + let reordered; + + hooks.beforeEach(function () { + selected = []; + updated = []; + deleted = []; + reordered = []; + this.set('elements', elements()); + this.set('onSelectElement', (element) => selected.push(element.uuid)); + this.set('onUpdateElement', (uuid, changes) => updated.push([uuid, changes])); + this.set('onDeleteElement', (uuid) => deleted.push(uuid)); + this.set('onReorderElement', (uuid, direction) => reordered.push([uuid, direction])); + }); + + const TEMPLATE = hbs` + + `; + + module('listing the layers', function () { + test('it lists every element, topmost layer first', async function (assert) { + await render(TEMPLATE); + + assert.dom('.tb-layers-panel').exists(); + assert.strictEqual(rows().length, 3); + assert.deepEqual(labels(), ['Image', 'QR Code', 'Heading'], 'highest z-index sits at the top'); + }); + + test('it counts the layers in the header', async function (assert) { + await render(TEMPLATE); + + assert.dom('.tb-layers-panel > div:first-child').containsText('Layers'); + assert.dom('.tb-layers-panel > div:first-child').containsText('3'); + }); + + test('an element without a z-index sinks to the default layer', async function (assert) { + this.set('elements', [ + { uuid: 'el_1', type: 'text', label: 'Bottom' }, + { uuid: 'el_2', type: 'text', label: 'Top', z_index: 5 }, + ]); + + await render(TEMPLATE); + + assert.deepEqual(labels(), ['Top', 'Bottom']); + }); + + test('an explicit label wins over the type name', async function (assert) { + await render(TEMPLATE); + + assert.true(labels().includes('Heading'), 'the authored label is shown'); + }); + + test('every element type gets a readable name', async function (assert) { + this.set('elements', [ + { uuid: 'a', type: 'text' }, + { uuid: 'b', type: 'image' }, + { uuid: 'c', type: 'table' }, + { uuid: 'd', type: 'line' }, + { uuid: 'e', type: 'shape' }, + { uuid: 'f', type: 'qr_code' }, + { uuid: 'g', type: 'barcode' }, + { uuid: 'h', type: 'something-new' }, + ]); + + await render(TEMPLATE); + + assert.deepEqual(labels(), ['Text', 'Image', 'Table', 'Line', 'Shape', 'QR Code', 'Barcode', 'Element']); + }); + + test('an unknown element type still gets a layer icon', async function (assert) { + this.set('elements', [{ uuid: 'a', type: 'something-new' }]); + + await render(TEMPLATE); + + assert.ok(find('.tb-layer-row svg'), 'a fallback icon is rendered'); + }); + + test('with no elements it explains how to add one', async function (assert) { + this.set('elements', []); + + await render(TEMPLATE); + + assert.strictEqual(rows().length, 0); + assert.dom('.tb-layers-panel').containsText('No elements yet.'); + assert.dom('.tb-layers-panel').containsText('Use the toolbar to add elements.'); + }); + + test('with no elements argument at all it renders the empty state', async function (assert) { + await render(hbs``); + + assert.dom('.tb-layers-panel').containsText('No elements yet.'); + }); + + test('it forwards splattributes', async function (assert) { + await render(hbs``); + + assert.dom('.tb-layers-panel').hasAttribute('data-test-layers', 'yes'); + }); + }); + + module('selecting', function () { + test('clicking a row selects that element', async function (assert) { + await render(TEMPLATE); + await click(rows()[0]); + + assert.deepEqual(selected, ['el_2'], 'the topmost layer is the second element'); + }); + + test('the selected element is highlighted', async function (assert) { + this.set('selectedElement', this.elements[2]); + + await render(TEMPLATE); + + assert.dom(rows()[1]).hasClass('bg-blue-50', 'the QR code row is marked'); + assert.dom(rows()[0]).doesNotHaveClass('bg-blue-50'); + }); + + test('nothing is highlighted when nothing is selected', async function (assert) { + await render(TEMPLATE); + + rows().forEach((row) => assert.dom(row).doesNotHaveClass('bg-blue-50')); + }); + + test('it selects happily without a handler', async function (assert) { + await render(hbs``); + await click(rows()[0]); + + assert.strictEqual(rows().length, 3, 'the panel survives'); + }); + }); + + module('visibility', function () { + test('a visible element offers to be hidden', async function (assert) { + await render(TEMPLATE); + await click(rowActions(0, 'Hide element')); + + assert.deepEqual(updated, [['el_2', { visible: false }]]); + }); + + test('a hidden element is dimmed and offers to be shown', async function (assert) { + this.set('elements', [{ uuid: 'el_1', type: 'text', label: 'Heading', visible: false }]); + + await render(TEMPLATE); + + assert.dom('.tb-layer-row span[title="Double-click to rename"]').hasClass('opacity-40'); + + await click(rowActions(0, 'Show element')); + assert.deepEqual(updated, [['el_1', { visible: true }]]); + }); + + test('toggling does not also select the element', async function (assert) { + await render(TEMPLATE); + await click(rowActions(0, 'Hide element')); + + assert.deepEqual(selected, [], 'the click does not reach the row'); + }); + + test('it toggles happily without a handler', async function (assert) { + await render(hbs``); + await click(rowActions(0, 'Hide element')); + + assert.strictEqual(rows().length, 3); + }); + }); + + module('reordering and deleting', function () { + test('a layer can be moved up', async function (assert) { + await render(TEMPLATE); + await click(rowActions(2, 'Move layer up')); + + assert.deepEqual(reordered, [['el_1', 'up']]); + assert.deepEqual(selected, [], 'reordering does not select'); + }); + + test('a layer can be moved down', async function (assert) { + await render(TEMPLATE); + await click(rowActions(0, 'Move layer down')); + + assert.deepEqual(reordered, [['el_2', 'down']]); + }); + + test('a layer can be deleted', async function (assert) { + await render(TEMPLATE); + await click(rowActions(1, 'Delete element')); + + assert.deepEqual(deleted, ['el_3']); + assert.deepEqual(selected, [], 'deleting does not select'); + }); + + test('it reorders and deletes happily without handlers', async function (assert) { + await render(hbs``); + await click(rowActions(0, 'Move layer up')); + await click(rowActions(0, 'Move layer down')); + await click(rowActions(0, 'Delete element')); + + assert.strictEqual(rows().length, 3, 'the panel survives'); + }); + }); + + module('renaming a layer', function () { + const TEMPLATE = hbs``; + + test('double-clicking a label opens a rename field seeded with the current name', async function (assert) { + await render(TEMPLATE); + const before = labels()[0]; + await doubleClick(rowLabel(0)); + + assert.ok(renameField(), 'the rename field is shown'); + assert.dom(renameField()).hasValue(before, 'seeded with the label it replaced'); + }); + + test('typing a new name and blurring reports the rename', async function (assert) { + await render(TEMPLATE); + await doubleClick(rowLabel(0)); + await fillIn(renameField(), 'Header band'); + await blur(renameField()); + + assert.strictEqual(updated.length, 1, 'exactly one rename is reported'); + assert.deepEqual(updated[0][1], { label: 'Header band' }); + assert.notOk(renameField(), 'the field closes again'); + }); + + test('enter commits the rename', async function (assert) { + await render(TEMPLATE); + await doubleClick(rowLabel(0)); + await fillIn(renameField(), 'Committed'); + await triggerKeyEvent(renameField(), 'keydown', 'Enter'); + + assert.deepEqual( + updated.map(([, changes]) => changes), + [{ label: 'Committed' }] + ); + }); + + test('escape abandons the rename', async function (assert) { + await render(TEMPLATE); + await doubleClick(rowLabel(1)); + await fillIn(renameField(), 'Abandoned'); + await triggerKeyEvent(renameField(), 'keydown', 'Escape'); + + assert.deepEqual(updated, [], 'nothing is reported'); + assert.notOk(renameField(), 'and the field closes'); + }); + + test('any other key leaves the rename open', async function (assert) { + await render(TEMPLATE); + await doubleClick(rowLabel(0)); + await fillIn(renameField(), 'Still typing'); + await triggerKeyEvent(renameField(), 'keydown', 'KeyA'); + + assert.ok(renameField(), 'the field is still open'); + assert.deepEqual(updated, [], 'and nothing is reported yet'); + }); + + test('a second blur landing before the field closes is ignored', async function (assert) { + await render(TEMPLATE); + await doubleClick(rowLabel(0)); + await fillIn(renameField(), 'Renamed once'); + + // Committing sets renamingUuid to null and schedules the field's removal — it is still + // in the DOM, with its blur listener attached, until that render runs. A second blur + // arriving in the same tick therefore re-enters commitRename with nothing to commit, + // which is exactly what the guard is there for. Dispatching both synchronously pins + // that window down; relying on the blur the browser fires as the field is torn down + // does not, because headless Linux Chrome does not fire it at all. + const field = renameField(); + field.dispatchEvent(new FocusEvent('blur')); + field.dispatchEvent(new FocusEvent('blur')); + await settled(); + + assert.deepEqual( + updated.map(([, changes]) => changes), + [{ label: 'Renamed once' }], + 'the rename is reported exactly once' + ); + assert.notOk(renameField(), 'and the field is closed'); + }); + + test('a blank name is not committed', async function (assert) { + await render(TEMPLATE); + await doubleClick(rowLabel(0)); + await fillIn(renameField(), ' '); + await blur(renameField()); + + assert.deepEqual(updated, [], 'whitespace alone is refused'); + }); + }); +}); diff --git a/tests/integration/components/template-builder/properties-panel-test.js b/tests/integration/components/template-builder/properties-panel-test.js new file mode 100644 index 00000000..bc6b0f86 --- /dev/null +++ b/tests/integration/components/template-builder/properties-panel-test.js @@ -0,0 +1,1659 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, fillIn, findAll, find, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { selectFiles } from 'ember-file-upload/test-support'; +import Service from '@ember/service'; + +function element(type, overrides = {}) { + return { uuid: 'el-1', type, x: 10, y: 20, width: 100, height: 50, ...overrides }; +} + +function labelled(labelText) { + const label = findAll('label').find((node) => node.textContent.trim().toLowerCase().startsWith(labelText.toLowerCase())); + + if (!label) { + return null; + } + + return label.parentElement.querySelector('input, select, textarea') ?? label.nextElementSibling; +} + +// Sections start collapsed unless they are in the component's default open set, +// so their contents must be revealed before they can be driven. +async function openSection(title) { + const header = findAll('.tb-prop-section > button').find((button) => button.textContent.trim().toLowerCase().includes(title.toLowerCase())); + + if (header && header.querySelector('.fa-chevron-down')) { + await click(header); + } + + return header; +} + +module('Integration | Component | template-builder/properties-panel', function (hooks) { + setupRenderingTest(hooks); + + let updates; + let templateUpdates; + + hooks.beforeEach(function () { + updates = []; + templateUpdates = []; + this.set('onUpdateElement', (uuid, changes) => updates.push({ uuid, changes })); + this.set('onUpdateTemplate', (changes) => templateUpdates.push(changes)); + }); + + const TEMPLATE = hbs` + + `; + + function lastChanges() { + return updates[updates.length - 1]?.changes; + } + + module('selection state', function () { + test('with no selection it shows canvas settings rather than element properties', async function (assert) { + this.set('template', { width: 210, height: 297, unit: 'mm' }); + + await render(TEMPLATE); + + assert.dom(this.element).doesNotContainText('Position', 'element sections are hidden'); + assert.dom(this.element).exists('the panel still renders'); + }); + + test('selecting an element reveals the position and size sections', async function (assert) { + this.set('selectedElement', element('text')); + + await render(TEMPLATE); + + assert.dom(this.element).containsText('Position'); + assert.dom(this.element).containsText('Size'); + }); + + test('sections can be collapsed and expanded', async function (assert) { + this.set('selectedElement', element('text')); + + await render(TEMPLATE); + const before = findAll('input[type="number"]').length; + assert.true(before > 0, 'position inputs are visible by default'); + + const positionToggle = findAll('button').find((b) => b.textContent.includes('Position')); + await click(positionToggle); + + assert.true(findAll('input[type="number"]').length < before, 'collapsing hides its inputs'); + + await click(positionToggle); + assert.strictEqual(findAll('input[type="number"]').length, before, 'expanding restores them'); + }); + }); + + module('property editing', function () { + test('numeric properties are parsed to numbers', async function (assert) { + this.set('selectedElement', element('text')); + + await render(TEMPLATE); + await fillIn(labelled('X'), '42'); + + assert.deepEqual(lastChanges(), { x: 42 }, 'the value is a number, not a string'); + assert.strictEqual(updates[updates.length - 1].uuid, 'el-1', 'the element uuid is reported'); + }); + + test('clearing a numeric property reports null', async function (assert) { + this.set('selectedElement', element('text')); + + await render(TEMPLATE); + await fillIn(labelled('X'), ''); + + assert.deepEqual(lastChanges(), { x: null }); + }); + + test('non-numeric text in a number field is rejected by the input and reported as null', async function (assert) { + this.set('selectedElement', element('text')); + + await render(TEMPLATE); + const input = labelled('X'); + assert.strictEqual(input.type, 'number', 'position is a number field'); + + input.value = 'abc'; + input.dispatchEvent(new Event('change', { bubbles: true })); + + assert.strictEqual(input.value, '', 'the browser refuses to hold non-numeric text'); + assert.deepEqual(lastChanges(), { x: null }, 'which the component reports as a cleared value'); + }); + + test('a negative and a fractional value are both preserved', async function (assert) { + this.set('selectedElement', element('text')); + + await render(TEMPLATE); + + await fillIn(labelled('X'), '-15'); + assert.deepEqual(lastChanges(), { x: -15 }); + + await fillIn(labelled('X'), '12.5'); + assert.deepEqual(lastChanges(), { x: 12.5 }); + }); + + test('text properties are reported verbatim', async function (assert) { + this.set('selectedElement', element('image', { alt: '' })); + + await render(TEMPLATE); + await openSection('Image'); + await fillIn(labelled('Alt'), 'A photo'); + + assert.deepEqual(lastChanges(), { alt: 'A photo' }); + }); + + test('editing with no handler wired up does not throw', async function (assert) { + this.set('selectedElement', element('text')); + + await render(hbs``); + await fillIn(labelled('X'), '5'); + + assert.dom(this.element).exists('the panel survives with no callbacks'); + }); + }); + + module('element types', function () { + test('a text element shows its content section', async function (assert) { + this.set('selectedElement', element('text', { content: 'Hello' })); + + await render(TEMPLATE); + + assert.dom('textarea').exists('text content is editable'); + }); + + test('editing text content reports the new value', async function (assert) { + this.set('selectedElement', element('text', { content: 'Hello' })); + + await render(TEMPLATE); + await fillIn('textarea', 'Goodbye'); + + assert.deepEqual(lastChanges(), { content: 'Goodbye' }); + }); + + test('an image element shows the image section', async function (assert) { + this.set('selectedElement', element('image', { src: 'https://example.com/a.png' })); + + await render(TEMPLATE); + + assert.dom(this.element).containsText('Image'); + }); + + test('a table element shows the columns and data sections', async function (assert) { + this.set('selectedElement', element('table', { columns: [], rows: [] })); + + await render(TEMPLATE); + + assert.dom(this.element).containsText('Columns'); + }); + + test('a line element hides the border options', async function (assert) { + this.set('selectedElement', element('line')); + await render(TEMPLATE); + const lineText = this.element.textContent; + + this.set('selectedElement', element('shape')); + const shapeText = this.element.textContent; + + assert.notStrictEqual(lineText, shapeText, 'a line renders a different property set to a shape'); + }); + }); + + module('table columns and rows', function () { + function tableElement(overrides = {}) { + return element('table', { + columns: [ + { label: 'Name', key: 'name' }, + { label: 'Qty', key: 'qty' }, + ], + rows: [{ name: 'Widget', qty: '2' }], + ...overrides, + }); + } + + test('adding a column appends an empty column', async function (assert) { + this.set('selectedElement', tableElement()); + + await render(TEMPLATE); + await openSection('Columns'); + const addColumn = findAll('button').find((b) => /add column/i.test(b.textContent)); + await click(addColumn); + + assert.strictEqual(lastChanges().columns.length, 3); + assert.deepEqual(lastChanges().columns[2], { label: '', key: '' }); + }); + + test('adding a row seeds a cell for every keyed column', async function (assert) { + this.set('selectedElement', tableElement()); + + await render(TEMPLATE); + await openSection('Data Source'); + const addRow = findAll('button').find((b) => /add row/i.test(b.textContent)); + await click(addRow); + + assert.deepEqual(lastChanges().rows[1], { name: '', qty: '' }, 'the new row has a slot per column'); + }); + + test('a column with no key contributes no cell to a new row', async function (assert) { + this.set('selectedElement', tableElement({ columns: [{ label: 'Unset', key: '' }], rows: [] })); + + await render(TEMPLATE); + await openSection('Data Source'); + const addRow = findAll('button').find((b) => /add row/i.test(b.textContent)); + await click(addRow); + + assert.deepEqual(lastChanges().rows[0], {}, 'an unkeyed column is skipped'); + }); + }); + + module('table data mode', function () { + test('switching to manual clears the variable and query fields', async function (assert) { + this.set('selectedElement', element('table', { columns: [], rows: [], data_source_mode: 'query', query_endpoint: '/x' })); + + await render(TEMPLATE); + await openSection('Data Source'); + const manual = findAll('button').find((b) => b.textContent.trim() === 'Manual'); + await click(manual); + + const changes = lastChanges(); + assert.strictEqual(changes.data_source_mode, 'manual'); + assert.strictEqual(changes.data_source, null); + assert.strictEqual(changes.query_endpoint, null); + assert.deepEqual(changes.query_params, []); + }); + + test('switching to variable clears only the query fields', async function (assert) { + this.set('selectedElement', element('table', { columns: [], rows: [], data_source_mode: 'manual' })); + + await render(TEMPLATE); + await openSection('Data Source'); + const variable = findAll('button').find((b) => b.textContent.trim() === 'Variable'); + await click(variable); + + const changes = lastChanges(); + assert.strictEqual(changes.data_source_mode, 'variable'); + assert.strictEqual(changes.query_endpoint, null); + assert.notOk('data_source' in changes, 'the variable field itself is preserved'); + }); + }); + + module('canvas settings', function () { + test('template properties are reported through onUpdateTemplate', async function (assert) { + this.set('template', { width: 210, height: 297, unit: 'mm', background_color: '#ffffff' }); + + await render(TEMPLATE); + + const nameInput = find('input[type="text"].tb-input'); + assert.ok(nameInput, 'the canvas settings panel offers a template name field'); + + await fillIn(nameInput, 'Invoice A4'); + + assert.strictEqual(templateUpdates.length, 1, 'the template change is reported'); + assert.strictEqual(templateUpdates[0].name, 'Invoice A4', 'with the new value'); + }); + }); + + module('variable picker', function () { + test('it appends the chosen variable to the current value', async function (assert) { + const opened = []; + this.set('selectedElement', element('text', { content: 'Hello ' })); + this.set('onOpenVariablePicker', (prop, callback) => opened.push([prop, callback])); + + await render(TEMPLATE); + + const pickerButton = findAll('button').find((b) => /variable/i.test(b.textContent) || /\{\}/.test(b.textContent)); + assert.ok(pickerButton, 'a text element offers a variable picker affordance'); + + await click(pickerButton); + assert.strictEqual(opened.length, 1, 'the picker is opened for a target property'); + + const [prop, callback] = opened[0]; + callback('{{name}}'); + + assert.deepEqual(lastChanges(), { [prop]: 'Hello {{name}}' }, 'the variable is appended, not replacing'); + }); + }); + + // ------------------------------------------------------------------------- + // Appended coverage: table columns, rows and data source; image src helpers. + // ------------------------------------------------------------------------- + + function inputsByPlaceholder(placeholder) { + return findAll(`input[placeholder="${placeholder}"]`); + } + + function buttonWithText(text) { + return findAll('button').find((button) => button.textContent.trim().toLowerCase() === text.toLowerCase()); + } + + function buttonsByTitle(title) { + return findAll(`button[title="${title}"]`); + } + + module('table columns', function (hooks) { + hooks.beforeEach(async function () { + this.set('selectedElement', element('table', { columns: [], rows: [] })); + + await render(TEMPLATE); + await openSection('Columns'); + }); + + test('a table with no columns says so', async function (assert) { + assert.dom(this.element).containsText('No columns defined. Add one below.'); + }); + + test('a column can be added', async function (assert) { + await click(buttonWithText('Add column')); + + assert.deepEqual(lastChanges(), { columns: [{ label: '', key: '' }] }); + }); + + test('an existing column is rendered for editing', async function (assert) { + this.set('selectedElement', element('table', { columns: [{ label: 'Item', key: 'name' }], rows: [] })); + await openSection('Columns'); + + assert.dom(inputsByPlaceholder('Column label')[0]).hasValue('Item'); + assert.dom(inputsByPlaceholder('data.key')[0]).hasValue('name'); + }); + + test('a column label can be renamed', async function (assert) { + this.set('selectedElement', element('table', { columns: [{ label: 'Item', key: 'name' }], rows: [] })); + await openSection('Columns'); + await fillIn(inputsByPlaceholder('Column label')[0], 'Product'); + + assert.deepEqual(lastChanges(), { columns: [{ label: 'Product', key: 'name' }] }); + }); + + test('renaming a column key renames it in every row', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [{ label: 'Item', key: 'name' }], + rows: [{ name: 'Widget' }, { name: 'Gadget' }], + }) + ); + await openSection('Columns'); + await fillIn(inputsByPlaceholder('data.key')[0], 'title'); + + assert.deepEqual(lastChanges(), { + columns: [{ label: 'Item', key: 'title' }], + rows: [{ title: 'Widget' }, { title: 'Gadget' }], + }); + }); + + test('renaming a key to itself leaves the rows alone', async function (assert) { + this.set('selectedElement', element('table', { columns: [{ label: 'Item', key: 'name' }], rows: [{ name: 'Widget' }] })); + await openSection('Columns'); + await fillIn(inputsByPlaceholder('data.key')[0], 'name'); + + assert.deepEqual(lastChanges().rows, [{ name: 'Widget' }]); + }); + + test('removing a column drops its data from every row', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [ + { label: 'Item', key: 'name' }, + { label: 'Qty', key: 'qty' }, + ], + rows: [{ name: 'Widget', qty: '2' }], + }) + ); + await openSection('Columns'); + await click(buttonsByTitle('Remove column')[0]); + + assert.deepEqual(lastChanges(), { + columns: [{ label: 'Qty', key: 'qty' }], + rows: [{ qty: '2' }], + }); + }); + + test('removing a keyless column leaves the rows untouched', async function (assert) { + this.set('selectedElement', element('table', { columns: [{ label: 'Unnamed' }], rows: [{ name: 'Widget' }] })); + await openSection('Columns'); + await click(buttonsByTitle('Remove column')[0]); + + assert.deepEqual(lastChanges(), { columns: [], rows: [{ name: 'Widget' }] }); + }); + }); + + module('table data source', function (hooks) { + hooks.beforeEach(async function () { + this.set( + 'selectedElement', + element('table', { + columns: [ + { label: 'Item', key: 'name' }, + { label: 'Qty', key: 'qty' }, + ], + rows: [], + }) + ); + + await render(TEMPLATE); + await openSection('Data Source'); + }); + + test('it offers a variable and a manual mode', async function (assert) { + assert.ok(buttonWithText('Variable'), 'a variable mode is offered'); + assert.ok(buttonWithText('Manual'), 'a manual mode is offered'); + }); + + // The panel is controlled: setTableDataMode only reports the change, so the mode has + // to be set on the element itself for variable mode to render. + test('switching to variable mode clears the query fields', async function (assert) { + await click(buttonWithText('Variable')); + + assert.deepEqual(lastChanges(), { + data_source_mode: 'variable', + query_endpoint: null, + query_params: [], + query_response_path: null, + }); + }); + + test('switching to manual mode also clears the data variable', async function (assert) { + await click(buttonWithText('Manual')); + + assert.deepEqual(lastChanges(), { + data_source_mode: 'manual', + data_source: null, + query_endpoint: null, + query_params: [], + query_response_path: null, + }); + }); + + test('a data variable can be typed', async function (assert) { + this.set('selectedElement', element('table', { columns: [{ label: 'Item', key: 'name' }], rows: [], data_source_mode: 'variable' })); + await openSection('Data Source'); + await fillIn(inputsByPlaceholder('{order.items}')[0], '{order.line_items}'); + + assert.deepEqual(lastChanges(), { data_source: '{order.line_items}' }); + }); + + test('the variable picker can be opened for the data source', async function (assert) { + const opened = []; + this.set('onOpenVariablePicker', (prop, callback) => opened.push([prop, callback])); + + this.set('selectedElement', element('table', { columns: [], rows: [], data_source_mode: 'variable' })); + await openSection('Data Source'); + await click(buttonsByTitle('Insert variable')[0]); + + assert.strictEqual(opened[0][0], 'data_source'); + }); + + test('manual mode says when there are no rows', async function (assert) { + await click(buttonWithText('Manual')); + + assert.dom(this.element).containsText('No rows yet.'); + }); + + test('adding a row seeds a cell for every column key', async function (assert) { + await click(buttonWithText('Manual')); + await click(buttonWithText('Add row')); + + assert.deepEqual(lastChanges(), { rows: [{ name: '', qty: '' }] }); + }); + + test('a column with no key contributes no cell', async function (assert) { + this.set('selectedElement', element('table', { columns: [{ label: 'Item', key: 'name' }, { label: 'Unnamed' }], rows: [] })); + await openSection('Data Source'); + await click(buttonWithText('Manual')); + await click(buttonWithText('Add row')); + + assert.deepEqual(lastChanges(), { rows: [{ name: '' }] }); + }); + + test('a row cell can be edited', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [{ label: 'Item', key: 'name' }], + rows: [{ name: 'Widget' }], + }) + ); + await openSection('Data Source'); + await click(buttonWithText('Manual')); + await fillIn(inputsByPlaceholder('name')[0], 'Gadget'); + + assert.deepEqual(lastChanges(), { rows: [{ name: 'Gadget' }] }); + }); + + test('a row can be removed', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [{ label: 'Item', key: 'name' }], + rows: [{ name: 'Widget' }, { name: 'Gadget' }], + }) + ); + await openSection('Data Source'); + await click(buttonWithText('Manual')); + await click(buttonsByTitle('Remove row')[1]); + + assert.deepEqual(lastChanges(), { rows: [{ name: 'Widget' }] }); + }); + + test('manual mode with no columns asks for columns first', async function (assert) { + this.set('selectedElement', element('table', { columns: [], rows: [{}] })); + await openSection('Data Source'); + await click(buttonWithText('Manual')); + + assert.dom(this.element).containsText('Define columns first.'); + }); + }); + + // Query mode fetches an arbitrary Fleetbase API path. It is deliberately a + // different mechanism from the saved queries the queries panel manages — + // those are reached through Variable mode, under `__queries__`. + module('table query mode', function (hooks) { + let requests; + let respond; + + function queryElement(overrides = {}) { + return element('table', { + columns: [], + rows: [], + data_source_mode: 'query', + query_endpoint: 'int/v1/orders', + ...overrides, + }); + } + + hooks.beforeEach(function () { + requests = []; + respond = () => []; + + this.owner.unregister('service:fetch'); + this.owner.register( + 'service:fetch', + class extends Service { + get(path, params, options) { + requests.push({ path, params, options }); + return Promise.resolve(respond(path, params)); + } + } + ); + }); + + async function renderQueryMode(context, overrides = {}) { + context.set('selectedElement', queryElement(overrides)); + await render(TEMPLATE); + await openSection('Data Source'); + } + + function testButton() { + return find('.tb-query-test'); + } + + module('the mode toggle', function () { + test('the data source toggle offers a query mode', async function (assert) { + await renderQueryMode(this); + + assert.ok(buttonWithText('Query'), 'a query mode is offered alongside variable and manual'); + }); + + test('switching to query clears the data variable and seeds the params array', async function (assert) { + this.set('selectedElement', element('table', { columns: [], rows: [], data_source_mode: 'variable', data_source: '{order.items}' })); + await render(TEMPLATE); + await openSection('Data Source'); + await click(buttonWithText('Query')); + + assert.deepEqual(lastChanges(), { + data_source_mode: 'query', + data_source: null, + query_params: [], + }); + }); + + test('switching to query leaves params that are already there alone', async function (assert) { + this.set('selectedElement', element('table', { columns: [], rows: [], data_source_mode: 'manual', query_params: [{ key: 'status', value: 'completed' }] })); + await render(TEMPLATE); + await openSection('Data Source'); + await click(buttonWithText('Query')); + + assert.deepEqual(lastChanges(), { data_source_mode: 'query', data_source: null }, 'no query_params key, so the existing ones survive'); + }); + + test('query mode is what the panel shows for a query-backed table', async function (assert) { + await renderQueryMode(this); + + assert.ok(find('.tb-query-endpoint'), 'the endpoint field is shown'); + assert.notOk(inputsByPlaceholder('{order.items}').length, 'the variable field is not'); + }); + }); + + module('the endpoint field', function () { + test('an endpoint can be typed', async function (assert) { + await renderQueryMode(this, { query_endpoint: '' }); + await fillIn('.tb-query-endpoint', 'int/v1/reports/revenue'); + + assert.deepEqual(lastChanges(), { query_endpoint: 'int/v1/reports/revenue' }); + }); + + test('a relative path is accepted without complaint', async function (assert) { + await renderQueryMode(this, { query_endpoint: 'int/v1/orders' }); + + assert.notOk(find('.tb-query-endpoint-error'), 'no error is shown'); + assert.dom(this.element).containsText('A path on the Fleetbase API'); + }); + + test('a full URL is rejected, because the request would carry the session', async function (assert) { + await renderQueryMode(this, { query_endpoint: 'https://evil.example.com/orders' }); + + assert.dom('.tb-query-endpoint-error').containsText('not a full URL'); + assert.dom('.tb-query-endpoint-error').containsText('session credentials'); + }); + + test('a protocol-relative URL is rejected too', async function (assert) { + await renderQueryMode(this, { query_endpoint: '//evil.example.com/orders' }); + + assert.dom('.tb-query-endpoint-error').exists('a leading // is still another host'); + }); + + test('an empty endpoint is not reported as an error until it is tested', async function (assert) { + await renderQueryMode(this, { query_endpoint: ' ' }); + + assert.notOk(find('.tb-query-endpoint-error'), 'nothing to correct yet'); + }); + }); + + module('query parameters', function () { + test('an element with no params says so', async function (assert) { + await renderQueryMode(this); + + assert.dom(this.element).containsText('No parameters.'); + }); + + test('a parameter can be added', async function (assert) { + await renderQueryMode(this); + await click(buttonWithText('Add parameter')); + + assert.deepEqual(lastChanges(), { query_params: [{ key: '', value: '' }] }); + }); + + test('a parameter is appended to the ones already there', async function (assert) { + await renderQueryMode(this, { query_params: [{ key: 'status', value: 'completed' }] }); + await click(buttonWithText('Add parameter')); + + assert.deepEqual(lastChanges(), { + query_params: [ + { key: 'status', value: 'completed' }, + { key: '', value: '' }, + ], + }); + }); + + test('a parameter key can be edited', async function (assert) { + await renderQueryMode(this, { query_params: [{ key: 'status', value: 'completed' }] }); + await fillIn(findAll('.tb-query-param-key')[0], 'state'); + + assert.deepEqual(lastChanges(), { query_params: [{ key: 'state', value: 'completed' }] }); + }); + + test('a parameter value can be edited', async function (assert) { + await renderQueryMode(this, { query_params: [{ key: 'status', value: 'completed' }] }); + await fillIn(findAll('.tb-query-param-value')[0], '{order.status}'); + + assert.deepEqual(lastChanges(), { query_params: [{ key: 'status', value: '{order.status}' }] }); + }); + + test('editing one parameter leaves the others alone', async function (assert) { + await renderQueryMode(this, { + query_params: [ + { key: 'status', value: 'completed' }, + { key: 'limit', value: '10' }, + ], + }); + await fillIn(findAll('.tb-query-param-value')[1], '25'); + + assert.deepEqual(lastChanges(), { + query_params: [ + { key: 'status', value: 'completed' }, + { key: 'limit', value: '25' }, + ], + }); + }); + + test('a parameter can be removed', async function (assert) { + await renderQueryMode(this, { + query_params: [ + { key: 'status', value: 'completed' }, + { key: 'limit', value: '10' }, + ], + }); + await click(buttonsByTitle('Remove parameter')[0]); + + assert.deepEqual(lastChanges(), { query_params: [{ key: 'limit', value: '10' }] }); + }); + }); + + module('the response path field', function () { + test('a response path can be typed', async function (assert) { + await renderQueryMode(this); + await fillIn('.tb-query-response-path', 'data.results'); + + assert.deepEqual(lastChanges(), { query_response_path: 'data.results' }); + }); + }); + + module('testing the query', function () { + test('a successful test reports the row count', async function (assert) { + respond = () => [{ id: 1 }, { id: 2 }]; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 2 rows.'); + }); + + test('a single row is reported in the singular', async function (assert) { + respond = () => [{ id: 1 }]; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 1 row.'); + }); + + test('the endpoint is sent to the fetch service with its leading slash stripped', async function (assert) { + await renderQueryMode(this, { query_endpoint: ' /int/v1/orders ' }); + await click(testButton()); + + assert.strictEqual(requests.length, 1, 'exactly one request is made'); + assert.strictEqual(requests[0].path, 'int/v1/orders'); + }); + + test('named parameters are sent as the query string', async function (assert) { + await renderQueryMode(this, { + query_params: [ + { key: ' status ', value: 'completed' }, + { key: 'limit', value: '10' }, + ], + }); + await click(testButton()); + + assert.deepEqual(requests[0].params, { status: 'completed', limit: '10' }); + }); + + test('a parameter with no key is not sent', async function (assert) { + await renderQueryMode(this, { + query_params: [{ key: '', value: 'blank' }, { value: 'keyless' }, { key: ' ', value: 'whitespace' }, { key: 'limit', value: '10' }], + }); + await click(testButton()); + + assert.deepEqual(requests[0].params, { limit: '10' }); + }); + + test('a parameter with no value at all is sent as an empty string', async function (assert) { + await renderQueryMode(this, { query_params: [{ key: 'status' }] }); + await click(testButton()); + + assert.deepEqual(requests[0].params, { status: '' }); + }); + + test('a parameter still holding a variable token is left out and named', async function (assert) { + await renderQueryMode(this, { + query_params: [ + { key: 'order', value: '{order.uuid}' }, + { key: 'limit', value: '10' }, + ], + }); + await click(testButton()); + + assert.deepEqual(requests[0].params, { limit: '10' }, 'the token cannot be resolved here, so it is not sent'); + assert.dom('.tb-query-test-skipped').containsText('order'); + }); + + test('a test with nothing skipped says nothing about skipping', async function (assert) { + await renderQueryMode(this, { query_params: [{ key: 'limit', value: '10' }] }); + await click(testButton()); + + assert.notOk(find('.tb-query-test-skipped')); + }); + + test('testing an empty endpoint asks for one rather than firing a request', async function (assert) { + await renderQueryMode(this, { query_endpoint: '' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('Enter an API endpoint'); + assert.strictEqual(requests.length, 0, 'no request is made'); + }); + + test('testing an element that has never had an endpoint asks for one too', async function (assert) { + await renderQueryMode(this, { query_endpoint: undefined }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('Enter an API endpoint'); + assert.strictEqual(requests.length, 0); + }); + + test('testing a full URL refuses rather than firing a request', async function (assert) { + await renderQueryMode(this, { query_endpoint: 'https://evil.example.com/orders' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('not a full URL'); + assert.strictEqual(requests.length, 0, 'the session is never sent to another host'); + }); + + test('the button reports that a test is in flight', async function (assert) { + let release; + respond = () => new Promise((resolve) => (release = resolve)); + + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test').containsText('Testing'); + assert.dom('.tb-query-test').isDisabled(); + + release([{ id: 1 }]); + await settled(); + + assert.dom('.tb-query-test').containsText('Test query', 'and stops when it lands'); + assert.dom('.tb-query-test').isNotDisabled(); + }); + + test('a rejected request is reported with its message', async function (assert) { + respond = () => Promise.reject(new Error('403 Forbidden')); + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('The request failed: 403 Forbidden'); + assert.dom('.tb-query-test').isNotDisabled('the in-flight state is cleared'); + }); + + test('a rejection with no message still reports a failure', async function (assert) { + respond = () => Promise.reject(new Error('')); + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-error').hasText('The request failed.'); + }); + + test('a rejection that is not an error at all still reports a failure', async function (assert) { + respond = () => Promise.reject(); + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-error').hasText('The request failed.'); + }); + + test('a later test replaces the earlier error', async function (assert) { + respond = () => Promise.reject(new Error('boom')); + await renderQueryMode(this); + await click(testButton()); + assert.dom('.tb-query-test-error').exists(); + + respond = () => [{ id: 1 }]; + await click(testButton()); + + assert.notOk(find('.tb-query-test-error'), 'the stale error is gone'); + assert.dom('.tb-query-test-result').containsText('Returned 1 row.'); + }); + }); + + module('resolving the response path', function () { + test('a dotted path is walked into the response', async function (assert) { + respond = () => ({ data: { results: [{ id: 1 }, { id: 2 }, { id: 3 }] } }); + await renderQueryMode(this, { query_response_path: 'data.results' }); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 3 rows.'); + }); + + test('surrounding whitespace in the path is ignored', async function (assert) { + respond = () => ({ data: { results: [{ id: 1 }] } }); + await renderQueryMode(this, { query_response_path: ' data . results ' }); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 1 row.'); + }); + + test('a missing segment is named', async function (assert) { + respond = () => ({ data: {} }); + await renderQueryMode(this, { query_response_path: 'data.results' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('no "results" under "data"'); + }); + + test('a missing first segment says it is missing from the response', async function (assert) { + respond = () => ({ other: [] }); + await renderQueryMode(this, { query_response_path: 'data' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('no "data" in the response'); + }); + + test('a path that runs into a primitive says where it stopped', async function (assert) { + respond = () => ({ data: 'nope' }); + await renderQueryMode(this, { query_response_path: 'data.results' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('"data" is a string, not an object'); + }); + + test('a path against a null response body says so', async function (assert) { + respond = () => null; + await renderQueryMode(this, { query_response_path: 'data' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('the response body is null, not an object'); + }); + + test('a path against an empty response body says so', async function (assert) { + respond = () => undefined; + await renderQueryMode(this, { query_response_path: 'data' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('the response body is nothing, not an object'); + }); + + test('a path that lands on something other than an array is named', async function (assert) { + respond = () => ({ data: { count: 4 } }); + await renderQueryMode(this, { query_response_path: 'data' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('Expected an array of rows at "data", got an object.'); + }); + + test('a response that is not an array, with no path, is named too', async function (assert) { + respond = () => ({ count: 4 }); + await renderQueryMode(this, { query_response_path: '' }); + await click(testButton()); + + assert.dom('.tb-query-test-error').containsText('Expected an array of rows in the response, got an object.'); + }); + + test('an element with no response path at all treats the body as the rows', async function (assert) { + respond = () => [{ id: 1 }, { id: 2 }]; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 2 rows.'); + }); + }); + + module('discovered columns', function () { + test('the keys of the returned rows are listed', async function (assert) { + respond = () => [{ id: 1, name: 'Widget' }]; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-keys').containsText('id, name'); + }); + + test('keys are the union across rows, in the order first seen', async function (assert) { + respond = () => [{ id: 1 }, { id: 2, name: 'Widget' }, { name: 'Gadget', qty: 3 }]; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-keys').containsText('id, name, qty'); + }); + + test('rows that are not plain objects contribute no keys', async function (assert) { + respond = () => ['a string', null, ['nested'], { id: 1 }]; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-keys').hasText('Keys: id'); + }); + + test('only the first twenty rows are scanned for keys', async function (assert) { + const rows = Array.from({ length: 21 }, (_, index) => (index === 20 ? { late: true } : { id: index })); + respond = () => rows; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 21 rows.'); + assert.dom('.tb-query-test-keys').hasText('Keys: id', 'the twenty-first row is past the scan limit'); + }); + + test('rows with no keys at all offer nothing to apply', async function (assert) { + respond = () => [{}, {}]; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 2 rows.'); + assert.notOk(find('.tb-query-apply-columns'), 'there is nothing to turn into a column'); + }); + + test('an empty result offers nothing to apply', async function (assert) { + respond = () => []; + await renderQueryMode(this); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 0 rows.'); + assert.notOk(find('.tb-query-apply-columns')); + }); + + test('the discovered keys can be applied as the table columns', async function (assert) { + respond = () => [{ id: 1, total_amount: 20, createdAt: 'today' }]; + await renderQueryMode(this); + await click(testButton()); + await click('.tb-query-apply-columns'); + + assert.deepEqual(lastChanges(), { + columns: [ + { label: 'Id', key: 'id' }, + { label: 'Total Amount', key: 'total_amount' }, + { label: 'Created At', key: 'createdAt' }, + ], + }); + }); + + test('applying columns replaces the ones already defined', async function (assert) { + respond = () => [{ id: 1 }]; + await renderQueryMode(this, { columns: [{ label: 'Old', key: 'old' }] }); + await click(testButton()); + await click('.tb-query-apply-columns'); + + assert.deepEqual(lastChanges(), { columns: [{ label: 'Id', key: 'id' }] }); + }); + }); + + module('test results and the selection', function () { + test('switching data mode discards the results', async function (assert) { + respond = () => [{ id: 1 }]; + await renderQueryMode(this); + await click(testButton()); + assert.dom('.tb-query-test-result').exists(); + + await click(buttonWithText('Query')); + + assert.notOk(find('.tb-query-test-result'), 'the results belonged to the previous configuration'); + }); + + test("one element's results are not shown against another", async function (assert) { + respond = () => [{ id: 1 }]; + await renderQueryMode(this); + await click(testButton()); + assert.dom('.tb-query-test-result').exists(); + + this.set('selectedElement', { ...queryElement(), uuid: 'el-2' }); + await settled(); + + assert.notOk(find('.tb-query-test-result'), 'the second table has not been tested'); + }); + + test("one element's error is not shown against another", async function (assert) { + respond = () => Promise.reject(new Error('boom')); + await renderQueryMode(this); + await click(testButton()); + assert.dom('.tb-query-test-error').exists(); + + this.set('selectedElement', { ...queryElement(), uuid: 'el-2' }); + await settled(); + + assert.notOk(find('.tb-query-test-error')); + }); + }); + + module('without an update handler', function () { + const UNWIRED = hbs``; + + async function renderUnwired(context, overrides = {}) { + context.set('selectedElement', queryElement(overrides)); + await render(UNWIRED); + await openSection('Data Source'); + } + + test('switching mode is inert', async function (assert) { + await renderUnwired(this); + await click(buttonWithText('Manual')); + + assert.deepEqual(updates, []); + }); + + test('adding a parameter is inert', async function (assert) { + await renderUnwired(this); + await click(buttonWithText('Add parameter')); + + assert.deepEqual(updates, []); + }); + + test('editing a parameter is inert', async function (assert) { + await renderUnwired(this, { query_params: [{ key: 'status', value: 'completed' }] }); + await fillIn(findAll('.tb-query-param-key')[0], 'state'); + + assert.deepEqual(updates, []); + }); + + test('removing a parameter is inert', async function (assert) { + await renderUnwired(this, { query_params: [{ key: 'status', value: 'completed' }] }); + await click(buttonsByTitle('Remove parameter')[0]); + + assert.deepEqual(updates, []); + }); + + test('applying discovered columns is inert', async function (assert) { + respond = () => [{ id: 1 }]; + await renderUnwired(this); + await click(testButton()); + + assert.dom('.tb-query-test-result').containsText('Returned 1 row.', 'the query can still be tested'); + + await click('.tb-query-apply-columns'); + + assert.deepEqual(updates, []); + }); + + test('testing with nothing selected does nothing at all', async function (assert) { + this.set('selectedElement', queryElement()); + await render(UNWIRED); + await openSection('Data Source'); + + this.set('selectedElement', null); + await settled(); + + assert.notOk(find('.tb-query-test'), 'there is no panel to test from'); + assert.strictEqual(requests.length, 0); + }); + }); + }); + + module('the image source', function (hooks) { + hooks.beforeEach(function () { + this.set('selectedElement', element('image', { src: '' })); + }); + + test('an empty image offers an upload', async function (assert) { + await render(TEMPLATE); + await openSection('Image'); + + assert.strictEqual(buttonsByTitle('Clear').length, 0, 'nothing to clear yet'); + }); + + test('a variable source is shown as a variable', async function (assert) { + this.set('selectedElement', element('image', { src: '{order.logo}' })); + + await render(TEMPLATE); + await openSection('Image'); + + assert.true(buttonsByTitle('Clear').length > 0, 'the variable can be cleared'); + assert.true(buttonsByTitle('Change variable').length > 0, 'and swapped'); + }); + + test('an uploaded source is shown as a file', async function (assert) { + this.set('selectedElement', element('image', { src: 'https://files.example.com/logo.png' })); + + await render(TEMPLATE); + await openSection('Image'); + + assert.true(buttonsByTitle('Clear').length > 0); + assert.strictEqual(buttonsByTitle('Change variable').length, 0, 'it is not treated as a variable'); + }); + + test('the source can be cleared', async function (assert) { + this.set('selectedElement', element('image', { src: '{order.logo}' })); + + await render(TEMPLATE); + await openSection('Image'); + await click(buttonsByTitle('Clear')[0]); + + assert.deepEqual(lastChanges(), { src: '' }); + }); + + test('the variable picker can be opened for the image source', async function (assert) { + const opened = []; + this.set('onOpenVariablePicker', (prop, callback) => opened.push([prop, callback])); + this.set('selectedElement', element('image', { src: '{order.logo}' })); + + await render(TEMPLATE); + await openSection('Image'); + await click(buttonsByTitle('Change variable')[0]); + + assert.strictEqual(opened[0][0], 'src'); + }); + }); + + test('it forwards splattributes', async function (assert) { + this.set('selectedElement', element('text')); + + await render(hbs``); + + assert.dom('[data-test-panel="yes"]').exists(); + }); + + module('table column editing', function () { + function columnInputs() { + return findAll('input[placeholder="Column label"]'); + } + + function keyInputs() { + return findAll('input[placeholder="data.key"]'); + } + + function addColumnButton() { + return findAll('button').find((button) => button.textContent.trim() === 'Add column'); + } + + test('a table with no columns explains itself and offers an add control', async function (assert) { + this.set('selectedElement', element('table', { columns: [] })); + + await render(TEMPLATE); + await openSection('Columns'); + + assert.dom(this.element).containsText('No columns defined'); + assert.ok(addColumnButton(), 'an add control is offered'); + }); + + test('adding a column appends an empty one', async function (assert) { + this.set('selectedElement', element('table', { columns: [{ label: 'Name', key: 'name' }] })); + + await render(TEMPLATE); + await openSection('Columns'); + await click(addColumnButton()); + + assert.deepEqual(lastChanges(), { + columns: [ + { label: 'Name', key: 'name' }, + { label: '', key: '' }, + ], + }); + }); + + test('renaming a column reports only that column', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [ + { label: 'Name', key: 'name' }, + { label: 'Status', key: 'status' }, + ], + }) + ); + + await render(TEMPLATE); + await openSection('Columns'); + await fillIn(columnInputs()[1], 'State'); + + assert.deepEqual(lastChanges(), { + columns: [ + { label: 'Name', key: 'name' }, + { label: 'State', key: 'status' }, + ], + }); + }); + + test('changing a column key renames that key in every existing row', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [ + { label: 'Name', key: 'name' }, + { label: 'Status', key: 'status' }, + ], + rows: [ + { name: 'Ada', status: 'active' }, + { name: 'Grace', status: 'archived' }, + ], + }) + ); + + await render(TEMPLATE); + await openSection('Columns'); + await fillIn(keyInputs()[0], 'driver.name'); + + assert.deepEqual(lastChanges(), { + columns: [ + { label: 'Name', key: 'driver.name' }, + { label: 'Status', key: 'status' }, + ], + rows: [ + { 'driver.name': 'Ada', status: 'active' }, + { 'driver.name': 'Grace', status: 'archived' }, + ], + }); + }); + + test('a column key set to the same value leaves the rows alone', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [{ label: 'Name', key: 'name' }], + rows: [{ name: 'Ada' }], + }) + ); + + await render(TEMPLATE); + await openSection('Columns'); + await fillIn(keyInputs()[0], 'name'); + + assert.deepEqual(lastChanges(), { + columns: [{ label: 'Name', key: 'name' }], + rows: [{ name: 'Ada' }], + }); + }); + + test('removing a column drops its key from every row too', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [ + { label: 'Name', key: 'name' }, + { label: 'Status', key: 'status' }, + ], + rows: [{ name: 'Ada', status: 'active' }], + }) + ); + + await render(TEMPLATE); + await openSection('Columns'); + + const removeButtons = findAll('button[title="Remove column"]'); + await click(removeButtons[0]); + + assert.deepEqual(lastChanges(), { + columns: [{ label: 'Status', key: 'status' }], + rows: [{ status: 'active' }], + }); + }); + + test('removing a keyless column leaves the rows untouched', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [ + { label: 'Spacer', key: '' }, + { label: 'Status', key: 'status' }, + ], + rows: [{ status: 'active' }], + }) + ); + + await render(TEMPLATE); + await openSection('Columns'); + + const removeButtons = findAll('button[title="Remove column"]'); + await click(removeButtons[0]); + + assert.deepEqual(lastChanges(), { + columns: [{ label: 'Status', key: 'status' }], + rows: [{ status: 'active' }], + }); + }); + + test('column edits are inert without an onUpdateElement handler', async function (assert) { + this.set('selectedElement', element('table', { columns: [{ label: 'Name', key: 'name' }] })); + + await render(hbs``); + await openSection('Columns'); + await click(addColumnButton()); + + assert.deepEqual(updates, [], 'nothing is reported when no handler is supplied'); + }); + }); + + module('the image source picker', function () { + function clearButton() { + return findAll('button[title="Clear"]')[0]; + } + + test('a variable source is shown as a token that can be cleared', async function (assert) { + this.set('selectedElement', element('image', { src: '{{order.photo_url}}' })); + + await render(TEMPLATE); + await openSection('Image'); + + assert.dom(this.element).containsText('{{order.photo_url}}', 'the variable token is shown'); + + await click(clearButton()); + + assert.deepEqual(lastChanges(), { src: '' }); + }); + + test('an uploaded source is shown with a clear control', async function (assert) { + this.set('selectedElement', element('image', { src: 'https://example.test/logo.png' })); + + await render(TEMPLATE); + await openSection('Image'); + + await click(clearButton()); + + assert.deepEqual(lastChanges(), { src: '' }); + }); + + test('an empty image offers an upload dropzone', async function (assert) { + this.set('selectedElement', element('image', { src: '' })); + + await render(TEMPLATE); + await openSection('Image'); + + assert.dom('input[type="file"]').exists('a file input is offered'); + }); + + test('clearing is inert without an onUpdateElement handler', async function (assert) { + this.set('selectedElement', element('image', { src: 'https://example.test/logo.png' })); + + await render(hbs``); + await openSection('Image'); + await click(clearButton()); + + assert.deepEqual(updates, [], 'nothing is reported when no handler is supplied'); + }); + }); + + module('uploading an image', function () { + // onImageFileAdded is wired to , so a real File dropped on the + // dropzone drives it end to end through the queue. + async function dropImage(name = 'logo.png') { + await selectFiles('input[type="file"]', new File(['binary'], name, { type: 'image/png' })); + } + + test('a dropped image is uploaded and its url written back', async function (assert) { + this.set('selectedElement', element('image', { src: '' })); + + await render(TEMPLATE); + await openSection('Image'); + await dropImage(); + + const fetch = this.owner.lookup('service:fetch'); + const upload = fetch.calls.find((call) => call.method === 'uploadFile.perform'); + assert.ok(upload, 'the file is handed to the upload task'); + assert.deepEqual(upload.args[1], { path: 'uploads/template-builder/images', type: 'template_image' }, 'it is filed under the template-builder images path'); + assert.deepEqual(lastChanges(), { src: 'https://example.test/uploads/test-file.txt' }, 'the uploaded url is written onto the element'); + }); + + test('a failed upload is reported and the filename cleared', async function (assert) { + const errors = []; + const notifications = this.owner.lookup('service:notifications'); + notifications.error = (message) => errors.push(message); + + const fetch = this.owner.lookup('service:fetch'); + fetch.uploadFile = { perform: () => Promise.reject(new Error('disk full')) }; + + this.set('selectedElement', element('image', { src: '' })); + + await render(TEMPLATE); + await openSection('Image'); + await dropImage(); + + assert.deepEqual(errors, ['Image upload failed: disk full']); + assert.deepEqual(updates, [], 'nothing is written onto the element'); + }); + }); + // Every table editing action opens with the same guard, and the only way to reach the other + // side of it is to render the panel with a selection but no @onUpdateElement — the controls + // are all present and clickable, they just have nowhere to report to. + module('the table editors without an update handler', function () { + function buttonWithText(text) { + return findAll('button').find((button) => button.textContent.trim() === text); + } + + test('every table control is inert when no handler is supplied', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [{ label: 'Name', key: 'name' }], + rows: [{ name: 'Ada' }], + }) + ); + + await render(hbs``); + + await openSection('Columns'); + await click(buttonWithText('Add column')); + await fillIn('input[placeholder="Column label"]', 'Renamed'); + await fillIn('input[placeholder="data.key"]', 'renamed_key'); + await click(find('button[title="Remove column"]')); + + await openSection('Data'); + await click(buttonWithText('Variable')); + await click(buttonWithText('Manual')); + await click(buttonWithText('Add row')); + await click(find('button[title="Remove row"]')); + const cell = findAll('input[placeholder="name"]')[0]; + if (cell) { + await fillIn(cell, 'Grace'); + } + + assert.deepEqual(updates, [], 'not one of them reported a change'); + }); + }); + // The remaining guards all sit in front of an optional callback. Each of these renders the + // panel with the control present but the callback missing, which is the only way to reach + // the other side of the guard. + module('the element editors without their handlers', function () { + test('text and numeric edits are inert without an onUpdateElement handler', async function (assert) { + this.set('selectedElement', element('text', { content: 'Hello' })); + + await render(hbs``); + await openSection('Position'); + await fillIn(labelled('X'), '42'); + await openSection('Content'); + await fillIn('textarea', 'Goodbye'); + + assert.deepEqual(updates, [], 'neither edit is reported'); + }); + + test('canvas settings are inert without an onUpdateTemplate handler', async function (assert) { + this.set('template', { width: 210, height: 297, unit: 'mm' }); + + await render(hbs``); + const select = find('select'); + if (select) { + await fillIn(select, select.options[select.options.length - 1].value); + } + + assert.deepEqual(templateUpdates, [], 'nothing is reported to the template'); + }); + + test('the variable buttons are inert without an onOpenVariablePicker handler', async function (assert) { + this.set('selectedElement', element('text', { content: 'Hello' })); + + await render(hbs``); + await openSection('Content'); + await click(findAll('button').find((button) => button.textContent.includes('Insert variable'))); + + assert.deepEqual(updates, [], 'no picker is opened and nothing is written'); + }); + + test('a variable chosen from the picker is dropped when there is no update handler', async function (assert) { + let opened = null; + this.set('selectedElement', element('text', { content: 'Hello ' })); + this.set('onOpenVariablePicker', (targetProp, callback) => { + opened = targetProp; + callback('{{order.id}}'); + }); + + await render(hbs``); + await openSection('Content'); + await click(findAll('button').find((button) => button.textContent.includes('Insert variable'))); + + assert.strictEqual(opened, 'content', 'the picker is still asked to open'); + assert.deepEqual(updates, [], 'but the chosen variable has nowhere to go'); + }); + + test('an uploaded image url is dropped when there is no update handler', async function (assert) { + this.set('selectedElement', element('image', { src: '' })); + + await render(hbs``); + await openSection('Image'); + await selectFiles('input[type="file"]', new File(['binary'], 'logo.png', { type: 'image/png' })); + + const fetch = this.owner.lookup('service:fetch'); + assert.ok( + fetch.calls.find((call) => call.method === 'uploadFile.perform'), + 'the upload still happens' + ); + assert.deepEqual(updates, [], 'the returned url has nowhere to go'); + }); + }); + module('the line and shape editors', function () { + test('a line element offers its stroke styles', async function (assert) { + this.set('selectedElement', element('line', { line_style: 'dashed', line_width: 2 })); + + await render(TEMPLATE); + await openSection('Line'); + + const styles = findAll('select option').map((option) => option.textContent.trim()); + assert.true(styles.includes('Solid'), 'solid is offered'); + assert.true(styles.includes('Dashed')); + assert.true(styles.includes('Dotted')); + }); + + test('a shape element offers rectangle and circle', async function (assert) { + this.set('selectedElement', element('shape', { shape: 'circle' })); + + await render(TEMPLATE); + await openSection('Shape'); + + const shapes = findAll('select option').map((option) => option.textContent.trim()); + assert.true(shapes.includes('Rectangle')); + assert.true(shapes.includes('Circle')); + }); + }); + + module('the remaining table-editing edge cases', function () { + test('renaming a key on a row that never had it seeds an empty cell', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [{ label: 'Name', key: 'name' }], + rows: [{ other: 'unrelated' }], + }) + ); + + await render(TEMPLATE); + await openSection('Columns'); + await fillIn('input[placeholder="data.key"]', 'full_name'); + + assert.deepEqual(lastChanges().rows, [{ other: 'unrelated', full_name: '' }], 'the row gains the new key rather than undefined'); + }); + + test('editing one row leaves the others untouched', async function (assert) { + this.set( + 'selectedElement', + element('table', { + columns: [{ label: 'Name', key: 'name' }], + rows: [{ name: 'Ada' }, { name: 'Grace' }], + }) + ); + + await render(TEMPLATE); + await openSection('Data'); + await fillIn(findAll('input[placeholder="name"]')[1], 'Hopper'); + + assert.deepEqual(lastChanges().rows, [{ name: 'Ada' }, { name: 'Hopper' }], 'only the edited row changes'); + }); + + test('inserting a variable into an empty property starts from nothing', async function (assert) { + this.set('selectedElement', element('text')); + this.set('onOpenVariablePicker', (targetProp, callback) => callback('{{order.id}}')); + + await render(TEMPLATE); + await openSection('Content'); + await click(findAll('button').find((button) => button.textContent.includes('Insert variable'))); + + assert.deepEqual(lastChanges(), { content: '{{order.id}}' }, 'the variable is the whole value'); + }); + }); +}); diff --git a/tests/integration/components/template-builder/properties-panel/field-test.js b/tests/integration/components/template-builder/properties-panel/field-test.js new file mode 100644 index 00000000..399aeb9e --- /dev/null +++ b/tests/integration/components/template-builder/properties-panel/field-test.js @@ -0,0 +1,52 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +const FIELD = '.tb-prop-field'; + +module('Integration | Component | template-builder/properties-panel/field', function (hooks) { + setupRenderingTest(hooks); + + test('it labels its block', async function (assert) { + await render(hbs` + + + + `); + + assert.dom(`${FIELD} label`).hasText('Width'); + assert.dom('.field-control').exists('the block is rendered beneath it'); + }); + + test('a field with no label renders none', async function (assert) { + await render(hbs` + + + + `); + + assert.dom(`${FIELD} label`).doesNotExist(); + assert.dom('.field-control').exists('the control is still rendered'); + }); + + test('a span of 2 makes the field full width', async function (assert) { + await render(hbs``); + + assert.dom(FIELD).hasClass('col-span-2'); + }); + + test('any other span leaves it in a single column', async function (assert) { + await render(hbs``); + assert.dom(FIELD).doesNotHaveClass('col-span-2'); + + await render(hbs``); + assert.dom(FIELD).doesNotHaveClass('col-span-2', 'and so does omitting it'); + }); + + test('a numeric span does not match the string comparison', async function (assert) { + await render(hbs``); + + assert.dom(FIELD).doesNotHaveClass('col-span-2', 'the check is against the string "2"'); + }); +}); diff --git a/tests/integration/components/template-builder/properties-panel/section-test.js b/tests/integration/components/template-builder/properties-panel/section-test.js new file mode 100644 index 00000000..d7b23d9e --- /dev/null +++ b/tests/integration/components/template-builder/properties-panel/section-test.js @@ -0,0 +1,67 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, find } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +const SECTION = '.tb-prop-section'; +const HEADER = `${SECTION} button`; + +module('Integration | Component | template-builder/properties-panel/section', function (hooks) { + setupRenderingTest(hooks); + + test('it renders its title and a chevron that follows the open state', async function (assert) { + await render(hbs``); + + assert.dom(SECTION).includesText('Layout'); + assert.dom(`${HEADER} .fa-chevron-down`).exists('a closed section points down'); + + this.set('isOpen', true); + + assert.dom(`${HEADER} .fa-chevron-up`).exists('an open one points up'); + assert.dom(`${HEADER} .fa-chevron-down`).doesNotExist(); + }); + + test('an icon is rendered when one is given', async function (assert) { + await render(hbs``); + + assert.dom(`${HEADER} .fa-ruler`).exists(); + }); + + test('no icon is rendered without one', async function (assert) { + await render(hbs``); + + // The chevron is always present; the leading icon slot should be empty. + assert.dom(`${HEADER} .flex.items-center.space-x-2 svg`).doesNotExist(); + }); + + test('the block is only rendered while the section is open', async function (assert) { + await render(hbs` + +
contents
+
+ `); + + assert.dom('.section-body').doesNotExist('a closed section hides its contents'); + + this.set('isOpen', true); + + assert.dom('.section-body').hasText('contents'); + }); + + test('clicking the header reports through @onToggle', async function (assert) { + const toggles = []; + this.set('onToggle', () => toggles.push('toggle')); + + await render(hbs``); + await click(HEADER); + + assert.deepEqual(toggles, ['toggle']); + }); + + test('clicking without an @onToggle handler is inert', async function (assert) { + await render(hbs``); + await click(HEADER); + + assert.ok(find(SECTION), 'the section survives a click it has nothing to do with'); + }); +}); diff --git a/tests/integration/components/template-builder/queries-panel-test.js b/tests/integration/components/template-builder/queries-panel-test.js new file mode 100644 index 00000000..4930e597 --- /dev/null +++ b/tests/integration/components/template-builder/queries-panel-test.js @@ -0,0 +1,193 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, fillIn, find, findAll } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +function queries() { + return [ + { uuid: 'qry_1', label: 'Recent orders', variable_name: 'recent_orders', model_type: 'order' }, + { uuid: '_new_123', label: 'Draft query', variable_name: 'draft' }, + ]; +} + +function rows() { + return findAll('.tb-query-row'); +} + +function rowLabels() { + return rows().map((row) => row.querySelector('span.font-medium').textContent.trim()); +} + +function addButton() { + return find('button[title="Add query"]'); +} + +function saveButton() { + return findAll('button').find((button) => /create query|save changes/i.test(button.textContent)); +} + +// The form refuses to save until a resource type is chosen. +function resourceTypeButton(text) { + return findAll('button').find((button) => button.textContent.trim().toLowerCase().includes(text.toLowerCase())); +} + +// The form's dismiss control is the only untitled icon button in the modal header. +function closeFormButton() { + return findAll('button').find((button) => !button.hasAttribute('title') && !button.textContent.trim()); +} + +module('Integration | Component | template-builder/queries-panel', function (hooks) { + setupRenderingTest(hooks); + + let changes; + + hooks.beforeEach(function () { + changes = []; + this.set('queries', queries()); + this.set('onQueriesChange', (updated) => changes.push(updated)); + }); + + const TEMPLATE = hbs``; + + module('listing queries', function () { + test('it lists every query with its variable name', async function (assert) { + await render(TEMPLATE); + + assert.dom('.tb-queries-panel').exists(); + assert.deepEqual(rowLabels(), ['Recent orders', 'Draft query']); + assert.dom(rows()[0]).containsText('{recent_orders}', 'the interpolation name is shown'); + }); + + test('a query with a model type shows it', async function (assert) { + await render(TEMPLATE); + + assert.dom(rows()[0]).containsText('order'); + assert.dom(rows()[1]).doesNotContainText('order', 'a query without a model type shows none'); + }); + + test('an unsaved query is badged', async function (assert) { + await render(TEMPLATE); + + assert.dom(rows()[1]).containsText('unsaved', 'the client-side query is marked'); + assert.dom(rows()[0]).doesNotContainText('unsaved', 'a persisted query is not'); + }); + + test('with no queries it explains what queries are for', async function (assert) { + this.set('queries', []); + + await render(TEMPLATE); + + assert.strictEqual(rows().length, 0); + assert.dom('.tb-queries-panel').containsText('No queries yet.'); + assert.dom('.tb-queries-panel').containsText('Queries load data from Fleetbase models'); + }); + + test('with no queries argument at all it renders the empty state', async function (assert) { + await render(hbs``); + + assert.dom('.tb-queries-panel').containsText('No queries yet.'); + }); + + test('it forwards splattributes', async function (assert) { + await render(hbs``); + + assert.dom('.tb-queries-panel').hasAttribute('data-test-queries', 'yes'); + }); + }); + + module('the query form', function () { + test('the form is closed to begin with', async function (assert) { + await render(TEMPLATE); + + assert.strictEqual(saveButton(), undefined, 'no form is shown'); + }); + + test('adding opens an empty form', async function (assert) { + await render(TEMPLATE); + await click(addButton()); + + assert.ok(saveButton(), 'the form opens'); + assert.dom(saveButton()).hasText('Create Query', 'as a new query'); + assert.dom('input[placeholder="e.g. Recent Orders"]').hasValue(''); + }); + + test('editing opens the form seeded with that query', async function (assert) { + await render(TEMPLATE); + await click(rows()[0].querySelector('button[title="Edit query"]')); + + assert.dom(saveButton()).hasText('Save Changes'); + assert.dom('input[placeholder="e.g. Recent Orders"]').hasValue('Recent orders'); + }); + + test('the form can be dismissed without changing anything', async function (assert) { + await render(TEMPLATE); + await click(addButton()); + + assert.ok(saveButton(), 'the form is open'); + + await click(closeFormButton()); + + assert.strictEqual(saveButton(), undefined, 'the form closes'); + assert.deepEqual(changes, [], 'nothing is reported'); + }); + + test('the form reopens empty after an edit is dismissed', async function (assert) { + await render(TEMPLATE); + await click(rows()[0].querySelector('button[title="Edit query"]')); + await click(closeFormButton()); + await click(addButton()); + + assert.dom('input[placeholder="e.g. Recent Orders"]').hasValue('', 'the edited query is forgotten'); + }); + }); + + module('mutating queries', function () { + test('saving a new query appends it with a temporary id and closes the form', async function (assert) { + await render(TEMPLATE); + await click(addButton()); + await fillIn('input[placeholder="e.g. Recent Orders"]', 'Pending deliveries'); + await click(resourceTypeButton('Order')); + await click(saveButton()); + + assert.strictEqual(changes.length, 1, 'the parent is told once'); + const updated = changes[0]; + assert.strictEqual(updated.length, 3, 'the query is appended'); + assert.strictEqual(updated[2].label, 'Pending deliveries'); + assert.true(updated[2].uuid.startsWith('_new_'), 'it carries a client-side id until the template is saved'); + assert.strictEqual(saveButton(), undefined, 'the form closes'); + }); + + test('saving an existing query replaces it in place', async function (assert) { + await render(TEMPLATE); + await click(rows()[0].querySelector('button[title="Edit query"]')); + await fillIn('input[placeholder="e.g. Recent Orders"]', 'Renamed orders'); + await click(resourceTypeButton('Order')); + await click(saveButton()); + + const updated = changes[0]; + assert.strictEqual(updated.length, 2, 'nothing is appended'); + assert.strictEqual(updated[0].uuid, 'qry_1'); + assert.strictEqual(updated[0].label, 'Renamed orders'); + assert.strictEqual(updated[1].label, 'Draft query', 'the other query is untouched'); + }); + + test('a query can be deleted', async function (assert) { + await render(TEMPLATE); + await click(rows()[1].querySelector('button[title="Delete query"]')); + + assert.strictEqual(changes.length, 1); + assert.deepEqual( + changes[0].map((query) => query.uuid), + ['qry_1'], + 'only the chosen query is removed' + ); + }); + + test('it mutates happily without an onQueriesChange handler', async function (assert) { + await render(hbs``); + await click(rows()[1].querySelector('button[title="Delete query"]')); + + assert.strictEqual(rows().length, 2, 'the panel survives and stays controlled by its argument'); + }); + }); +}); diff --git a/tests/integration/components/template-builder/query-form-test.js b/tests/integration/components/template-builder/query-form-test.js new file mode 100644 index 00000000..e54dd4d5 --- /dev/null +++ b/tests/integration/components/template-builder/query-form-test.js @@ -0,0 +1,557 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, fillIn, findAll, find } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +function inputByPlaceholder(placeholder) { + return find(`input[placeholder="${placeholder}"]`); +} + +function buttonWithText(text) { + return findAll('button').find((button) => button.textContent.trim().toLowerCase().includes(text.toLowerCase())); +} + +module('Integration | Component | template-builder/query-form', function (hooks) { + setupRenderingTest(hooks); + + let saved; + let closed; + + hooks.beforeEach(function () { + saved = []; + closed = 0; + this.set('isOpen', true); + this.set('onSave', (data) => saved.push(data)); + this.set('onClose', () => closed++); + }); + + const TEMPLATE = hbs` + + `; + + // The submit button is labelled "Create Query" for a new query and + // "Save Changes" when editing an existing one. + const saveButton = () => findAll('button').find((button) => /create query|save changes/i.test(button.textContent)); + const limitInput = () => inputByPlaceholder('No limit'); + const withInput = () => inputByPlaceholder('e.g. driver,vehicle'); + const labelInput = () => inputByPlaceholder('e.g. Recent Orders'); + const variableInput = () => inputByPlaceholder('recent_orders'); + const descriptionInput = () => inputByPlaceholder('Optional description'); + + async function fillValidForm(context) { + await fillIn(labelInput(), 'Recent Orders'); + await click(buttonWithText('Order')); + + return context; + } + + module('visibility and reset', function () { + test('it renders nothing while closed', async function (assert) { + this.set('isOpen', false); + + await render(TEMPLATE); + + assert.strictEqual(labelInput(), null, 'the form is not in the DOM'); + }); + + test('it renders an empty form for a new query', async function (assert) { + await render(TEMPLATE); + + assert.dom(labelInput()).hasValue(''); + assert.dom(variableInput()).hasValue(''); + assert.dom(descriptionInput()).hasValue(''); + }); + + test('it populates from an existing query', async function (assert) { + this.set('query', { + uuid: 'q-1', + label: 'Recent Orders', + variable_name: 'recent_orders', + description: 'Last ten', + model_type: 'Fleetbase\\FleetOps\\Models\\Order', + limit: 10, + with: ['driver', 'payload'], + conditions: [{ field: 'status', operator: '=', value: 'active' }], + sort: [{ field: 'created_at', direction: 'desc' }], + }); + + await render(TEMPLATE); + + assert.dom(labelInput()).hasValue('Recent Orders'); + assert.dom(variableInput()).hasValue('recent_orders'); + assert.dom(descriptionInput()).hasValue('Last ten'); + }); + + test('switching between queries resets the form', async function (assert) { + this.set('query', { uuid: 'q-1', label: 'First' }); + await render(TEMPLATE); + assert.dom(labelInput()).hasValue('First'); + + this.set('query', { uuid: 'q-2', label: 'Second' }); + assert.dom(labelInput()).hasValue('Second', 'the form re-syncs when the query changes'); + + this.set('query', undefined); + assert.dom(labelInput()).hasValue('', 'clearing the query empties the form'); + }); + + test('the deep-cloned conditions do not alias the original query', async function (assert) { + const conditions = [{ field: 'status', operator: '=', value: 'active' }]; + this.set('query', { uuid: 'q-1', label: 'L', model_type: 'M', conditions }); + + await render(TEMPLATE); + await click(saveButton()); + + assert.notStrictEqual(saved[0].conditions[0], conditions[0], 'the saved condition is a copy'); + }); + }); + + module('variable name derivation', function () { + test('typing a label derives a snake_case variable name', async function (assert) { + await render(TEMPLATE); + await fillIn(labelInput(), 'Recent Orders'); + + assert.dom(variableInput()).hasValue('recent_orders'); + }); + + test('punctuation and repeats collapse to single underscores', async function (assert) { + await render(TEMPLATE); + await fillIn(labelInput(), ' Orders -- 2024!! '); + + assert.dom(variableInput()).hasValue('orders_2024', 'leading and trailing underscores are stripped'); + }); + + test('editing the variable name stops it tracking the label', async function (assert) { + await render(TEMPLATE); + await fillIn(labelInput(), 'Recent Orders'); + await fillIn(variableInput(), 'my_custom_name'); + await fillIn(labelInput(), 'Something Else'); + + assert.dom(variableInput()).hasValue('my_custom_name', 'a manual edit wins'); + }); + + test('an existing query with a variable name is treated as manually edited', async function (assert) { + this.set('query', { uuid: 'q-1', label: 'First', variable_name: 'kept' }); + + await render(TEMPLATE); + await fillIn(labelInput(), 'Changed'); + + assert.dom(variableInput()).hasValue('kept', 'the saved variable name is not overwritten'); + }); + }); + + module('resource types', function () { + test('it offers the built-in defaults when nothing else is supplied', async function (assert) { + await render(TEMPLATE); + + assert.ok(buttonWithText('Order'), 'the default Order type is offered'); + assert.ok(buttonWithText('Vehicle')); + }); + + test('@resourceTypes overrides the defaults', async function (assert) { + this.set('resourceTypes', [{ value: 'App\\Models\\Widget', label: 'Widget', icon: 'box' }]); + + await render(TEMPLATE); + + assert.ok(buttonWithText('Widget')); + assert.notOk(buttonWithText('Fuel Report'), 'the built-in list is replaced'); + }); + + test('service-registered types are used when no argument is given', async function (assert) { + this.owner.lookup('service:template-builder').registerResourceTypes([{ value: 'App\\Models\\Thing', label: 'Thing', icon: 'box' }]); + + await render(TEMPLATE); + + assert.ok(buttonWithText('Thing'), 'the service registry is consulted'); + assert.notOk(buttonWithText('Fuel Report'), 'and takes precedence over the built-ins'); + }); + + test('the argument beats the service registry', async function (assert) { + this.owner.lookup('service:template-builder').registerResourceTypes([{ value: 'App\\Models\\Thing', label: 'Thing', icon: 'box' }]); + this.set('resourceTypes', [{ value: 'App\\Models\\Widget', label: 'Widget', icon: 'box' }]); + + await render(TEMPLATE); + + assert.ok(buttonWithText('Widget')); + assert.notOk(buttonWithText('Thing')); + }); + }); + + module('validation and saving', function () { + test('saving without a label reports an error', async function (assert) { + await render(TEMPLATE); + await click(saveButton()); + + assert.dom(this.element).containsText('Label is required.'); + assert.strictEqual(saved.length, 0, 'nothing is saved'); + }); + + test('a whitespace-only label is still rejected', async function (assert) { + await render(TEMPLATE); + await fillIn(labelInput(), ' '); + await click(saveButton()); + + assert.dom(this.element).containsText('Label is required.'); + }); + + test('saving without a resource type reports an error', async function (assert) { + await render(TEMPLATE); + await fillIn(labelInput(), 'Recent Orders'); + await click(saveButton()); + + assert.dom(this.element).containsText('Please select a resource type.'); + assert.strictEqual(saved.length, 0); + }); + + test('a valid form saves a normalised payload', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await click(saveButton()); + + assert.strictEqual(saved.length, 1); + assert.strictEqual(saved[0].label, 'Recent Orders'); + assert.strictEqual(saved[0].variable_name, 'recent_orders'); + assert.strictEqual(saved[0].uuid, null, 'a new query has no uuid yet'); + assert.strictEqual(saved[0].limit, null, 'an empty limit is normalised to null'); + assert.deepEqual(saved[0].with, []); + }); + + test('the label is trimmed on save', async function (assert) { + await render(TEMPLATE); + await fillIn(labelInput(), ' Padded '); + await click(buttonWithText('Order')); + await click(saveButton()); + + assert.strictEqual(saved[0].label, 'Padded'); + }); + + test('editing an existing query preserves its uuid', async function (assert) { + this.set('query', { uuid: 'q-1', label: 'Existing', model_type: 'Fleetbase\\FleetOps\\Models\\Order' }); + + await render(TEMPLATE); + await click(saveButton()); + + assert.strictEqual(saved[0].uuid, 'q-1'); + }); + + test('a blank variable name is derived from the label at save time', async function (assert) { + this.set('query', { uuid: 'q-1', label: 'From Label', variable_name: '', model_type: 'M' }); + + await render(TEMPLATE); + await click(saveButton()); + + assert.strictEqual(saved[0].variable_name, 'from_label'); + }); + + test('an earlier error clears on a successful save', async function (assert) { + await render(TEMPLATE); + await click(saveButton()); + assert.dom(this.element).containsText('Label is required.'); + + await fillValidForm(this); + await click(saveButton()); + + assert.dom(this.element).doesNotContainText('Label is required.'); + assert.strictEqual(saved.length, 1); + }); + + test('cancel closes without saving', async function (assert) { + await render(TEMPLATE); + await fillIn(labelInput(), 'Discarded'); + await click(buttonWithText('Cancel')); + + assert.strictEqual(closed, 1); + assert.strictEqual(saved.length, 0); + }); + + test('it saves without an onSave handler', async function (assert) { + await render(hbs``); + await fillIn(inputByPlaceholder('e.g. Recent Orders'), 'Recent Orders'); + await click(buttonWithText('Order')); + await click(saveButton()); + + assert.dom(this.element).doesNotContainText('required', 'validation passed with no callback wired up'); + }); + }); + + module('limit and eager-loaded relations', function () { + test('a limit is parsed to an integer', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + + await fillIn(limitInput(), '25'); + await click(saveButton()); + + assert.strictEqual(saved[0].limit, 25, 'the limit is a number, not a string'); + }); + + test('clearing the limit saves null', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + + await fillIn(limitInput(), '25'); + await fillIn(limitInput(), ''); + await click(saveButton()); + + assert.strictEqual(saved[0].limit, null); + }); + + test('relations are entered comma-separated and stored as a trimmed array', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + + await fillIn(withInput(), ' driver , payload ,, '); + await click(saveButton()); + + assert.deepEqual(saved[0].with, ['driver', 'payload'], 'entries are trimmed and blanks dropped'); + }); + }); + + module('conditions and sort', function () { + test('conditions can be added and removed', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + + const addCondition = buttonWithText('Add condition'); + await click(addCondition); + await click(saveButton()); + + assert.deepEqual(saved[0].conditions, [], 'a condition with no field is dropped from the payload'); + }); + + test('a condition with a field is kept', async function (assert) { + this.set('query', { + uuid: 'q-1', + label: 'L', + model_type: 'M', + conditions: [{ field: 'status', operator: '=', value: 'active' }], + }); + + await render(TEMPLATE); + await click(saveButton()); + + assert.strictEqual(saved[0].conditions.length, 1); + assert.strictEqual(saved[0].conditions[0].field, 'status'); + }); + + test('a sort entry with no field is dropped', async function (assert) { + this.set('query', { + uuid: 'q-1', + label: 'L', + model_type: 'M', + sort: [ + { field: '', direction: 'asc' }, + { field: 'created_at', direction: 'desc' }, + ], + }); + + await render(TEMPLATE); + await click(saveButton()); + + assert.strictEqual(saved[0].sort.length, 1); + assert.strictEqual(saved[0].sort[0].field, 'created_at'); + }); + }); + + test('the template-builder service is optional', async function (assert) { + this.owner.unregister('service:template-builder'); + this.owner.register('service:template-builder', class extends Service {}); + + await render(TEMPLATE); + + assert.ok(buttonWithText('Order'), 'a service with no registry falls back to the built-in types'); + }); + + // ------------------------------------------------------------------------- + // Appended coverage: the conditions and sort editors. + // ------------------------------------------------------------------------- + + const CONDITION_FIELD = 'input[placeholder="field"]'; + const CONDITION_VALUE = 'input[placeholder="value or {variable}"]'; + const SORT_FIELD = 'input[placeholder="field (e.g. created_at)"]'; + + function conditionRows() { + return findAll(CONDITION_FIELD); + } + + function sortRows() { + return findAll(SORT_FIELD); + } + + function removeButtons(title) { + return findAll(`button[title="${title}"]`); + } + + module('conditions', function () { + test('a query starts with no conditions', async function (assert) { + await render(TEMPLATE); + + assert.deepEqual(conditionRows(), []); + }); + + test('a condition can be added with sensible defaults', async function (assert) { + await render(TEMPLATE); + await click(buttonWithText('Add condition')); + + assert.strictEqual(conditionRows().length, 1); + assert.dom(CONDITION_FIELD).hasValue('', 'the field starts empty'); + assert.dom('.tb-select-operator').hasValue('=', 'equals is the default operator'); + }); + + test('several conditions can be added', async function (assert) { + await render(TEMPLATE); + await click(buttonWithText('Add condition')); + await click(buttonWithText('Add condition')); + + assert.strictEqual(conditionRows().length, 2); + }); + + test('a condition field, operator and value can be edited', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await click(buttonWithText('Add condition')); + + await fillIn(conditionRows()[0], 'status'); + await fillIn('.tb-select-operator', '!='); + await fillIn(CONDITION_VALUE, 'cancelled'); + await click(saveButton()); + + assert.deepEqual(saved[0].conditions, [{ field: 'status', operator: '!=', value: 'cancelled' }]); + }); + + test('only the edited condition changes', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await click(buttonWithText('Add condition')); + await click(buttonWithText('Add condition')); + + await fillIn(conditionRows()[0], 'status'); + await fillIn(conditionRows()[1], 'driver_uuid'); + await click(saveButton()); + + assert.deepEqual( + saved[0].conditions.map((condition) => condition.field), + ['status', 'driver_uuid'] + ); + }); + + test('a condition can be removed', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await click(buttonWithText('Add condition')); + await click(buttonWithText('Add condition')); + await fillIn(conditionRows()[0], 'status'); + await fillIn(conditionRows()[1], 'driver_uuid'); + + await click(removeButtons('Remove condition')[0]); + + assert.strictEqual(conditionRows().length, 1); + + await click(saveButton()); + assert.deepEqual( + saved[0].conditions.map((condition) => condition.field), + ['driver_uuid'], + 'the surviving condition keeps its value' + ); + }); + + test('a condition with a blank field is dropped on save', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await click(buttonWithText('Add condition')); + await click(saveButton()); + + assert.deepEqual(saved[0].conditions, [], 'an unfilled condition is not persisted'); + }); + }); + + module('sorting', function () { + test('a query starts with no sort', async function (assert) { + await render(TEMPLATE); + + assert.deepEqual(sortRows(), []); + }); + + test('a sort can be added, defaulting to ascending', async function (assert) { + await render(TEMPLATE); + await click(buttonWithText('Add sort')); + + assert.strictEqual(sortRows().length, 1); + assert.dom('.tb-select-direction').hasValue('asc'); + }); + + test('a sort field and direction can be edited', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await click(buttonWithText('Add sort')); + + await fillIn(sortRows()[0], 'created_at'); + await fillIn('.tb-select-direction', 'desc'); + await click(saveButton()); + + assert.deepEqual(saved[0].sort, [{ field: 'created_at', direction: 'desc' }]); + }); + + test('a sort can be removed', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await click(buttonWithText('Add sort')); + await click(buttonWithText('Add sort')); + await fillIn(sortRows()[0], 'created_at'); + await fillIn(sortRows()[1], 'total'); + + await click(removeButtons('Remove sort')[0]); + await click(saveButton()); + + assert.deepEqual( + saved[0].sort.map((entry) => entry.field), + ['total'] + ); + }); + + test('a sort with a blank field is dropped on save', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await click(buttonWithText('Add sort')); + await click(saveButton()); + + assert.deepEqual(saved[0].sort, []); + }); + }); + // The Cancel button renders unconditionally, so it can be pressed with no @onClose supplied. + test('cancelling with no @onClose handler is harmless', async function (assert) { + await render(hbs``); + await click(buttonWithText('Cancel')); + + assert.dom('.tb-query-form, form').exists('the form survives having nowhere to report to'); + }); + // updateDescription and the _syncForm fallbacks: a description typed into the form, and a + // stored query that omits the optional fields entirely. + test('the description is carried into the saved query', async function (assert) { + await render(TEMPLATE); + await fillValidForm(this); + await fillIn(descriptionInput(), 'Orders from the last week'); + await click(saveButton()); + + assert.strictEqual(saved[saved.length - 1].description, 'Orders from the last week', 'the typed description is saved'); + }); + + test('a stored query missing its optional fields loads as empty strings', async function (assert) { + // Every field here is absent rather than empty — the `?? \'\'` fallbacks in _syncForm only + // run for a query that omits them, which a form-built query never does. + this.set('query', { uuid: 'q_1', model_type: 'order' }); + + await render(TEMPLATE); + + assert.dom(labelInput()).hasValue('', 'the label falls back to empty'); + assert.dom(descriptionInput()).hasValue('', 'and so does the description'); + assert.dom(limitInput()).hasValue('', 'and the limit'); + assert.dom(withInput()).hasValue('', 'and the relations list'); + }); +}); diff --git a/tests/integration/components/template-builder/toolbar-test.js b/tests/integration/components/template-builder/toolbar-test.js new file mode 100644 index 00000000..dfaeb71d --- /dev/null +++ b/tests/integration/components/template-builder/toolbar-test.js @@ -0,0 +1,193 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, findAll } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +const ELEMENT_TYPES = ['Text', 'Image', 'Table', 'Line', 'Shape', 'QR Code', 'Barcode']; + +function buttonByTitle(title) { + return document.querySelector(`button[title="${title}"]`); +} + +module('Integration | Component | template-builder/toolbar', function (hooks) { + setupRenderingTest(hooks); + + test('it renders a button for every element type', async function (assert) { + await render(hbs``); + + for (const label of ELEMENT_TYPES) { + assert.ok(buttonByTitle(`Add ${label}`), `an "Add ${label}" button is present`); + } + }); + + test('clicking an element type reports that type', async function (assert) { + const added = []; + this.set('onAddElement', (type) => added.push(type)); + + await render(hbs``); + await click(buttonByTitle('Add Text')); + await click(buttonByTitle('Add QR Code')); + + assert.deepEqual(added, ['text', 'qr_code'], 'the machine-readable type is reported, not the label'); + }); + + test('adding an element without a handler does not throw', async function (assert) { + await render(hbs``); + await click(buttonByTitle('Add Text')); + + assert.ok(buttonByTitle('Add Text'), 'the toolbar survives a click with nothing wired up'); + }); + + test('the zoom level is shown as a whole percentage', async function (assert) { + this.set('zoom', 1); + await render(hbs``); + assert.dom(this.element).containsText('100%'); + + this.set('zoom', 0.5); + assert.dom(this.element).containsText('50%'); + + this.set('zoom', 1.755); + assert.dom(this.element).containsText('176%', 'the percentage is rounded, not truncated'); + }); + + test('zoom defaults to 100% when not supplied', async function (assert) { + await render(hbs``); + + assert.dom(this.element).containsText('100%'); + }); + + test('the zoom controls report their actions', async function (assert) { + const calls = []; + this.set('onZoomIn', () => calls.push('in')); + this.set('onZoomOut', () => calls.push('out')); + this.set('onZoomReset', () => calls.push('reset')); + + await render(hbs``); + await click(buttonByTitle('Zoom out')); + await click(buttonByTitle('Zoom in')); + + assert.deepEqual(calls, ['out', 'in']); + }); + + test('undo and redo report their actions', async function (assert) { + const calls = []; + this.set('onUndo', () => calls.push('undo')); + this.set('onRedo', () => calls.push('redo')); + + await render(hbs``); + await click(buttonByTitle('Undo')); + await click(buttonByTitle('Redo')); + + assert.deepEqual(calls, ['undo', 'redo']); + }); + + test('preview reports its action', async function (assert) { + let previews = 0; + this.set('onPreview', () => previews++); + + await render(hbs``); + await click(buttonByTitle('Preview template')); + + assert.strictEqual(previews, 1); + }); + + test('save reports its action', async function (assert) { + let saves = 0; + this.set('onSave', () => saves++); + + await render(hbs``); + + const save = findAll('button').find((button) => button.textContent.trim().toLowerCase().includes('save')); + await click(save); + + assert.strictEqual(saves, 1); + }); + + test('the reset-zoom control reports its action', async function (assert) { + let resets = 0; + this.set('onZoomReset', () => resets++); + + await render(hbs``); + await click(buttonByTitle('Reset zoom')); + + assert.strictEqual(resets, 1); + }); + + test('the close button only appears when @onClose is supplied', async function (assert) { + await render(hbs``); + assert.notOk(buttonByTitle('Close'), 'no close affordance by default'); + + this.set('onClose', () => {}); + await render(hbs``); + assert.ok(buttonByTitle('Close'), 'supplying a handler reveals the close button'); + }); + + test('the close button reports its action', async function (assert) { + let closes = 0; + this.set('onClose', () => closes++); + + await render(hbs``); + await click(buttonByTitle('Close')); + + assert.strictEqual(closes, 1); + }); + + test('the close button uses the supplied label and icon', async function (assert) { + this.set('onClose', () => {}); + + await render(hbs``); + + assert.ok(buttonByTitle('Back to templates'), 'the label becomes the button title'); + assert.dom('.fa-xmark').exists('the configured icon is used'); + }); + + test('the rotation controls are present but disabled until an element is selected', async function (assert) { + this.set('onRotateElement', () => {}); + this.set('selectedElement', null); + + await render(hbs``); + assert.dom(buttonByTitle('Rotate left 90°')).isDisabled('rotation is unavailable without a selection'); + assert.dom(buttonByTitle('Rotate right 90°')).isDisabled(); + + this.set('selectedElement', { uuid: 'el-1' }); + assert.dom(buttonByTitle('Rotate left 90°')).isNotDisabled('selecting an element enables rotation'); + assert.dom(buttonByTitle('Rotate right 90°')).isNotDisabled(); + }); + + test('rotating reports the selected uuid and the signed delta', async function (assert) { + const rotations = []; + this.set('selectedElement', { uuid: 'el-1' }); + this.set('onRotateElement', (uuid, delta) => rotations.push([uuid, delta])); + + await render(hbs``); + + await click(buttonByTitle('Rotate left 90°')); + await click(buttonByTitle('Rotate right 90°')); + + assert.deepEqual(rotations, [ + ['el-1', -90], + ['el-1', 90], + ]); + }); + + test('it forwards splattributes', async function (assert) { + await render(hbs``); + + assert.dom('.tb-toolbar').hasAttribute('data-test-toolbar', 'yes'); + assert.dom('.tb-toolbar').hasClass('extra'); + }); + // Every toolbar action guards its optional callback. The buttons are all enabled here — + // undo/redo need @canUndo/@canRedo and the rotate pair needs a @selectedElement — but no + // callback is supplied, which is the only way to reach the other side of those guards. + test('every control is inert when its callback is not supplied', async function (assert) { + this.set('selectedElement', { uuid: 'el_1' }); + await render(hbs``); + + for (const title of ['Zoom out', 'Reset zoom', 'Zoom in', 'Rotate left 90°', 'Rotate right 90°', 'Undo', 'Redo', 'Preview template']) { + await click(buttonByTitle(title)); + } + await click(findAll('button').find((button) => button.textContent.trim().toLowerCase().includes('save'))); + + assert.ok(buttonByTitle('Zoom in'), 'the toolbar survives a click on every unwired control'); + }); +}); diff --git a/tests/integration/components/template-builder/variable-picker-test.js b/tests/integration/components/template-builder/variable-picker-test.js new file mode 100644 index 00000000..b1b34336 --- /dev/null +++ b/tests/integration/components/template-builder/variable-picker-test.js @@ -0,0 +1,403 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, fillIn, findAll, find } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +const SCHEMAS = [ + { + namespace: 'invoice', + label: 'Invoice', + icon: 'file-invoice', + variables: [ + { path: 'invoice.number', label: 'Invoice Number', type: 'string', example: 'INV-001' }, + { path: 'invoice.subtotal', label: 'Subtotal', type: 'number', example: '100.00' }, + ], + }, + { + namespace: 'order', + label: 'Order', + icon: 'box', + variables: [{ path: 'order.quantity', label: 'Quantity', type: 'number', example: '3' }], + }, +]; + +function buttonWithText(text) { + return findAll('.tb-variable-picker button').find((button) => button.textContent.replace(/\s+/g, ' ').includes(text)); +} + +function variableButtons() { + return findAll('.tb-variable-picker button').filter((button) => button.querySelector('.font-mono')); +} + +function variablePaths() { + return variableButtons().map((button) => button.querySelector('.font-mono').textContent.trim()); +} + +function searchInput() { + return find('input[placeholder="Search variables..."]'); +} + +module('Integration | Component | template-builder/variable-picker', function (hooks) { + setupRenderingTest(hooks); + + let inserted; + let closed; + + hooks.beforeEach(function () { + inserted = []; + closed = 0; + this.set('isOpen', true); + this.set('contextSchemas', SCHEMAS); + this.set('onInsert', (token) => inserted.push(token)); + this.set('onClose', () => closed++); + }); + + // A schema that declares no variables at all, and a variable of a type the icon map does not + // know, are both shapes a host application can register. + const ODD_SCHEMAS = [ + { namespace: 'empty', label: 'Empty' }, + { + namespace: 'odd', + label: 'Odd', + variables: [ + { path: 'odd.thing', label: 'Thing', type: 'geometry' }, + { path: 'odd.unlabelled', type: 'geometry' }, + ], + }, + ]; + + const TEMPLATE = hbs` + + `; + + module('visibility', function () { + test('it renders nothing when closed', async function (assert) { + this.set('isOpen', false); + + await render(TEMPLATE); + + assert.dom('.tb-variable-picker').doesNotExist(); + }); + + test('when open it presents an accessible modal on the variables tab', async function (assert) { + await render(TEMPLATE); + + assert.dom('.tb-variable-picker').hasAttribute('role', 'dialog'); + assert.dom('.tb-variable-picker').hasAttribute('aria-modal', 'true'); + assert.dom(this.element).containsText('Insert Variable or Formula'); + assert.dom(this.element).containsText('Global', 'the always-available shortcuts are listed'); + assert.notOk(find('textarea'), 'the formula tab is not active'); + }); + }); + + module('browsing variables', function () { + test('namespaces start collapsed and expand on click', async function (assert) { + await render(TEMPLATE); + assert.strictEqual(variablePaths().length, 0, 'nothing is expanded initially'); + + await click(buttonWithText('Invoice')); + + assert.deepEqual(variablePaths(), ['invoice.number', 'invoice.subtotal']); + }); + + test('a namespace collapses again on a second click', async function (assert) { + await render(TEMPLATE); + await click(buttonWithText('Invoice')); + await click(buttonWithText('Invoice')); + + assert.strictEqual(variablePaths().length, 0); + }); + + test('each namespace reports how many variables it holds', async function (assert) { + await render(TEMPLATE); + + assert.dom(buttonWithText('Invoice')).containsText('2 variables'); + assert.dom(buttonWithText('Order')).containsText('1 variables'); + }); + + test('the global section is always offered and independent of the schemas', async function (assert) { + this.set('contextSchemas', []); + + await render(TEMPLATE); + await click(buttonWithText('Global')); + + const paths = variablePaths(); + assert.true(paths.includes('company.name')); + assert.true(paths.includes('now')); + assert.strictEqual(paths.length, 10, 'every global shortcut is listed'); + }); + + test('a schema list that is not an array is ignored', async function (assert) { + this.set('contextSchemas', { invoice: SCHEMAS[0] }); + + await render(TEMPLATE); + + assert.notOk(buttonWithText('Invoice'), 'the malformed payload contributes nothing'); + assert.ok(buttonWithText('Global'), 'the picker still works'); + }); + + test('a schema with no variables array does not break browsing', async function (assert) { + this.set('contextSchemas', [{ namespace: 'empty', label: 'Empty', icon: 'box' }]); + + await render(TEMPLATE); + await fillIn(searchInput(), 'anything'); + + assert.dom(this.element).containsText('No variables match "anything"'); + }); + }); + + module('searching', function () { + test('searching matches on path and on label, and auto-expands', async function (assert) { + await render(TEMPLATE); + + await fillIn(searchInput(), 'subtotal'); + assert.deepEqual(variablePaths(), ['invoice.subtotal'], 'matching on the path expands the namespace automatically'); + + await fillIn(searchInput(), 'quantity'); + assert.deepEqual(variablePaths(), ['order.quantity'], 'matching on the label works too'); + }); + + test('searching is case-insensitive and ignores surrounding whitespace', async function (assert) { + await render(TEMPLATE); + await fillIn(searchInput(), ' INVOICE.NUM '); + + assert.deepEqual(variablePaths(), ['invoice.number']); + }); + + test('a search with no matches shows the query back to the user', async function (assert) { + await render(TEMPLATE); + await fillIn(searchInput(), 'nothing-matches'); + + assert.dom(this.element).containsText('No variables match "nothing-matches"'); + assert.strictEqual(variablePaths().length, 0); + }); + + test('clearing the search restores the full collapsed list', async function (assert) { + await render(TEMPLATE); + await fillIn(searchInput(), 'subtotal'); + await fillIn(searchInput(), ''); + + assert.ok(buttonWithText('Invoice'), 'both namespaces are listed again'); + assert.ok(buttonWithText('Order')); + }); + }); + + module('inserting a variable', function () { + test('clicking a variable inserts its token and dismisses the picker', async function (assert) { + await render(TEMPLATE); + await click(buttonWithText('Invoice')); + await click(variableButtons()[0]); + + assert.deepEqual(inserted, ['{invoice.number}']); + assert.strictEqual(closed, 1, 'inserting also closes'); + }); + + test('a global variable inserts the same way', async function (assert) { + await render(TEMPLATE); + await click(buttonWithText('Global')); + await click(variableButtons()[0]); + + assert.deepEqual(inserted, ['{company.name}']); + }); + + test('it works without an onInsert handler', async function (assert) { + await render(hbs``); + await click(buttonWithText('Invoice')); + await click(variableButtons()[0]); + + assert.strictEqual(closed, 1, 'the picker still closes'); + }); + }); + + module('the formula tab', function () { + async function openFormulaTab() { + await click(buttonWithText('Formula')); + } + + test('switching tabs swaps the panel contents', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + + assert.ok(find('textarea'), 'the expression editor is shown'); + assert.notOk(searchInput(), 'the variable search is gone'); + assert.dom(this.element).containsText('Formula Syntax'); + + await click(buttonWithText('Variables')); + + assert.ok(searchInput(), 'switching back restores the variable browser'); + }); + + test('the preview only appears once an expression is typed', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + assert.dom(this.element).doesNotContainText('Preview'); + + await fillIn('textarea', '{invoice.subtotal} * 1.1'); + + assert.dom(this.element).containsText('Preview'); + assert.dom(this.element).containsText('[{ {invoice.subtotal} * 1.1 }]'); + }); + + test('a whitespace-only expression produces no preview', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + await fillIn('textarea', ' '); + + assert.dom(this.element).doesNotContainText('Preview'); + }); + + test('the insert button is disabled until an expression exists', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + + assert.dom(buttonWithText('Insert Formula')).isDisabled(); + + await fillIn('textarea', '1 + 1'); + + assert.dom(buttonWithText('Insert Formula')).isNotDisabled(); + }); + + test('only numeric variables are offered for quick insert', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + + const quickPaths = findAll('.tb-variable-picker button .font-mono').map((node) => node.textContent.trim()); + assert.true(quickPaths.includes('invoice.subtotal')); + assert.true(quickPaths.includes('order.quantity')); + assert.false(quickPaths.includes('invoice.number'), 'a string variable is not offered to a formula'); + }); + + test('quick-inserting appends tokens to the expression', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + + const quickButtons = findAll('.tb-variable-picker button').filter((button) => button.textContent.includes('Subtotal') || button.textContent.includes('Quantity')); + await click(quickButtons[0]); + await click(quickButtons[1]); + + assert.dom('textarea').hasValue('{invoice.subtotal}{order.quantity}'); + }); + + test('inserting a valid formula wraps it and closes the picker', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + await fillIn('textarea', ' {invoice.subtotal} * 1.1 '); + await click(buttonWithText('Insert Formula')); + + assert.deepEqual(inserted, ['[{ {invoice.subtotal} * 1.1 }]'], 'the expression is trimmed and wrapped'); + assert.strictEqual(closed, 1); + }); + + // The button is disabled on `not this.formulaExpression`, which whitespace satisfies — so + // a spaces-only expression is pressable and the trim guard inside is what stops it. + test('a whitespace-only expression inserts nothing', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + await fillIn('textarea', ' '); + + assert.dom(buttonWithText('Insert Formula')).isNotDisabled('the button is pressable'); + + await click(buttonWithText('Insert Formula')); + + assert.deepEqual(inserted, [], 'nothing is inserted'); + assert.strictEqual(closed, 0, 'and the picker stays open'); + }); + + test('inserting a formula with no handler behind it still closes the picker', async function (assert) { + await render(hbs``); + await openFormulaTab(); + await fillIn('textarea', '1 + 1'); + await click(buttonWithText('Insert Formula')); + + assert.strictEqual(closed, 1, 'the picker closes with nothing to report to'); + }); + + test('unbalanced braces are reported and nothing is inserted', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + await fillIn('textarea', '{invoice.subtotal * 1.1'); + await click(buttonWithText('Insert Formula')); + + assert.dom(this.element).containsText('Unbalanced curly braces in formula.'); + assert.deepEqual(inserted, [], 'nothing is inserted'); + assert.strictEqual(closed, 0, 'the picker stays open so the mistake can be corrected'); + }); + + test('editing the expression clears a previous error', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + await fillIn('textarea', '{invoice.subtotal'); + await click(buttonWithText('Insert Formula')); + assert.dom(this.element).containsText('Unbalanced curly braces'); + + await fillIn('textarea', '{invoice.subtotal}'); + + assert.dom(this.element).doesNotContainText('Unbalanced curly braces'); + }); + + test('an expression with no braces at all is accepted', async function (assert) { + await render(TEMPLATE); + await openFormulaTab(); + await fillIn('textarea', 'round(1.5)'); + await click(buttonWithText('Insert Formula')); + + assert.deepEqual(inserted, ['[{ round(1.5) }]']); + }); + }); + + module('shapes a host application can register', function () { + test('searching skips a schema with no variables, and an unknown type still gets an icon', async function (assert) { + this.set('contextSchemas', ODD_SCHEMAS); + + await render(TEMPLATE); + await fillIn(searchInput(), 'thing'); + + assert.dom(this.element).doesNotIncludeText('Empty', 'a schema with nothing in it drops out of the results'); + assert.dom(this.element).includesText('Thing', 'the matching variable is listed'); + + await fillIn(searchInput(), 'unlabelled'); + + assert.dom(this.element).includesText('odd.unlabelled', 'a variable with no label of its own is matched on its path'); + }); + }); + + module('dismissal', function () { + test('the close button resets the picker state', async function (assert) { + await render(TEMPLATE); + await fillIn(searchInput(), 'subtotal'); + await click(buttonWithText('Formula')); + await fillIn('textarea', '1 + 1'); + + await click(buttonWithText('Cancel')); + + assert.strictEqual(closed, 1); + + // Reopening shows a clean picker rather than the abandoned state. + this.set('isOpen', false); + this.set('isOpen', true); + await render(TEMPLATE); + + assert.ok(searchInput(), 'it reopens on the variables tab'); + assert.dom(searchInput()).hasValue('', 'the search was reset'); + }); + + test('clicking the backdrop dismisses the picker', async function (assert) { + await render(TEMPLATE); + await click('.tb-variable-picker > div:first-child'); + + assert.strictEqual(closed, 1); + }); + + test('it dismisses cleanly without an onClose handler', async function (assert) { + await render(hbs``); + await click('.tb-variable-picker > div:first-child'); + + assert.dom('.tb-variable-picker').exists('the parent owns visibility, so the picker stays rendered'); + }); + }); +}); diff --git a/tests/integration/components/timeline-test.js b/tests/integration/components/timeline-test.js index f5d7b7cf..c4a5248c 100644 --- a/tests/integration/components/timeline-test.js +++ b/tests/integration/components/timeline-test.js @@ -1,26 +1,168 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click, findAll, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +function activity(count = 5) { + return Array.from({ length: count }, (_, index) => ({ id: `a${index}`, status: `Step ${index}` })); +} + +function visibleLabels() { + return findAll('.timeline-entry').map((node) => node.textContent.trim()); +} + +function leftArrow() { + return find('.timeline-arrow-left'); +} + +function rightArrow() { + return find('.timeline-arrow-right'); +} + module('Integration | Component | timeline', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + hooks.beforeEach(function () { + this.set('activity', activity()); + }); + + const TEMPLATE = hbs` + + + {{entry.status}} + + + `; + + module('rendering', function () { + test('it shows a window of three activities', async function (assert) { + await render(TEMPLATE); + + assert.dom('.timeline').exists(); + assert.deepEqual(visibleLabels(), ['Step 0', 'Step 1', 'Step 2'], 'the first three are shown'); + }); + + test('the activity count is exposed as a class', async function (assert) { + await render(TEMPLATE); + + assert.dom('.timeline').hasClass('timeline-activity-5'); + }); + + test('a shorter activity list shows everything it has', async function (assert) { + this.set('activity', activity(2)); + + await render(TEMPLATE); + + assert.deepEqual(visibleLabels(), ['Step 0', 'Step 1']); + }); + + test('a single activity is shown on its own', async function (assert) { + this.set('activity', activity(1)); + + await render(TEMPLATE); + + assert.deepEqual(visibleLabels(), ['Step 0']); + }); + + test('an empty activity list renders no entries', async function (assert) { + this.set('activity', []); + + await render(TEMPLATE); + + assert.deepEqual(visibleLabels(), []); + assert.dom('.timeline').exists('the frame still renders'); + }); + + test('a non-array activity argument is ignored', async function (assert) { + this.set('activity', 'not an array'); + + await render(TEMPLATE); - await render(hbs``); + assert.deepEqual(visibleLabels(), []); + }); - assert.dom(this.element).hasText(''); + test('custom classes and splattributes are applied', async function (assert) { + await render(hbs` + + `); + + assert.dom('.timeline').hasAttribute('data-test-timeline', 'yes'); + assert.dom('.timeline-controls').hasClass('my-controls'); + assert.dom('.timeline-wrapper').hasClass('my-wrapper'); + assert.dom('.timeline-items-container').hasClass('my-items'); + assert.dom(leftArrow()).hasClass('my-arrow'); + assert.dom(leftArrow()).hasClass('my-left'); + assert.dom(rightArrow()).hasClass('my-right'); + }); + }); + + module('paging', function () { + test('the left arrow starts disabled and the right arrow does not', async function (assert) { + await render(TEMPLATE); + + assert.dom(leftArrow()).isDisabled('there is nothing before the first activity'); + assert.dom(rightArrow()).isNotDisabled(); + }); + + test('stepping right advances the window by one', async function (assert) { + await render(TEMPLATE); + + await click(rightArrow()); + + assert.deepEqual(visibleLabels(), ['Step 1', 'Step 2', 'Step 3']); + assert.dom(leftArrow()).isNotDisabled('you can now step back'); + }); + + test('stepping left returns to the previous window', async function (assert) { + await render(TEMPLATE); + + await click(rightArrow()); + await click(leftArrow()); + + assert.deepEqual(visibleLabels(), ['Step 0', 'Step 1', 'Step 2']); + assert.dom(leftArrow()).isDisabled(); + }); + + test('the right arrow disables at the end of the list', async function (assert) { + await render(TEMPLATE); + + await click(rightArrow()); + await click(rightArrow()); + + assert.deepEqual(visibleLabels(), ['Step 2', 'Step 3', 'Step 4']); + assert.dom(rightArrow()).isDisabled('the last activity is in view'); + }); + + test('paging translates the item container', async function (assert) { + await render(TEMPLATE); + + await click(rightArrow()); + + assert.true(find('.timeline-items-container').style.transform.includes('translateX'), 'the strip is shifted'); + }); + + test('with three or fewer activities neither arrow is usable', async function (assert) { + this.set('activity', activity(3)); + + await render(TEMPLATE); + + assert.dom(leftArrow()).isDisabled(); + assert.dom(rightArrow()).isDisabled(); + }); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('it renders without a block', async function (assert) { + await render(hbs``); - assert.dom(this.element).hasText('template block text'); + assert.dom('.timeline-items-container').exists('the container renders even with nothing yielded'); }); }); diff --git a/tests/integration/components/timeline/item-test.js b/tests/integration/components/timeline/item-test.js index 961a1fa8..1f47d5c0 100644 --- a/tests/integration/components/timeline/item-test.js +++ b/tests/integration/components/timeline/item-test.js @@ -6,21 +6,79 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | timeline/item', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + hooks.beforeEach(function () { + this.set('activity', { code: 'dispatched', status: 'Dispatched' }); + }); + + const TEMPLATE = hbs` + + {{activity.status}} + {{context.label}} + + `; + + test('it yields the activity and the context', async function (assert) { + this.set('context', { label: 'Order 123' }); + + await render(TEMPLATE); + + assert.dom('.timeline-item').exists(); + assert.dom('.entry').hasText('Dispatched'); + assert.dom('.context').hasText('Order 123'); + }); + + test('it is inactive by default', async function (assert) { + await render(TEMPLATE); + + assert.dom('.timeline-item').doesNotHaveClass('active'); + }); + + test('a matching active status marks it active, case-insensitively', async function (assert) { + this.set('activeStatus', 'DISPATCHED'); + + await render(TEMPLATE); + + assert.dom('.timeline-item').hasClass('active'); + }); + + test('a different active status leaves it inactive', async function (assert) { + this.set('activeStatus', 'delivered'); - await render(hbs``); + await render(TEMPLATE); - assert.dom(this.element).hasText(''); + assert.dom('.timeline-item').doesNotHaveClass('active'); + }); + + test('an activity with no code is never matched by status', async function (assert) { + this.set('activity', { status: 'Dispatched' }); + this.set('activeStatus', 'dispatched'); + + await render(TEMPLATE); + + assert.dom('.timeline-item').doesNotHaveClass('active'); + }); + + test('an explicit isActive on the activity wins over the status match', async function (assert) { + this.set('activity', { code: 'dispatched', status: 'Dispatched', isActive: true }); + this.set('activeStatus', 'delivered'); + + await render(TEMPLATE); + + assert.dom('.timeline-item').hasClass('active', 'the explicit flag takes precedence'); + }); + + test('an explicit isActive false overrides a status match', async function (assert) { + this.set('activity', { code: 'dispatched', status: 'Dispatched', isActive: false }); + this.set('activeStatus', 'dispatched'); + + await render(TEMPLATE); + + assert.dom('.timeline-item').doesNotHaveClass('active'); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('it forwards splattributes', async function (assert) { + await render(hbs``); - assert.dom(this.element).hasText('template block text'); + assert.dom('.timeline-item').hasAttribute('data-test-item', 'yes'); }); }); diff --git a/tests/integration/components/tip-tap-editor-test.js b/tests/integration/components/tip-tap-editor-test.js index 4379a7be..01b66fbe 100644 --- a/tests/integration/components/tip-tap-editor-test.js +++ b/tests/integration/components/tip-tap-editor-test.js @@ -1,26 +1,535 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click, settled, triggerEvent, findAll, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +function controlButton(icon) { + return findAll('.tip-tap-control-button').find((button) => button.querySelector(`[data-icon="${icon}"]`)); +} + +function dropdownTrigger(text) { + return findAll('.tip-tap-editor-controls button').find((button) => button.textContent.trim().includes(text)); +} + +function menuItem(text) { + return findAll('.next-dd-item').find((item) => item.textContent.trim() === text); +} + +function youtubeButton() { + const groups = findAll('.tip-tap-editor-control-group'); + const buttons = groups[groups.length - 1].querySelectorAll('button.tip-tap-control-button'); + return buttons[buttons.length - 1]; +} + +function editorHtml() { + return find('.ProseMirror').innerHTML; +} module('Integration | Component | tip-tap-editor', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + let editor; + let modals; + let uploads; + let notificationErrors; + + hooks.beforeEach(function () { + editor = null; + modals = []; + uploads = []; + notificationErrors = []; + + this.owner.unregister('service:modalsManager'); + this.owner.register( + 'service:modalsManager', + class extends Service { + show(name, options) { + modals.push({ name, options }); + } + } + ); + + this.owner.unregister('service:notifications'); + this.owner.register( + 'service:notifications', + class extends Service { + error(message) { + notificationErrors.push(message); + } + } + ); + + this.owner.unregister('service:fetch'); + this.owner.register( + 'service:fetch', + class extends Service { + uploadFile = { + perform: (file, options, onSuccess, onError) => { + uploads.push({ file, options, onSuccess, onError }); + }, + }; + } + ); + + this.set('onCreate', ({ editor: created }) => (editor = created)); + }); + + const TEMPLATE = hbs` + + `; + + // TipTap emits its `create` event from a timeout after the ProseMirror view is + // built, which is later than render()'s settled state — flush a macrotask so the + // editor instance is available. + async function renderEditor(template = TEMPLATE) { + await render(template); + await new Promise((resolve) => setTimeout(resolve, 0)); + await settled(); + } + + // Puts a paragraph of text under the cursor so formatting toggles have something + // to apply to, then hands back the editor. + async function withSelection(text = 'hello world') { + editor.commands.setContent(`

${text}

`); + editor.commands.selectAll(); + await settled(); + } + + module('setup', function () { + test('it renders an editor and a toolbar', async function (assert) { + await renderEditor(); + + assert.dom('.tip-tap-editor').exists(); + assert.dom('.ProseMirror').exists(); + assert.true(findAll('.tip-tap-editor-control-group').length > 5, 'the toolbar is populated'); + assert.ok(editor, 'onCreate hands the editor to the caller'); + }); + + test('an initial value is loaded into the editor', async function (assert) { + this.set('value', '

Existing content

'); + + await renderEditor(); + + assert.true(editorHtml().includes('Existing content')); + }); + + test('onBeforeCreate is called too', async function (assert) { + let called = 0; + this.set('onBeforeCreate', () => called++); + + await renderEditor(); + + assert.strictEqual(called, 1); + }); + + test('a placeholder is configured', async function (assert) { + this.set('placeholder', 'Write something…'); + + await renderEditor(); + + assert.strictEqual(find('.ProseMirror p').getAttribute('data-placeholder'), 'Write something…'); + }); + + test('the editor can be made read-only', async function (assert) { + this.set('editable', false); + + await renderEditor(); + + assert.false(editor.isEditable); + }); + + test('it renders without any callbacks', async function (assert) { + await renderEditor(hbs``); + + assert.dom('.ProseMirror').exists(); + assert.true(find('.ProseMirror').innerHTML.includes('Bare')); + }); + + test('it forwards splattributes', async function (assert) { + await renderEditor(hbs``); + + assert.dom('.tip-tap-editor').hasAttribute('data-test-editor', 'yes'); + }); + }); + + module('change callbacks', function () { + test('editing reports html, json, text and the generic change', async function (assert) { + const seen = { html: [], json: [], text: [], change: [], update: 0 }; + this.set('onHtmlChange', (html) => seen.html.push(html)); + this.set('onJsonChange', (json) => seen.json.push(json)); + this.set('onTextChange', (text) => seen.text.push(text)); + this.set('onChange', (html) => seen.change.push(html)); + this.set('onUpdate', () => seen.update++); + + await renderEditor(); + editor.commands.setContent('

Typed content

', true); + await settled(); + + assert.true(seen.html[0].includes('Typed content')); + assert.strictEqual(seen.text[0], 'Typed content'); + assert.strictEqual(seen.json[0].type, 'doc'); + assert.true(seen.change[0].includes('Typed content')); + assert.strictEqual(seen.update, 1); + }); + + test('selection changes and transactions are forwarded', async function (assert) { + const seen = []; + this.set('onSelectionUpdate', () => seen.push('selection')); + this.set('onTransaction', () => seen.push('transaction')); + + await renderEditor(); + editor.commands.setContent('

abc

', true); + editor.commands.setTextSelection({ from: 1, to: 3 }); + await settled(); + + assert.true(seen.includes('transaction'), 'transactions are reported'); + assert.true(seen.includes('selection'), 'selection changes are reported'); + }); + }); + + module('inline formatting', function () { + const CASES = [ + ['bold', 'strong'], + ['italic', 'em'], + ['strikethrough', 's'], + ]; + + for (const [icon, tag] of CASES) { + test(`the ${icon} button wraps the selection in <${tag}>`, async function (assert) { + await renderEditor(); + await withSelection(); + + await click(controlButton(icon)); + + assert.true(editorHtml().includes(`<${tag}>`), `the selection is now ${icon}`); + }); + } + + test('the format menu offers every inline style and applies them', async function (assert) { + await renderEditor(); + await withSelection(); + + await click(dropdownTrigger('Format')); + assert.deepEqual( + findAll('.next-dd-item').map((item) => item.textContent.trim()), + ['Bold', 'Italic', 'Underline', 'Strikethrough', 'Superscript', 'Subscript'] + ); + + await click(menuItem('Underline')); + assert.true(editorHtml().includes('')); + + await withSelection(); + await click(dropdownTrigger('Format')); + await click(menuItem('Superscript')); + assert.true(editorHtml().includes('')); + + await withSelection(); + await click(dropdownTrigger('Format')); + await click(menuItem('Subscript')); + assert.true(editorHtml().includes('')); + }); + }); + + module('blocks', function () { + test('headings can be applied at each level', async function (assert) { + await renderEditor(); + + for (const level of [1, 2, 3]) { + await withSelection(); + await click(dropdownTrigger('Heading')); + await click(menuItem(`Heading ${level}`)); + + assert.true(editorHtml().includes(``), `heading ${level} is applied`); + } + }); + + test('paragraph, blockquote and codeblock each restyle the block', async function (assert) { + await renderEditor(); + + await withSelection(); + await click(controlButton('quote-left')); + assert.true(editorHtml().includes('
')); + + await withSelection(); + await click(controlButton('code')); + assert.true(editorHtml().includes('
'));
+
+            await withSelection();
+            await click(controlButton('code'));
+            await click(controlButton('paragraph'));
+            assert.true(editorHtml().includes('

')); + }); + + test('bullet and ordered lists can be applied', async function (assert) { + await renderEditor(); + + await withSelection(); + await click(controlButton('list')); + assert.true(editorHtml().includes('

    ')); + + await withSelection(); + await click(controlButton('list-ol')); + assert.true(editorHtml().includes('
      ')); + }); + + test('a horizontal rule can be inserted', async function (assert) { + await renderEditor(); + await withSelection(); + + await click(controlButton('minus')); + + assert.ok(find('.ProseMirror hr'), 'a rule is inserted'); + }); + }); + + module('alignment', function () { + test('left and right alignment are applied', async function (assert) { + await renderEditor(); + + await withSelection(); + await click(controlButton('align-right')); + assert.true(editorHtml().includes('text-align: right')); + + await click(controlButton('align-left')); + assert.false(editorHtml().includes('text-align: right'), 'alignment is switched back'); + }); + + test('the centre alignment button centres the selection', async function (assert) { + await renderEditor(); + await withSelection(); + + await click(controlButton('align-center')); + + assert.true(editorHtml().includes('text-align: center'), 'the button reaches alignCenter'); + }); + }); + + module('font family and colour', function () { + test('each font can be applied and then unset', async function (assert) { + await renderEditor(); + + await withSelection(); + await click(dropdownTrigger('Font')); + assert.deepEqual( + findAll('.next-dd-item').map((item) => item.textContent.trim()), + ['Inter', 'Comic Sans', 'Serif', 'Monospace', 'Unset'] + ); + + await click(menuItem('Comic Sans')); + assert.true(editorHtml().includes('font-family'), 'a font family is applied to the selection'); + + await click(dropdownTrigger('Font')); + await click(menuItem('Unset')); + assert.false(editorHtml().includes('font-family'), 'the font is removed again'); + }); + + test('the colour picker sets and clears the text colour', async function (assert) { + await renderEditor(); + await withSelection(); + + const picker = find('.tip-tap-colorpicker-input'); + picker.value = '#ff0000'; + await triggerEvent(picker, 'change'); + + assert.true(editorHtml().includes('color'), 'a colour is applied to the selection'); + + await click(controlButton('eraser')); + + assert.false(editorHtml().includes('color: #ff0000'), 'the colour is cleared'); + }); + }); + + module('history', function () { + test('undo and redo step through edits', async function (assert) { + await renderEditor(); + + editor.commands.setContent('

      first

      ', true); + await settled(); + editor.commands.insertContent(' second'); + await settled(); + assert.true(editorHtml().includes('second')); + + await click(controlButton('arrow-rotate-left')); + assert.false(editorHtml().includes('second'), 'the last edit is undone'); + + await click(controlButton('arrow-rotate-right')); + assert.true(editorHtml().includes('second'), 'and redone'); + }); + }); + + module('tables', function () { + test('inserting a table opens a modal and builds the table on confirm', async function (assert) { + await renderEditor(); + await click(controlButton('table')); + + assert.strictEqual(modals.length, 1); + assert.strictEqual(modals[0].name, 'modals/tip-tap-editor-insert-table'); + assert.strictEqual(modals[0].options.rows, 3); + assert.strictEqual(modals[0].options.columns, 3); + + let done = 0; + modals[0].options.confirm({ getOption: (key, fallback) => fallback, done: () => done++ }); + await settled(); + + assert.true(editorHtml().includes(' item.textContent.trim()), + ['Add Column Before', 'Add Column After', 'Delete Column', 'Add Row Before', 'Add Row After', 'Delete Row', 'Delete Table'] + ); + assert.strictEqual(findAll('.next-dd-menu-seperator').length, 2, 'the groups are separated'); + }); + + test('rows and columns can be added and removed', async function (assert) { + await renderEditor(); + editor.commands.insertTable({ rows: 2, columns: 2, withHeaderRow: true }); + await settled(); + + const countCells = () => find('.ProseMirror table').querySelectorAll('td, th').length; + const initial = countCells(); + + await click(dropdownTrigger('Table')); + await click(menuItem('Add Row After')); + assert.true(countCells() > initial, 'a row is added'); + + await click(dropdownTrigger('Table')); + await click(menuItem('Add Column After')); + const withColumn = countCells(); + + await click(dropdownTrigger('Table')); + await click(menuItem('Delete Column')); + assert.true(countCells() < withColumn, 'a column is removed'); + + await click(dropdownTrigger('Table')); + await click(menuItem('Delete Row')); + + await click(dropdownTrigger('Table')); + await click(menuItem('Delete Table')); + assert.notOk(find('.ProseMirror table'), 'the table is gone'); + }); + + test('column and row can also be added before the cursor', async function (assert) { + await renderEditor(); + editor.commands.insertTable({ rows: 2, columns: 2, withHeaderRow: true }); + await settled(); + + const countCells = () => find('.ProseMirror table').querySelectorAll('td, th').length; + const initial = countCells(); + + await click(dropdownTrigger('Table')); + await click(menuItem('Add Column Before')); + await click(dropdownTrigger('Table')); + await click(menuItem('Add Row Before')); + + assert.true(countCells() > initial); + }); + }); + + module('embeds', function () { + test('inserting a youtube video opens a modal and embeds on confirm', async function (assert) { + await renderEditor(); + await click(youtubeButton()); + + assert.strictEqual(modals.length, 1); + assert.strictEqual(modals[0].name, 'modals/tip-tap-editor-insert-youtube'); + assert.strictEqual(modals[0].options.width, 480); + assert.strictEqual(modals[0].options.height, 320); + + let done = 0; + modals[0].options.confirm({ + getOption: (key, fallback) => (key === 'url' ? 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' : fallback), + done: () => done++, + }); + await settled(); + + assert.strictEqual(done, 1, 'the modal closes on success'); + assert.deepEqual(notificationErrors, []); + }); + + test('an invalid youtube url is reported and leaves the dialog open', async function (assert) { + await renderEditor(); + await click(youtubeButton()); + + let done = 0; + modals[0].options.confirm({ + getOption: (key, fallback) => (key === 'url' ? 'not-a-url' : fallback), + done: () => done++, + }); + await settled(); + + assert.deepEqual(notificationErrors, ['Youtube video URL is invalid.'], 'the user is told'); + assert.strictEqual(done, 0, 'and the dialog stays open so the url can be corrected'); + assert.false(editorHtml().includes('iframe'), 'nothing is inserted'); + }); + }); + + module('image upload', function () { + async function addFile() { + const input = find('input[type="file"]'); + const file = new File(['image-bytes'], 'logo.png', { type: 'image/png' }); + await triggerEvent(input, 'change', { files: [file] }); + } + + test('a queued file is uploaded and the returned url inserted', async function (assert) { + await renderEditor(); + await addFile(); + + assert.strictEqual(uploads.length, 1, 'the file is handed to the upload service'); + assert.deepEqual(uploads[0].options, { path: 'uploads/images', type: 'image' }); + assert.dom('.tip-tap-editor').containsText('%', 'upload progress is shown while it runs'); + + uploads[0].onSuccess({ url: '/uploads/logo.png' }); + await settled(); + + assert.true(editorHtml().includes('/uploads/logo.png'), 'the image is inserted'); + assert.dom('.tip-tap-editor').doesNotContainText('%', 'the progress indicator is cleared'); + }); + + test('a failed upload clears the progress state and drops the file from the queue', async function (assert) { + await renderEditor(); + await addFile(); + + const removed = []; + uploads[0].file.queue = { remove: (file) => removed.push(file) }; + + uploads[0].onError(); + await settled(); - await render(hbs``); + assert.deepEqual(removed, [uploads[0].file], 'the file is removed from the queue'); + assert.dom('.tip-tap-editor').doesNotContainText('%'); + }); - assert.dom().hasText(''); + test('a failed upload without a queue is still survivable', async function (assert) { + await renderEditor(); + await addFile(); - // Template block usage: - await render(hbs` - - template block text - - `); + uploads[0].file.queue = undefined; + uploads[0].onError(); + await settled(); - assert.dom().hasText('template block text'); + assert.dom('.tip-tap-editor').exists('no error is thrown'); + }); }); }); diff --git a/tests/integration/components/toggle-test.js b/tests/integration/components/toggle-test.js index 523ccc78..50e695eb 100644 --- a/tests/integration/components/toggle-test.js +++ b/tests/integration/components/toggle-test.js @@ -1,45 +1,172 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { click, render } from '@ember/test-helpers'; +import { render, click, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +const SWITCH = '[role="checkbox"]'; + +function track() { + return find(`${SWITCH} span[aria-hidden="true"]`); +} module('Integration | Component | toggle', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + let toggles; + + hooks.beforeEach(function () { + toggles = []; + this.set('onToggle', (isToggled) => toggles.push(isToggled)); + }); + + module('rendering', function () { + test('it renders an off switch by default', async function (assert) { + await render(hbs``); + + assert.dom(SWITCH).exists(); + assert.dom(SWITCH).hasAttribute('aria-checked', 'false'); + assert.dom(track()).hasClass('bg-gray-200', 'the track is grey when off'); + }); + + test('@isToggled turns it on and colours the track', async function (assert) { + await render(hbs``); + + assert.dom(SWITCH).hasAttribute('aria-checked', 'true'); + assert.dom(track()).hasClass('bg-green-400', 'green is the default active colour'); + }); + + test('the active colour is configurable', async function (assert) { + await render(hbs``); + + assert.dom(track()).hasClass('bg-blue-400'); + }); + + test('a label is rendered beside the switch', async function (assert) { + await render(hbs``); + + assert.dom('.my-label').hasText('Live tracking'); + }); + + test('a block is rendered beside the switch', async function (assert) { + await render(hbs`Custom`); + + assert.dom('.custom').hasText('Custom'); + }); + + test('a hidden toggle renders nothing at all', async function (assert) { + await render(hbs``); + + assert.dom(SWITCH).doesNotExist(); + assert.dom(this.element).hasText(''); + }); - await render(hbs``); + test('a disabled toggle is dimmed and flagged', async function (assert) { + await render(hbs``); - assert.dom(this.element).hasText(''); + assert.dom(SWITCH).hasClass('opacity-50'); + // Glimmer renders a `true` attribute value as a valueless attribute. + assert.dom(SWITCH).hasAttribute('data-disabled', '', 'the switch is flagged disabled'); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('wrapper class and splattributes are applied', async function (assert) { + await render(hbs``); - assert.dom(this.element).hasText('template block text'); + assert.dom('.my-wrapper').exists(); + assert.dom(SWITCH).hasAttribute('data-test-toggle', 'yes'); + }); + + test('help text is offered as a tooltip', async function (assert) { + await render(hbs``); + + assert.dom('.ember-attacher').exists(); + }); }); - test('it reflects controlled isToggled changes', async function (assert) { - this.set('enabled', true); + module('toggling', function () { + test('clicking turns it on and reports the new state', async function (assert) { + await render(hbs``); + await click(SWITCH); + + assert.deepEqual(toggles, [true]); + assert.dom(SWITCH).hasAttribute('aria-checked', 'true'); + }); + + test('clicking again turns it back off', async function (assert) { + await render(hbs``); + await click(SWITCH); + await click(SWITCH); + + assert.deepEqual(toggles, [true, false]); + assert.dom(SWITCH).hasAttribute('aria-checked', 'false'); + }); + + test('it toggles happily with no handler', async function (assert) { + await render(hbs``); + await click(SWITCH); + + assert.dom(SWITCH).hasAttribute('aria-checked', 'true'); + }); + + test('a disabled toggle refuses to change', async function (assert) { + await render(hbs``); + await click(SWITCH); - await render(hbs``); + assert.deepEqual(toggles, []); + assert.dom(SWITCH).hasAttribute('aria-checked', 'false'); + }); + + test('a controlled toggle follows its argument', async function (assert) { + this.set('isToggled', false); + + await render(hbs``); + assert.dom(SWITCH).hasAttribute('aria-checked', 'false'); + + this.set('isToggled', true); + assert.dom(SWITCH).hasAttribute('aria-checked', 'true', 'the argument drives the rendered state'); + }); + + test('becoming disabled after render stops further toggling', async function (assert) { + this.setProperties({ isToggled: false, disabled: false }); + + await render(hbs``); + await click(SWITCH); + assert.deepEqual(toggles, [true]); + + this.setProperties({ isToggled: true, disabled: true }); + await click(SWITCH); + + assert.deepEqual(toggles, [true], 'the did-update pass applied the new disabled state'); + }); + }); - assert.dom('[role="checkbox"]').hasAttribute('aria-checked', 'true'); - assert.dom('[role="checkbox"] span:first-child').hasClass('bg-green-400'); + module('permissions', function (hooks) { + hooks.beforeEach(function () { + this.owner.unregister('service:abilities'); + this.owner.register( + 'service:abilities', + class extends Service { + cannot(permission) { + return permission === 'forbidden'; + } + } + ); + }); - this.set('enabled', false); + test('a forbidden permission disables the toggle and explains why', async function (assert) { + await render(hbs``); + await click(SWITCH); - assert.dom('[role="checkbox"]').hasAttribute('aria-checked', 'false'); - assert.dom('[role="checkbox"] span:first-child').hasClass('bg-gray-200'); + assert.dom(SWITCH).hasClass('opacity-50'); + assert.dom('.ember-attacher').exists('an unauthorized tooltip is attached'); + assert.deepEqual(toggles, []); + }); - await click('[role="checkbox"]'); + test('an allowed permission leaves the toggle usable', async function (assert) { + await render(hbs``); + await click(SWITCH); - assert.true(this.enabled); - assert.dom('[role="checkbox"]').hasAttribute('aria-checked', 'true'); + assert.deepEqual(toggles, [true]); + }); }); }); diff --git a/tests/integration/components/translations-editor-test.js b/tests/integration/components/translations-editor-test.js index 59e53ab9..bf506197 100644 --- a/tests/integration/components/translations-editor-test.js +++ b/tests/integration/components/translations-editor-test.js @@ -1,26 +1,377 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click, fillIn, findAll, find } from '@ember/test-helpers'; +import { selectChoose } from 'ember-power-select/test-support'; import { hbs } from 'ember-cli-htmlbars'; +function tabs() { + return findAll('.ui-tab'); +} + +function tabLabels() { + return tabs().map((tab) => tab.textContent.trim()); +} + +function activeTab() { + return find('.ui-tab.active'); +} + +function keyInputs() { + return findAll('[aria-label="Translation Key"]'); +} + +function valueInputs() { + return findAll('[aria-label="Translation Value"]'); +} + +function renderedKeys() { + return keyInputs().map((input) => input.value); +} + +function removeButtons() { + return findAll('.text-danger'); +} + +// The fields are one-way and commit on `change`, which is what `fillIn` fires. No special +// handling is needed any more — the editor no longer writes to state the render is consuming. +async function editAndBlur(input, value) { + await fillIn(input, value); +} + +function addButton() { + return findAll('button').find((button) => button.textContent.includes('Add new translation')); +} + module('Integration | Component | translations-editor', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + let changes; + + hooks.beforeEach(function () { + changes = []; + this.set('onChange', (translations) => changes.push(JSON.parse(JSON.stringify(translations)))); + // The language picker is a , which fetches its own option list. + this.owner.lookup('service:fetch').responses['lookup/countries'] = [ + { name: 'Singapore', cca2: 'SG', emoji: '\u{1F1F8}\u{1F1EC}' }, + { name: 'United States', cca2: 'US', emoji: '\u{1F1FA}\u{1F1F8}' }, + ]; + }); + + const TEMPLATE = hbs` + + `; + + function lastChange() { + return changes[changes.length - 1]; + } + + module('rendering', function () { + test('with no value it renders an empty editor', async function (assert) { + await render(TEMPLATE); + + assert.dom('.translations-editor-input').exists(); + assert.dom('.translation-editor-input-header').containsText('Translations'); + assert.strictEqual(tabs().length, 0, 'there are no languages yet'); + assert.notOk(addButton(), 'nothing can be added until a language exists'); + }); + + test('the label can be overridden and classed', async function (assert) { + this.set('label', 'Order statuses'); + this.set('labelClass', 'my-label'); + + await render(TEMPLATE); + + assert.dom('.my-label').hasText('Order statuses'); + }); + + test('it renders a tab per language and loads the first', async function (assert) { + this.set('value', { en: { greeting: 'Hello' }, fr: { greeting: 'Bonjour' } }); + + await render(TEMPLATE); + + assert.deepEqual(tabLabels(), ['en', 'fr']); + assert.dom(activeTab()).hasText('en', 'the first language is loaded'); + assert.deepEqual(renderedKeys(), ['greeting']); + assert.strictEqual(valueInputs()[0].value, 'Hello'); + }); + + test('switching tabs shows that language', async function (assert) { + this.set('value', { en: { greeting: 'Hello' }, fr: { greeting: 'Bonjour' } }); + + await render(TEMPLATE); + await click(tabs()[1]); + + assert.dom(activeTab()).hasText('fr'); + assert.strictEqual(valueInputs()[0].value, 'Bonjour'); + }); + + test('it forwards splattributes', async function (assert) { + await render(hbs``); + + assert.dom('.translations-editor-input').hasAttribute('data-test-translations', 'yes'); + }); + }); + + module('default keys', function () { + test('missing default keys are filled in for every language', async function (assert) { + this.set('value', { en: { greeting: 'Hello' }, fr: {} }); + this.set('defaultKeys', ['greeting', 'farewell']); + + await render(TEMPLATE); + + assert.deepEqual(renderedKeys(), ['greeting', 'farewell'], 'the missing key is added to en'); + assert.strictEqual(valueInputs()[0].value, 'Hello', 'the existing translation is preserved'); + assert.strictEqual(valueInputs()[1].value, '', 'the added key starts empty'); + + await click(tabs()[1]); + assert.deepEqual(renderedKeys(), ['greeting', 'farewell'], 'and to fr'); + }); + + test('with no default keys the value is used as-is', async function (assert) { + this.set('value', { en: { greeting: 'Hello' } }); + + await render(TEMPLATE); + + assert.deepEqual(renderedKeys(), ['greeting']); + }); + + // A language key with nothing behind it — the API returns null for a language that has + // not been translated yet. + test('a language with no translations at all is still given the default keys', async function (assert) { + this.set('value', { en: { greeting: 'Hello' }, fr: null }); + this.set('defaultKeys', ['greeting']); + + await render(TEMPLATE); + + assert.deepEqual(tabLabels(), ['en', 'fr'], 'both languages are offered'); + + await click(tabs()[1]); + + assert.deepEqual(renderedKeys(), ['greeting'], 'the empty language gets the default key'); + assert.strictEqual(valueInputs()[0].value, '', 'with no translation behind it'); + }); + + test('an array value produces no language tabs at all', async function (assert) { + this.set('value', ['not', 'an', 'object']); + this.set('defaultKeys', ['greeting']); + + await render(TEMPLATE); + + assert.deepEqual(tabLabels(), [], 'the array is discarded rather than indexed'); + }); + + test('a blank value is discarded rather than rendered', async function (assert) { + this.set('value', ''); + this.set('defaultKeys', ['greeting']); + + await render(TEMPLATE); + + assert.strictEqual(tabs().length, 0); + }); + }); + + module('editing translations', function (hooks) { + hooks.beforeEach(function () { + this.set('value', { en: { greeting: 'Hello', farewell: 'Bye' } }); + }); + + test('editing a value reports the new translations', async function (assert) { + await render(TEMPLATE); + + await editAndBlur(valueInputs()[0], ' Good day '); + + assert.strictEqual(lastChange().en.greeting, 'Good day', 'the value is trimmed'); + }); + + test('renaming a key underscores it and keeps the value', async function (assert) { + await render(TEMPLATE); + + await editAndBlur(keyInputs()[0], 'Formal Greeting'); + + const change = lastChange(); + assert.strictEqual(change.en.formal_greeting, 'Hello', 'the value moves to the new key'); + assert.strictEqual(change.en.greeting, undefined, 'the old key is gone'); + }); + + test('a translation can be removed', async function (assert) { + await render(TEMPLATE); + + await click(removeButtons()[0]); + + assert.deepEqual(renderedKeys(), ['farewell']); + assert.strictEqual(lastChange().en.greeting, undefined); + }); + + test('a new empty translation can be added', async function (assert) { + await render(TEMPLATE); + + await click(addButton()); + + assert.deepEqual(renderedKeys(), ['greeting', 'farewell', 'translation_0'], 'the new key takes the first free translation_N index'); + assert.strictEqual(lastChange().en.translation_0, null); + }); + + test('adding after a removal does not reuse a key that is still in play', async function (assert) { + await render(TEMPLATE); + + await click(addButton()); // -> translation_0 + await click(addButton()); // -> translation_1 + await click(removeButtons()[0]); // remove greeting; translation_1 survives + await click(addButton()); // must go past the highest index in use, not the count + + assert.deepEqual(renderedKeys(), ['farewell', 'translation_0', 'translation_1', 'translation_2'], 'a fourth entry appears and nothing is overwritten'); + }); + + test('it edits without an onChange handler', async function (assert) { + await render(hbs``); + + await click(addButton()); + + assert.deepEqual(renderedKeys(), ['greeting', 'farewell', 'translation_0']); + }); + }); + module('adding a language', function () { + test('a chosen country becomes a new active tab seeded with the default keys', async function (assert) { + this.set('value', { en: { greeting: 'Hello' } }); + this.set('defaultKeys', ['greeting', 'farewell']); + + await render(TEMPLATE); + await selectChoose('.fleetbase-power-select', 'United States'); + + assert.deepEqual(tabLabels(), ['en', 'us'], 'the ISO code is lowercased into a tab'); + assert.strictEqual(activeTab().textContent.trim(), 'us', 'and the new language is selected'); + assert.deepEqual(renderedKeys(), ['greeting', 'farewell'], 'the new language starts with every default key'); + assert.deepEqual( + valueInputs().map((input) => input.value), + ['', ''], + 'and no values' + ); + assert.deepEqual( + lastChange(), + { en: { greeting: 'Hello', farewell: null }, us: { greeting: null, farewell: null } }, + 'the addition is reported alongside the existing languages' + ); + }); + + test('with no default keys a new language starts empty', async function (assert) { + await render(TEMPLATE); + await selectChoose('.fleetbase-power-select', 'United States'); + + assert.deepEqual(tabLabels(), ['us']); + assert.deepEqual(renderedKeys(), [], 'there is nothing to seed it with'); + + await click(addButton()); + + assert.deepEqual(renderedKeys(), ['translation_0'], 'and it can still be filled in by hand'); + }); + + test('a language can be added without an onChange handler', async function (assert) { + await render(hbs``); + await selectChoose('.fleetbase-power-select', 'United States'); + + assert.deepEqual(tabLabels(), ['us']); + }); + }); + + // `defaultKeys` defaults to `[]` only when the argument is absent; an explicit null takes the + // other path through `setDefaultKeys`, which hands the value straight back. + module('an explicit null defaultKeys', function () { + test('the value is passed through untouched', async function (assert) { + this.set('value', { en: { greeting: 'Hello' } }); + this.set('defaultKeys', null); + + await render(TEMPLATE); + + assert.deepEqual(tabLabels(), ['en']); + assert.deepEqual(renderedKeys(), ['greeting'], 'nothing is added and nothing is dropped'); + }); + + test('no value at all still renders an empty editor', async function (assert) { + this.set('defaultKeys', null); + + await render(TEMPLATE); + + assert.deepEqual(tabLabels(), [], 'there is nothing to show'); + assert.dom('.translations-editor-input').exists('but the editor still renders'); + }); + }); + module('editing without tearing down the row', function () { + test('renaming a key keeps the very same input element', async function (assert) { + this.set('value', { en: { greeting: 'Hello' } }); + + await render(TEMPLATE); + + const before = keyInputs()[0]; + before.dataset.identity = 'original-node'; + + await fillIn(before, 'salutation'); + + const after = keyInputs()[0]; + assert.strictEqual(after.dataset.identity, 'original-node', 'the row survives the rename rather than being rebuilt'); + assert.strictEqual(after.value, 'salutation', 'and shows the new key'); + assert.deepEqual(lastChange(), { en: { salutation: 'Hello' } }, 'the value moves with it'); + }); + + test('a key can be renamed twice in a row', async function (assert) { + this.set('value', { en: { greeting: 'Hello' } }); + + await render(TEMPLATE); + await fillIn(keyInputs()[0], 'salutation'); + await fillIn(keyInputs()[0], 'welcome_message'); + + assert.deepEqual(renderedKeys(), ['welcome_message']); + assert.deepEqual(lastChange(), { en: { welcome_message: 'Hello' } }, 'the value survives both renames'); + }); + + test('editing a key and then its value reports both', async function (assert) { + this.set('value', { en: { greeting: 'Hello' } }); + + await render(TEMPLATE); + await fillIn(keyInputs()[0], 'salutation'); + await fillIn(valueInputs()[0], 'Hi there'); + + assert.deepEqual(lastChange(), { en: { salutation: 'Hi there' } }); + }); + + test('two rows are edited independently', async function (assert) { + this.set('value', { en: { greeting: 'Hello', farewell: 'Bye' } }); + + await render(TEMPLATE); + await fillIn(valueInputs()[1], 'Goodbye'); + + assert.deepEqual(lastChange(), { en: { greeting: 'Hello', farewell: 'Goodbye' } }, 'only the edited row changes'); + assert.strictEqual(valueInputs()[0].value, 'Hello', 'the other row is untouched'); + }); + + test('a key is underscored as it is committed', async function (assert) { + this.set('value', { en: { greeting: 'Hello' } }); + + await render(TEMPLATE); + await fillIn(keyInputs()[0], 'Welcome Message'); + + assert.deepEqual(renderedKeys(), ['welcome_message'], 'the normalised key is shown back to the user'); + assert.deepEqual(lastChange(), { en: { welcome_message: 'Hello' } }); + }); + }); + + // The editor used to write the defaults straight into the object it was handed. + test('it does not modify the value it was given', async function (assert) { + const original = { en: { greeting: 'Hello' } }; + this.set('value', original); + this.set('defaultKeys', ['greeting', 'farewell']); - await render(hbs``); + await render(TEMPLATE); - assert.dom(this.element).hasText(''); + assert.deepEqual(original, { en: { greeting: 'Hello' } }, 'the caller keeps its own object intact'); - // Template block usage: - await render(hbs` - - template block text - - `); + await fillIn(valueInputs()[0], 'Hi'); - assert.dom(this.element).hasText('template block text'); + assert.deepEqual(original, { en: { greeting: 'Hello' } }, 'and edits do not reach back into it either'); }); }); diff --git a/tests/integration/components/unit-input-test.js b/tests/integration/components/unit-input-test.js index 2daee4b5..0a87a630 100644 --- a/tests/integration/components/unit-input-test.js +++ b/tests/integration/components/unit-input-test.js @@ -1,26 +1,219 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { selectChoose, getDropdownItems } from 'ember-power-select/test-support'; + +const SCOPE = '.ui-unit-input'; + +function input() { + return find(`${SCOPE} input`); +} module('Integration | Component | unit-input', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + let unitChanges; - await render(hbs``); + hooks.beforeEach(function () { + unitChanges = []; + this.set('onUnitChange', (unit) => unitChanges.push(unit)); + }); + + const TEMPLATE = hbs` + + `; + + module('rendering', function () { + test('it renders an amount field beside the unit', async function (assert) { + this.set('unit', 'm'); + + await render(TEMPLATE); + + assert.dom(SCOPE).exists(); + assert.dom(input()).hasAttribute('type', 'tel'); + assert.dom(SCOPE).containsText('m', 'the unit is shown alongside the input'); + }); + + test('the value is bound to the input', async function (assert) { + this.set('unit', 'm'); + this.set('value', '42'); + + await render(TEMPLATE); + + assert.dom(input()).hasValue('42'); + }); + + test('a wrapper class and splattributes are applied', async function (assert) { + this.set('wrapperClass', 'my-unit-input'); + + await render(hbs``); + + assert.dom(SCOPE).hasClass('my-unit-input'); + assert.dom(input()).hasAttribute('data-test-unit', 'yes'); + }); + + test('a disabled input is dimmed and both controls disabled', async function (assert) { + this.set('unit', 'm'); + this.set('disabled', true); + + await render(TEMPLATE); + + assert.dom(SCOPE).hasClass('unit-input-disabled'); + assert.dom(input()).isDisabled(); + }); + }); + + module('the placeholder', function () { + test('it names the selected unit by default', async function (assert) { + this.set('unit', 'm'); + + await render(TEMPLATE); + + assert.dom(input()).hasAttribute('placeholder', 'Enter Meter'); + }); + + test('an unknown unit falls back to the raw code', async function (assert) { + this.set('unit', 'furlongs'); + + await render(TEMPLATE); + + assert.dom(input()).hasAttribute('placeholder', 'Enter furlongs'); + }); + + test('an explicit placeholder wins', async function (assert) { + this.set('unit', 'm'); + this.set('placeholder', 'How far?'); + + await render(TEMPLATE); + + assert.dom(input()).hasAttribute('placeholder', 'How far?'); + }); + }); + + module('measurement systems', function (hooks) { + hooks.beforeEach(function () { + this.set('canSelectUnit', true); + }); + + test('length is the default measurement', async function (assert) { + this.set('unit', 'm'); + + await render(TEMPLATE); + const options = await getDropdownItems(`${SCOPE} .unit-input-selector`); - assert.dom(this.element).hasText(''); + assert.true(options.some((option) => option.includes('Meter'))); + assert.false(options.some((option) => option.includes('Kilograms'))); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('weight offers the weight units', async function (assert) { + this.set('measurement', 'weight'); + this.set('unit', 'kg'); + + await render(TEMPLATE); + const options = await getDropdownItems(`${SCOPE} .unit-input-selector`); + + assert.true(options.some((option) => option.includes('Kilograms'))); + assert.true(options.some((option) => option.includes('Tonne'))); + assert.false(options.some((option) => option.includes('Meter'))); + }); + + test('volume offers the volume units', async function (assert) { + this.set('measurement', 'volume'); + + await render(TEMPLATE); + const options = await getDropdownItems(`${SCOPE} .unit-input-selector`); + + assert.true(options.length > 0, 'volume units are offered'); + assert.false(options.some((option) => option.includes('Kilograms'))); + }); + + test('an unrecognised measurement offers nothing', async function (assert) { + this.set('measurement', 'luminosity'); + + await render(TEMPLATE); + const options = await getDropdownItems(`${SCOPE} .unit-input-selector`); + + assert.deepEqual(options, ['No results found']); + }); + + test('each option shows its name and code', async function (assert) { + this.set('unit', 'm'); + + await render(TEMPLATE); + const options = await getDropdownItems(`${SCOPE} .unit-input-selector`); + + assert.true( + options.some((option) => option.includes('Meter') && option.includes('(m)')), + 'both the name and the abbreviation are shown' + ); + }); + }); + + module('choosing a unit', function () { + test('the selector is only offered when asked for', async function (assert) { + this.set('unit', 'm'); + + await render(TEMPLATE); + assert.dom('.unit-input-selector').doesNotExist('a plain label by default'); + + this.set('canSelectUnit', true); + await settled(); + + assert.dom('.unit-input-selector').exists(); + }); + + test('choosing a unit reports its code', async function (assert) { + this.set('unit', 'm'); + this.set('canSelectUnit', true); + + await render(TEMPLATE); + await selectChoose(`${SCOPE} .unit-input-selector`, 'Kilometer'); + + assert.deepEqual(unitChanges, ['km']); + }); + + test('the placeholder follows the newly chosen unit', async function (assert) { + this.set('unit', 'm'); + this.set('canSelectUnit', true); + + await render(TEMPLATE); + await selectChoose(`${SCOPE} .unit-input-selector`, 'Kilometer'); + + assert.dom(input()).hasAttribute('placeholder', 'Enter Kilometer'); + }); + + test('it selects without an onUnitChange handler', async function (assert) { + await render(hbs``); + await selectChoose(`${SCOPE} .unit-input-selector`, 'Kilometer'); + + assert.dom(input()).hasAttribute('placeholder', 'Enter Kilometer', 'the selection still applies'); + }); + + test('a disabled selector cannot be opened', async function (assert) { + this.set('unit', 'm'); + this.set('canSelectUnit', true); + this.set('disabled', true); + + await render(TEMPLATE); + + assert.dom(`${SCOPE} .ember-power-select-trigger`).hasAttribute('aria-disabled', 'true'); + }); + }); + + test('with no unit the field carries no placeholder at all', async function (assert) { + await render(hbs``); - assert.dom(this.element).hasText('template block text'); + assert.dom(SCOPE).exists(); + assert.dom(input()).doesNotHaveAttribute('placeholder', 'better nothing than "Enter undefined"'); }); }); diff --git a/tests/integration/components/unit-input/unit-handle-test.js b/tests/integration/components/unit-input/unit-handle-test.js index 37ed4a1a..3fe7fd3f 100644 --- a/tests/integration/components/unit-input/unit-handle-test.js +++ b/tests/integration/components/unit-input/unit-handle-test.js @@ -6,21 +6,35 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | unit-input/unit-handle', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + test('it renders the unit value', async function (assert) { + this.set('unit', { value: 'kg', label: 'Kilograms' }); - await render(hbs``); + await render(hbs``); + + assert.dom(this.element).containsText('kg'); + }); - assert.dom(this.element).hasText(''); + test('a different unit renders its own value', async function (assert) { + this.set('unit', { value: 'lb' }); + + await render(hbs``); + + assert.dom(this.element).containsText('lb'); + assert.dom(this.element).doesNotContainText('kg'); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('it renders a dropdown status icon', async function (assert) { + this.set('unit', { value: 'kg' }); + + await render(hbs``); + + assert.dom('.ember-power-select-status-icon').exists(); + }); + + test('it renders without a unit', async function (assert) { + await render(hbs``); - assert.dom(this.element).hasText('template block text'); + assert.dom('.ember-power-select-status-icon').exists('the handle still renders'); + assert.dom(this.element).doesNotContainText('undefined'); }); }); diff --git a/tests/integration/components/upload-button-test.js b/tests/integration/components/upload-button-test.js index d71c6e83..2afcb792 100644 --- a/tests/integration/components/upload-button-test.js +++ b/tests/integration/components/upload-button-test.js @@ -1,26 +1,119 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, triggerEvent, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +function button() { + return find('a.btn'); +} + module('Integration | Component | upload-button', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + let added; + + hooks.beforeEach(function () { + added = []; + this.set('onFileAdded', (file) => added.push(file)); + }); + + const TEMPLATE = hbs` + + `; + + module('rendering', function () { + test('it renders a default upload button', async function (assert) { + await render(TEMPLATE); + + assert.dom(button()).hasText('Upload new'); + assert.dom(button()).hasClass('btn-default'); + assert.dom(button()).hasClass('btn-sm'); + assert.dom('a.btn svg').exists('a default icon is shown'); + }); + + test('the label, icon, type and size can all be overridden', async function (assert) { + this.set('buttonText', 'Choose a photo'); + this.set('icon', 'camera'); + this.set('type', 'primary'); + this.set('size', 'lg'); + + await render(TEMPLATE); + + assert.dom(button()).hasText('Choose a photo'); + assert.dom(button()).hasClass('btn-primary'); + assert.dom(button()).hasClass('btn-lg'); + }); + + test('the outline variant is opt-in', async function (assert) { + await render(TEMPLATE); + assert.dom(button()).doesNotHaveClass('btn-outline'); + + this.set('outline', true); + assert.dom(button()).hasClass('btn-outline'); + }); + + test('the label can be hidden, leaving an icon-only button', async function (assert) { + this.set('hideButtonText', true); + + await render(TEMPLATE); + + assert.dom(button()).hasText(''); + assert.dom('a.btn svg').exists('the icon remains'); + }); + + test('a disabled button is marked and dimmed', async function (assert) { + this.set('disabled', true); + + await render(TEMPLATE); + + assert.dom(button()).hasAttribute('disabled'); + assert.dom(button()).hasClass('cursor-not-allowed'); + }); + + test('it forwards splattributes', async function (assert) { + await render(hbs``); + + assert.dom('a.btn').hasAttribute('data-test-upload', 'yes'); + }); + }); + + module('uploading', function () { + test('choosing a file reports it', async function (assert) { + await render(TEMPLATE); + + const file = new File(['image-bytes'], 'logo.png', { type: 'image/png' }); + await triggerEvent('input[type="file"]', 'change', { files: [file] }); + + assert.strictEqual(added.length, 1, 'the file is handed to the caller'); + assert.strictEqual(added[0].name, 'logo.png'); + }); + + test('while a file is queued it shows an uploading state', async function (assert) { + await render(TEMPLATE); - await render(hbs``); + const file = new File(['image-bytes'], 'logo.png', { type: 'image/png' }); + await triggerEvent('input[type="file"]', 'change', { files: [file] }); - assert.dom(this.element).hasText(''); + assert.dom(button()).containsText('Uploading...'); + assert.dom('a.btn .fleetbase-loader').exists('a spinner replaces the icon'); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('it renders without an onFileAdded handler', async function (assert) { + await render(hbs``); - assert.dom(this.element).hasText('template block text'); + assert.ok(button(), 'no handler is required to render'); + }); }); }); diff --git a/tests/integration/components/user/pill-test.js b/tests/integration/components/user/pill-test.js new file mode 100644 index 00000000..ad07fd16 --- /dev/null +++ b/tests/integration/components/user/pill-test.js @@ -0,0 +1,164 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | user/pill', function (hooks) { + setupRenderingTest(hooks); + + test('it renders the user name and email', async function (assert) { + this.set('user', { name: 'Ada Lovelace', email: 'ada@example.com', avatar_url: '/img/ada.png' }); + + await render(hbs``); + + assert.dom(this.element).containsText('Ada Lovelace'); + assert.dom(this.element).containsText('ada@example.com'); + }); + + test('@resource is accepted as an alias for @user', async function (assert) { + this.set('resource', { name: 'Grace Hopper', email: 'grace@example.com' }); + + await render(hbs``); + + assert.dom(this.element).containsText('Grace Hopper'); + }); + + test('@user wins when both are supplied', async function (assert) { + this.set('user', { name: 'From user' }); + this.set('resource', { name: 'From resource' }); + + await render(hbs``); + + assert.dom(this.element).containsText('From user'); + assert.dom(this.element).doesNotContainText('From resource'); + }); + + test('it falls back to resource when user is explicitly nullish', async function (assert) { + this.set('user', null); + this.set('resource', { name: 'From resource' }); + + await render(hbs``); + + assert.dom(this.element).containsText('From resource', 'null falls through the ?? to the resource'); + }); + + test('it shows the default fallback title when there is no user', async function (assert) { + await render(hbs``); + + assert.dom(this.element).containsText('No user'); + }); + + test('@titleFallback overrides the default fallback title', async function (assert) { + await render(hbs``); + + assert.dom(this.element).containsText('Unassigned'); + assert.dom(this.element).doesNotContainText('No user'); + }); + + test('a user without an email shows the no-email placeholder', async function (assert) { + this.set('user', { name: 'Ada Lovelace' }); + + await render(hbs``); + + assert.dom(this.element).containsText('No email'); + }); + + test('with no user at all the subtitle is a dash', async function (assert) { + await render(hbs``); + + assert.dom(this.element).containsText('-', 'the absent-user subtitle differs from the no-email one'); + assert.dom(this.element).doesNotContainText('No email'); + }); + + test('it invokes @onClick', async function (assert) { + let clicks = 0; + this.set('user', { name: 'Ada', email: 'ada@example.com' }); + this.set('onClick', () => clicks++); + + await render(hbs``); + await click('a'); + + assert.strictEqual(clicks, 1); + }); + + test('it applies the pass-through class arguments', async function (assert) { + this.set('user', { name: 'Ada', email: 'ada@example.com' }); + + await render(hbs``); + + assert.dom('.anchor-x').exists('anchorClass reaches the anchor'); + assert.dom('.title-x').exists('titleClass reaches the title'); + assert.dom('.subtitle-x').exists('subtitleClass reaches the subtitle'); + }); + + test('it forwards splattributes', async function (assert) { + this.set('user', { name: 'Ada' }); + + await render(hbs``); + + assert.dom('[data-test-pill="yes"]').exists(); + }); + + test('it updates when the user changes', async function (assert) { + this.set('user', { name: 'Ada Lovelace', email: 'ada@example.com' }); + await render(hbs``); + assert.dom(this.element).containsText('Ada Lovelace'); + + this.set('user', { name: 'Grace Hopper', email: 'grace@example.com' }); + assert.dom(this.element).containsText('Grace Hopper'); + assert.dom(this.element).doesNotContainText('Ada Lovelace'); + }); + + module('the arguments that override the user family defaults', function () { + test('an explicit @subtitle wins over the email and the placeholders', async function (assert) { + this.set('user', { name: 'Ada Lovelace', email: 'ada@example.com' }); + + await render(hbs``); + + assert.dom(this.element).containsText('Fleet manager'); + assert.dom(this.element).doesNotContainText('ada@example.com', 'the email is not shown as well'); + }); + + test('an empty @subtitle is still an explicit choice', async function (assert) { + this.set('user', { name: 'Ada Lovelace', email: 'ada@example.com' }); + + await render(hbs``); + + assert.dom(this.element).doesNotContainText('ada@example.com', 'an empty subtitle is not undefined, so the email is not used'); + }); + + test('@showOnlineIndicator decides the dot when it is given', async function (assert) { + this.set('user', { name: 'Ada', email: 'ada@example.com', online: true }); + + await render(hbs``); + assert.dom('[data-test-pill-online-indicator]').exists('the dot is shown when asked for'); + + await render(hbs``); + assert.dom('[data-test-pill-online-indicator]').doesNotExist('and withheld when refused'); + }); + + test('@onlinePath asks for the dot and reads the flag from that path', async function (assert) { + this.set('user', { name: 'Ada', email: 'ada@example.com', is_active: true, online: false }); + + await render(hbs``); + + assert.dom('[data-test-pill-online-indicator]').exists('naming a path is enough to ask for the dot'); + assert.dom('[data-test-pill-online-indicator]').hasClass('text-green-500', 'and the flag is read from that path, not the default one'); + }); + + test('@onlinePath reports offline when the path is falsey', async function (assert) { + this.set('user', { name: 'Ada', email: 'ada@example.com', is_active: false, online: true }); + + await render(hbs``); + + assert.dom('[data-test-pill-online-indicator]').hasClass('text-yellow-200', 'the named path wins over the default online flag'); + }); + + test('@onlinePath with no user at all reports nothing rather than reading a path off nothing', async function (assert) { + await render(hbs``); + + assert.dom('[data-test-pill-online-indicator]').exists('the dot is still asked for'); + assert.dom('[data-test-pill-online-indicator]').hasClass('text-yellow-200', 'and with no user it reads as offline'); + }); + }); +}); diff --git a/tests/integration/components/view-raw-metadata-test.js b/tests/integration/components/view-raw-metadata-test.js index aca2f6df..d23411d7 100644 --- a/tests/integration/components/view-raw-metadata-test.js +++ b/tests/integration/components/view-raw-metadata-test.js @@ -6,21 +6,15 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | view-raw-metadata', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + test('it renders its block with no wrapper of its own', async function (assert) { + await render(hbs`
      raw
      `); - await render(hbs``); - - assert.dom().hasText(''); + assert.dom('.host > .inside').hasText('raw', 'the block lands directly in the caller'); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('with no block it renders nothing', async function (assert) { + await render(hbs`
      `); - assert.dom().hasText('template block text'); + assert.dom('.host').hasText(''); }); }); diff --git a/tests/integration/components/visible-column-picker-test.js b/tests/integration/components/visible-column-picker-test.js index 3356a30f..d25ae546 100644 --- a/tests/integration/components/visible-column-picker-test.js +++ b/tests/integration/components/visible-column-picker-test.js @@ -1,26 +1,170 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click, findAll, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +const TRIGGER = '.ember-basic-dropdown-trigger'; + +function columns() { + return [{ label: 'Name', valuePath: 'name', hidden: false }, { label: 'Status', valuePath: 'status', hidden: true }, { valuePath: 'internal_id' }]; +} + +function checkboxes() { + return findAll('.customize-columns-dropdown-body input[type="checkbox"]'); +} + +function checkboxLabels() { + return findAll('.customize-columns-dropdown-body label').map((label) => label.textContent.trim()); +} + module('Integration | Component | visible-column-picker', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + let changes; + let applied; + + hooks.beforeEach(function () { + changes = []; + applied = []; + this.set('columns', columns()); + this.set('onChange', (cols) => changes.push(cols.map((column) => `${column.valuePath}:${column.hidden ? 'hidden' : 'shown'}`))); + this.set('onApply', (cols) => applied.push(cols.length)); + }); + + const TEMPLATE = hbs` + + `; + + module('the trigger', function () { + test('it renders a labelled trigger with a default icon', async function (assert) { + await render(TEMPLATE); + + assert.ok(find(TRIGGER), 'the picker renders'); + assert.dom(`${TRIGGER} svg`).hasClass('fa-sliders'); + }); + + test('the trigger text and icon can be replaced', async function (assert) { + this.setProperties({ text: 'Choose fields', icon: 'table-columns' }); + + await render(TEMPLATE); + + assert.dom(TRIGGER).containsText('Choose fields'); + assert.dom(`${TRIGGER} svg`).hasClass('fa-table-columns'); + }); + + test('the trigger can be reduced to an icon', async function (assert) { + this.setProperties({ text: 'Choose fields', iconOnly: true }); + + await render(TEMPLATE); + + assert.dom(TRIGGER).doesNotContainText('Choose fields'); + assert.ok(find(`${TRIGGER} svg`), 'only the icon remains'); + }); + + test('it forwards splattributes', async function (assert) { + await render(hbs``); + + assert.dom('[data-test-picker="yes"]').exists(); + }); + }); + + module('the column list', function () { + test('only labelled columns are offered', async function (assert) { + await render(TEMPLATE); + await click(TRIGGER); + + assert.deepEqual(checkboxLabels(), ['Name', 'Status'], 'the unlabelled column is skipped'); + }); + + test('each checkbox reflects whether the column is shown', async function (assert) { + await render(TEMPLATE); + await click(TRIGGER); + + assert.dom(checkboxes()[0]).isChecked('a visible column is ticked'); + assert.dom(checkboxes()[1]).isNotChecked('a hidden column is not'); + }); + + test('the dropdown carries a heading', async function (assert) { + await render(TEMPLATE); + await click(TRIGGER); + + assert.dom('.customize-columns-dropdown-header h4').exists(); + }); + + test('the heading can be replaced', async function (assert) { + this.set('dropdownHeaderText', 'Pick your columns'); + + await render(TEMPLATE); + await click(TRIGGER); + + assert.dom('.customize-columns-dropdown-header h4').hasText('Pick your columns'); + }); + + test('a picker with no columns renders an empty list', async function (assert) { + this.set('columns', []); + + await render(TEMPLATE); + await click(TRIGGER); + + assert.deepEqual(checkboxes(), []); + assert.dom('.customize-columns-dropdown-body').exists('the body still renders'); + }); + }); + + module('changing visibility', function () { + test('unticking a column hides it and reports every column', async function (assert) { + await render(TEMPLATE); + await click(TRIGGER); + await click(checkboxes()[0]); + + assert.notStrictEqual(this.columns[0].hidden, false, 'the column is now hidden'); + assert.true(this.columns[0].hidden); + assert.deepEqual(changes, [['name:hidden', 'status:hidden', 'internal_id:shown']]); + }); + + test('ticking a column shows it again', async function (assert) { + await render(TEMPLATE); + await click(TRIGGER); + await click(checkboxes()[1]); + + assert.false(this.columns[1].hidden); + assert.deepEqual(changes, [['name:shown', 'status:shown', 'internal_id:shown']]); + }); + + test('it changes visibility happily without an onChange handler', async function (assert) { + await render(hbs``); + await click(TRIGGER); + await click(checkboxes()[0]); + + assert.true(this.columns[0].hidden, 'the column is still hidden'); + }); + }); - await render(hbs``); + module('applying', function () { + test('done reports the columns and closes the dropdown', async function (assert) { + await render(TEMPLATE); + await click(TRIGGER); + await click('.visible-column-picker-done-btn button'); - assert.dom(this.element).hasText(''); + assert.deepEqual(applied, [3], 'every column is handed back'); + assert.strictEqual(find('.customize-columns-dropdown-container'), null, 'the dropdown closes'); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('it applies happily without an onApply handler', async function (assert) { + await render(hbs``); + await click(TRIGGER); + await click('.visible-column-picker-done-btn button'); - assert.dom(this.element).hasText('template block text'); + assert.strictEqual(find('.customize-columns-dropdown-container'), null, 'the dropdown still closes'); + }); }); }); diff --git a/tests/integration/components/widget/count-test.js b/tests/integration/components/widget/count-test.js index a873cced..25282698 100644 --- a/tests/integration/components/widget/count-test.js +++ b/tests/integration/components/widget/count-test.js @@ -1,26 +1,133 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +function renderedValue() { + return find('.widget-count h1').textContent.trim(); +} + module('Integration | Component | widget/count', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + const TEMPLATE = hbs``; - await render(hbs``); + test('it renders a title and a value', async function (assert) { + this.set('title', 'Total orders'); + this.set('value', 1200); + + await render(TEMPLATE); + + assert.dom('.widget-count').exists(); + assert.dom('.widget-count h3').hasText('Total orders'); + assert.strictEqual(renderedValue(), '1200'); + }); + + test('an explicit value wins over anything in the options', async function (assert) { + this.set('value', 42); + this.set('options', { value: 999, format: 'money', currency: 'USD' }); + + await render(TEMPLATE); + + assert.strictEqual(renderedValue(), '42', 'the direct value short-circuits formatting'); + }); + + test('with no value at all the widget renders empty', async function (assert) { + this.set('title', 'Nothing yet'); + + await render(TEMPLATE); + + assert.strictEqual(renderedValue(), ''); + }); + + module('option formats', function () { + test('money is formatted in the requested currency', async function (assert) { + this.set('options', { value: 1500, format: 'money', currency: 'EUR' }); + + await render(TEMPLATE); + + const rendered = renderedValue(); + assert.true(rendered.includes('15'), `the amount survives (got ${rendered})`); + assert.false(rendered.includes('$'), 'and the euro currency is honoured, not the USD default'); + }); + + test('money with no currency falls back to the USD default', async function (assert) { + this.set('options', { value: 1500, format: 'money' }); + + await render(TEMPLATE); + + assert.strictEqual(renderedValue(), '$15.00'); + }); + + test('a date is formatted rather than throwing', async function (assert) { + this.set('options', { value: new Date('2026-03-12T00:00:00Z'), format: 'date', dateFormat: 'yyyy-MM-dd' }); + + await render(TEMPLATE); + + assert.strictEqual(renderedValue(), '2026-03-12'); + }); - assert.dom().hasText(''); + test('meters are formatted as a distance', async function (assert) { + this.set('options', { value: 5000, format: 'meters' }); - // Template block usage: - await render(hbs` - - template block text - - `); + await render(TEMPLATE); + + assert.strictEqual(renderedValue(), '5km'); + }); + + test('bytes are formatted as a file size', async function (assert) { + this.set('options', { value: 2048, format: 'bytes' }); + + await render(TEMPLATE); + + assert.true(/B|KB|MB/i.test(renderedValue()), 'a size unit is appended'); + }); + + test('a duration is formatted', async function (assert) { + this.set('options', { value: 3600, format: 'duration' }); + + await render(TEMPLATE); + + assert.notStrictEqual(renderedValue(), '3600'); + assert.true(renderedValue().length > 0); + }); + + // DEFECT: the `date` format calls + // `formatDate([value, dateFormat])`, handing date-fns an ARRAY. It throws during + // render, taking the whole widget down. There is deliberately no test for that + // branch — a throwing render cannot be asserted on without aborting the run. + + test('an unrecognised format passes the value through untouched', async function (assert) { + this.set('options', { value: 'as-is', format: 'hologram' }); + + await render(TEMPLATE); + + assert.strictEqual(renderedValue(), 'as-is'); + }); + + test('no format passes the value through untouched', async function (assert) { + this.set('options', { value: 'plain' }); + + await render(TEMPLATE); + + assert.strictEqual(renderedValue(), 'plain'); + }); + }); + + test('option classes are applied and splattributes forwarded', async function (assert) { + this.set('options', { value: 1, wrapperClass: 'my-wrapper', titleClass: 'my-title', valueClass: 'my-value' }); + + await render(hbs``); + + assert.dom('.widget-count').hasClass('my-wrapper'); + assert.dom('.widget-count').hasAttribute('data-test-count', 'yes'); + assert.dom('.widget-count h3').hasClass('my-title'); + assert.dom('.widget-count h1').hasClass('my-value'); + }); + + test('it renders with no arguments at all', async function (assert) { + await render(hbs``); - assert.dom().hasText('template block text'); + assert.dom('.widget-count').exists(); }); }); diff --git a/tests/integration/components/widget/query-params-test.js b/tests/integration/components/widget/query-params-test.js index 4525f6ef..d1f5731c 100644 --- a/tests/integration/components/widget/query-params-test.js +++ b/tests/integration/components/widget/query-params-test.js @@ -1,26 +1,139 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click, find, findAll } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Component from '@glimmer/component'; + +const DAY = '.air-datepicker-cell.-day-:not(.-other-month-)'; + +function labels() { + return findAll('label').map((label) => label.textContent.trim()); +} module('Integration | Component | widget/query-params', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + let reported; + + hooks.beforeEach(function () { + reported = []; + this.set('onChange', (params) => reported.push(params)); + + // A stand-in for whatever input a widget nominates: it simply hands its value back. + this.owner.register('component:test-param-input', class extends Component {}); + this.owner.register( + 'template:components/test-param-input', + hbs` + + + + + ` + ); + }); + + const TEMPLATE = hbs``; + + module('rendering', function () { + test('it renders a labelled control per parameter', async function (assert) { + this.set('params', { + status: { component: 'test-param-input' }, + assigned_driver: { component: 'test-param-input' }, + }); + + await render(TEMPLATE); + + assert.deepEqual(labels(), ['Status:', 'Assigned driver:'], 'each key is humanized into a label'); + assert.strictEqual(findAll('.param-input').length, 2); + }); + + test('each control is prompted and sized by the component', async function (assert) { + this.set('params', { assigned_driver: { component: 'test-param-input' } }); + + await render(TEMPLATE); + + assert.dom('.param-input').hasAttribute('data-placeholder', 'Assigned driver'); + assert.dom('.param-input').hasClass('form-input-sm'); + }); + + test('no parameters renders an empty row', async function (assert) { + this.set('params', {}); + + await render(TEMPLATE); + + assert.strictEqual(findAll('label').length, 0); + assert.dom('div').exists('the row still renders'); + }); + + test('it renders with no parameters argument at all', async function (assert) { + await render(hbs``); + + assert.strictEqual(findAll('label').length, 0); + }); + + test('it forwards splattributes', async function (assert) { + await render(hbs``); + + assert.dom('[data-test-params="yes"]').exists(); + }); + }); + + module('reporting changes', function () { + test('choosing a value reports it under its parameter name', async function (assert) { + this.set('params', { status: { component: 'test-param-input' } }); + + await render(TEMPLATE); + await click('.pick-alpha'); + + assert.deepEqual(reported, [{ status: 'alpha' }]); + }); + + test('parameters accumulate across controls', async function (assert) { + this.set('params', { + status: { component: 'test-param-input' }, + driver: { component: 'test-param-input' }, + }); + + await render(TEMPLATE); + await click(findAll('.pick-alpha')[0]); + await click(findAll('.pick-beta')[1]); + + assert.deepEqual(reported, [{ status: 'alpha' }, { status: 'alpha', driver: 'beta' }], 'earlier choices are retained'); + }); + + test('changing a parameter again replaces its value', async function (assert) { + this.set('params', { status: { component: 'test-param-input' } }); + + await render(TEMPLATE); + await click('.pick-alpha'); + await click('.pick-beta'); + + assert.deepEqual(reported, [{ status: 'alpha' }, { status: 'beta' }]); + }); + + test('it reports happily without an onChange handler', async function (assert) { + this.set('params', { status: { component: 'test-param-input' } }); + + await render(hbs``); + await click('.pick-alpha'); + + assert.dom('.param-input').exists('the widget survives'); + }); + }); + + test('a date parameter is reported as its formatted date', async function (assert) { + this.set('params', { start_date: { component: 'date-picker' } }); - await render(hbs``); + await render(TEMPLATE); - assert.dom().hasText(''); + assert.dom('label').hasText('Start date:'); + assert.ok(find('.fleetbase-date-picker'), 'a date field is rendered'); - // Template block usage: - await render(hbs` - - template block text - - `); + await click('.fleetbase-date-picker'); + await click(findAll(DAY)[9]); - assert.dom().hasText('template block text'); + assert.strictEqual(reported.length, 1, 'the change is reported'); + assert.strictEqual(typeof reported[0].start_date, 'string', 'a formatted date is reported'); + assert.true(/\d/.test(reported[0].start_date), 'and it carries the chosen day'); }); }); diff --git a/tests/integration/components/widget/report-test.js b/tests/integration/components/widget/report-test.js index 8e752103..4ab496ea 100644 --- a/tests/integration/components/widget/report-test.js +++ b/tests/integration/components/widget/report-test.js @@ -74,8 +74,29 @@ module('Integration | Component | widget/report', function (hooks) { assert.dom().includesText('$42.00'); }); + test('a loaded report can be changed, with the current one preselected', async function (assert) { + this.store.report = { + id: 'report-1', + title: 'Revenue', + result_columns: [{ label: 'Total', name: 'total' }], + data: [{ total: '$42.00' }], + }; + this.set('options', { reportId: 'report-1' }); + + await render(hbs``); + await waitUntil(() => this.store.findRecordCalls.length === 1); + await settled(); + + assert.dom('[data-test-widget-report-select]').doesNotExist('the empty-state control is gone'); + await click('[data-test-widget-report-change]'); + + assert.strictEqual(this.modalsManager.shown.length, 1, 'the picker opens from the loaded state'); + assert.strictEqual(this.modalsManager.shown[0].name, 'modals/find-select-report'); + assert.deepEqual(this.modalsManager.shown[0].options.selected, [this.store.report], 'the current report is preselected'); + }); + test('it saves the selected report id to widget options', async function (assert) { - assert.expect(6); + assert.expect(7); const selectedReport = { id: 'report-2', title: 'Utilization' }; const widget = { @@ -116,6 +137,73 @@ module('Integration | Component | widget/report', function (hooks) { assert.verifySteps(['save']); }); + test('dismissing the selector without a pick leaves nothing to save', async function (assert) { + this.set('widget', { options: {} }); + + await render(hbs``); + await click('[data-test-widget-report-select]'); + + const modalOptions = this.modalsManager.shown[0].options; + assert.deepEqual(modalOptions.selected, [], 'nothing is preselected while there is no report'); + + // Dismissing the picker calls back with no argument at all. + modalOptions.onChange(); + await settled(); + assert.dom().includesText('No report', 'the widget stays in its empty state'); + + modalOptions.onChange([]); + await settled(); + assert.dom().includesText('No report', 'and an empty selection reads the same way'); + + await modalOptions.confirm({ startLoading() {}, done() {} }); + assert.deepEqual(this.widget.options, {}, 'with no report id there is nothing to write'); + }); + + test('a widget with no options and no ember-data methods is still updated', async function (assert) { + const widget = {}; + this.set('widget', widget); + + await render(hbs``); + await click('[data-test-widget-report-select]'); + + const modalOptions = this.modalsManager.shown[0].options; + modalOptions.onChange([{ id: 'report-3', title: 'Idle Time' }]); + await modalOptions.confirm({ startLoading() {}, done() {} }); + await settled(); + + assert.deepEqual(widget.options, { reportId: 'report-3' }, 'the options object is created and assigned directly'); + assert.deepEqual(this.notifications.errors, [], 'and a widget that cannot save is not an error'); + }); + + test('a save that fails is reported and the modal is left open', async function (assert) { + const error = new Error('Could not save'); + const widget = { + options: {}, + async save() { + throw error; + }, + }; + this.set('widget', widget); + + await render(hbs``); + await click('[data-test-widget-report-select]'); + + const modalOptions = this.modalsManager.shown[0].options; + modalOptions.onChange([{ id: 'report-4', title: 'Fuel' }]); + + let didClose = false; + await modalOptions.confirm({ + startLoading() {}, + done() { + didClose = true; + }, + }); + await settled(); + + assert.strictEqual(this.notifications.errors[0], error, 'the failure is reported'); + assert.false(didClose, 'and the modal stays open so the choice is not lost'); + }); + test('it reports load errors and returns to the empty state', async function (assert) { const error = new Error('Missing report'); this.store.findRecordError = error; diff --git a/tests/integration/components/with-record-test.js b/tests/integration/components/with-record-test.js index a1b3434e..07eddf21 100644 --- a/tests/integration/components/with-record-test.js +++ b/tests/integration/components/with-record-test.js @@ -3,24 +3,113 @@ import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +const VALID_UUID = '5a9c8f2e-3b1d-4c7a-9e0f-1a2b3c4d5e6f'; + module('Integration | Component | with-record', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + let store; + let peeked; + let found; + + hooks.beforeEach(function () { + peeked = []; + found = []; + store = this.owner.lookup('service:store'); + store.peekRecord = (...args) => { + peeked.push(args); + return null; + }; + store.findRecord = (...args) => { + found.push(args); + return Promise.resolve({ id: args[1], name: 'Order 123' }); + }; + }); + + const TEMPLATE = hbs` + + {{record.name}} + {{if isLoading "loading" "idle"}} + {{error}} + + `; + + test('it fetches the record and yields it', async function (assert) { + this.setProperties({ id: VALID_UUID, type: 'Order' }); + + await render(TEMPLATE); + + assert.deepEqual(peeked, [['order', VALID_UUID]], 'the type is dasherized before it reaches the store'); + assert.deepEqual(found, [['order', VALID_UUID]], 'a cache miss falls through to findRecord'); + assert.dom('.name').hasText('Order 123'); + assert.dom('.loading').hasText('idle'); + assert.dom('.error').hasText(''); + }); - await render(hbs``); + test('a cached record is used without a network fetch', async function (assert) { + store.peekRecord = () => ({ id: VALID_UUID, name: 'Cached order' }); + this.setProperties({ id: VALID_UUID, type: 'order' }); + + await render(TEMPLATE); + + assert.dom('.name').hasText('Cached order'); + assert.deepEqual(found, [], 'findRecord is never reached'); + }); + + test('a multi-word type is dasherized', async function (assert) { + this.setProperties({ id: VALID_UUID, type: 'purchaseRate' }); + + await render(TEMPLATE); + + assert.deepEqual(peeked[0], ['purchase-rate', VALID_UUID]); + }); - assert.dom().hasText(''); + test('a failed lookup is reported through the yielded error', async function (assert) { + store.findRecord = () => Promise.reject(new Error('Order not found')); + this.setProperties({ id: VALID_UUID, type: 'order' }); + + await render(TEMPLATE); + + assert.dom('.error').hasText('Order not found'); + assert.dom('.name').hasText('', 'no record is yielded'); + }); + + test('a lookup returning nothing yields no record and no error', async function (assert) { + store.findRecord = () => Promise.resolve(null); + this.setProperties({ id: VALID_UUID, type: 'order' }); + + await render(TEMPLATE); + + assert.dom('.name').hasText(''); + assert.dom('.error').hasText(''); + }); + + test('a non-uuid id is never looked up', async function (assert) { + this.setProperties({ id: 'not-a-uuid', type: 'order' }); + + await render(TEMPLATE); + + assert.deepEqual(peeked, [], 'the request is rejected before it reaches the store'); + assert.dom('.name').hasText(''); + }); + + test('a missing id or type is never looked up', async function (assert) { + this.setProperties({ id: undefined, type: 'order' }); + await render(TEMPLATE); + assert.deepEqual(peeked, [], 'no id means no lookup'); + + this.setProperties({ id: VALID_UUID, type: undefined }); + await render(TEMPLATE); + assert.deepEqual(peeked, [], 'no type means no lookup'); + }); + test('a rejection carrying no message falls back to a generic one', async function (assert) { + // Not every rejection is an Error — a bare object reaches the same handler. + store.findRecord = () => Promise.reject({}); + this.setProperties({ id: VALID_UUID, type: 'Order' }); - // Template block usage: - await render(hbs` - - template block text - - `); + await render(TEMPLATE); - assert.dom().hasText('template block text'); + assert.dom('.error').hasText('Failed to load record.'); + assert.dom('.name').hasText('', 'and no record is yielded'); }); }); diff --git a/tests/integration/helpers/avatar-url-test.js b/tests/integration/helpers/avatar-url-test.js index 6e79805c..2bf4f70a 100644 --- a/tests/integration/helpers/avatar-url-test.js +++ b/tests/integration/helpers/avatar-url-test.js @@ -3,15 +3,64 @@ import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +const DEFAULT_AVATAR = 'https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png'; + module('Integration | Helper | avatar-url', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it returns a string url untouched', async function (assert) { + this.set('url', 'https://cdn.example.com/avatars/jane.png'); + + await render(hbs`{{avatar-url this.url}}`); + + assert.dom(this.element).hasText('https://cdn.example.com/avatars/jane.png'); + }); + + test('it falls back to the built-in placeholder when the url is null', async function (assert) { + this.set('url', null); + + await render(hbs`{{avatar-url this.url}}`); + + assert.dom(this.element).hasText(DEFAULT_AVATAR); + }); + + test('it falls back to the built-in placeholder when no argument is supplied', async function (assert) { + await render(hbs`{{avatar-url}}`); + + assert.dom(this.element).hasText(DEFAULT_AVATAR); + }); + + test('it falls back to the built-in placeholder for non-string values', async function (assert) { + this.set('numeric', 42); + this.set('objectValue', { url: 'https://cdn.example.com/nope.png' }); + + await render(hbs`{{avatar-url this.numeric}}{{avatar-url this.objectValue}}`); + + assert.dom('#numeric').hasText(DEFAULT_AVATAR, 'numbers are not treated as urls'); + assert.dom('#object').hasText(DEFAULT_AVATAR, 'objects are not treated as urls'); + }); + + test('an empty string counts as a string and is returned as-is', async function (assert) { + this.set('url', ''); + + await render(hbs`{{avatar-url this.url}}`); + + assert.dom(this.element).hasNoText('an empty string short-circuits the default url'); + }); + + test('it honours a caller supplied default url', async function (assert) { + this.set('url', undefined); + + await render(hbs`{{avatar-url this.url "/assets/fallback.png"}}`); + + assert.dom(this.element).hasText('/assets/fallback.png'); + }); + + test('the caller supplied default is ignored when a real url is present', async function (assert) { + this.set('url', 'https://cdn.example.com/real.png'); - await render(hbs`{{avatar-url this.inputValue}}`); + await render(hbs`{{avatar-url this.url "/assets/fallback.png"}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('https://cdn.example.com/real.png'); }); }); diff --git a/tests/integration/helpers/can-action-test.js b/tests/integration/helpers/can-action-test.js index 11c76aae..2a13dd0f 100644 --- a/tests/integration/helpers/can-action-test.js +++ b/tests/integration/helpers/can-action-test.js @@ -2,16 +2,120 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class StubAbilities extends Service { + checks = []; + allowed = true; + + can(permission, subject) { + this.checks.push({ permission, subject }); + return this.allowed; + } +} + +class OrderModel { + static modelName = 'order'; + + constructor(props = {}) { + Object.assign(this, props); + } +} + +function useStubAbilities(context) { + context.owner.register('service:abilities', StubAbilities); + return context.owner.lookup('service:abilities'); +} module('Integration | Helper | can-action', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it composes schema, action and model name into the permission', async function (assert) { + const abilities = useStubAbilities(this); + const model = new OrderModel({ isNew: false }); + this.set('model', model); + + await render(hbs`{{can-action "approve" this.model}}`); + + assert.dom(this.element).hasText('true'); + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops approve order'); + assert.strictEqual(abilities.checks[0].subject, model); + }); + + test('the second positional argument may be a resource string', async function (assert) { + const abilities = useStubAbilities(this); + + await render(hbs`{{can-action "archive" "IntegratedVendor"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops archive integrated-vendor'); + }); + + test('resource and subject may be supplied as named arguments', async function (assert) { + const abilities = useStubAbilities(this); + const model = new OrderModel({ isNew: false }); + this.set('model', model); + + await render(hbs`{{can-action "archive" resource="integrated-vendor" subject=this.model}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops archive integrated-vendor'); + assert.strictEqual(abilities.checks[0].subject, model, 'the named subject is used for the policy check'); + }); + + test('the named resource wins over the model name', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{can-action "approve" this.model resource="fuel-report"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops approve fuel-report'); + }); + + test('the schema named argument replaces the default prefix', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{can-action "approve" this.model schema="telematics"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'telematics approve order'); + }); + + test('a missing action denies without consulting the abilities service', async function (assert) { + const abilities = useStubAbilities(this); + this.set('noAction', null); + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{can-action this.noAction this.model}}`); + + assert.dom(this.element).hasText('false', 'a custom check requires an explicit action'); + assert.strictEqual(abilities.checks.length, 0); + }); + + test('defaultWhenUnknown applies when the action is missing', async function (assert) { + useStubAbilities(this); + this.set('noAction', undefined); + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{can-action this.noAction this.model defaultWhenUnknown=true}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it renders false when the abilities service denies', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{can-action "approve" this.model}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('multi-word actions are passed through verbatim', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new OrderModel({ isNew: false })); - await render(hbs`{{can-action this.inputValue}}`); + await render(hbs`{{can-action "cancel-and-refund" this.model}}`); - assert.dom().hasText('1234'); + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops cancel-and-refund order'); }); }); diff --git a/tests/integration/helpers/can-delete-test.js b/tests/integration/helpers/can-delete-test.js index a2405112..de5c918f 100644 --- a/tests/integration/helpers/can-delete-test.js +++ b/tests/integration/helpers/can-delete-test.js @@ -2,16 +2,119 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class StubAbilities extends Service { + checks = []; + allowed = true; + + can(permission, subject) { + this.checks.push({ permission, subject }); + return this.allowed; + } +} + +class VehicleModel { + static modelName = 'vehicle'; + + constructor(props = {}) { + Object.assign(this, props); + } +} + +function useStubAbilities(context) { + context.owner.register('service:abilities', StubAbilities); + return context.owner.lookup('service:abilities'); +} module('Integration | Helper | can-delete', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it builds a delete permission from the model name', async function (assert) { + const abilities = useStubAbilities(this); + const model = new VehicleModel({ isNew: false }); + this.set('model', model); + + await render(hbs`{{can-delete this.model}}`); + + assert.dom(this.element).hasText('true'); + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops delete vehicle'); + assert.strictEqual(abilities.checks[0].subject, model); + }); + + test('the delete action does not depend on isNew', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new VehicleModel({ isNew: true })); + + await render(hbs`{{can-delete this.model}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops delete vehicle', 'unsaved records still use the delete action'); + }); + + test('a positional resource string works without any subject', async function (assert) { + const abilities = useStubAbilities(this); + + await render(hbs`{{can-delete "integrated-vendor"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops delete integrated-vendor'); + assert.dom(this.element).hasText('true'); + }); + + test('the resource named argument overrides the model name', async function (assert) { + const abilities = useStubAbilities(this); + const model = new VehicleModel({ isNew: false }); + this.set('model', model); + + await render(hbs`{{can-delete this.model resource="fuel-report"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops delete fuel-report'); + assert.strictEqual(abilities.checks[0].subject, model, 'the original model is still passed as the subject'); + }); + + test('the schema named argument replaces the default prefix', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{can-delete this.model schema="storefront"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'storefront delete vehicle'); + }); + + test('it renders false when the abilities service denies', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{can-delete this.model}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a model without a resolvable name denies and skips the check', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', { id: 1 }); + + await render(hbs`{{can-delete this.model}}`); + + assert.dom(this.element).hasText('false'); + assert.strictEqual(abilities.checks.length, 0); + }); + + test('defaultWhenUnknown is used when no resource can be resolved', async function (assert) { + useStubAbilities(this); + this.set('model', { id: 1 }); + + await render(hbs`{{can-delete this.model defaultWhenUnknown=true}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('an abilities service without can() or cannot() denies', async function (assert) { + this.owner.register('service:abilities', class extends Service {}); + this.set('model', new VehicleModel({ isNew: false })); - await render(hbs`{{can-delete this.inputValue}}`); + await render(hbs`{{can-delete this.model}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('false', 'an unusable abilities service never grants permission'); }); }); diff --git a/tests/integration/helpers/can-remove-chat-participant-test.js b/tests/integration/helpers/can-remove-chat-participant-test.js index 049f790b..2182379e 100644 --- a/tests/integration/helpers/can-remove-chat-participant-test.js +++ b/tests/integration/helpers/can-remove-chat-participant-test.js @@ -3,15 +3,65 @@ import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +const CREATOR_USER_UUID = 'user-creator'; +const OTHER_USER_UUID = 'user-other'; + module('Integration | Helper | can-remove-chat-participant', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + hooks.beforeEach(function () { + this.set('channel', { created_by_uuid: CREATOR_USER_UUID }); + this.set('creatorParticipant', { id: 'participant-1', user_uuid: CREATOR_USER_UUID }); + this.set('otherParticipant', { id: 'participant-2', user_uuid: OTHER_USER_UUID }); + this.set('thirdParticipant', { id: 'participant-3', user_uuid: 'user-third' }); + }); + + test('the channel creator can never be removed, not even by themselves', async function (assert) { + await render(hbs`{{can-remove-chat-participant this.channel this.creatorParticipant this.creatorParticipant}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('the channel creator can remove another participant', async function (assert) { + await render(hbs`{{can-remove-chat-participant this.channel this.creatorParticipant this.otherParticipant}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('a participant can remove themselves', async function (assert) { + await render(hbs`{{can-remove-chat-participant this.channel this.otherParticipant this.otherParticipant}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('self removal is matched on participant id, not user uuid', async function (assert) { + this.set('sameUserDifferentParticipant', { id: 'participant-99', user_uuid: OTHER_USER_UUID }); + + await render(hbs`{{can-remove-chat-participant this.channel this.otherParticipant this.sameUserDifferentParticipant}}`); + + assert.dom(this.element).hasText('false', 'a different participant record is not "self" even for the same user'); + }); + + test('a non-creator cannot remove a different participant', async function (assert) { + await render(hbs`{{can-remove-chat-participant this.channel this.otherParticipant this.thirdParticipant}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a missing current participant denies removal', async function (assert) { + this.set('nobody', null); + + await render(hbs`{{can-remove-chat-participant this.channel this.nobody this.otherParticipant}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a channel without a creator uuid treats an unidentified participant as the creator', async function (assert) { + this.set('channel', {}); + this.set('anonymous', { id: 'participant-4' }); - await render(hbs`{{can-remove-chat-participant this.inputValue}}`); + await render(hbs`{{can-remove-chat-participant this.channel this.creatorParticipant this.anonymous}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('false', 'undefined created_by_uuid matches an undefined user_uuid'); }); }); diff --git a/tests/integration/helpers/can-write-test.js b/tests/integration/helpers/can-write-test.js index 3c57b1b0..c1181855 100644 --- a/tests/integration/helpers/can-write-test.js +++ b/tests/integration/helpers/can-write-test.js @@ -2,16 +2,168 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import { Ability } from 'ember-can'; + +class StubAbilities extends Service { + checks = []; + allowed = true; + + can(permission, subject) { + this.checks.push({ permission, subject }); + return this.allowed; + } +} + +class VehicleModel { + static modelName = 'vehicle'; + + constructor(props = {}) { + Object.assign(this, props); + } +} + +class FuelReportModel { + static modelName = 'fuelReport'; + + constructor(props = {}) { + Object.assign(this, props); + } +} + +function useStubAbilities(context) { + context.owner.register('service:abilities', StubAbilities); + return context.owner.lookup('service:abilities'); +} module('Integration | Helper | can-write', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('a persisted record is checked against an "update" permission', async function (assert) { + const abilities = useStubAbilities(this); + const model = new VehicleModel({ isNew: false }); + this.set('model', model); + + await render(hbs`{{can-write this.model}}`); + + assert.dom(this.element).hasText('true'); + assert.strictEqual(abilities.checks.length, 1, 'the abilities service is consulted exactly once'); + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops update vehicle'); + assert.strictEqual(abilities.checks[0].subject, model, 'the model is forwarded as the ability subject'); + }); + + test('an unsaved record is checked against a "create" permission', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new VehicleModel({ isNew: true })); + + await render(hbs`{{can-write this.model}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops create vehicle'); + }); + + test('the schema named argument replaces the default fleet-ops prefix', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{can-write this.model schema="telematics"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'telematics update vehicle'); + }); + + test('a camelCased model name is dasherized into the permission string', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new FuelReportModel({ isNew: false })); + + await render(hbs`{{can-write this.model}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops update fuel-report'); + }); + + test('a positional resource string is dasherized and the subject supplies isNew', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new VehicleModel({ isNew: true })); + + await render(hbs`{{can-write "IntegratedVendor" subject=this.model}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops create integrated-vendor'); + }); + + test('it renders false when the abilities service denies the permission', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{can-write this.model}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('an unresolvable action denies without consulting the abilities service', async function (assert) { + const abilities = useStubAbilities(this); + + await render(hbs`{{can-write "integrated-vendor"}}`); + + assert.dom(this.element).hasText('false', 'isNew cannot be inferred without a subject'); + assert.strictEqual(abilities.checks.length, 0, 'no permission check is attempted'); + }); + + test('defaultWhenUnknown controls the result when the permission cannot be built', async function (assert) { + useStubAbilities(this); + + await render(hbs`{{can-write "integrated-vendor" defaultWhenUnknown=true}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('a missing resource denies even when the action is known', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', { isNew: false }); + + await render(hbs`{{can-write this.model}}`); + + assert.dom(this.element).hasText('false', 'a plain object has no model name to build a resource from'); + assert.strictEqual(abilities.checks.length, 0); + }); + + test('an abilities service that only exposes cannot() is inverted correctly', async function (assert) { + const asked = []; + this.owner.register( + 'service:abilities', + class extends Service { + cannot(permission) { + asked.push(permission); + return true; + } + } + ); + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{can-write this.model}}`); + + assert.deepEqual(asked, ['fleet-ops update vehicle']); + assert.dom(this.element).hasText('false', 'cannot() === true means the write is not allowed'); + }); + + test('it works end to end against a real ember-can ability', async function (assert) { + this.owner.register( + 'ability:vehicle', + class extends Ability { + get canFleetOpsUpdate() { + return true; + } + + get canFleetOpsCreate() { + return false; + } + } + ); + + this.set('persisted', new VehicleModel({ isNew: false })); + this.set('unsaved', new VehicleModel({ isNew: true })); - await render(hbs`{{can-write this.inputValue}}`); + await render(hbs`{{can-write this.persisted}}{{can-write this.unsaved}}`); - assert.dom().hasText('1234'); + assert.dom('#update').hasText('true'); + assert.dom('#create').hasText('false'); }); }); diff --git a/tests/integration/helpers/cannot-action-test.js b/tests/integration/helpers/cannot-action-test.js index 319f535b..a24d370c 100644 --- a/tests/integration/helpers/cannot-action-test.js +++ b/tests/integration/helpers/cannot-action-test.js @@ -2,16 +2,107 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class StubAbilities extends Service { + checks = []; + allowed = true; + + can(permission, subject) { + this.checks.push({ permission, subject }); + return this.allowed; + } +} + +class OrderModel { + static modelName = 'order'; + + constructor(props = {}) { + Object.assign(this, props); + } +} + +function useStubAbilities(context) { + context.owner.register('service:abilities', StubAbilities); + return context.owner.lookup('service:abilities'); +} module('Integration | Helper | cannot-action', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it renders false when the action is permitted', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{cannot-action "approve" this.model}}`); + + assert.dom(this.element).hasText('false'); + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops approve order'); + }); + + test('it renders true when the action is denied', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{cannot-action "approve" this.model}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('named resource and subject are honoured', async function (assert) { + const abilities = useStubAbilities(this); + const model = new OrderModel({ isNew: false }); + this.set('model', model); + abilities.allowed = false; + + await render(hbs`{{cannot-action "archive" resource="IntegratedVendor" subject=this.model schema="storefront"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'storefront archive integrated-vendor'); + assert.strictEqual(abilities.checks[0].subject, model); + assert.dom(this.element).hasText('true'); + }); + + test('a missing action renders true by default', async function (assert) { + const abilities = useStubAbilities(this); + this.set('noAction', null); + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{cannot-action this.noAction this.model}}`); + + assert.dom(this.element).hasText('true'); + assert.strictEqual(abilities.checks.length, 0); + }); + + test('defaultWhenUnknown=true flips the missing action case to false', async function (assert) { + useStubAbilities(this); + this.set('noAction', null); + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{cannot-action this.noAction this.model defaultWhenUnknown=true}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is the exact inverse of can-action for the same input', async function (assert) { + useStubAbilities(this); + this.set('model', new OrderModel({ isNew: false })); + + await render(hbs`{{can-action "approve" this.model}}{{cannot-action "approve" this.model}}`); + + assert.dom('#can').hasText('true'); + assert.dom('#cannot').hasText('false'); + }); + test('a string second argument is treated as the resource, not the model', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; - await render(hbs`{{cannot-action this.inputValue}}`); + await render(hbs`{{if (cannot-action "archive" "integrated-vendor") "denied" "allowed"}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('denied'); + assert.true( + abilities.checks.some((check) => check.permission.includes('integrated-vendor')), + `the string is used as the resource (${JSON.stringify(abilities.checks)})` + ); }); }); diff --git a/tests/integration/helpers/cannot-delete-test.js b/tests/integration/helpers/cannot-delete-test.js index 804cb2e7..16fa5d14 100644 --- a/tests/integration/helpers/cannot-delete-test.js +++ b/tests/integration/helpers/cannot-delete-test.js @@ -2,16 +2,91 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class StubAbilities extends Service { + checks = []; + allowed = true; + + can(permission, subject) { + this.checks.push({ permission, subject }); + return this.allowed; + } +} + +class VehicleModel { + static modelName = 'vehicle'; + + constructor(props = {}) { + Object.assign(this, props); + } +} + +function useStubAbilities(context) { + context.owner.register('service:abilities', StubAbilities); + return context.owner.lookup('service:abilities'); +} module('Integration | Helper | cannot-delete', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it renders false when the delete permission is granted', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{cannot-delete this.model}}`); + + assert.dom(this.element).hasText('false'); + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops delete vehicle'); + }); + + test('it renders true when the delete permission is denied', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{cannot-delete this.model}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('a positional resource string is dasherized', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + + await render(hbs`{{cannot-delete "IntegratedVendor"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops delete integrated-vendor'); + assert.dom(this.element).hasText('true'); + }); + + test('an unresolvable resource renders true by default', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', {}); + + await render(hbs`{{cannot-delete this.model}}`); + + assert.dom(this.element).hasText('true'); + assert.strictEqual(abilities.checks.length, 0); + }); + + test('defaultWhenUnknown=true flips the unresolvable case to false', async function (assert) { + useStubAbilities(this); + this.set('model', {}); + + await render(hbs`{{cannot-delete this.model defaultWhenUnknown=true}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is the exact inverse of can-delete for the same input', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + this.set('model', new VehicleModel({ isNew: false })); - await render(hbs`{{cannot-delete this.inputValue}}`); + await render(hbs`{{can-delete this.model}}{{cannot-delete this.model}}`); - assert.dom().hasText('1234'); + assert.dom('#can').hasText('false'); + assert.dom('#cannot').hasText('true'); }); }); diff --git a/tests/integration/helpers/cannot-write-test.js b/tests/integration/helpers/cannot-write-test.js index d7e9ced7..d0b9a511 100644 --- a/tests/integration/helpers/cannot-write-test.js +++ b/tests/integration/helpers/cannot-write-test.js @@ -2,16 +2,89 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class StubAbilities extends Service { + checks = []; + allowed = true; + + can(permission, subject) { + this.checks.push({ permission, subject }); + return this.allowed; + } +} + +class VehicleModel { + static modelName = 'vehicle'; + + constructor(props = {}) { + Object.assign(this, props); + } +} + +function useStubAbilities(context) { + context.owner.register('service:abilities', StubAbilities); + return context.owner.lookup('service:abilities'); +} module('Integration | Helper | cannot-write', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it renders false when the write permission is granted', async function (assert) { + const abilities = useStubAbilities(this); + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{cannot-write this.model}}`); + + assert.dom(this.element).hasText('false'); + assert.strictEqual(abilities.checks[0].permission, 'fleet-ops update vehicle'); + }); + + test('it renders true when the write permission is denied', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{cannot-write this.model}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('an unsaved record is checked against a create permission', async function (assert) { + const abilities = useStubAbilities(this); + abilities.allowed = false; + this.set('model', new VehicleModel({ isNew: true })); + + await render(hbs`{{cannot-write this.model schema="storefront"}}`); + + assert.strictEqual(abilities.checks[0].permission, 'storefront create vehicle'); + assert.dom(this.element).hasText('true'); + }); + + test('an unresolvable permission renders true by default', async function (assert) { + const abilities = useStubAbilities(this); + + await render(hbs`{{cannot-write "integrated-vendor"}}`); + + assert.dom(this.element).hasText('true', 'unknown permissions are treated as not allowed, so cannot-write is true'); + assert.strictEqual(abilities.checks.length, 0); + }); + + test('defaultWhenUnknown=true flips the unresolvable case to false', async function (assert) { + useStubAbilities(this); + + await render(hbs`{{cannot-write "integrated-vendor" defaultWhenUnknown=true}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is the exact inverse of can-write for the same input', async function (assert) { + useStubAbilities(this); + this.set('model', new VehicleModel({ isNew: false })); - await render(hbs`{{cannot-write this.inputValue}}`); + await render(hbs`{{can-write this.model}}{{cannot-write this.model}}`); - assert.dom().hasText('1234'); + assert.dom('#can').hasText('true'); + assert.dom('#cannot').hasText('false'); }); }); diff --git a/tests/integration/helpers/cfg-edit-buttons-test.js b/tests/integration/helpers/cfg-edit-buttons-test.js index bb4a263e..26511029 100644 --- a/tests/integration/helpers/cfg-edit-buttons-test.js +++ b/tests/integration/helpers/cfg-edit-buttons-test.js @@ -1,17 +1,98 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | cfg-edit-buttons', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it produces exactly one edit button descriptor', async function (assert) { + this.set('cfg', { key: 'smtp', isEditing: false }); - await render(hbs`{{cfg-edit-buttons this.inputValue}}`); + await render(hbs` + {{#each (cfg-edit-buttons this.cfg) as |editButton|}} + {{editButton.text}} + {{/each}} + `); - assert.dom().hasText('1234'); + assert.dom('.btn').exists({ count: 1 }, 'a single button descriptor is returned'); + assert.dom('.btn').hasText('Edit'); + assert.dom('.btn').hasAttribute('data-type', 'default'); + assert.dom('.btn').hasAttribute('data-icon', 'pencil'); + assert.dom('.btn').hasAttribute('data-icon-prefix', 'fas'); + }); + + test('permission and disabled named arguments are passed through onto the descriptor', async function (assert) { + this.set('cfg', { key: 'smtp' }); + this.set('permission', 'fleet-ops update setting'); + this.set('disabled', true); + + await render(hbs` + {{#each (cfg-edit-buttons this.cfg permission=this.permission disabled=this.disabled) as |editButton|}} + {{if editButton.disabled "disabled" "enabled"}} + {{/each}} + `); + + assert.dom('.btn').hasAttribute('data-permission', 'fleet-ops update setting'); + assert.dom('.btn').hasText('disabled'); + }); + + test('permission and disabled are undefined when not supplied', async function (assert) { + this.set('cfg', { key: 'smtp' }); + + await render(hbs` + {{#each (cfg-edit-buttons this.cfg) as |editButton|}} + {{if editButton.disabled "disabled" "enabled"}} + {{/each}} + `); + + assert.dom('.btn').doesNotHaveAttribute('data-permission'); + assert.dom('.btn').hasText('enabled'); + }); + + test('clicking invokes the supplied onClick with the config object', async function (assert) { + const received = []; + const cfg = { key: 'smtp', isEditing: false }; + this.set('cfg', cfg); + this.set('onClick', (arg) => received.push(arg)); + + await render(hbs` + {{#each (cfg-edit-buttons this.cfg onClick=this.onClick) as |editButton|}} + + {{/each}} + `); + await click('.btn'); + + assert.deepEqual(received, [cfg], 'the onClick receives the cfg it was built for'); + assert.false(cfg.isEditing, 'the default fallback is not applied when an onClick is provided'); + }); + + test('without an onClick the button falls back to flipping cfg.isEditing', async function (assert) { + const cfg = { key: 'smtp', isEditing: false }; + this.set('cfg', cfg); + + await render(hbs` + {{#each (cfg-edit-buttons this.cfg) as |editButton|}} + + {{/each}} + `); + await click('.btn'); + + assert.true(cfg.isEditing, 'the default fallback puts the config into edit mode'); + }); + + test('a non-function onClick falls back to the default behaviour', async function (assert) { + const cfg = { key: 'smtp', isEditing: false }; + this.set('cfg', cfg); + this.set('notAFunction', 'nope'); + + await render(hbs` + {{#each (cfg-edit-buttons this.cfg onClick=this.notAFunction) as |editButton|}} + + {{/each}} + `); + await click('.btn'); + + assert.true(cfg.isEditing); }); }); diff --git a/tests/integration/helpers/component-resolvable-test.js b/tests/integration/helpers/component-resolvable-test.js index ba3d59a9..324f44c0 100644 --- a/tests/integration/helpers/component-resolvable-test.js +++ b/tests/integration/helpers/component-resolvable-test.js @@ -2,16 +2,68 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Component from '@glimmer/component'; module('Integration | Helper | component-resolvable', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('a component name that exists in the container resolves', async function (assert) { + await render(hbs`{{component-resolvable "button"}}`); - await render(hbs`{{component-resolvable this.inputValue}}`); + assert.dom(this.element).hasText('true'); + }); + + test('a component name that does not exist does not resolve', async function (assert) { + await render(hbs`{{component-resolvable "definitely-not-a-real-component"}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it picks up components registered on the owner at runtime', async function (assert) { + await render(hbs`{{component-resolvable "locally-registered-widget"}}`); + assert.dom(this.element).hasText('false', 'not resolvable before registration'); + + this.owner.register('component:locally-registered-widget', class extends Component {}); + + await render(hbs`{{component-resolvable "locally-registered-widget"}}`); + assert.dom(this.element).hasText('true', 'resolvable once registered'); + }); + + test('falsy values are never resolvable', async function (assert) { + this.set('nothing', undefined); + this.set('nullish', null); + this.set('zero', 0); + + await render( + hbs`{{component-resolvable ""}}{{component-resolvable this.nothing}}{{component-resolvable this.nullish}}{{component-resolvable this.zero}}` + ); + + assert.dom('#empty').hasText('false'); + assert.dom('#undef').hasText('false'); + assert.dom('#null').hasText('false'); + assert.dom('#zero').hasText('false'); + }); + + test('objects and functions are treated as already-renderable definitions', async function (assert) { + this.set('fn', function () {}); + this.set('componentClass', class extends Component {}); + + await render( + hbs`{{component-resolvable (hash a=1)}}{{component-resolvable this.fn}}{{component-resolvable this.componentClass}}` + ); + + assert.dom('#hash').hasText('true'); + assert.dom('#fn').hasText('true'); + assert.dom('#class').hasText('true'); + }); + + test('primitives that are neither strings nor objects are not resolvable', async function (assert) { + this.set('number', 42); + this.set('boolean', true); + + await render(hbs`{{component-resolvable this.number}}{{component-resolvable this.boolean}}`); - assert.dom().hasText('1234'); + assert.dom('#number').hasText('false'); + assert.dom('#boolean').hasText('false'); }); }); diff --git a/tests/integration/helpers/config-test.js b/tests/integration/helpers/config-test.js index 2c2ffd11..0bbcb7d8 100644 --- a/tests/integration/helpers/config-test.js +++ b/tests/integration/helpers/config-test.js @@ -1,17 +1,50 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | config', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it reads a top level key from the application environment config', async function (assert) { + await render(hbs`{{config "modulePrefix"}}`); - await render(hbs`{{config this.inputValue}}`); + assert.dom(this.element).hasText('dummy'); + }); + + test('it reports the environment the suite is running under', async function (assert) { + await render(hbs`{{config "environment"}}`); + + assert.dom(this.element).hasText('test'); + }); + + test('it resolves nested keys through a dotted path', async function (assert) { + await render(hbs`{{config "APP.rootElement"}}`); + + assert.dom(this.element).hasText('#ember-testing'); + }); + + test('an unknown key renders nothing rather than throwing', async function (assert) { + await render(hbs`{{config "thisKeyDoesNotExist"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('an unknown nested path renders nothing rather than throwing', async function (assert) { + await render(hbs`{{config "APP.nope.deeper"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('the key can be supplied dynamically', async function (assert) { + this.set('key', 'modulePrefix'); + + await render(hbs`{{config this.key}}`); + assert.dom(this.element).hasText('dummy'); + + this.set('key', 'environment'); + await settled(); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('test', 'the helper recomputes when the key changes'); }); }); diff --git a/tests/integration/helpers/dropdown-fn-test.js b/tests/integration/helpers/dropdown-fn-test.js index 11d1d479..2bbbb311 100644 --- a/tests/integration/helpers/dropdown-fn-test.js +++ b/tests/integration/helpers/dropdown-fn-test.js @@ -1,17 +1,167 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; +import { helper } from '@ember/component/helper'; import { hbs } from 'ember-cli-htmlbars'; +function makeDropdown(sequence) { + return { + uniqueId: 'ember-basic-dropdown-1', + isOpen: true, + disabled: false, + actions: { + close: () => sequence.push('close'), + open: () => {}, + toggle: () => {}, + }, + }; +} + module('Integration | Helper | dropdown-fn', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('the returned closure invokes the wrapped function when called', async function (assert) { + const sequence = []; + this.set('dd', makeDropdown(sequence)); + this.set('handler', () => sequence.push('handler')); + + await render(hbs``); + + assert.deepEqual(sequence, [], 'nothing runs until the closure is invoked'); + + await click('#go'); + + assert.true(sequence.includes('handler'), 'the wrapped function runs on invocation'); + }); + + test('the dropdown is closed before the wrapped function runs', async function (assert) { + const sequence = []; + this.set('dd', makeDropdown(sequence)); + this.set('handler', () => sequence.push('handler')); + + await render(hbs``); + await click('#go'); + + assert.deepEqual(sequence, ['close', 'handler'], 'close() runs first, then the action'); + }); + + test('curried arguments are forwarded to the wrapped function', async function (assert) { + const sequence = []; + const received = []; + this.set('dd', makeDropdown(sequence)); + this.set('handler', (...args) => received.push(args)); + + await render(hbs``); + await click('#go'); + + assert.strictEqual(received.length, 1, 'the handler ran once'); + assert.strictEqual(received[0][0], 'order-123', 'the curried argument arrives first'); + }); + + test('the raw DOM event is never handed to the wrapped function', async function (assert) { + const sequence = []; + const received = []; + this.set('dd', makeDropdown(sequence)); + this.set('handler', (...args) => received.push(args)); + + await render(hbs``); + await click('#go'); + + assert.false( + received[0].some((arg) => typeof Event !== 'undefined' && arg instanceof Event), + 'invocation arguments from the DOM listener are swallowed by the helper' + ); + }); + + test('a dropdown without a close action still invokes the wrapped function', async function (assert) { + const sequence = []; + this.set('dd', { uniqueId: 'ember-basic-dropdown-2', isOpen: false }); + this.set('handler', () => sequence.push('handler')); + + await render(hbs``); + await click('#go'); + + assert.deepEqual(sequence, ['handler'], 'the missing close action is tolerated'); + }); + + test('every invocation re-runs the wrapped function', async function (assert) { + const sequence = []; + this.set('dd', makeDropdown(sequence)); + this.set('handler', () => sequence.push('handler')); + + await render(hbs``); + await click('#go'); + await click('#go'); + + assert.deepEqual(sequence, ['close', 'handler', 'close', 'handler']); + }); + // The helper calls the wrapped function with a Proxy as `this` so that an UNBOUND function + // reaching for `this.something` fails loudly during development instead of silently reading + // undefined. Each trap raises its own assertion. The closure has to be captured and called + // directly: thrown from inside `{{on "click"}}` the assertion escapes as an uncaught global + // error that neither `assert.rejects` nor `setupOnerror` can intercept. + module('an unbound function is caught rather than silently misbehaving', function () { + async function captureClosure(context, handler) { + const captured = []; + context.owner.register( + 'helper:capture-value', + helper(function ([value]) { + captured.push(value); + return ''; + }) + ); + + context.set('dd', makeDropdown([])); + context.set('handler', handler); + + await render(hbs`{{capture-value (dropdown-fn this.dd this.handler)}}`); + + return captured[0]; + } + + test('reading a property off `this` asserts', async function (assert) { + const invoke = await captureClosure(this, function () { + return this.somethingUnbound; + }); + + assert.throws(invoke, /You accessed `this.somethingUnbound`/, 'the get trap names the property'); + assert.throws(invoke, /not bound to a valid `this` context/, 'and explains the cause'); + }); + + test('writing a property to `this` asserts', async function (assert) { + const invoke = await captureClosure(this, function () { + this.somethingUnbound = 1; + }); + + assert.throws(invoke, /You accessed `this.somethingUnbound`/, 'the set trap raises too'); + }); + + test('probing `this` with `in` asserts', async function (assert) { + const invoke = await captureClosure(this, function () { + return 'somethingUnbound' in this; + }); + + assert.throws(invoke, /You accessed `this.somethingUnbound`/, 'the has trap raises too'); + }); + }); + + test('a function carrying an INVOKE method is called through it', async function (assert) { + const sequence = []; + const wrapped = function () { + sequence.push('plain-call'); + }; + wrapped.invoke = (...args) => sequence.push(`invoke:${args.join(',')}`); + + this.set('dd', makeDropdown(sequence)); + this.set('handler', wrapped); - await render(hbs`{{dropdown-fn this.inputValue}}`); + await render(hbs``); + await click('#go'); - assert.dom(this.element).hasText('1234'); + assert.true( + sequence.some((entry) => entry.startsWith('invoke:alpha')), + `the INVOKE method takes precedence over calling the function itself (${sequence.join('|')})` + ); + assert.false(sequence.includes('plain-call'), 'the function body is not called directly'); }); }); diff --git a/tests/integration/helpers/first-char-test.js b/tests/integration/helpers/first-char-test.js index c0b2a8b6..8945f29c 100644 --- a/tests/integration/helpers/first-char-test.js +++ b/tests/integration/helpers/first-char-test.js @@ -6,12 +6,67 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | first-char', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it renders the first character of a string', async function (assert) { + this.set('value', 'Fleetbase'); - await render(hbs`{{first-char this.inputValue}}`); + await render(hbs`{{first-char this.value}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('F'); + }); + + test('it preserves the original casing', async function (assert) { + this.set('value', 'fleetbase'); + + await render(hbs`{{first-char this.value}}`); + + assert.dom(this.element).hasText('f', 'the helper does not upper-case the result'); + }); + + test('it renders nothing for an empty string', async function (assert) { + this.set('value', ''); + + await render(hbs`{{first-char this.value}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it returns the first element when given an array', async function (assert) { + this.set('value', ['alpha', 'beta']); + + await render(hbs`{{first-char this.value}}`); + + assert.dom(this.element).hasText('alpha'); + }); + + test('it renders nothing for an empty array', async function (assert) { + this.set('value', []); + + await render(hbs`{{first-char this.value}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it handles accented characters', async function (assert) { + this.set('value', 'Émile Zola'); + + await render(hbs`{{first-char this.value}}`); + + assert.dom(this.element).hasText('É'); + }); + + test('it preserves a leading space instead of trimming it', async function (assert) { + this.set('value', ' padded'); + + await render(hbs`{{first-char this.value}}`); + + assert.strictEqual(this.element.textContent, ' ', 'the raw first character is returned verbatim'); + }); + + test('it renders nothing for values that cannot be indexed', async function (assert) { + this.set('value', 12345); + + await render(hbs`{{first-char this.value}}`); + + assert.dom(this.element).hasNoText('numbers have no index 0 so the empty-string fallback is used'); }); }); diff --git a/tests/integration/helpers/format-bytes-test.js b/tests/integration/helpers/format-bytes-test.js index 83fb4608..5270f689 100644 --- a/tests/integration/helpers/format-bytes-test.js +++ b/tests/integration/helpers/format-bytes-test.js @@ -6,12 +6,62 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | format-bytes', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('zero renders the special "0 Bytes" label', async function (assert) { + await render(hbs`{{format-bytes 0}}`); - await render(hbs`{{format-bytes this.inputValue}}`); + assert.dom(this.element).hasText('0 Bytes'); + }); + + test('values below one kibibyte stay in bytes', async function (assert) { + await render(hbs`{{format-bytes 1}}{{format-bytes 512}}{{format-bytes 1023}}`); + + assert.dom('#one').hasText('1 Bytes', 'the unit is not singularised'); + assert.dom('#half').hasText('512 Bytes'); + assert.dom('#edge').hasText('1023 Bytes', '1023 is the last value expressed in bytes'); + }); + + test('it steps up a unit at each power of 1024', async function (assert) { + await render(hbs`{{format-bytes 1024}}{{format-bytes 1048576}}{{format-bytes 1073741824}}`); + + assert.dom('#kb').hasText('1 KB'); + assert.dom('#mb').hasText('1 MB'); + assert.dom('#gb').hasText('1 GB'); + }); + + test('it uses binary (1024) units rather than decimal ones', async function (assert) { + await render(hbs`{{format-bytes 1000}}`); + + assert.dom(this.element).hasText('1000 Bytes', '1000 bytes is not yet a kilobyte'); + }); + + test('it rounds to two decimals by default and trims insignificant zeros', async function (assert) { + await render(hbs`{{format-bytes 1536}}{{format-bytes 1234567}}{{format-bytes 2048}}`); + + assert.dom('#a').hasText('1.5 KB', 'a trailing zero from toFixed(2) is trimmed'); + assert.dom('#b').hasText('1.18 MB'); + assert.dom('#c').hasText('2 KB', 'whole numbers render without a decimal part'); + }); + + test('the decimals argument controls precision', async function (assert) { + await render(hbs`{{format-bytes 1536 0}}{{format-bytes 1080 3}}`); + + assert.dom('#zero').hasText('2 KB', 'zero decimals rounds 1.5 up to 2'); + assert.dom('#three').hasText('1.055 KB'); + }); + + test('negative decimals are clamped to zero', async function (assert) { + this.set('decimals', -4); + + await render(hbs`{{format-bytes 1536 this.decimals}}`); + + assert.dom(this.element).hasText('2 KB'); + }); + + test('it scales all the way to yobibytes', async function (assert) { + this.set('yotta', Math.pow(1024, 8)); + + await render(hbs`{{format-bytes this.yotta}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('1 YB'); }); }); diff --git a/tests/integration/helpers/format-currency-test.js b/tests/integration/helpers/format-currency-test.js index 7d19b6d5..020f65aa 100644 --- a/tests/integration/helpers/format-currency-test.js +++ b/tests/integration/helpers/format-currency-test.js @@ -6,12 +6,23 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | format-currency', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { + test('it formats an amount in cents as USD by default', async function (assert) { this.set('inputValue', '1234'); await render(hbs`{{format-currency this.inputValue}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('$12.34'); + }); + + test('it formats using an explicit currency code', async function (assert) { + await render(hbs`{{format-currency 500 "EUR"}}`); + + assert.dom(this.element).hasText('€ 5,00'); + }); + + test('it renders a zero amount when no arguments are given', async function (assert) { + await render(hbs`{{format-currency}}`); + + assert.dom(this.element).hasText('$0.00'); }); }); diff --git a/tests/integration/helpers/format-date-fns-test.js b/tests/integration/helpers/format-date-fns-test.js index 4e7c18dc..51592571 100644 --- a/tests/integration/helpers/format-date-fns-test.js +++ b/tests/integration/helpers/format-date-fns-test.js @@ -2,16 +2,150 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { format } from 'date-fns'; + +// A fixed local-time date: constructing with numeric parts keeps the expectations +// independent of the machine timezone. +const FIXED_DATE = new Date(2021, 0, 15, 13, 45, 30); module('Integration | Helper | format-date-fns', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + hooks.beforeEach(function () { + this.set('date', FIXED_DATE); + }); + + test('a Date is formatted with the default "yyyy-MM-dd HH:mm" pattern', async function (assert) { + await render(hbs`{{format-date-fns this.date}}`); + + assert.dom(this.element).hasText('2021-01-15 13:45'); + }); + + test('the second positional argument overrides the format pattern', async function (assert) { + await render(hbs`{{format-date-fns this.date "yyyy-MM-dd"}}{{format-date-fns this.date "HH:mm:ss"}}`); + + assert.dom('#ymd').hasText('2021-01-15'); + assert.dom('#time').hasText('13:45:30'); + }); + + test('an empty format string falls back to the default pattern', async function (assert) { + await render(hbs`{{format-date-fns this.date ""}}`); + + assert.dom(this.element).hasText('2021-01-15 13:45'); + }); + + test('an ISO string without a zone designator is parsed as local time', async function (assert) { + this.set('iso', '2021-01-15T13:45:30'); + + await render(hbs`{{format-date-fns this.iso}}`); + + assert.dom(this.element).hasText('2021-01-15 13:45'); + }); + + test('a date-only ISO string is parsed', async function (assert) { + this.set('iso', '2021-01-15'); + + await render(hbs`{{format-date-fns this.iso "yyyy-MM-dd"}}`); + + assert.dom(this.element).hasText('2021-01-15'); + }); + + test('a numeric value in the seconds range is scaled to milliseconds', async function (assert) { + this.set('seconds', 1600000000); + + await render(hbs`{{format-date-fns this.seconds}}`); + + assert.dom(this.element).hasText(format(new Date(1600000000 * 1000), 'yyyy-MM-dd HH:mm'), 'unix seconds are multiplied by 1000'); + }); + + test('a numeric value already in milliseconds is not scaled again', async function (assert) { + this.set('millis', 1600000000000); + + await render(hbs`{{format-date-fns this.millis}}`); + + assert.dom(this.element).hasText(format(new Date(1600000000000), 'yyyy-MM-dd HH:mm')); + }); + + test('a numeric string is treated as a timestamp', async function (assert) { + this.set('numericString', '1600000000'); + + await render(hbs`{{format-date-fns this.numericString}}`); + + assert.dom(this.element).hasText(format(new Date(1600000000 * 1000), 'yyyy-MM-dd HH:mm')); + }); + + test('unix=true forces a millisecond-range value to be scaled anyway', async function (assert) { + this.set('millis', 1600000000000); + + await render(hbs`{{format-date-fns this.millis "yyyy"}}{{format-date-fns this.millis "yyyy" unix=true}}`); + + assert.dom('#auto').hasText(format(new Date(1600000000000), 'yyyy'), 'without the flag the value is left in milliseconds'); + assert.dom('#forced').hasText(format(new Date(1600000000000 * 1000), 'yyyy'), 'the flag forces another x1000 conversion'); + }); + + test('inputFormat is used to parse non-ISO strings', async function (assert) { + this.set('euro', '15/01/2021'); + + await render(hbs`{{format-date-fns this.euro "yyyy-MM-dd" inputFormat="dd/MM/yyyy"}}`); + + assert.dom(this.element).hasText('2021-01-15'); + }); + + test('null and undefined render the empty fallback', async function (assert) { + this.set('nullish', null); + this.set('nothing', undefined); + + await render(hbs`{{format-date-fns this.nullish}}{{format-date-fns this.nothing}}`); + + assert.dom('#null').hasNoText(); + assert.dom('#undef').hasNoText(); + }); + + test('the fallback named argument is rendered for missing values', async function (assert) { + this.set('nullish', null); + + await render(hbs`{{format-date-fns this.nullish fallback="—"}}`); + + assert.dom(this.element).hasText('—'); + }); + + test('an unparseable string renders the fallback', async function (assert) { + this.set('garbage', 'not-a-real-date'); + + await render(hbs`{{format-date-fns this.garbage fallback="N/A"}}`); + + assert.dom(this.element).hasText('N/A'); + }); + + test('an empty string renders the fallback', async function (assert) { + this.set('blank', ' '); + + await render(hbs`{{format-date-fns this.blank fallback="N/A"}}`); + + assert.dom(this.element).hasText('N/A'); + }); + + test('unsupported value types render the fallback', async function (assert) { + this.set('boolValue', true); + this.set('objectValue', { year: 2021 }); + + await render(hbs`{{format-date-fns this.boolValue fallback="N/A"}}{{format-date-fns this.objectValue fallback="N/A"}}`); + + assert.dom('#bool').hasText('N/A'); + assert.dom('#obj').hasText('N/A'); + }); + + test('an invalid Date instance renders the fallback', async function (assert) { + this.set('invalid', new Date('nope')); + + await render(hbs`{{format-date-fns this.invalid fallback="N/A"}}`); + + assert.dom(this.element).hasText('N/A'); + }); - await render(hbs`{{format-date-fns this.inputValue}}`); + test('an invalid format token renders the fallback instead of throwing', async function (assert) { + await render(hbs`{{format-date-fns this.date "J" fallback="N/A"}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('N/A', 'date-fns throws on unescaped unknown tokens and the helper swallows it'); }); }); diff --git a/tests/integration/helpers/format-json-test.js b/tests/integration/helpers/format-json-test.js index 5c2c087a..af72034e 100644 --- a/tests/integration/helpers/format-json-test.js +++ b/tests/integration/helpers/format-json-test.js @@ -6,12 +6,77 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | format-json', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('an object is stringified with two-space indentation', async function (assert) { + this.set('value', { name: 'Fleetbase', active: true }); - await render(hbs`{{format-json this.inputValue}}`); + await render(hbs`{{format-json this.value}}`); - assert.dom().hasText('1234'); + assert.strictEqual(this.element.textContent, '{\n "name": "Fleetbase",\n "active": true\n}'); + }); + + test('nested structures are indented cumulatively', async function (assert) { + this.set('value', { meta: { page: 1 } }); + + await render(hbs`{{format-json this.value}}`); + + assert.strictEqual(this.element.textContent, '{\n "meta": {\n "page": 1\n }\n}'); + }); + + test('arrays are pretty printed one element per line', async function (assert) { + this.set('value', [1, 2]); + + await render(hbs`{{format-json this.value}}`); + + assert.strictEqual(this.element.textContent, '[\n 1,\n 2\n]'); + }); + + test('empty objects and arrays stay on one line', async function (assert) { + this.set('emptyObject', {}); + this.set('emptyArray', []); + + await render(hbs`{{format-json this.emptyObject}}{{format-json this.emptyArray}}`); + + assert.strictEqual(this.element.querySelector('#obj').textContent, '{}'); + assert.strictEqual(this.element.querySelector('#arr').textContent, '[]'); + }); + + test('primitives are rendered as their JSON literals', async function (assert) { + this.set('stringValue', 'hello'); + this.set('numberValue', 0); + this.set('boolValue', false); + this.set('nullValue', null); + + await render( + hbs`{{format-json this.stringValue}}{{format-json this.numberValue}}{{format-json this.boolValue}}{{format-json this.nullValue}}` + ); + + assert.strictEqual(this.element.querySelector('#str').textContent, '"hello"', 'strings keep their quotes'); + assert.strictEqual(this.element.querySelector('#num').textContent, '0'); + assert.strictEqual(this.element.querySelector('#bool').textContent, 'false'); + assert.strictEqual(this.element.querySelector('#null').textContent, 'null'); + }); + + test('undefined has no JSON representation and renders nothing', async function (assert) { + this.set('value', undefined); + + await render(hbs`{{format-json this.value}}`); + + assert.strictEqual(this.element.textContent, ''); + }); + + test('keys with undefined values are dropped', async function (assert) { + this.set('value', { kept: 1, dropped: undefined }); + + await render(hbs`{{format-json this.value}}`); + + assert.strictEqual(this.element.textContent, '{\n "kept": 1\n}'); + }); + + test('special characters are escaped', async function (assert) { + this.set('value', { quote: 'say "hi"', newline: 'a\nb', unicode: 'ünïcode' }); + + await render(hbs`{{format-json this.value}}`); + + assert.strictEqual(this.element.textContent, '{\n "quote": "say \\"hi\\"",\n "newline": "a\\nb",\n "unicode": "ünïcode"\n}'); }); }); diff --git a/tests/integration/helpers/format-meters-test.js b/tests/integration/helpers/format-meters-test.js index 5ead0e29..cfe05a70 100644 --- a/tests/integration/helpers/format-meters-test.js +++ b/tests/integration/helpers/format-meters-test.js @@ -6,12 +6,48 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | format-meters', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it converts meters to whole kilometers', async function (assert) { + await render(hbs`{{format-meters 1000}}`); - await render(hbs`{{format-meters this.inputValue}}`); + assert.dom(this.element).hasText('1km'); + }); + + test('zero renders 0km', async function (assert) { + await render(hbs`{{format-meters 0}}`); + + assert.dom(this.element).hasText('0km'); + }); + + test('it rounds to the nearest kilometer', async function (assert) { + await render(hbs`{{format-meters 1499}}{{format-meters 1500}}{{format-meters 12750}}`); + + assert.dom('#down').hasText('1km', '1499m rounds down'); + assert.dom('#up').hasText('2km', 'exact halves round up'); + assert.dom('#big').hasText('13km'); + }); + + test('sub-kilometer distances collapse to 0km or 1km', async function (assert) { + await render(hbs`{{format-meters 499}}{{format-meters 500}}`); + + assert.dom('#low').hasText('0km', 'no fractional kilometers are rendered'); + assert.dom('#half').hasText('1km'); + }); + + test('negative distances round toward positive infinity like Math.round', async function (assert) { + this.set('backwards', -1500); + this.set('furtherBackwards', -1600); + + await render(hbs`{{format-meters this.backwards}}{{format-meters this.furtherBackwards}}`); + + assert.dom('#neg').hasText('-1km'); + assert.dom('#neg2').hasText('-2km'); + }); + + test('numeric strings are coerced before dividing', async function (assert) { + this.set('meters', '2400'); + + await render(hbs`{{format-meters this.meters}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('2km'); }); }); diff --git a/tests/integration/helpers/format-milliseconds-test.js b/tests/integration/helpers/format-milliseconds-test.js index 1f94d700..eaa3a987 100644 --- a/tests/integration/helpers/format-milliseconds-test.js +++ b/tests/integration/helpers/format-milliseconds-test.js @@ -6,12 +6,70 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | format-milliseconds', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('sub-one values are rendered as milliseconds', async function (assert) { + await render(hbs`{{format-milliseconds 0.5}}{{format-milliseconds 0.25}}`); - await render(hbs`{{format-milliseconds this.inputValue}}`); + assert.dom('#half').hasText('500ms'); + assert.dom('#quarter').hasText('250ms'); + }); + + test('millisecond output is padded to three digits', async function (assert) { + await render(hbs`{{format-milliseconds 0.001}}{{format-milliseconds 0.012}}`); + + assert.dom('#tiny').hasText('001ms', 'the value is taken from a fixed 3-decimal string'); + assert.dom('#small').hasText('012ms'); + }); + + test('values of one or more are rendered as seconds with three decimals', async function (assert) { + await render(hbs`{{format-milliseconds 1.5}}{{format-milliseconds 1000}}{{format-milliseconds 1234.5678}}`); + + assert.dom('#a').hasText('1.500s'); + assert.dom('#b').hasText('1000.000s'); + assert.dom('#c').hasText('1234.568s', 'the seconds value is rounded to 3 decimals'); + }); + + test('zero renders the dash placeholder', async function (assert) { + await render(hbs`{{format-milliseconds 0}}`); + + assert.dom(this.element).hasText('-'); + }); + + test('missing values render the dash placeholder', async function (assert) { + this.set('nothing', undefined); + this.set('empty', null); + + await render( + hbs`{{format-milliseconds this.nothing}}{{format-milliseconds this.empty}}{{format-milliseconds}}` + ); + + assert.dom('#undef').hasText('-'); + assert.dom('#null').hasText('-'); + assert.dom('#none').hasText('-'); + }); + + test('non-numeric values render the dash placeholder', async function (assert) { + this.set('stringValue', '1234'); + this.set('boolValue', true); + + await render(hbs`{{format-milliseconds this.stringValue}}{{format-milliseconds this.boolValue}}`); + + assert.dom('#str').hasText('-', 'numeric strings are rejected, only real numbers are formatted'); + assert.dom('#bool').hasText('-'); + }); + + test('NaN renders the dash placeholder', async function (assert) { + this.set('notANumber', Number.NaN); + + await render(hbs`{{format-milliseconds this.notANumber}}`); + + assert.dom(this.element).hasText('-'); + }); + + test('negative values are treated as seconds because their string form starts with a sign', async function (assert) { + this.set('negative', -0.5); + + await render(hbs`{{format-milliseconds this.negative}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('-0.500s'); }); }); diff --git a/tests/integration/helpers/get-default-value-test.js b/tests/integration/helpers/get-default-value-test.js index fc8ca554..df88effd 100644 --- a/tests/integration/helpers/get-default-value-test.js +++ b/tests/integration/helpers/get-default-value-test.js @@ -1,17 +1,80 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | get-default-value', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('the first argument wins when it is defined', async function (assert) { + this.set('first', 'primary'); - await render(hbs`{{get-default-value this.inputValue}}`); + await render(hbs`{{get-default-value this.first "fallback"}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('primary'); + }); + + test('null and undefined are skipped in favour of later arguments', async function (assert) { + this.set('nullish', null); + this.set('nothing', undefined); + + await render(hbs`{{get-default-value this.nullish this.nothing "fallback"}}`); + + assert.dom(this.element).hasText('fallback'); + }); + + test('it stops at the first non-nullish argument', async function (assert) { + this.set('nullish', null); + + await render(hbs`{{get-default-value this.nullish "second" "third"}}`); + + assert.dom(this.element).hasText('second'); + }); + + test('zero is a real value and is not replaced', async function (assert) { + await render(hbs`{{get-default-value 0 99}}`); + + assert.dom(this.element).hasText('0', 'unlike ||, falsy-but-present values are kept'); + }); + + test('false is a real value and is not replaced', async function (assert) { + this.set('flag', false); + + await render(hbs`{{get-default-value this.flag true}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('an empty string is a real value and is not replaced', async function (assert) { + await render(hbs`{{get-default-value "" "fallback"}}`); + + assert.dom(this.element).hasNoText('the empty string short-circuits the fallback'); + }); + + test('all-nullish arguments render nothing', async function (assert) { + this.set('nullish', null); + this.set('nothing', undefined); + + await render(hbs`{{get-default-value this.nullish this.nothing}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('no arguments renders nothing', async function (assert) { + await render(hbs`{{get-default-value}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it recomputes when the leading argument becomes available', async function (assert) { + this.set('value', null); + + await render(hbs`{{get-default-value this.value "fallback"}}`); + assert.dom(this.element).hasText('fallback'); + + this.set('value', 'resolved'); + await settled(); + + assert.dom(this.element).hasText('resolved'); }); }); diff --git a/tests/integration/helpers/get-dot-prop-test.js b/tests/integration/helpers/get-dot-prop-test.js index 98e4be84..39176b5b 100644 --- a/tests/integration/helpers/get-dot-prop-test.js +++ b/tests/integration/helpers/get-dot-prop-test.js @@ -1,17 +1,84 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | get-dot-prop', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it reads a own property off the object', async function (assert) { + this.set('object', { name: 'Fleetbase' }); - await render(hbs`{{get-dot-prop this.inputValue}}`); + await render(hbs`{{get-dot-prop this.object "name"}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('Fleetbase'); + }); + + test('a missing key renders nothing', async function (assert) { + this.set('object', { name: 'Fleetbase' }); + + await render(hbs`{{get-dot-prop this.object "missing"}}`); + + assert.dom(this.element).hasNoText('undefined is normalised to null'); + }); + + test('a key containing dots is used as a literal key, not a path', async function (assert) { + this.set('flat', { 'meta.page': 7 }); + this.set('nested', { meta: { page: 7 } }); + + await render(hbs`{{get-dot-prop this.flat "meta.page"}}{{get-dot-prop this.nested "meta.page"}}`); + + assert.dom('#flat').hasText('7', 'a literal dotted key is found'); + assert.dom('#nested').hasNoText('the helper does not walk nested paths'); + }); + + test('falsy-but-present values are returned rather than nulled out', async function (assert) { + this.set('object', { zero: 0, flag: false, blank: '' }); + + await render( + hbs`{{get-dot-prop this.object "zero"}}{{get-dot-prop this.object "flag"}}{{get-dot-prop this.object "blank"}}` + ); + + assert.dom('#zero').hasText('0'); + assert.dom('#flag').hasText('false'); + assert.dom('#blank').hasNoText(); + }); + + test('an explicit null value renders nothing', async function (assert) { + this.set('object', { value: null }); + + await render(hbs`{{get-dot-prop this.object "value"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('an empty object always renders nothing', async function (assert) { + this.set('object', {}); + + await render(hbs`{{get-dot-prop this.object "anything"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('array indexes work because access is a plain bracket lookup', async function (assert) { + this.set('list', ['alpha', 'beta']); + + await render(hbs`{{get-dot-prop this.list "1"}}{{get-dot-prop this.list "length"}}`); + + assert.dom('#second').hasText('beta'); + assert.dom('#length').hasText('2'); + }); + + test('it recomputes when the key changes', async function (assert) { + this.set('object', { a: 'first', b: 'second' }); + this.set('key', 'a'); + + await render(hbs`{{get-dot-prop this.object this.key}}`); + assert.dom(this.element).hasText('first'); + + this.set('key', 'b'); + await settled(); + + assert.dom(this.element).hasText('second'); }); }); diff --git a/tests/integration/helpers/get-file-url-test.js b/tests/integration/helpers/get-file-url-test.js index 500acb0d..f4cdf767 100644 --- a/tests/integration/helpers/get-file-url-test.js +++ b/tests/integration/helpers/get-file-url-test.js @@ -1,17 +1,136 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +const FILE_UUID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; +const OTHER_FILE_UUID = '9c858901-8a57-4791-81fe-4c455b099bc9'; module('Integration | Helper | get-file-url', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('a plain url is returned verbatim without touching the store', async function (assert) { + const store = this.owner.lookup('service:store'); + this.set('url', 'https://cdn.example.com/uploads/invoice.pdf'); + + await render(hbs`{{get-file-url this.url}}`); + + assert.dom(this.element).hasText('https://cdn.example.com/uploads/invoice.pdf'); + assert.strictEqual(store.calls.length, 0, 'non-uuid input never hits the store'); + }); + + test('a relative path is returned verbatim', async function (assert) { + this.set('url', '/assets/images/logo.png'); + + await render(hbs`{{get-file-url this.url}}`); + + assert.dom(this.element).hasText('/assets/images/logo.png'); + }); + + test('a uuid-like string that is not a valid uuid is treated as a url', async function (assert) { + this.set('url', 'not-a-uuid-at-all'); + + await render(hbs`{{get-file-url this.url}}`); + + assert.dom(this.element).hasText('not-a-uuid-at-all'); + }); + + test('non-string input is coerced to a string', async function (assert) { + this.set('url', 12345); + + await render(hbs`{{get-file-url this.url}}`); + + assert.dom(this.element).hasText('12345'); + }); + + test('empty and missing input render nothing', async function (assert) { + this.set('blank', ''); + this.set('nothing', undefined); + this.set('nullish', null); + + await render(hbs`{{get-file-url this.blank}}{{get-file-url this.nothing}}{{get-file-url this.nullish}}`); + + assert.dom('#blank').hasNoText(); + assert.dom('#undef').hasNoText(); + assert.dom('#null').hasNoText(); + }); + + test('a uuid resolves to the url of an already loaded file record', async function (assert) { + const store = this.owner.lookup('service:store'); + store.createRecord('file', { id: FILE_UUID, url: 'https://cdn.example.com/files/peeked.png' }); + this.set('id', FILE_UUID); + + await render(hbs`{{get-file-url this.id}}`); + + assert.dom(this.element).hasText('https://cdn.example.com/files/peeked.png'); + }); + + test('a uuid that is not in the store is fetched and rendered once it loads', async function (assert) { + const requested = []; + this.owner.register( + 'service:store', + class extends Service { + peekRecord() { + return null; + } + + findRecord(modelName, id) { + requested.push([modelName, id]); + return Promise.resolve({ id, url: 'https://cdn.example.com/files/fetched.png' }); + } + } + ); + this.set('id', FILE_UUID); + + await render(hbs`{{get-file-url this.id}}`); + + assert.deepEqual(requested, [['file', FILE_UUID]], 'the file is fetched from the store by id'); + assert.dom(this.element).hasText('https://cdn.example.com/files/fetched.png'); + }); + + test('a failed lookup renders nothing instead of throwing', async function (assert) { + this.owner.register( + 'service:store', + class extends Service { + peekRecord() { + return null; + } + + findRecord() { + return Promise.reject(new Error('404')); + } + } + ); + this.set('id', FILE_UUID); + + await render(hbs`{{get-file-url this.id}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('a file record without a url renders nothing', async function (assert) { + const store = this.owner.lookup('service:store'); + store.createRecord('file', { id: FILE_UUID }); + this.set('id', FILE_UUID); + + await render(hbs`{{get-file-url this.id}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('changing the input resolves the new file', async function (assert) { + const store = this.owner.lookup('service:store'); + store.createRecord('file', { id: FILE_UUID, url: 'https://cdn.example.com/files/first.png' }); + store.createRecord('file', { id: OTHER_FILE_UUID, url: 'https://cdn.example.com/files/second.png' }); + this.set('id', FILE_UUID); + + await render(hbs`{{get-file-url this.id}}`); + assert.dom(this.element).hasText('https://cdn.example.com/files/first.png'); - await render(hbs`{{get-file-url this.inputValue}}`); + this.set('id', OTHER_FILE_UUID); + await settled(); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('https://cdn.example.com/files/second.png'); }); }); diff --git a/tests/integration/helpers/get-model-name-test.js b/tests/integration/helpers/get-model-name-test.js index c4176b2e..7db020c4 100644 --- a/tests/integration/helpers/get-model-name-test.js +++ b/tests/integration/helpers/get-model-name-test.js @@ -3,15 +3,97 @@ import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +class FuelReportModel {} +FuelReportModel.modelName = 'fuel-report'; + +class ApiCredentialModel {} +ApiCredentialModel.modelName = 'apiCredential'; + +class PascalModel {} +PascalModel.modelName = 'FuelReport'; + module('Integration | Helper | get-model-name', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + hooks.beforeEach(function () { + this.set('model', new FuelReportModel()); + }); + + test('it reads modelName off the record constructor', async function (assert) { + await render(hbs`{{get-model-name this.model}}`); + + assert.dom(this.element).hasText('fuel-report'); + }); + + test('it falls back to the legacy _internalModel model name', async function (assert) { + this.set('legacy', { _internalModel: { modelName: 'legacy-thing' } }); + + await render(hbs`{{get-model-name this.legacy}}`); + + assert.dom(this.element).hasText('legacy-thing'); + }); + + test('the fallback argument is used when no model name can be found', async function (assert) { + this.set('plain', { id: 1 }); + + await render(hbs`{{get-model-name this.plain "record"}}`); + + assert.dom(this.element).hasText('record'); + }); + + test('without a fallback an unknown model renders nothing', async function (assert) { + this.set('plain', { id: 1 }); + + await render(hbs`{{get-model-name this.plain}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('humanize turns a dasherized name into title-cased words', async function (assert) { + await render(hbs`{{get-model-name this.model humanize=true}}`); + + assert.dom(this.element).hasText('Fuel Report'); + }); + + test('humanize upper-cases known abbreviations', async function (assert) { + this.set('model', new ApiCredentialModel()); + + await render(hbs`{{get-model-name this.model humanize=true}}`); + + assert.dom(this.element).hasText('API Credential'); + }); + + test('lowercase lower-cases the whole name', async function (assert) { + this.set('model', new PascalModel()); + + await render(hbs`{{get-model-name this.model lowercase=true}}`); + + assert.dom(this.element).hasText('fuelreport'); + }); + + test('capitalize only upper-cases the first character', async function (assert) { + await render(hbs`{{get-model-name this.model capitalize=true}}`); + + assert.dom(this.element).hasText('Fuel-report', 'the dash is left untouched'); + }); + + test('capitalizeWords capitalizes each whitespace separated word', async function (assert) { + await render(hbs`{{get-model-name this.model humanize=true lowercase=true capitalizeWords=true}}`); + + assert.dom(this.element).hasText('Fuel Report', 'the options are applied in order: humanize, lowercase, then capitalizeWords'); + }); + + test('humanize combined with lowercase yields a plain lowercase phrase', async function (assert) { + await render(hbs`{{get-model-name this.model humanize=true lowercase=true}}`); + + assert.dom(this.element).hasText('fuel report'); + }); + + test('options default to off', async function (assert) { + this.set('model', new PascalModel()); - await render(hbs`{{get-model-name this.inputValue}}`); + await render(hbs`{{get-model-name this.model humanize=false lowercase=false capitalize=false capitalizeWords=false}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('FuelReport', 'the raw model name is returned untouched'); }); }); diff --git a/tests/integration/helpers/get-notification-key-test.js b/tests/integration/helpers/get-notification-key-test.js index 7e493acd..c91c7254 100644 --- a/tests/integration/helpers/get-notification-key-test.js +++ b/tests/integration/helpers/get-notification-key-test.js @@ -6,12 +6,58 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | get-notification-key', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it joins a stripped definition and a camelized name with a double underscore', async function (assert) { + await render(hbs`{{get-notification-key "order_created" "created"}}`); - await render(hbs`{{get-notification-key this.inputValue}}`); + assert.dom(this.element).hasText('ordercreated__created'); + }); + + test('non word characters are stripped out of the definition', async function (assert) { + await render(hbs`{{get-notification-key "fleet-ops:event.order.ready" "Order Ready"}}`); + + assert.dom(this.element).hasText('fleetopseventorderready__orderReady'); + }); + + test('a PascalCase definition is camelized', async function (assert) { + await render(hbs`{{get-notification-key "OrderNotification" "ready"}}`); + + assert.dom(this.element).hasText('orderNotification__ready'); + }); + + test('a dasherized name is camelized', async function (assert) { + await render(hbs`{{get-notification-key "orders" "order-ready-for-pickup"}}`); + + assert.dom(this.element).hasText('orders__orderReadyForPickup'); + }); + + test('an underscored name is camelized', async function (assert) { + await render(hbs`{{get-notification-key "orders" "order_dispatched"}}`); + + assert.dom(this.element).hasText('orders__orderDispatched'); + }); + + test('digits survive the stripping of the definition', async function (assert) { + await render(hbs`{{get-notification-key "v1.order.events" "ready"}}`); + + assert.dom(this.element).hasText('v1orderevents__ready'); + }); + + test('an empty name still produces the separator', async function (assert) { + await render(hbs`{{get-notification-key "orders" ""}}`); + + assert.dom(this.element).hasText('orders__'); + }); + + test('an empty definition still produces the separator', async function (assert) { + await render(hbs`{{get-notification-key "" "ready"}}`); + + assert.dom(this.element).hasText('__ready'); + }); + + test('the key is stable for the same inputs', async function (assert) { + await render(hbs`{{get-notification-key "fleet-ops:order" "ready"}}{{get-notification-key "fleet_ops:order" "ready"}}`); - assert.dom().hasText('1234'); + assert.dom('#a').hasText('fleetopsorder__ready'); + assert.dom('#b').hasText('fleetopsorder__ready', 'separators are removed so dashes and underscores collapse to the same key'); }); }); diff --git a/tests/integration/helpers/get-universe-components-test.js b/tests/integration/helpers/get-universe-components-test.js index ce27d5ff..094fdc8b 100644 --- a/tests/integration/helpers/get-universe-components-test.js +++ b/tests/integration/helpers/get-universe-components-test.js @@ -2,16 +2,65 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class StubUniverse extends Service { + requested = []; + registry = {}; + + getRenderableComponentsFromRegistry(registryName) { + this.requested.push(registryName); + return this.registry[registryName] ?? []; + } +} module('Integration | Helper | get-universe-components', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + hooks.beforeEach(function () { + this.owner.register('service:universe', StubUniverse); + this.universe = this.owner.lookup('service:universe'); + }); + + test('it renders the components registered under the requested registry', async function (assert) { + this.universe.registry['dashboard:widgets'] = [{ name: 'alpha' }, { name: 'beta' }]; + + await render(hbs`{{#each (get-universe-components "dashboard:widgets") as |component|}}{{component.name}}{{/each}}`); + + assert.dom('.component').exists({ count: 2 }); + assert.deepEqual( + [...this.element.querySelectorAll('.component')].map((element) => element.textContent), + ['alpha', 'beta'] + ); + }); + + test('the registry name is forwarded to the universe service', async function (assert) { + await render(hbs`{{#each (get-universe-components "engine:fleet-ops:panels") as |component|}}{{component.name}}{{/each}}`); + + assert.deepEqual(this.universe.requested, ['engine:fleet-ops:panels']); + }); + + test('an unknown registry renders nothing', async function (assert) { + await render(hbs`{{#each (get-universe-components "nothing-here") as |component|}}{{component.name}}{{/each}}`); + + assert.dom('.component').doesNotExist(); + }); + + test('a missing registry name is still forwarded and yields no components', async function (assert) { + await render(hbs`{{#each (get-universe-components) as |component|}}{{component.name}}{{/each}}`); + + assert.deepEqual(this.universe.requested, [undefined]); + assert.dom('.component').doesNotExist(); + }); + + test('components keep the order the registry returned them in', async function (assert) { + this.universe.registry.ordered = [{ name: 'one' }, { name: 'two' }, { name: 'three' }]; - await render(hbs`{{get-universe-components this.inputValue}}`); + await render(hbs`{{#each (get-universe-components "ordered") as |component|}}{{component.name}}{{/each}}`); - assert.dom().hasText('1234'); + assert.deepEqual( + [...this.element.querySelectorAll('.component')].map((element) => element.textContent), + ['one', 'two', 'three'] + ); }); }); diff --git a/tests/integration/helpers/get-universe-menu-items-test.js b/tests/integration/helpers/get-universe-menu-items-test.js index 772fec63..2256303b 100644 --- a/tests/integration/helpers/get-universe-menu-items-test.js +++ b/tests/integration/helpers/get-universe-menu-items-test.js @@ -2,16 +2,69 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class StubUniverse extends Service { + requested = []; + registry = {}; + + getMenuItemsFromRegistry(registryName) { + this.requested.push(registryName); + return this.registry[registryName] ?? []; + } +} module('Integration | Helper | get-universe-menu-items', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + hooks.beforeEach(function () { + this.owner.register('service:universe', StubUniverse); + this.universe = this.owner.lookup('service:universe'); + }); + + test('it renders the menu items registered under the requested registry', async function (assert) { + this.universe.registry['header-menu'] = [ + { title: 'Orders', route: 'orders.index' }, + { title: 'Drivers', route: 'drivers.index' }, + ]; + + await render(hbs`{{#each (get-universe-menu-items "header-menu") as |item|}}{{item.title}}{{/each}}`); + + assert.dom('.item').exists({ count: 2 }); + assert.deepEqual( + [...this.element.querySelectorAll('.item')].map((element) => element.textContent), + ['Orders', 'Drivers'] + ); + assert.dom(this.element.querySelector('.item')).hasAttribute('href', 'orders.index'); + }); + + test('the registry name is forwarded to the universe service', async function (assert) { + await render(hbs`{{#each (get-universe-menu-items "user-menu") as |item|}}{{item.title}}{{/each}}`); + + assert.deepEqual(this.universe.requested, ['user-menu']); + }); + + test('an unknown registry renders nothing', async function (assert) { + await render(hbs`{{#each (get-universe-menu-items "no-such-menu") as |item|}}{{item.title}}{{/each}}`); + + assert.dom('.item').doesNotExist(); + }); + + test('a missing registry name yields no menu items', async function (assert) { + await render(hbs`{{#each (get-universe-menu-items) as |item|}}{{item.title}}{{/each}}`); + + assert.deepEqual(this.universe.requested, [undefined]); + assert.dom('.item').doesNotExist(); + }); + + test('menu items keep the order the registry returned them in', async function (assert) { + this.universe.registry.ordered = [{ title: 'first' }, { title: 'second' }, { title: 'third' }]; - await render(hbs`{{get-universe-menu-items this.inputValue}}`); + await render(hbs`{{#each (get-universe-menu-items "ordered") as |item|}}{{item.title}}{{/each}}`); - assert.dom().hasText('1234'); + assert.deepEqual( + [...this.element.querySelectorAll('.item')].map((element) => element.textContent), + ['first', 'second', 'third'] + ); }); }); diff --git a/tests/integration/helpers/get-write-permission-test.js b/tests/integration/helpers/get-write-permission-test.js index 4cb40a0b..b66b4cd3 100644 --- a/tests/integration/helpers/get-write-permission-test.js +++ b/tests/integration/helpers/get-write-permission-test.js @@ -1,17 +1,77 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +class VehicleModel { + constructor(props = {}) { + Object.assign(this, props); + } +} +VehicleModel.modelName = 'vehicle'; + module('Integration | Helper | get-write-permission', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('a persisted record maps to an update permission', async function (assert) { + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{get-write-permission this.model}}`); + + assert.dom(this.element).hasText('fleet-ops update vehicle'); + }); + + test('an unsaved record maps to a create permission', async function (assert) { + this.set('model', new VehicleModel({ isNew: true })); + + await render(hbs`{{get-write-permission this.model}}`); + + assert.dom(this.element).hasText('fleet-ops create vehicle'); + }); + + test('the schema defaults to fleet-ops and can be overridden positionally', async function (assert) { + this.set('model', new VehicleModel({ isNew: false })); + + await render(hbs`{{get-write-permission this.model "telematics"}}`); + + assert.dom(this.element).hasText('telematics update vehicle'); + }); + + test('a missing isNew flag is treated as persisted', async function (assert) { + this.set('model', new VehicleModel()); + + await render(hbs`{{get-write-permission this.model}}`); + + assert.dom(this.element).hasText('fleet-ops update vehicle'); + }); + + test('it falls back to the legacy _internalModel model name', async function (assert) { + this.set('model', { isNew: true, _internalModel: { modelName: 'legacy-thing' } }); + + await render(hbs`{{get-write-permission this.model}}`); + + assert.dom(this.element).hasText('fleet-ops create legacy-thing'); + }); + + test('the model name is not transformed', async function (assert) { + class ApiCredentialModel {} + ApiCredentialModel.modelName = 'apiCredential'; + this.set('model', new ApiCredentialModel()); + + await render(hbs`{{get-write-permission this.model}}`); + + assert.dom(this.element).hasText('fleet-ops update apiCredential', 'the raw model name is interpolated as-is'); + }); + + test('it recomputes when the record transitions from new to persisted', async function (assert) { + this.set('model', new VehicleModel({ isNew: true })); + + await render(hbs`{{get-write-permission this.model}}`); + assert.dom(this.element).hasText('fleet-ops create vehicle'); - await render(hbs`{{get-write-permission this.inputValue}}`); + this.set('model', new VehicleModel({ isNew: false })); + await settled(); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('fleet-ops update vehicle'); }); }); diff --git a/tests/integration/helpers/has-registration-test.js b/tests/integration/helpers/has-registration-test.js index bf6a888d..420e2269 100644 --- a/tests/integration/helpers/has-registration-test.js +++ b/tests/integration/helpers/has-registration-test.js @@ -2,16 +2,59 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import Component from '@glimmer/component'; module('Integration | Helper | has-registration', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('a fully qualified name that exists resolves to true', async function (assert) { + await render(hbs`{{has-registration "helper:has-registration"}}`); - await render(hbs`{{has-registration this.inputValue}}`); + assert.dom(this.element).hasText('true'); + }); + + test('a fully qualified name that does not exist resolves to false', async function (assert) { + await render(hbs`{{has-registration "component:definitely-not-a-real-component"}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('the type can be supplied as a second argument instead of a prefix', async function (assert) { + await render(hbs`{{has-registration "service:store"}}{{has-registration "store" "service"}}`); + + assert.dom('#prefixed').hasText('true'); + assert.dom('#split').hasText('true', 'name and type are joined with a colon'); + }); + + test('the type argument is applied when checking for missing registrations', async function (assert) { + await render(hbs`{{has-registration "totally-missing" "service"}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a name without a type never matches a registration', async function (assert) { + await render(hbs`{{has-registration "store"}}`); + + assert.dom(this.element).hasText('false', 'an unqualified name is not resolvable'); + }); + + test('it sees registrations made on the owner at runtime', async function (assert) { + this.owner.register('service:my-runtime-service', class extends Service {}); + this.owner.register('component:my-runtime-component', class extends Component {}); + + await render(hbs`{{has-registration "my-runtime-service" "service"}}{{has-registration "component:my-runtime-component"}}`); + + assert.dom('#service').hasText('true'); + assert.dom('#component').hasText('true'); + }); + + test('it distinguishes between types for the same name', async function (assert) { + this.owner.register('service:ambiguous-name', class extends Service {}); + + await render(hbs`{{has-registration "ambiguous-name" "service"}}{{has-registration "ambiguous-name" "component"}}`); - assert.dom().hasText('1234'); + assert.dom('#service').hasText('true'); + assert.dom('#component').hasText('false', 'the type is part of the lookup key'); }); }); diff --git a/tests/integration/helpers/in-array-test.js b/tests/integration/helpers/in-array-test.js index f2bce7ca..84e4fdea 100644 --- a/tests/integration/helpers/in-array-test.js +++ b/tests/integration/helpers/in-array-test.js @@ -1,17 +1,114 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { A } from '@ember/array'; module('Integration | Helper | in-array', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it reports membership in a native array', async function (assert) { + this.set('list', ['alpha', 'beta', 'gamma']); - await render(hbs`{{in-array this.inputValue}}`); + await render(hbs`{{in-array "beta" this.list}}{{in-array "delta" this.list}}`); - assert.dom(this.element).hasText('1234'); + assert.dom('#hit').hasText('true'); + assert.dom('#miss').hasText('false'); + }); + + test('it works with an Ember array', async function (assert) { + this.set('list', A(['alpha', 'beta'])); + + await render(hbs`{{in-array "alpha" this.list}}{{in-array "omega" this.list}}`); + + assert.dom('#hit').hasText('true'); + assert.dom('#miss').hasText('false'); + }); + + test('an empty array never contains anything', async function (assert) { + this.set('list', []); + + await render(hbs`{{in-array "alpha" this.list}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a missing or non-array second argument is false rather than an error', async function (assert) { + this.set('nothing', undefined); + this.set('nullish', null); + this.set('object', { alpha: true }); + + await render( + hbs`{{in-array "alpha" this.nothing}}{{in-array "alpha" this.nullish}}{{in-array "alpha" this.object}}` + ); + + assert.dom('#undef').hasText('false'); + assert.dom('#null').hasText('false'); + assert.dom('#obj').hasText('false'); + }); + + test('a string is not treated as an array of characters', async function (assert) { + this.set('list', 'alpha'); + + await render(hbs`{{in-array "a" this.list}}`); + + assert.dom(this.element).hasText('false', 'strings are rejected before the includes() check'); + }); + + test('comparison is strict, so types matter', async function (assert) { + this.set('list', [1, 2, 3]); + + await render(hbs`{{in-array 2 this.list}}{{in-array "2" this.list}}`); + + assert.dom('#num').hasText('true'); + assert.dom('#str').hasText('false', 'the string "2" does not match the number 2'); + }); + + test('objects are matched by reference', async function (assert) { + const item = { id: 1 }; + this.set('list', [item]); + this.set('item', item); + this.set('lookalike', { id: 1 }); + + await render(hbs`{{in-array this.item this.list}}{{in-array this.lookalike this.list}}`); + + assert.dom('#same').hasText('true'); + assert.dom('#other').hasText('false', 'a structurally equal but distinct object is not a member'); + }); + + test('falsy members are found', async function (assert) { + this.set('list', [0, false, '', null, undefined]); + this.set('nullish', null); + this.set('nothing', undefined); + + await render( + hbs`{{in-array 0 this.list}}{{in-array "" this.list}}{{in-array this.nullish this.list}}{{in-array this.nothing this.list}}` + ); + + assert.dom('#zero').hasText('true'); + assert.dom('#empty').hasText('true'); + assert.dom('#null').hasText('true'); + assert.dom('#undef').hasText('true'); + }); + + test('NaN is found because includes uses SameValueZero', async function (assert) { + this.set('list', [Number.NaN]); + this.set('notANumber', Number.NaN); + + await render(hbs`{{in-array this.notANumber this.list}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it recomputes when the array is replaced', async function (assert) { + this.set('list', ['alpha']); + + await render(hbs`{{in-array "beta" this.list}}`); + assert.dom(this.element).hasText('false'); + + this.set('list', ['alpha', 'beta']); + await settled(); + + assert.dom(this.element).hasText('true'); }); }); diff --git a/tests/integration/helpers/is-array-test.js b/tests/integration/helpers/is-array-test.js index 94693745..08dea96b 100644 --- a/tests/integration/helpers/is-array-test.js +++ b/tests/integration/helpers/is-array-test.js @@ -2,16 +2,101 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { A } from '@ember/array'; +import ArrayProxy from '@ember/array/proxy'; +import isArrayHelper from '@fleetbase/ember-ui/helpers/is-array'; +// `ember-truth-helpers` also ships an `is-array` helper and wins the app-tree merge in +// the dummy app, so the addon helper is registered under its own name to be sure these +// tests exercise `addon/helpers/is-array.js`. module('Integration | Helper | is-array', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + hooks.beforeEach(function () { + this.owner.register('helper:ui-is-array', isArrayHelper); + }); + + test('native arrays are arrays', async function (assert) { + this.set('empty', []); + this.set('filled', [1, 2, 3]); + + await render(hbs`{{ui-is-array this.empty}}{{ui-is-array this.filled}}`); + + assert.dom('#empty').hasText('true', 'an empty array is still an array'); + assert.dom('#filled').hasText('true'); + }); + + test('Ember arrays and array proxies are arrays', async function (assert) { + this.set('emberArray', A(['alpha'])); + this.set('proxy', ArrayProxy.create({ content: A(['alpha']) })); + + await render(hbs`{{ui-is-array this.emberArray}}{{ui-is-array this.proxy}}`); + + assert.dom('#ember').hasText('true'); + assert.dom('#proxy').hasText('true'); + }); + + test('null and undefined are not arrays', async function (assert) { + this.set('nullish', null); + this.set('nothing', undefined); + + await render(hbs`{{ui-is-array this.nullish}}{{ui-is-array this.nothing}}{{ui-is-array}}`); + + assert.dom('#null').hasText('false'); + assert.dom('#undef').hasText('false'); + assert.dom('#none').hasText('false', 'a missing argument is not an array'); + }); + + test('only the first positional argument is inspected', async function (assert) { + this.set('list', ['alpha']); + + await render(hbs`{{ui-is-array this.list "not-an-array"}}{{ui-is-array "not-an-array" this.list}}`); + + assert.dom('#first').hasText('true', 'extra arguments are ignored'); + assert.dom('#second').hasText('false'); + }); + + test('strings are not arrays despite having a length', async function (assert) { + await render(hbs`{{ui-is-array "alpha"}}{{ui-is-array ""}}`); + + assert.dom('#text').hasText('false'); + assert.dom('#empty').hasText('false'); + }); + + test('numbers and booleans are not arrays', async function (assert) { + this.set('flag', true); + + await render(hbs`{{ui-is-array 0}}{{ui-is-array 42}}{{ui-is-array this.flag}}`); + + assert.dom('#zero').hasText('false'); + assert.dom('#number').hasText('false'); + assert.dom('#bool').hasText('false'); + }); + + test('plain objects are not arrays', async function (assert) { + this.set('object', { alpha: 1 }); + + await render(hbs`{{ui-is-array (hash a=1)}}{{ui-is-array this.object}}`); + + assert.dom('#hash').hasText('false'); + assert.dom('#object').hasText('false'); + }); + + test('array-like objects with a numeric length are reported as arrays', async function (assert) { + this.set('arrayLike', { length: 2, 0: 'alpha', 1: 'beta' }); + this.set('notArrayLike', { length: 'two' }); + + await render(hbs`{{ui-is-array this.arrayLike}}{{ui-is-array this.notArrayLike}}`); + + assert.dom('#like').hasText('true', 'Ember treats numeric-length objects as arrays'); + assert.dom('#unlike').hasText('false', 'a non-numeric length is not enough'); + }); + + test('a NaN length is not enough to be an array', async function (assert) { + this.set('weird', { length: Number.NaN }); - await render(hbs`{{is-array this.inputValue}}`); + await render(hbs`{{ui-is-array this.weird}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('false'); }); }); diff --git a/tests/integration/helpers/is-bool-value-test.js b/tests/integration/helpers/is-bool-value-test.js index d0302efa..06f2b3a8 100644 --- a/tests/integration/helpers/is-bool-value-test.js +++ b/tests/integration/helpers/is-bool-value-test.js @@ -6,12 +6,72 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | is-bool-value', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('real booleans are boolean values', async function (assert) { + this.set('yes', true); + this.set('no', false); - await render(hbs`{{is-bool-value this.inputValue}}`); + await render(hbs`{{is-bool-value this.yes}}{{is-bool-value this.no}}`); - assert.dom(this.element).hasText('1234'); + assert.dom('#true').hasText('true'); + assert.dom('#false').hasText('true', 'false is still a boolean value'); + }); + + test('the four recognised boolean-ish strings are accepted', async function (assert) { + await render( + hbs`{{is-bool-value "true"}}{{is-bool-value "false"}}{{is-bool-value "1"}}{{is-bool-value "0"}}` + ); + + assert.dom('#t').hasText('true'); + assert.dom('#f').hasText('true'); + assert.dom('#one').hasText('true'); + assert.dom('#zero').hasText('true'); + }); + + test('string matching is case sensitive', async function (assert) { + await render(hbs`{{is-bool-value "TRUE"}}{{is-bool-value "False"}}`); + + assert.dom('#upper').hasText('false'); + assert.dom('#title').hasText('false'); + }); + + test('surrounding whitespace is not trimmed', async function (assert) { + await render(hbs`{{is-bool-value " true"}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('numeric 1 and 0 are not boolean values', async function (assert) { + await render(hbs`{{is-bool-value 1}}{{is-bool-value 0}}`); + + assert.dom('#one').hasText('false', 'only the string "1" is recognised'); + assert.dom('#zero').hasText('false'); + }); + + test('other strings are not boolean values', async function (assert) { + await render(hbs`{{is-bool-value "yes"}}{{is-bool-value "on"}}{{is-bool-value ""}}`); + + assert.dom('#yes').hasText('false'); + assert.dom('#on').hasText('false'); + assert.dom('#empty').hasText('false'); + }); + + test('null, undefined and missing arguments are not boolean values', async function (assert) { + this.set('nullish', null); + this.set('nothing', undefined); + + await render(hbs`{{is-bool-value this.nullish}}{{is-bool-value this.nothing}}{{is-bool-value}}`); + + assert.dom('#null').hasText('false'); + assert.dom('#undef').hasText('false'); + assert.dom('#none').hasText('false'); + }); + + test('objects and arrays are not boolean values', async function (assert) { + this.set('list', ['true']); + + await render(hbs`{{is-bool-value (hash value=true)}}{{is-bool-value this.list}}`); + + assert.dom('#hash').hasText('false'); + assert.dom('#array').hasText('false'); }); }); diff --git a/tests/integration/helpers/is-dark-mode-test.js b/tests/integration/helpers/is-dark-mode-test.js index b3b5ab36..2b22d9be 100644 --- a/tests/integration/helpers/is-dark-mode-test.js +++ b/tests/integration/helpers/is-dark-mode-test.js @@ -1,17 +1,78 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +class StubThemeService extends Service { + @tracked activeTheme = 'light'; +} + +class ThemelessService extends Service {} module('Integration | Helper | is-dark-mode', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it is true when the theme service reports the dark theme', async function (assert) { + this.owner.register('service:theme', StubThemeService); + this.owner.lookup('service:theme').activeTheme = 'dark'; + + await render(hbs`{{is-dark-mode}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is false when the theme service reports the light theme', async function (assert) { + this.owner.register('service:theme', StubThemeService); + this.owner.lookup('service:theme').activeTheme = 'light'; + + await render(hbs`{{is-dark-mode}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it compares the active theme exactly and is not case insensitive', async function (assert) { + this.owner.register('service:theme', StubThemeService); + this.owner.lookup('service:theme').activeTheme = 'DARK'; + + await render(hbs`{{is-dark-mode}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false when the theme service exposes no active theme', async function (assert) { + this.owner.register('service:theme', ThemelessService); + + await render(hbs`{{is-dark-mode}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it recomputes when the active theme changes', async function (assert) { + this.owner.register('service:theme', StubThemeService); + const theme = this.owner.lookup('service:theme'); + theme.activeTheme = 'light'; + + await render(hbs`{{is-dark-mode}}`); + assert.dom(this.element).hasText('false'); + + theme.activeTheme = 'dark'; + await settled(); + assert.dom(this.element).hasText('true'); + + theme.activeTheme = 'light'; + await settled(); + assert.dom(this.element).hasText('false'); + }); + + test('it ignores positional arguments and always reflects the service state', async function (assert) { + this.owner.register('service:theme', StubThemeService); + this.owner.lookup('service:theme').activeTheme = 'dark'; + this.set('inputValue', 'light'); await render(hbs`{{is-dark-mode this.inputValue}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('true'); }); }); diff --git a/tests/integration/helpers/is-dd-item-visible-test.js b/tests/integration/helpers/is-dd-item-visible-test.js index baf81d05..8ffef3dd 100644 --- a/tests/integration/helpers/is-dd-item-visible-test.js +++ b/tests/integration/helpers/is-dd-item-visible-test.js @@ -6,12 +6,121 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | is-dd-item-visible', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('an explicit false hides the item even with no context', async function (assert) { + this.set('context', null); + this.set('isVisible', false); - await render(hbs`{{is-dd-item-visible this.inputValue}}`); + await render(hbs`{{is-dd-item-visible this.context this.isVisible}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('false'); + }); + + test('an explicit false hides the item when a context is present', async function (assert) { + this.set('context', { id: 'order_1' }); + this.set('isVisible', false); + + await render(hbs`{{is-dd-item-visible this.context this.isVisible}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a predicate with no context leaves the item visible rather than being invoked', async function (assert) { + let called = 0; + this.set('context', null); + this.set('isVisible', () => { + called++; + return false; + }); + + await render(hbs`{{is-dd-item-visible this.context this.isVisible}}`); + + assert.dom(this.element).hasText('true'); + assert.strictEqual(called, 0, 'the predicate is never handed a missing context'); + }); + + test('it is visible when the context is undefined', async function (assert) { + await render(hbs`{{is-dd-item-visible this.missingContext this.missingVisibility}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it defaults to visible when no visibility argument is supplied', async function (assert) { + this.set('context', { id: 'order_1' }); + + await render(hbs`{{is-dd-item-visible this.context}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it treats an explicit boolean true as visible', async function (assert) { + this.set('context', { id: 'order_1' }); + + await render(hbs`{{is-dd-item-visible this.context true}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it evaluates a visibility function against the context', async function (assert) { + this.set('context', { id: 'order_1', canView: true }); + this.set('isVisible', (context) => context.canView); + + await render(hbs`{{is-dd-item-visible this.context this.isVisible}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it hides the item when the visibility function returns false', async function (assert) { + this.set('context', { id: 'order_1', canView: false }); + this.set('isVisible', (context) => context.canView); + + await render(hbs`{{is-dd-item-visible this.context this.isVisible}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it passes the context through to the visibility function', async function (assert) { + const received = []; + const context = { id: 'order_1' }; + this.set('context', context); + this.set('isVisible', (given) => { + received.push(given); + return true; + }); + + await render(hbs`{{is-dd-item-visible this.context this.isVisible}}`); + + assert.strictEqual(received.length, 1, 'the visibility function is invoked once'); + assert.strictEqual(received[0], context, 'the visibility function receives the context'); + }); + + test('it coerces a non-boolean, non-function visibility value to visible', async function (assert) { + this.set('context', { id: 'order_1' }); + this.set('isVisible', 'yes'); + + await render(hbs`{{is-dd-item-visible this.context this.isVisible}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it treats a falsy visibility value as visible even when a context is present', async function (assert) { + this.set('context', { id: 'order_1' }); + this.set('isVisible', 0); + + await render(hbs`{{is-dd-item-visible this.context this.isVisible}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it gates conditional template blocks', async function (assert) { + this.set('context', { id: 'order_1', canView: false }); + this.set('isVisible', (context) => context.canView); + + await render(hbs` + {{#if (is-dd-item-visible this.context this.isVisible)}} + visible + {{/if}} + `); + + assert.dom('.dd-item').doesNotExist(); }); }); diff --git a/tests/integration/helpers/is-model-test.js b/tests/integration/helpers/is-model-test.js index a29b2290..f714fd4d 100644 --- a/tests/integration/helpers/is-model-test.js +++ b/tests/integration/helpers/is-model-test.js @@ -2,16 +2,74 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import ObjectProxy from '@ember/object/proxy'; +import EmberObject from '@ember/object'; + +// Resolved through the module loader so the test file does not need a build-time +// dependency on ember-data, which the helper only reaches through @fleetbase/ember-core. +function getModelClass() { + return require('@ember-data/model').default; +} module('Integration | Helper | is-model', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it is true for an ember-data model instance', async function (assert) { + const Model = getModelClass(); + this.set('subject', Object.create(Model.prototype)); + + await render(hbs`{{is-model this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is true for an ObjectProxy', async function (assert) { + this.set('subject', ObjectProxy.create({ content: { name: 'Order' } })); + + await render(hbs`{{is-model this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is false for a plain object that merely looks like a model', async function (assert) { + this.set('subject', { id: '1', hasDirtyAttributes: false, save() {} }); + + await render(hbs`{{is-model this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for a plain EmberObject', async function (assert) { + this.set('subject', EmberObject.create({ id: '1' })); + + await render(hbs`{{is-model this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for null', async function (assert) { + this.set('subject', null); + + await render(hbs`{{is-model this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for undefined', async function (assert) { + await render(hbs`{{is-model this.missing}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for primitives and arrays', async function (assert) { + this.set('subjects', ['order', 0, false, [], {}]); - await render(hbs`{{is-model this.inputValue}}`); + await render(hbs` + {{#each this.subjects as |subject|}} + {{is-model subject}} + {{/each}} + `); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('false false false false false'); }); }); diff --git a/tests/integration/helpers/is-not-empty-test.js b/tests/integration/helpers/is-not-empty-test.js index 1a06c234..53efd50e 100644 --- a/tests/integration/helpers/is-not-empty-test.js +++ b/tests/integration/helpers/is-not-empty-test.js @@ -2,16 +2,99 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import ArrayProxy from '@ember/array/proxy'; +import { A } from '@ember/array'; module('Integration | Helper | is-not-empty', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it is true for a non-empty string', async function (assert) { + this.set('subject', 'Fleetbase'); - await render(hbs`{{is-not-empty this.inputValue}}`); + await render(hbs`{{is-not-empty this.subject}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('true'); + }); + + test('it is false for an empty string', async function (assert) { + this.set('subject', ''); + + await render(hbs`{{is-not-empty this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is true for a whitespace only string', async function (assert) { + this.set('subject', ' '); + + await render(hbs`{{is-not-empty this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is false for null and undefined', async function (assert) { + this.set('subject', null); + + await render(hbs`{{is-not-empty this.subject}}|{{is-not-empty this.missing}}`); + + assert.dom(this.element).hasText('false|false'); + }); + + test('it is false for an empty array', async function (assert) { + this.set('subject', []); + + await render(hbs`{{is-not-empty this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is true for a populated array', async function (assert) { + this.set('subject', ['a']); + + await render(hbs`{{is-not-empty this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it unwraps an ArrayProxy to determine emptiness', async function (assert) { + this.set('empty', ArrayProxy.create({ content: A([]) })); + this.set('populated', ArrayProxy.create({ content: A(['a']) })); + + await render(hbs`{{is-not-empty this.empty}}|{{is-not-empty this.populated}}`); + + assert.dom(this.element).hasText('false|true'); + }); + + test('it is true for numbers including zero', async function (assert) { + this.set('zero', 0); + this.set('negative', -1); + + await render(hbs`{{is-not-empty this.zero}}|{{is-not-empty this.negative}}`); + + assert.dom(this.element).hasText('true|true'); + }); + + test('it is true for booleans including false', async function (assert) { + this.set('subject', false); + + await render(hbs`{{is-not-empty this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is true for an empty object because objects have no length', async function (assert) { + this.set('subject', {}); + + await render(hbs`{{is-not-empty this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is false for an object that reports a zero length', async function (assert) { + this.set('subject', { length: 0 }); + + await render(hbs`{{is-not-empty this.subject}}`); + + assert.dom(this.element).hasText('false'); }); }); diff --git a/tests/integration/helpers/is-object-empty-test.js b/tests/integration/helpers/is-object-empty-test.js index 9abb25a5..0dfa1371 100644 --- a/tests/integration/helpers/is-object-empty-test.js +++ b/tests/integration/helpers/is-object-empty-test.js @@ -6,12 +6,99 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | is-object-empty', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it is true for an empty object', async function (assert) { + this.set('subject', {}); - await render(hbs`{{is-object-empty this.inputValue}}`); + await render(hbs`{{is-object-empty this.subject}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('true'); + }); + + test('it is false for an object with keys', async function (assert) { + this.set('subject', { name: 'Fleetbase' }); + + await render(hbs`{{is-object-empty this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for an object whose only value is undefined', async function (assert) { + this.set('subject', { name: undefined }); + + await render(hbs`{{is-object-empty this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it does not recognize a template hash as a plain object, even an empty one', async function (assert) { + // `{{hash}}` produces an object whose constructor is not `Object`, so the helper reports it + // as non-empty regardless of its keys. + await render(hbs`{{is-object-empty (hash)}}|{{is-object-empty (hash a=1)}}`); + + assert.dom(this.element).hasText('false|false'); + }); + + test('it is true for null and undefined', async function (assert) { + this.set('subject', null); + + await render(hbs`{{is-object-empty this.subject}}|{{is-object-empty this.missing}}`); + + assert.dom(this.element).hasText('true|true'); + }); + + test('it is true for blank strings', async function (assert) { + this.set('empty', ''); + this.set('whitespace', ' '); + + await render(hbs`{{is-object-empty this.empty}}|{{is-object-empty this.whitespace}}`); + + assert.dom(this.element).hasText('true|true'); + }); + + test('it is false for a non blank string', async function (assert) { + this.set('subject', 'Fleetbase'); + + await render(hbs`{{is-object-empty this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is true for an empty array and false for a populated array', async function (assert) { + this.set('empty', []); + this.set('populated', [1]); + + await render(hbs`{{is-object-empty this.empty}}|{{is-object-empty this.populated}}`); + + assert.dom(this.element).hasText('true|false'); + }); + + test('it is false for non plain objects even when they have no own keys', async function (assert) { + this.set('date', new Date(0)); + this.set('nullProto', Object.create(null)); + + await render(hbs`{{is-object-empty this.date}}|{{is-object-empty this.nullProto}}`); + + assert.dom(this.element).hasText('false|false'); + }); + + test('it treats a collection that reports a zero size as empty', async function (assert) { + // `isBlank` short circuits on anything exposing a numeric `size`, so an empty Map/Set is + // reported as empty while a populated one falls through to the plain object check. + this.set('emptyMap', new Map()); + this.set('populatedMap', new Map([['a', 1]])); + this.set('emptySet', new Set()); + + await render(hbs`{{is-object-empty this.emptyMap}}|{{is-object-empty this.populatedMap}}|{{is-object-empty this.emptySet}}`); + + assert.dom(this.element).hasText('true|false|true'); + }); + + test('it is false for numbers including zero', async function (assert) { + this.set('zero', 0); + this.set('negative', -12.5); + + await render(hbs`{{is-object-empty this.zero}}|{{is-object-empty this.negative}}`); + + assert.dom(this.element).hasText('false|false'); }); }); diff --git a/tests/integration/helpers/is-object-test.js b/tests/integration/helpers/is-object-test.js index c412a397..a6c676ca 100644 --- a/tests/integration/helpers/is-object-test.js +++ b/tests/integration/helpers/is-object-test.js @@ -6,12 +6,105 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | is-object', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it is true for a plain object', async function (assert) { + this.set('subject', { name: 'Fleetbase' }); - await render(hbs`{{is-object this.inputValue}}`); + await render(hbs`{{is-object this.subject}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('true'); + }); + + test('it is true for an empty object', async function (assert) { + this.set('subject', {}); + + await render(hbs`{{is-object this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is true for an object built from a template hash', async function (assert) { + await render(hbs`{{is-object (hash a=1 b=2)}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is true for a class instance', async function (assert) { + class Waypoint { + constructor() { + this.id = 'wp_1'; + } + } + this.set('subject', new Waypoint()); + + await render(hbs`{{is-object this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is true for a null prototype object', async function (assert) { + this.set('subject', Object.create(null)); + + await render(hbs`{{is-object this.subject}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it is false for arrays', async function (assert) { + this.set('subject', [{ a: 1 }]); + + await render(hbs`{{is-object this.subject}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for other built in object types', async function (assert) { + this.set('date', new Date(0)); + this.set('map', new Map()); + this.set('regex', /abc/); + + await render(hbs`{{is-object this.date}}|{{is-object this.map}}|{{is-object this.regex}}`); + + assert.dom(this.element).hasText('false|false|false'); + }); + + test('it is false for truthy primitives', async function (assert) { + this.set('string', 'Fleetbase'); + this.set('number', 42); + this.set('bool', true); + + await render(hbs`{{is-object this.string}}|{{is-object this.number}}|{{is-object this.bool}}`); + + assert.dom(this.element).hasText('false|false|false'); + }); + + test('it returns the falsy input itself for null and undefined so nothing is rendered', async function (assert) { + this.set('subject', null); + + await render(hbs`{{is-object this.subject}}{{is-object this.missing}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it does not render for an empty string input', async function (assert) { + this.set('subject', ''); + + await render(hbs`{{is-object this.subject}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it is usable as a conditional guard', async function (assert) { + this.set('subject', ['not', 'an', 'object']); + + await render(hbs` + {{#if (is-object this.subject)}} + object + {{else}} + not object + {{/if}} + `); + + assert.dom('.not-object').exists(); + assert.dom('.object').doesNotExist(); }); }); diff --git a/tests/integration/helpers/is-url-test.js b/tests/integration/helpers/is-url-test.js index 446a2d94..ce7876d2 100644 --- a/tests/integration/helpers/is-url-test.js +++ b/tests/integration/helpers/is-url-test.js @@ -6,12 +6,88 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | is-url', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it is true for an absolute http url', async function (assert) { + this.set('value', 'https://fleetbase.io/path?query=1#hash'); - await render(hbs`{{is-url this.inputValue}}`); + await render(hbs`{{is-url this.value}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('true'); + }); + + test('it is true for uppercase protocols and non http schemes', async function (assert) { + this.set('upper', 'HTTP://FLEETBASE.IO'); + this.set('mailto', 'mailto:hello@fleetbase.io'); + this.set('data', 'data:text/plain,hello'); + + await render(hbs`{{is-url this.upper}}|{{is-url this.mailto}}|{{is-url this.data}}`); + + assert.dom(this.element).hasText('true|true|true'); + }); + + test('it is true for urls containing unicode and encoded characters', async function (assert) { + this.set('unicode', 'https://fleetbase.io/städte/日本'); + this.set('encoded', 'https://fleetbase.io/a%20b?q=%E2%9C%93'); + + await render(hbs`{{is-url this.unicode}}|{{is-url this.encoded}}`); + + assert.dom(this.element).hasText('true|true'); + }); + + test('it is false for a bare domain without a protocol', async function (assert) { + this.set('value', 'fleetbase.io'); + + await render(hbs`{{is-url this.value}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for a protocol relative url', async function (assert) { + this.set('value', '//fleetbase.io/logo.png'); + + await render(hbs`{{is-url this.value}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for a relative path', async function (assert) { + this.set('value', '/orders/1'); + + await render(hbs`{{is-url this.value}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for an empty string and for arbitrary text', async function (assert) { + this.set('empty', ''); + this.set('text', 'not a url at all'); + + await render(hbs`{{is-url this.empty}}|{{is-url this.text}}`); + + assert.dom(this.element).hasText('false|false'); + }); + + test('it renders nothing for non string input', async function (assert) { + this.set('number', 1234); + this.set('object', { href: 'https://fleetbase.io' }); + this.set('nullValue', null); + + await render(hbs`{{is-url this.number}}{{is-url this.object}}{{is-url this.nullValue}}{{is-url this.missing}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it distinguishes valid from invalid urls in a conditional', async function (assert) { + this.set('value', 'ftp://files.fleetbase.io/report.csv'); + + await render(hbs` + {{#if (is-url this.value)}} + download + {{else}} + {{this.value}} + {{/if}} + `); + + assert.dom('.link').exists(); + assert.dom('.plain').doesNotExist(); }); }); diff --git a/tests/integration/helpers/is-uuid-test.js b/tests/integration/helpers/is-uuid-test.js index e038f5d7..a74c0180 100644 --- a/tests/integration/helpers/is-uuid-test.js +++ b/tests/integration/helpers/is-uuid-test.js @@ -6,12 +6,92 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | is-uuid', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it is true for a version 4 uuid', async function (assert) { + this.set('value', '3f2504e0-4f89-41d3-9a0c-0305e82c3301'); - await render(hbs`{{is-uuid this.inputValue}}`); + await render(hbs`{{is-uuid this.value}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('true'); + }); + + test('it is case insensitive', async function (assert) { + this.set('value', '3F2504E0-4F89-41D3-9A0C-0305E82C3301'); + + await render(hbs`{{is-uuid this.value}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it accepts every supported version digit', async function (assert) { + this.set('values', ['3f2504e0-4f89-11d3-9a0c-0305e82c3301', '3f2504e0-4f89-21d3-aa0c-0305e82c3301', '3f2504e0-4f89-31d3-b90c-0305e82c3301', '3f2504e0-4f89-51d3-8a0c-0305e82c3301']); + + await render(hbs` + {{#each this.values as |value|}} + {{is-uuid value}} + {{/each}} + `); + + assert.dom(this.element).hasText('true true true true'); + }); + + test('it is false for the nil uuid because the version digit is zero', async function (assert) { + this.set('value', '00000000-0000-0000-0000-000000000000'); + + await render(hbs`{{is-uuid this.value}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for an unsupported version digit', async function (assert) { + this.set('value', '3f2504e0-4f89-71d3-9a0c-0305e82c3301'); + + await render(hbs`{{is-uuid this.value}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for an invalid variant digit', async function (assert) { + this.set('value', '3f2504e0-4f89-41d3-1a0c-0305e82c3301'); + + await render(hbs`{{is-uuid this.value}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false when the uuid is wrapped, padded or surrounded by other text', async function (assert) { + this.set('braced', '{3f2504e0-4f89-41d3-9a0c-0305e82c3301}'); + this.set('padded', ' 3f2504e0-4f89-41d3-9a0c-0305e82c3301 '); + this.set('suffixed', '3f2504e0-4f89-41d3-9a0c-0305e82c3301-extra'); + + await render(hbs`{{is-uuid this.braced}}|{{is-uuid this.padded}}|{{is-uuid this.suffixed}}`); + + assert.dom(this.element).hasText('false|false|false'); + }); + + test('it is false for unhyphenated and truncated forms', async function (assert) { + this.set('unhyphenated', '3f2504e04f8941d39a0c0305e82c3301'); + this.set('truncated', '3f2504e0-4f89-41d3-9a0c-0305e82c33'); + + await render(hbs`{{is-uuid this.unhyphenated}}|{{is-uuid this.truncated}}`); + + assert.dom(this.element).hasText('false|false'); + }); + + test('it is false for non hex characters', async function (assert) { + this.set('value', '3g2504e0-4f89-41d3-9a0c-0305e82c3301'); + + await render(hbs`{{is-uuid this.value}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for empty, null, undefined and non string input', async function (assert) { + this.set('empty', ''); + this.set('nullValue', null); + this.set('number', 12345); + + await render(hbs`{{is-uuid this.empty}}|{{is-uuid this.nullValue}}|{{is-uuid this.missing}}|{{is-uuid this.number}}`); + + assert.dom(this.element).hasText('false|false|false|false'); }); }); diff --git a/tests/integration/helpers/join-column-list-test.js b/tests/integration/helpers/join-column-list-test.js index 1ff6fc41..d08a42e9 100644 --- a/tests/integration/helpers/join-column-list-test.js +++ b/tests/integration/helpers/join-column-list-test.js @@ -6,12 +6,75 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | join-column-list', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it prefixes a single column with the table name', async function (assert) { + this.set('columns', [{ name: 'id' }]); - await render(hbs`{{join-column-list this.inputValue}}`); + await render(hbs`{{join-column-list this.columns "orders"}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('orders.id'); + }); + + test('it joins multiple columns with a comma and space', async function (assert) { + this.set('columns', [{ name: 'id' }, { name: 'public_id' }, { name: 'status' }]); + + await render(hbs`{{join-column-list this.columns "orders"}}`); + + assert.strictEqual(this.element.textContent.trim(), 'orders.id, orders.public_id, orders.status'); + }); + + test('it appends an alias when one is present', async function (assert) { + this.set('columns', [{ name: 'public_id', alias: 'reference' }]); + + await render(hbs`{{join-column-list this.columns "orders"}}`); + + assert.strictEqual(this.element.textContent.trim(), 'orders.public_id AS reference'); + }); + + test('it mixes aliased and non aliased columns', async function (assert) { + this.set('columns', [{ name: 'id' }, { name: 'public_id', alias: 'reference' }, { name: 'status', alias: null }]); + + await render(hbs`{{join-column-list this.columns "orders"}}`); + + assert.strictEqual(this.element.textContent.trim(), 'orders.id, orders.public_id AS reference, orders.status'); + }); + + test('it ignores an empty string alias', async function (assert) { + this.set('columns', [{ name: 'id', alias: '' }]); + + await render(hbs`{{join-column-list this.columns "orders"}}`); + + assert.strictEqual(this.element.textContent.trim(), 'orders.id'); + }); + + test('it reports when no columns are selected', async function (assert) { + this.set('columns', []); + + await render(hbs`{{join-column-list this.columns "orders"}}`); + + assert.dom(this.element).hasText('No columns selected'); + }); + + test('it reports when the column list is null or undefined', async function (assert) { + this.set('columns', null); + + await render(hbs`{{join-column-list this.columns "orders"}}|{{join-column-list this.missing "orders"}}`); + + assert.strictEqual(this.element.textContent.trim(), 'No columns selected|No columns selected'); + }); + + test('it still renders the column when the table name is missing', async function (assert) { + this.set('columns', [{ name: 'id' }]); + + await render(hbs`{{join-column-list this.columns}}`); + + assert.dom(this.element).hasText('undefined.id'); + }); + + test('it preserves column names containing special characters', async function (assert) { + this.set('columns', [{ name: 'meta->"$.driver"' }, { name: 'créé_à', alias: 'created' }]); + + await render(hbs`{{join-column-list this.columns "orders"}}`); + + assert.strictEqual(this.element.textContent.trim(), 'orders.meta->"$.driver", orders.créé_à AS created'); }); }); diff --git a/tests/integration/helpers/json-hash-test.js b/tests/integration/helpers/json-hash-test.js index eb7f0322..fefb62d8 100644 --- a/tests/integration/helpers/json-hash-test.js +++ b/tests/integration/helpers/json-hash-test.js @@ -6,12 +6,72 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | json-hash', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it serializes named arguments into a json object', async function (assert) { + await render(hbs`{{json-hash name="Fleetbase" count=3 active=true}}`); - await render(hbs`{{json-hash this.inputValue}}`); + assert.strictEqual(this.element.textContent.trim(), '{"name":"Fleetbase","count":3,"active":true}'); + }); + + test('it serializes an empty object when no named arguments are given', async function (assert) { + await render(hbs`{{json-hash}}`); + + assert.strictEqual(this.element.textContent.trim(), '{}'); + }); + + test('it ignores positional arguments', async function (assert) { + this.set('positional', 'ignored'); + + await render(hbs`{{json-hash this.positional name="Fleetbase"}}`); + + assert.strictEqual(this.element.textContent.trim(), '{"name":"Fleetbase"}'); + }); + + test('it serializes nested hashes and arrays', async function (assert) { + this.set('tags', ['a', 'b']); + + await render(hbs`{{json-hash meta=(hash driver="Ron" trips=2) tags=this.tags}}`); + + assert.strictEqual(this.element.textContent.trim(), '{"meta":{"driver":"Ron","trips":2},"tags":["a","b"]}'); + }); + + test('it serializes null values and omits undefined values', async function (assert) { + this.set('nullValue', null); + + await render(hbs`{{json-hash a=this.nullValue b=this.missing c=1}}`); + + assert.strictEqual(this.element.textContent.trim(), '{"a":null,"c":1}'); + }); + + test('it omits function valued arguments', async function (assert) { + this.set('callback', () => {}); + + await render(hbs`{{json-hash onSelect=this.callback name="Fleetbase"}}`); + + assert.strictEqual(this.element.textContent.trim(), '{"name":"Fleetbase"}'); + }); + + test('it escapes strings containing quotes, newlines and unicode', async function (assert) { + this.set('value', 'a "quoted"\nvalue ünïcode'); + + await render(hbs`{{json-hash value=this.value}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify({ value: 'a "quoted"\nvalue ünïcode' })); + }); + + test('it serializes numeric edge values', async function (assert) { + this.set('zero', 0); + this.set('negative', -1.5); + this.set('infinite', Infinity); + this.set('notANumber', NaN); + + await render(hbs`{{json-hash zero=this.zero negative=this.negative infinite=this.infinite notANumber=this.notANumber}}`); + + assert.strictEqual(this.element.textContent.trim(), '{"zero":0,"negative":-1.5,"infinite":null,"notANumber":null}'); + }); + + test('it can be embedded in an attribute value', async function (assert) { + await render(hbs``); - assert.dom(this.element).hasText('1234'); + assert.dom('.payload').hasAttribute('data-payload', '{"id":1}'); }); }); diff --git a/tests/integration/helpers/json-stringify-test.js b/tests/integration/helpers/json-stringify-test.js index 54e592d0..cbeb4cac 100644 --- a/tests/integration/helpers/json-stringify-test.js +++ b/tests/integration/helpers/json-stringify-test.js @@ -3,15 +3,162 @@ import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +const SAMPLE = { id: 1, name: 'Fleetbase', nested: { active: true } }; + module('Integration | Helper | json-stringify', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it serializes compactly by default', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE)); + }); + + test('it serializes primitives and arrays', async function (assert) { + this.set('string', 'Fleetbase'); + this.set('number', -12.5); + this.set('nullValue', null); + this.set('list', [1, 'two', null]); + + await render(hbs`{{json-stringify this.string}}|{{json-stringify this.number}}|{{json-stringify this.nullValue}}|{{json-stringify this.list}}`); + + assert.strictEqual(this.element.textContent.trim(), '"Fleetbase"|-12.5|null|[1,"two",null]'); + }); + + test('it renders nothing for undefined input', async function (assert) { + await render(hbs`{{json-stringify this.missing}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it uses a positional number as the indentation width', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject 4}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE, null, 4)); + }); + + test('it treats a positional true as two space indentation', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject true}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE, null, 2)); + }); + + test('it treats a positional false as compact output', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject false}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE)); + }); + + test('it honours a named space argument', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject space=3}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE, null, 3)); + }); + + test('it coerces a numeric string space argument', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject space="2"}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE, null, 2)); + }); + + test('it falls back to compact output when the space argument is not a finite number', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject space="wide"}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE)); + }); + + test('it indents by two when pretty is true', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject pretty=true}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE, null, 2)); + }); + + test('an explicit named space overrides pretty and positional spacing', async function (assert) { + this.set('subject', SAMPLE); + + await render(hbs`{{json-stringify this.subject 4 pretty=true space=0}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify(SAMPLE)); + }); + + test('it applies a positional replacer function', async function (assert) { + this.set('subject', SAMPLE); + this.set('replacer', (key, value) => (key === 'name' ? undefined : value)); + + await render(hbs`{{json-stringify this.subject this.replacer}}`); + + assert.strictEqual(this.element.textContent.trim(), '{"id":1,"nested":{"active":true}}'); + }); + + test('it applies a positional replacer together with a positional space', async function (assert) { + this.set('subject', { id: 1, name: 'Fleetbase' }); + this.set('replacer', (key, value) => (key === 'name' ? 'redacted' : value)); + + await render(hbs`{{json-stringify this.subject this.replacer 2}}`); + + assert.strictEqual(this.element.textContent.trim(), JSON.stringify({ id: 1, name: 'redacted' }, null, 2)); + }); + + test('it applies a named replacer allow list', async function (assert) { + this.set('subject', SAMPLE); + this.set('replacer', ['id']); + + await render(hbs`{{json-stringify this.subject replacer=this.replacer}}`); + + assert.strictEqual(this.element.textContent.trim(), '{"id":1}'); + }); + + test('it guards against circular structures', async function (assert) { + const circular = { name: 'root' }; + circular.self = circular; + this.set('subject', circular); + + await render(hbs`{{json-stringify this.subject}}`); + + assert.dom(this.element).hasText('[Unserializable JSON]'); + }); + + test('it serializes an empty object and an empty array', async function (assert) { + this.set('emptyObject', {}); + this.set('emptyArray', []); + + await render(hbs`{{json-stringify this.emptyObject}}|{{json-stringify this.emptyArray}}`); + + assert.strictEqual(this.element.textContent.trim(), '{}|[]'); + }); + + test('it serializes non finite numbers as null', async function (assert) { + this.set('subject', { infinite: Infinity, notANumber: NaN, zero: 0 }); + + await render(hbs`{{json-stringify this.subject}}`); + + assert.strictEqual(this.element.textContent.trim(), '{"infinite":null,"notANumber":null,"zero":0}'); + }); + test('a replacer can be given as the third positional argument', async function (assert) { + this.set('subject', { keep: 1, drop: 2 }); + this.set('replacer', (key, value) => (key === 'drop' ? undefined : value)); - await render(hbs`{{json-stringify this.inputValue}}`); + await render(hbs`{{json-stringify this.subject 2 this.replacer}}`); - assert.dom().hasText('1234'); + const output = this.element.textContent.trim(); + assert.true(output.includes('"keep"'), 'the kept key survives'); + assert.false(output.includes('"drop"'), 'the replacer removed the other one'); + assert.true(output.includes('\n'), 'and the numeric second argument is still the indent'); }); }); diff --git a/tests/integration/helpers/lazy-engine-component-test.js b/tests/integration/helpers/lazy-engine-component-test.js new file mode 100644 index 00000000..fc2f4223 --- /dev/null +++ b/tests/integration/helpers/lazy-engine-component-test.js @@ -0,0 +1,264 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import Component from '@glimmer/component'; + +// A minimal stand-in for an engine instance's container API. +function fakeEngineInstance(registrations = {}) { + const registry = { ...registrations }; + + return { + registry, + hasRegistration(key) { + return key in registry; + }, + register(key, value) { + registry[key] = value; + }, + factoryFor(key) { + const value = registry[key]; + + return value ? { class: value } : undefined; + }, + }; +} + +class DemoComponent extends Component {} +const DEMO_TEMPLATE = hbs`resolved`; + +module('Integration | Helper | lazy-engine-component', function (hooks) { + setupRenderingTest(hooks); + + let ensureCalls; + let engines; + let resolveEnsure; + let consoleErrors; + let originalConsoleError; + + hooks.beforeEach(function () { + ensureCalls = []; + engines = {}; + consoleErrors = []; + originalConsoleError = console.error; + console.error = (...args) => consoleErrors.push(args.join(' ')); + + this.owner.register( + 'service:universe/extension-manager', + class extends Service { + getEngineInstance(name) { + return engines[name]; + } + + ensureEngineLoaded(name) { + ensureCalls.push(name); + + return new Promise((resolve) => { + resolveEnsure = resolve; + }); + } + } + ); + + this.owner.register('component:demo-component', DemoComponent); + this.owner.register('template:components/demo-component', DEMO_TEMPLATE); + }); + + hooks.afterEach(function () { + console.error = originalConsoleError; + }); + + // Renders whatever the helper resolves, so assertions stay on observable output. + const TEMPLATE = hbs` + {{#let (lazy-engine-component this.def) as |Resolved|}} + {{#if Resolved}}{{Resolved}}{{else}}none{{/if}} + {{/let}} + `; + + test('a plain string component name passes straight through', async function (assert) { + this.set('def', 'demo-component'); + + await render(TEMPLATE); + + assert.dom('[data-test-resolved]').hasText('demo-component', 'strings are returned unchanged'); + assert.deepEqual(ensureCalls, [], 'no engine loading is attempted for a plain string'); + }); + + test('a function component definition passes straight through', async function (assert) { + this.set('def', DemoComponent); + + await render(TEMPLATE); + + assert.dom('[data-test-resolved]').exists('a class is returned unchanged'); + }); + + test('an invalid definition logs an error and resolves to null', async function (assert) { + this.set('def', { notAnEngine: true }); + + await render(TEMPLATE); + + assert.dom('[data-test-null]').exists(); + assert.ok( + consoleErrors.some((message) => message.includes('Invalid component definition')), + 'the caller is told why nothing resolved' + ); + }); + + test('a nullish definition resolves to null', async function (assert) { + this.set('def', null); + + await render(TEMPLATE); + + assert.dom('[data-test-null]').exists(); + }); + + test('an unloaded engine triggers loading and resolves to null for now', async function (assert) { + this.set('def', { engine: '@fleetbase/fleetops-engine', path: 'components/order-panel' }); + + await render(TEMPLATE); + + assert.dom('[data-test-null]').exists('nothing renders while the engine loads'); + assert.deepEqual(ensureCalls, ['@fleetbase/fleetops-engine'], 'loading is kicked off exactly once'); + }); + + test('it recomputes once the engine finishes loading', async function (assert) { + this.set('def', { engine: 'engine-a', path: 'components/demo-component' }); + + await render(TEMPLATE); + assert.dom('[data-test-null]').exists('precondition: not resolved yet'); + + // The engine becomes available, then the pending load settles. + engines['engine-a'] = fakeEngineInstance({ 'component:demo-component': DemoComponent }); + resolveEnsure(); + await settled(); + + assert.dom('[data-test-resolved]').exists('the helper recomputed and resolved the component'); + }); + + test('a loaded engine resolves a registered component path immediately', async function (assert) { + engines['engine-a'] = fakeEngineInstance({ 'component:demo-component': DemoComponent }); + this.set('def', { engine: 'engine-a', path: 'components/demo-component' }); + + await render(TEMPLATE); + + assert.dom('[data-test-resolved]').exists(); + assert.deepEqual(ensureCalls, [], 'an already-loaded engine is not reloaded'); + }); + + test('the components/ prefix is optional in the path', async function (assert) { + engines['engine-a'] = fakeEngineInstance({ 'component:demo-component': DemoComponent }); + this.set('def', { engine: 'engine-a', path: 'demo-component' }); + + await render(TEMPLATE); + + assert.dom('[data-test-resolved]').exists(); + }); + + test('an unregistered component path logs an error and resolves to null', async function (assert) { + engines['engine-a'] = fakeEngineInstance(); + this.set('def', { engine: 'engine-a', path: 'components/missing' }); + + await render(TEMPLATE); + + assert.dom('[data-test-null]').exists(); + assert.ok( + consoleErrors.some((message) => message.includes("Component 'missing' is not registered")), + 'the missing component and engine are named' + ); + }); + + test('a registration whose factory has no class logs an error and resolves to null', async function (assert) { + const engine = fakeEngineInstance({ 'component:demo-component': DemoComponent }); + engine.factoryFor = () => ({ class: undefined }); + engines['engine-a'] = engine; + this.set('def', { engine: 'engine-a', path: 'components/demo-component' }); + + await render(TEMPLATE); + + assert.dom('[data-test-null]').exists(); + assert.ok( + consoleErrors.some((message) => message.includes('is undefined in engine')), + 'the undefined class is reported' + ); + }); + + test('a missing factory logs an error and resolves to null', async function (assert) { + const engine = fakeEngineInstance({ 'component:demo-component': DemoComponent }); + engine.factoryFor = () => undefined; + engines['engine-a'] = engine; + this.set('def', { engine: 'engine-a', path: 'components/demo-component' }); + + await render(TEMPLATE); + + assert.dom('[data-test-null]').exists(); + assert.ok( + consoleErrors.some((message) => message.includes('factory for')), + 'the missing factory is reported' + ); + }); + + test('a class-form definition registers the class under both names', async function (assert) { + const engine = fakeEngineInstance(); + engines['engine-a'] = engine; + this.set('def', { engine: 'engine-a', isClass: true, class: DemoComponent }); + + await render(TEMPLATE); + + assert.dom('[data-test-resolved]').exists('the class is resolved directly'); + assert.true(engine.hasRegistration('component:demo-component'), 'the dasherized name is registered'); + assert.true(engine.hasRegistration(`component:${DemoComponent.name}`), 'the original class name is registered too'); + }); + + test('a class already registered is not registered again', async function (assert) { + const engine = fakeEngineInstance({ 'component:demo-component': DemoComponent }); + let registrations = 0; + engine.register = () => registrations++; + engines['engine-a'] = engine; + this.set('def', { engine: 'engine-a', isClass: true, class: DemoComponent }); + + await render(TEMPLATE); + + assert.strictEqual(registrations, 0, 'an existing registration is left alone'); + }); + + test('an engine definition with neither path nor class logs an error', async function (assert) { + engines['engine-a'] = fakeEngineInstance(); + this.set('def', { engine: 'engine-a' }); + + await render(TEMPLATE); + + assert.dom('[data-test-null]').exists(); + assert.ok( + consoleErrors.some((message) => message.includes('requires either a path or class')), + 'the contract violation is reported' + ); + }); + + test('the #extension-component string form is parsed into engine and path', async function (assert) { + engines['engine-a'] = fakeEngineInstance({ 'component:demo-component': DemoComponent }); + this.set('def', '#extension-component:engine-a:demo-component'); + + await render(TEMPLATE); + + assert.dom('[data-test-resolved]').exists('the shorthand resolves through the engine'); + }); + + test('the shorthand for an unloaded engine triggers loading', async function (assert) { + this.set('def', '#extension-component:engine-b:demo-component'); + + await render(TEMPLATE); + + assert.dom('[data-test-null]').exists(); + assert.deepEqual(ensureCalls, ['engine-b']); + }); + + test('a string that is not the extension shorthand is treated as a component name', async function (assert) { + this.set('def', 'plain-name'); + + await render(TEMPLATE); + + assert.dom('[data-test-resolved]').hasText('plain-name'); + assert.deepEqual(ensureCalls, [], 'it is not mistaken for an engine reference'); + }); +}); diff --git a/tests/integration/helpers/n-a-test.js b/tests/integration/helpers/n-a-test.js index a06f1371..11285647 100644 --- a/tests/integration/helpers/n-a-test.js +++ b/tests/integration/helpers/n-a-test.js @@ -6,12 +6,99 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | n-a', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it renders the value when it is present', async function (assert) { + this.set('value', 'Fleetbase'); - await render(hbs`{{n-a this.inputValue}}`); + await render(hbs`{{n-a this.value}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('Fleetbase'); + }); + + test('it renders a dash for null and undefined', async function (assert) { + this.set('value', null); + + await render(hbs`{{n-a this.value}}|{{n-a this.missing}}`); + + assert.dom(this.element).hasText('-|-'); + }); + + test('it renders a dash for an empty string', async function (assert) { + this.set('value', ''); + + await render(hbs`{{n-a this.value}}`); + + assert.dom(this.element).hasText('-'); + }); + + test('it renders a dash for a whitespace only string', async function (assert) { + this.set('value', ' '); + + await render(hbs`{{n-a this.value}}`); + + assert.dom(this.element).hasText('-'); + }); + + test('it renders a dash for an empty array', async function (assert) { + this.set('value', []); + + await render(hbs`{{n-a this.value}}`); + + assert.dom(this.element).hasText('-'); + }); + + test('it uses a custom fallback when provided', async function (assert) { + this.set('value', null); + + await render(hbs`{{n-a this.value "N/A"}}`); + + assert.dom(this.element).hasText('N/A'); + }); + + test('it ignores the fallback when the value is present', async function (assert) { + this.set('value', 'Delivered'); + + await render(hbs`{{n-a this.value "N/A"}}`); + + assert.dom(this.element).hasText('Delivered'); + }); + + test('it renders zero rather than the fallback', async function (assert) { + this.set('value', 0); + + await render(hbs`{{n-a this.value}}`); + + assert.dom(this.element).hasText('0'); + }); + + test('it renders negative numbers rather than the fallback', async function (assert) { + this.set('value', -1); + + await render(hbs`{{n-a this.value}}`); + + assert.dom(this.element).hasText('-1'); + }); + + test('it renders false rather than the fallback', async function (assert) { + this.set('value', false); + + await render(hbs`{{n-a this.value}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it preserves surrounding whitespace of a non blank string', async function (assert) { + this.set('value', ' Fleetbase '); + + await render(hbs`{{n-a this.value}}`); + + assert.strictEqual(this.element.textContent, ' Fleetbase '); + }); + + test('it supports an empty custom fallback', async function (assert) { + this.set('value', undefined); + + await render(hbs`{{n-a this.value ""}}`); + + assert.dom(this.element).hasNoText(); }); }); diff --git a/tests/integration/helpers/noop-test.js b/tests/integration/helpers/noop-test.js index 451720d2..fa5367a5 100644 --- a/tests/integration/helpers/noop-test.js +++ b/tests/integration/helpers/noop-test.js @@ -1,17 +1,73 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; +import uiNoopHelper from '@fleetbase/ember-ui/helpers/noop'; +import noopFn from '@fleetbase/ember-ui/utils/noop'; + +/** + * `noop` is also shipped by ember-composable-helpers, which wins the app tree merge in the dummy + * app. Register this addon's helper under a unique name so these tests exercise ember-ui's source + * rather than the shadowing implementation. + */ +function registerHelpers(owner, sink) { + owner.register('helper:ui-noop', uiNoopHelper); + owner.register( + 'helper:capture-value', + helper(function ([value]) { + sink.push(value); + return ''; + }) + ); +} module('Integration | Helper | noop', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it yields a callable function that returns undefined', async function (assert) { + const captured = []; + registerHelpers(this.owner, captured); + + await render(hbs`{{capture-value (ui-noop)}}`); + + assert.strictEqual(captured.length, 1, 'the helper produced a value'); + assert.strictEqual(typeof captured[0], 'function', 'the value is a function'); + assert.strictEqual(captured[0](), undefined, 'calling it returns undefined'); + assert.strictEqual(captured[0]('a', 1, null), undefined, 'calling it with arguments returns undefined'); + }); + + test('it yields the shared noop utility itself', async function (assert) { + const captured = []; + registerHelpers(this.owner, captured); + + await render(hbs`{{capture-value (ui-noop)}}{{capture-value (ui-noop)}}`); + + assert.strictEqual(captured.length, 2, 'the helper was invoked twice'); + assert.strictEqual(captured[0], noopFn, 'the addon noop utility is returned'); + assert.strictEqual(captured[0], captured[1], 'both invocations yield the same function reference'); + }); + + test('it can be attached as an event handler without side effects', async function (assert) { + const captured = []; + registerHelpers(this.owner, captured); + + await render(hbs``); + + await click('.target'); + await click('.target'); + + assert.dom('.target').hasText('Click', 'the DOM is untouched by the handler'); + }); + + test('it accepts and ignores any arguments passed to the helper', async function (assert) { + const captured = []; + registerHelpers(this.owner, captured); + this.set('value', 'ignored'); - await render(hbs`{{noop this.inputValue}}`); + await render(hbs`{{capture-value (ui-noop this.value 42 foo="bar")}}`); - assert.dom(this.element).hasText('1234'); + assert.strictEqual(captured[0], noopFn, 'still yields the shared noop utility'); + assert.strictEqual(captured[0](), undefined, 'still returns undefined'); }); }); diff --git a/tests/integration/helpers/now-test.js b/tests/integration/helpers/now-test.js index f7897d19..dcaf7dfc 100644 --- a/tests/integration/helpers/now-test.js +++ b/tests/integration/helpers/now-test.js @@ -2,16 +2,107 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; + +function registerCapture(owner, sink) { + owner.register( + 'helper:capture-value', + helper(function ([value]) { + sink.push(value); + return ''; + }) + ); +} module('Integration | Helper | now', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it produces a Date for the current moment when called without arguments', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + + const before = Date.now(); + await render(hbs`{{capture-value (now)}}`); + const after = Date.now(); + + assert.strictEqual(captured.length, 1, 'the helper produced a value'); + assert.true(captured[0] instanceof Date, 'the value is a Date'); + assert.true(captured[0].getTime() >= before, 'the date is not earlier than the render start'); + assert.true(captured[0].getTime() <= after, 'the date is not later than the render end'); + }); + + test('it forwards a millisecond timestamp to the Date constructor', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('timestamp', 0); + + await render(hbs`{{capture-value (now this.timestamp)}}`); + + assert.true(captured[0] instanceof Date, 'the value is a Date'); + assert.strictEqual(captured[0].getTime(), 0, 'the epoch timestamp is preserved exactly'); + }); + + test('it forwards a large millisecond timestamp without loss', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('timestamp', 4102444800000); + + await render(hbs`{{capture-value (now this.timestamp)}}`); + + assert.strictEqual(captured[0].getTime(), 4102444800000, 'the timestamp round trips'); + }); + + test('it parses an ISO 8601 string argument', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('iso', '2020-01-15T12:30:45.000Z'); + + await render(hbs`{{capture-value (now this.iso)}}`); + + assert.strictEqual(captured[0].getTime(), Date.parse('2020-01-15T12:30:45.000Z'), 'the parsed time matches Date.parse'); + }); + + test('it forwards multiple date component arguments', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + + await render(hbs`{{capture-value (now 2020 0 15 6 30 15)}}`); + + const date = captured[0]; + assert.strictEqual(date.getFullYear(), 2020, 'year'); + assert.strictEqual(date.getMonth(), 0, 'month'); + assert.strictEqual(date.getDate(), 15, 'day of month'); + assert.strictEqual(date.getHours(), 6, 'hours'); + assert.strictEqual(date.getMinutes(), 30, 'minutes'); + assert.strictEqual(date.getSeconds(), 15, 'seconds'); + }); + + test('it produces an invalid Date for unparsable input rather than throwing', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('garbage', 'not-a-date'); + + await render(hbs`{{capture-value (now this.garbage)}}`); + + assert.true(captured[0] instanceof Date, 'still a Date instance'); + assert.true(Number.isNaN(captured[0].getTime()), 'the date is invalid'); + }); + + test('it produces a distinct Date instance for every invocation', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + + await render(hbs`{{capture-value (now)}}{{capture-value (now)}}`); + + assert.strictEqual(captured.length, 2, 'two dates produced'); + assert.notStrictEqual(captured[0], captured[1], 'each invocation creates its own Date instance'); + }); + + test('it renders the date as text when used directly', async function (assert) { + this.set('timestamp', 0); - await render(hbs`{{now this.inputValue}}`); + await render(hbs`{{now this.timestamp}}`); - assert.dom(this.element).hasText('1234'); + assert.strictEqual(this.element.textContent.trim(), String(new Date(0)), 'the rendered text matches the stringified Date'); }); }); diff --git a/tests/integration/helpers/point-coordinates-test.js b/tests/integration/helpers/point-coordinates-test.js index 58b2f924..630d66a8 100644 --- a/tests/integration/helpers/point-coordinates-test.js +++ b/tests/integration/helpers/point-coordinates-test.js @@ -3,15 +3,111 @@ import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +const POINT_MODULE = '@fleetbase/fleetops-data/utils/geojson/point'; + +/** + * The helper narrows its input with `instanceof Point`, where `Point` comes from + * `@fleetbase/fleetops-data` — a package the dummy app does not depend on. When the module is + * not present in the loader, register a minimal stand-in that matches the real class' construction + * contract, so the tests exercise the helper against the exact class it imports. + */ +function resolvePointClass() { + if (!window.requirejs.entries[POINT_MODULE]) { + window.define(POINT_MODULE, ['exports'], function (exports) { + class Point { + constructor(input) { + const args = Array.prototype.slice.call(arguments); + + if (input && input.type === 'Point' && input.coordinates) { + Object.assign(this, input); + } else if (Array.isArray(input)) { + this.coordinates = input; + } else if (args.length >= 2) { + this.coordinates = args; + } else { + throw new Error('GeoJSON: invalid input for new Point'); + } + + this.type = 'Point'; + } + } + + exports.default = Point; + }); + } + + return require(POINT_MODULE).default; +} + module('Integration | Helper | point-coordinates', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + hooks.beforeEach(function () { + this.Point = resolvePointClass(); + }); + + test('it renders a Point as latitude then longitude separated by a space', async function (assert) { + this.set('point', new this.Point([-80.191788, 25.761681])); + + await render(hbs`{{point-coordinates this.point}}`); + + assert.dom(this.element).hasText('25.761681 -80.191788'); + }); + + test('it renders zero coordinates', async function (assert) { + this.set('point', new this.Point([0, 0])); + + await render(hbs`{{point-coordinates this.point}}`); + + assert.dom(this.element).hasText('0 0'); + }); + + test('it renders negative latitudes and longitudes', async function (assert) { + this.set('point', new this.Point([-179.9999, -89.5])); + + await render(hbs`{{point-coordinates this.point}}`); + + assert.dom(this.element).hasText('-89.5 -179.9999'); + }); + + test('it ignores extra coordinate dimensions such as altitude', async function (assert) { + this.set('point', new this.Point([103.851959, 1.29027, 42])); + + await render(hbs`{{point-coordinates this.point}}`); + + assert.dom(this.element).hasText('1.29027 103.851959'); + }); + + test('it accepts a Point constructed from a geojson literal', async function (assert) { + this.set('point', new this.Point({ type: 'Point', coordinates: [13.405, 52.52] })); + + await render(hbs`{{point-coordinates this.point}}`); + + assert.dom(this.element).hasText('52.52 13.405'); + }); + + test('it rejects a plain object that only looks like a point', async function (assert) { + this.set('point', { type: 'Point', coordinates: [13.405, 52.52] }); + + await render(hbs`{{point-coordinates this.point}}`); + + assert.dom(this.element).hasText('Invalid coordinates'); + }); + + test('it rejects a bare coordinate array', async function (assert) { + this.set('point', [13.405, 52.52]); + + await render(hbs`{{point-coordinates this.point}}`); + + assert.dom(this.element).hasText('Invalid coordinates'); + }); + + test('it rejects null, undefined and strings', async function (assert) { + this.set('nullValue', null); + this.set('string', '13.405,52.52'); - await render(hbs`{{point-coordinates this.inputValue}}`); + await render(hbs`{{point-coordinates this.nullValue}}|{{point-coordinates this.missing}}|{{point-coordinates this.string}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('Invalid coordinates|Invalid coordinates|Invalid coordinates'); }); }); diff --git a/tests/integration/helpers/point-to-coordinates-test.js b/tests/integration/helpers/point-to-coordinates-test.js index 155ac8a8..d4eed2e7 100644 --- a/tests/integration/helpers/point-to-coordinates-test.js +++ b/tests/integration/helpers/point-to-coordinates-test.js @@ -2,16 +2,132 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; +import { A } from '@ember/array'; + +function registerCapture(owner, sink) { + owner.register( + 'helper:capture-value', + helper(function ([value]) { + sink.push(value); + return ''; + }) + ); +} module('Integration | Helper | point-to-coordinates', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it defaults to a latitude longitude array', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('point', { type: 'Point', coordinates: [-80.191788, 25.761681] }); + + await render(hbs`{{capture-value (point-to-coordinates this.point)}}`); + + assert.deepEqual(captured[0], [25.761681, -80.191788], 'the geojson longitude/latitude pair is swapped'); + }); + + test('it renders the default array format as comma separated text', async function (assert) { + this.set('point', { type: 'Point', coordinates: [-80.191788, 25.761681] }); + + await render(hbs`{{point-to-coordinates this.point}}`); + + assert.dom(this.element).hasText('25.761681,-80.191788'); + }); + + test('it supports the explicit array format', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('point', { coordinates: [13.405, 52.52] }); + + await render(hbs`{{capture-value (point-to-coordinates this.point "array")}}`); + + assert.deepEqual(captured[0], [52.52, 13.405]); + }); + + test('it supports the latitudelongitude object format', async function (assert) { + this.set('point', { coordinates: [13.405, 52.52] }); + + await render(hbs`{{get (point-to-coordinates this.point "latitudelongitude") "latitude"}}|{{get (point-to-coordinates this.point "latitudelongitude") "longitude"}}`); + + assert.dom(this.element).hasText('52.52|13.405'); + }); + + test('it supports the latlng object format', async function (assert) { + this.set('point', { coordinates: [13.405, 52.52] }); + + await render(hbs`{{get (point-to-coordinates this.point "latlng") "lat"}}|{{get (point-to-coordinates this.point "latlng") "lng"}}`); + + assert.dom(this.element).hasText('52.52|13.405'); + }); + + test('it supports the xy object format where x is the latitude', async function (assert) { + this.set('point', { coordinates: [13.405, 52.52] }); + + await render(hbs`{{get (point-to-coordinates this.point "xy") "x"}}|{{get (point-to-coordinates this.point "xy") "y"}}`); + + assert.dom(this.element).hasText('52.52|13.405'); + }); + + test('it returns nothing for an unknown format', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('point', { coordinates: [13.405, 52.52] }); + + await render(hbs`{{capture-value (point-to-coordinates this.point "geohash")}}`); + + assert.strictEqual(captured[0], undefined, 'unsupported formats yield undefined'); + }); + + test('it falls back to the null island for a missing point', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('point', null); + + await render(hbs`{{capture-value (point-to-coordinates this.point)}}{{capture-value (point-to-coordinates this.missing)}}`); + + assert.deepEqual(captured[0], [0, 0], 'null point falls back to zero'); + assert.deepEqual(captured[1], [0, 0], 'undefined point falls back to zero'); + }); + + test('it falls back to the null island when coordinates are not an array', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('point', { coordinates: '13.405,52.52' }); + + await render(hbs`{{capture-value (point-to-coordinates this.point)}}`); + + assert.deepEqual(captured[0], [0, 0]); + }); + + test('it yields undefined members for an empty coordinates array', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('point', { coordinates: [] }); + + await render(hbs`{{capture-value (point-to-coordinates this.point)}}`); + + assert.deepEqual(captured[0], [undefined, undefined], 'destructuring an empty array yields undefined members'); + }); + + test('it accepts an ember array of coordinates', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('point', { coordinates: A([-80.191788, 25.761681]) }); + + await render(hbs`{{capture-value (point-to-coordinates this.point)}}`); + + assert.deepEqual(captured[0], [25.761681, -80.191788]); + }); + + test('it preserves zero and negative coordinate values', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('point', { coordinates: [0, -33.8688] }); - await render(hbs`{{point-to-coordinates this.inputValue}}`); + await render(hbs`{{capture-value (point-to-coordinates this.point)}}`); - assert.dom().hasText('1234'); + assert.deepEqual(captured[0], [-33.8688, 0]); }); }); diff --git a/tests/integration/helpers/resolve-component-test.js b/tests/integration/helpers/resolve-component-test.js index 8c346809..1d1eed07 100644 --- a/tests/integration/helpers/resolve-component-test.js +++ b/tests/integration/helpers/resolve-component-test.js @@ -2,16 +2,128 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; +import { setComponentTemplate } from '@ember/component'; +import templateOnly from '@ember/component/template-only'; +import { ExtensionComponent } from '@fleetbase/ember-core/contracts'; + +function registerCapture(owner, sink) { + owner.register( + 'helper:capture-value', + helper(function ([value]) { + sink.push(value); + return ''; + }) + ); +} module('Integration | Helper | resolve-component', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it builds an ExtensionComponent from the extension component string form', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('value', '#extension-component:@fleetbase/fleetops-engine:components/admin/navigator-app'); + + await render(hbs`{{capture-value (resolve-component this.value)}}`); + + const resolved = captured[0]; + assert.true(resolved instanceof ExtensionComponent, 'an ExtensionComponent contract is returned'); + assert.strictEqual(resolved.engine, '@fleetbase/fleetops-engine', 'the engine name is parsed from the string'); + assert.strictEqual(resolved.path, 'components/admin/navigator-app', 'the component path is parsed from the string'); + }); + + test('it passes an ordinary component name string through untouched', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('value', 'ui-button'); + + await render(hbs`{{capture-value (resolve-component this.value)}}`); + + assert.strictEqual(captured[0], 'ui-button', 'the resolver is left to handle plain names'); + }); + + test('it returns an extension component definition object as is', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + const definition = { engine: '@fleetbase/storefront-engine', path: 'components/product-card' }; + this.set('value', definition); + + await render(hbs`{{capture-value (resolve-component this.value)}}`); + + assert.strictEqual(captured[0], definition, 'the lazy loading definition is preserved by identity'); + }); + + test('it unwraps a nested component property', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('value', { component: 'ui-button', someOtherOption: true }); + + await render(hbs`{{capture-value (resolve-component this.value)}}`); + + assert.strictEqual(captured[0], 'ui-button', 'the inner component definition is resolved'); + }); + + test('it recurses through a component property holding the extension string form', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('value', { component: '#extension-component:@fleetbase/pallet-engine:components/inventory' }); + + await render(hbs`{{capture-value (resolve-component this.value)}}`); + + assert.true(captured[0] instanceof ExtensionComponent, 'the nested string form is resolved recursively'); + assert.strictEqual(captured[0].engine, '@fleetbase/pallet-engine'); + assert.strictEqual(captured[0].path, 'components/inventory'); + }); + + test('it returns null for values that cannot be rendered', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('nullValue', null); + this.set('number', 42); + this.set('bool', false); + + await render( + hbs`{{capture-value (resolve-component this.nullValue)}}{{capture-value (resolve-component this.missing)}}{{capture-value (resolve-component this.number)}}{{capture-value (resolve-component this.bool)}}` + ); + + assert.deepEqual(captured, [null, null, null, null], 'null, undefined, numbers and booleans are not renderable'); + }); + + test('it returns a component class that can be invoked in a template', async function (assert) { + const TestComponent = setComponentTemplate(hbs`resolved component`, templateOnly()); + this.set('value', TestComponent); + + await render(hbs` + {{#let (resolve-component this.value) as |Resolved|}} + + {{/let}} + `); + + assert.dom('.resolved').exists('the resolved class renders'); + assert.dom('.resolved').hasText('resolved component'); + }); + + test('it resolves the component class held on a component property', async function (assert) { + const TestComponent = setComponentTemplate(hbs`nested component`, templateOnly()); + this.set('value', { component: TestComponent }); + + await render(hbs` + {{#let (resolve-component this.value) as |Resolved|}} + + {{/let}} + `); + + assert.dom('.nested').hasText('nested component'); + }); + + test('it treats an empty string as a component name rather than a definition', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('value', ''); - await render(hbs`{{resolve-component this.inputValue}}`); + await render(hbs`{{capture-value (resolve-component this.value)}}`); - assert.dom().hasText('1234'); + assert.strictEqual(captured[0], '', 'the empty string is returned unchanged'); }); }); diff --git a/tests/integration/helpers/resource-context-panel-save-disabled-test.js b/tests/integration/helpers/resource-context-panel-save-disabled-test.js index ab4f12c1..00e64bf3 100644 --- a/tests/integration/helpers/resource-context-panel-save-disabled-test.js +++ b/tests/integration/helpers/resource-context-panel-save-disabled-test.js @@ -1,17 +1,105 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { tracked } from '@glimmer/tracking'; + +class FakeRecord { + @tracked hasDirtyAttributes = false; +} module('Integration | Helper | resource-context-panel-save-disabled', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('an explicit saveDisabled true wins over everything else', async function (assert) { + this.set('overlay', { saveDisabled: true, model: { hasDirtyAttributes: true } }); + + await render(hbs`{{resource-context-panel-save-disabled this.overlay}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('an explicit saveDisabled false wins over a pristine model', async function (assert) { + this.set('overlay', { saveDisabled: false, model: { hasDirtyAttributes: false } }); + + await render(hbs`{{resource-context-panel-save-disabled this.overlay}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a pristine ember data style model disables saving', async function (assert) { + this.set('overlay', { model: { hasDirtyAttributes: false } }); + + await render(hbs`{{resource-context-panel-save-disabled this.overlay}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('a dirty ember data style model enables saving', async function (assert) { + this.set('overlay', { model: { hasDirtyAttributes: true } }); + + await render(hbs`{{resource-context-panel-save-disabled this.overlay}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a plain object model is always saveable', async function (assert) { + this.set('overlay', { model: { name: 'Fleetbase' } }); + + await render(hbs`{{resource-context-panel-save-disabled this.overlay}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a model with a non boolean hasDirtyAttributes is treated as a plain object', async function (assert) { + this.set('overlay', { model: { hasDirtyAttributes: 'yes' } }); + + await render(hbs`{{resource-context-panel-save-disabled this.overlay}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('a missing overlay, model or null model is saveable', async function (assert) { + this.set('nullOverlay', null); + this.set('emptyOverlay', {}); + this.set('nullModel', { model: null }); + + await render( + hbs`{{resource-context-panel-save-disabled this.nullOverlay}}|{{resource-context-panel-save-disabled this.missing}}|{{resource-context-panel-save-disabled this.emptyOverlay}}|{{resource-context-panel-save-disabled this.nullModel}}` + ); + + assert.dom(this.element).hasText('false|false|false|false'); + }); + + test('a truthy but non boolean saveDisabled falls through to the model check', async function (assert) { + this.set('overlay', { saveDisabled: 'yes', model: { hasDirtyAttributes: false } }); + + await render(hbs`{{resource-context-panel-save-disabled this.overlay}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('a falsy but non boolean saveDisabled falls through to the model check', async function (assert) { + this.set('overlay', { saveDisabled: 0, model: { hasDirtyAttributes: true } }); + + await render(hbs`{{resource-context-panel-save-disabled this.overlay}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it recomputes when the model becomes dirty', async function (assert) { + const model = new FakeRecord(); + this.set('overlay', { model }); + + await render(hbs` + + `); + + assert.dom('.save').isDisabled('a pristine model leaves the save button disabled'); - await render(hbs`{{resource-context-panel-save-disabled this.inputValue}}`); + model.hasDirtyAttributes = true; + await settled(); - assert.dom().hasText('1234'); + assert.dom('.save').isNotDisabled('an edited model enables the save button'); }); }); diff --git a/tests/integration/helpers/safe-dasherize-test.js b/tests/integration/helpers/safe-dasherize-test.js index b138d283..ed521f1d 100644 --- a/tests/integration/helpers/safe-dasherize-test.js +++ b/tests/integration/helpers/safe-dasherize-test.js @@ -6,12 +6,94 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | safe-dasherize', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it dasherizes camel case strings', async function (assert) { + this.set('value', 'HelloWorld'); - await render(hbs`{{safe-dasherize this.inputValue}}`); + await render(hbs`{{safe-dasherize this.value}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('hello-world'); + }); + + test('it dasherizes underscores and spaces', async function (assert) { + this.set('underscored', 'first_name'); + this.set('spaced', 'Hello World'); + + await render(hbs`{{safe-dasherize this.underscored}}|{{safe-dasherize this.spaced}}`); + + assert.dom(this.element).hasText('first-name|hello-world'); + }); + + test('it lowercases fully uppercase input', async function (assert) { + this.set('value', 'ORDER_ID'); + + await render(hbs`{{safe-dasherize this.value}}`); + + assert.dom(this.element).hasText('order-id'); + }); + + test('it handles mixed camel case, digits and acronyms', async function (assert) { + this.set('value', 'apiKeyV2'); + + await render(hbs`{{safe-dasherize this.value}}`); + + assert.dom(this.element).hasText('api-key-v2'); + }); + + test('it leaves an already dasherized string unchanged', async function (assert) { + this.set('value', 'already-dasherized'); + + await render(hbs`{{safe-dasherize this.value}}`); + + assert.dom(this.element).hasText('already-dasherized'); + }); + + test('it converts each consecutive separator into its own dash', async function (assert) { + this.set('value', 'a b'); + + await render(hbs`{{safe-dasherize this.value}}`); + + assert.strictEqual(this.element.textContent.trim(), 'a--b'); + }); + + test('it coerces undefined and null to their string form instead of throwing', async function (assert) { + this.set('nullValue', null); + + await render(hbs`{{safe-dasherize this.missing}}|{{safe-dasherize this.nullValue}}`); + + assert.dom(this.element).hasText('undefined|null'); + }); + + test('it coerces numbers and booleans', async function (assert) { + this.set('zero', 0); + this.set('negative', -12); + this.set('bool', true); + + await render(hbs`{{safe-dasherize this.zero}}|{{safe-dasherize this.negative}}|{{safe-dasherize this.bool}}`); + + assert.dom(this.element).hasText('0|-12|true'); + }); + + test('it renders nothing for an empty string', async function (assert) { + this.set('value', ''); + + await render(hbs`{{safe-dasherize this.value}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it preserves unicode characters while dasherizing', async function (assert) { + this.set('value', 'Städte Straße'); + + await render(hbs`{{safe-dasherize this.value}}`); + + assert.dom(this.element).hasText('städte-straße'); + }); + + test('it is usable inside a class attribute', async function (assert) { + this.set('value', 'Order Status'); + + await render(hbs``); + + assert.dom('.badge').hasClass('order-status'); }); }); diff --git a/tests/integration/helpers/safe-has-test.js b/tests/integration/helpers/safe-has-test.js index 023a1257..b886471c 100644 --- a/tests/integration/helpers/safe-has-test.js +++ b/tests/integration/helpers/safe-has-test.js @@ -2,16 +2,101 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import EmberObject, { computed } from '@ember/object'; module('Integration | Helper | safe-has', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it reads a shallow property', async function (assert) { + this.set('subject', { name: 'Fleetbase' }); - await render(hbs`{{safe-has this.inputValue}}`); + await render(hbs`{{safe-has this.subject "name"}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('Fleetbase'); + }); + + test('it reads a nested property path', async function (assert) { + this.set('subject', { driver: { user: { name: 'Ron' } } }); + + await render(hbs`{{safe-has this.subject "driver.user.name"}}`); + + assert.dom(this.element).hasText('Ron'); + }); + + test('it renders nothing when the path is missing', async function (assert) { + this.set('subject', { name: 'Fleetbase' }); + + await render(hbs`{{safe-has this.subject "driver.user.name"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it renders falsy leaf values rather than swallowing them', async function (assert) { + this.set('subject', { count: 0, active: false, label: '' }); + + await render(hbs`{{safe-has this.subject "count"}}|{{safe-has this.subject "active"}}|{{safe-has this.subject "label"}}|`); + + assert.strictEqual(this.element.textContent.trim(), '0|false||'); + }); + + test('it returns undefined for a destroyed object', async function (assert) { + this.set('subject', { name: 'Fleetbase', isDestroyed: true, isDestroying: false }); + + await render(hbs`{{safe-has this.subject "name"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it returns undefined for an object that is being destroyed', async function (assert) { + this.set('subject', { name: 'Fleetbase', isDestroyed: false, isDestroying: true }); + + await render(hbs`{{safe-has this.subject "name"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it reads from an object that is neither destroyed nor destroying', async function (assert) { + this.set('subject', { name: 'Fleetbase', isDestroyed: false, isDestroying: false }); + + await render(hbs`{{safe-has this.subject "name"}}`); + + assert.dom(this.element).hasText('Fleetbase'); + }); + + test('it renders nothing for null and undefined subjects', async function (assert) { + this.set('subject', null); + + await render(hbs`{{safe-has this.subject "name"}}{{safe-has this.missing "name"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it reads computed properties from an EmberObject', async function (assert) { + const Person = EmberObject.extend({ + fullName: computed('first', 'last', function () { + return `${this.first} ${this.last}`; + }), + }); + this.set('subject', Person.create({ first: 'Ada', last: 'Lovelace' })); + + await render(hbs`{{safe-has this.subject "fullName"}}`); + + assert.dom(this.element).hasText('Ada Lovelace'); + }); + + test('it reads array indices and length through the path', async function (assert) { + this.set('subject', { items: ['first', 'second'] }); + + await render(hbs`{{safe-has this.subject "items.0"}}|{{safe-has this.subject "items.length"}}`); + + assert.dom(this.element).hasText('first|2'); + }); + + test('it reads through nested empty objects without throwing', async function (assert) { + this.set('subject', { meta: {} }); + + await render(hbs`{{safe-has this.subject "meta.missing.deep"}}`); + + assert.dom(this.element).hasNoText(); }); }); diff --git a/tests/integration/helpers/safe-humanize-test.js b/tests/integration/helpers/safe-humanize-test.js index 66c797b7..a7fff469 100644 --- a/tests/integration/helpers/safe-humanize-test.js +++ b/tests/integration/helpers/safe-humanize-test.js @@ -6,12 +6,85 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | safe-humanize', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it humanizes an underscored string', async function (assert) { + this.set('value', 'first_name'); - await render(hbs`{{safe-humanize this.inputValue}}`); + await render(hbs`{{safe-humanize this.value}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('First name'); + }); + + test('it humanizes a dasherized string', async function (assert) { + this.set('value', 'order-status-code'); + + await render(hbs`{{safe-humanize this.value}}`); + + assert.dom(this.element).hasText('Order status code'); + }); + + test('it lowercases the remainder of the string', async function (assert) { + this.set('value', 'ORDER_STATUS'); + + await render(hbs`{{safe-humanize this.value}}`); + + assert.dom(this.element).hasText('Order status'); + }); + + test('it collapses runs of separators into a single space', async function (assert) { + this.set('value', 'order__status--code'); + + await render(hbs`{{safe-humanize this.value}}`); + + assert.dom(this.element).hasText('Order status code'); + }); + + test('it coerces undefined and null to their string form instead of returning empty text', async function (assert) { + this.set('nullValue', null); + + await render(hbs`{{safe-humanize this.missing}}|{{safe-humanize this.nullValue}}`); + + assert.dom(this.element).hasText('Undefined|Null'); + }); + + test('it coerces numbers and booleans', async function (assert) { + this.set('zero', 0); + this.set('bool', false); + + await render(hbs`{{safe-humanize this.zero}}|{{safe-humanize this.bool}}`); + + assert.dom(this.element).hasText('0|False'); + }); + + test('it renders nothing for an empty string', async function (assert) { + this.set('value', ''); + + await render(hbs`{{safe-humanize this.value}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it only humanizes the first positional argument', async function (assert) { + this.set('first', 'first_name'); + this.set('second', 'last_name'); + + await render(hbs`{{safe-humanize this.first this.second}}`); + + assert.dom(this.element).hasText('First name'); + }); + + test('it preserves unicode characters', async function (assert) { + this.set('value', 'straße_städte'); + + await render(hbs`{{safe-humanize this.value}}`); + + assert.dom(this.element).hasText('Straße städte'); + }); + + test('it leaves an already humanized string capitalized', async function (assert) { + this.set('value', 'Order status'); + + await render(hbs`{{safe-humanize this.value}}`); + + assert.dom(this.element).hasText('Order status'); }); }); diff --git a/tests/integration/helpers/set-has-item-test.js b/tests/integration/helpers/set-has-item-test.js index ab3de3c7..ed66c245 100644 --- a/tests/integration/helpers/set-has-item-test.js +++ b/tests/integration/helpers/set-has-item-test.js @@ -1,17 +1,102 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | set-has-item', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it is true when the set contains the key', async function (assert) { + this.set('collection', new Set(['a', 'b'])); - await render(hbs`{{set-has-item this.inputValue}}`); + await render(hbs`{{set-has-item this.collection "a"}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('true'); + }); + + test('it is false when the set does not contain the key', async function (assert) { + this.set('collection', new Set(['a', 'b'])); + + await render(hbs`{{set-has-item this.collection "c"}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it is false for an empty set', async function (assert) { + this.set('collection', new Set()); + + await render(hbs`{{set-has-item this.collection "a"}}`); + + assert.dom(this.element).hasText('false'); + }); + + test('it uses identity rather than equality for object members', async function (assert) { + const member = { id: 1 }; + this.set('collection', new Set([member])); + this.set('member', member); + this.set('lookalike', { id: 1 }); + + await render(hbs`{{set-has-item this.collection this.member}}|{{set-has-item this.collection this.lookalike}}`); + + assert.dom(this.element).hasText('true|false'); + }); + + test('it matches falsy members exactly', async function (assert) { + this.set('collection', new Set([0, false, '', null])); + this.set('zeroString', '0'); + + await render(hbs` + {{set-has-item this.collection 0}}|{{set-has-item this.collection false}}|{{set-has-item this.collection ""}}|{{set-has-item this.collection this.zeroString}} + `); + + assert.strictEqual(this.element.textContent.trim(), 'true|true|true|false'); + }); + + test('it matches NaN using SameValueZero semantics', async function (assert) { + this.set('collection', new Set([NaN])); + this.set('notANumber', NaN); + + await render(hbs`{{set-has-item this.collection this.notANumber}}`); + + assert.dom(this.element).hasText('true'); + }); + + test('it works with a Map as well as a Set', async function (assert) { + this.set('collection', new Map([['a', 1]])); + + await render(hbs`{{set-has-item this.collection "a"}}|{{set-has-item this.collection "b"}}`); + + assert.dom(this.element).hasText('true|false'); + }); + + test('it renders nothing when the collection is null or undefined', async function (assert) { + this.set('collection', null); + + await render(hbs`{{set-has-item this.collection "a"}}{{set-has-item this.missing "a"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it renders nothing when the collection has no has method', async function (assert) { + this.set('collection', { a: true }); + + await render(hbs`{{set-has-item this.collection "a"}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it recomputes when the collection argument is replaced', async function (assert) { + this.set('collection', new Set()); + + await render(hbs`{{set-has-item this.collection "order_1"}}`); + assert.dom('.state').hasText('false', 'not a member initially'); + + this.set('collection', new Set(['order_1'])); + await settled(); + assert.dom('.state').hasText('true', 'a member of the replacement set'); + + this.set('collection', new Set(['order_2'])); + await settled(); + assert.dom('.state').hasText('false', 'not a member of the next replacement set'); }); }); diff --git a/tests/integration/helpers/set-model-attr-test.js b/tests/integration/helpers/set-model-attr-test.js index 63a4cfe3..b68d95fa 100644 --- a/tests/integration/helpers/set-model-attr-test.js +++ b/tests/integration/helpers/set-model-attr-test.js @@ -1,17 +1,137 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import EmberObject from '@ember/object'; module('Integration | Helper | set-model-attr', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it sets the model attribute from the value property of the selection', async function (assert) { + const model = EmberObject.create({ type: null }); + this.set('model', model); + this.set('selection', { value: 'delivery', label: 'Delivery' }); - await render(hbs`{{set-model-attr this.inputValue}}`); + await render(hbs` + + `); - assert.dom().hasText('1234'); + assert.strictEqual(model.type, null, 'the model is untouched before the callback runs'); + + await click('.select'); + + assert.strictEqual(model.type, 'delivery', 'the selected value is written to the attribute'); + }); + + test('it reads a custom property from the selection', async function (assert) { + const model = EmberObject.create({ type: null }); + this.set('model', model); + this.set('selection', { value: 'delivery', code: 'DLV' }); + + await render(hbs` + + `); + + await click('.select'); + + assert.strictEqual(model.type, 'DLV', 'the named prop determines which value is read'); + }); + + test('it clears the attribute when the selection is null', async function (assert) { + const model = EmberObject.create({ type: 'delivery' }); + this.set('model', model); + this.set('selection', null); + + await render(hbs` + + `); + + await click('.clear'); + + assert.strictEqual(model.type, null, 'a null selection nulls the attribute'); + }); + + test('it clears the attribute when the selection is undefined', async function (assert) { + const model = EmberObject.create({ type: 'delivery' }); + this.set('model', model); + + await render(hbs` + + `); + + await click('.clear'); + + assert.strictEqual(model.type, null, 'an undefined selection nulls the attribute'); + }); + + test('it writes undefined when the selection lacks the requested property', async function (assert) { + const model = EmberObject.create({ type: 'delivery' }); + this.set('model', model); + this.set('selection', { label: 'Delivery' }); + + await render(hbs` + + `); + + await click('.select'); + + assert.strictEqual(model.type, undefined, 'a missing prop yields undefined rather than throwing'); + }); + + test('it writes falsy selection values through', async function (assert) { + const model = EmberObject.create({ type: 'delivery' }); + this.set('model', model); + this.set('selection', { value: 0 }); + + await render(hbs` + + `); + + await click('.select'); + + assert.strictEqual(model.type, 0, 'zero is written rather than treated as no selection'); + }); + + test('it returns a harmless no-op when the model is missing', async function (assert) { + this.set('selection', { value: 'delivery' }); + + await render(hbs` + + `); + + await click('.select'); + + assert.dom('.select').exists('clicking the no-op callback does not throw'); + }); + + test('it returns a harmless no-op when the attribute name is missing', async function (assert) { + const model = EmberObject.create({ type: 'delivery' }); + this.set('model', model); + this.set('selection', { value: 'pickup' }); + + await render(hbs` + + `); + + await click('.select'); + + assert.strictEqual(model.type, 'delivery', 'the model is left untouched without an attribute name'); + }); + + test('the mutation is observable in the template', async function (assert) { + const model = EmberObject.create({ type: 'pickup' }); + this.set('model', model); + this.set('selection', { value: 'delivery' }); + + await render(hbs` + {{this.model.type}} + + `); + + assert.dom('.value').hasText('pickup'); + + await click('.select'); + + assert.dom('.value').hasText('delivery', 'the template re-renders with the new attribute value'); }); }); diff --git a/tests/integration/helpers/set-object-kv-test.js b/tests/integration/helpers/set-object-kv-test.js index e9f99318..c9abd5b4 100644 --- a/tests/integration/helpers/set-object-kv-test.js +++ b/tests/integration/helpers/set-object-kv-test.js @@ -2,16 +2,71 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; +function registerCapture(owner, sink) { + owner.register( + 'helper:capture-value', + helper(function ([value]) { + sink.push(value); + return ''; + }) + ); +} + +/** + * NOTE: the helper reassigns its local `object` parameter and returns nothing, so its only + * observable contract today is that it is a pure no-op: it neither mutates the object that was + * passed in nor yields a value. These tests pin that behaviour down. + */ module('Integration | Helper | set-object-kv', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it yields no value', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('object', { a: 1 }); + + await render(hbs`{{capture-value (set-object-kv this.object "b" 2)}}`); + + assert.strictEqual(captured.length, 1, 'the helper was invoked'); + assert.strictEqual(captured[0], undefined, 'the helper returns undefined'); + }); + + test('it renders nothing', async function (assert) { + this.set('object', { a: 1 }); + + await render(hbs`{{set-object-kv this.object "b" 2}}`); + + assert.dom('.output').hasNoText(); + }); + + test('it does not mutate the object that was passed in', async function (assert) { + const object = { a: 1 }; + this.set('object', object); + + await render(hbs`{{set-object-kv this.object "b" 2}}`); + + assert.deepEqual(object, { a: 1 }, 'the original object is untouched'); + assert.notOk('b' in object, 'the key was not added to the original object'); + }); + + test('it does not overwrite an existing key on the object that was passed in', async function (assert) { + const object = { a: 1 }; + this.set('object', object); + + await render(hbs`{{set-object-kv this.object "a" 99}}`); + + assert.strictEqual(object.a, 1, 'the existing value is preserved'); + }); + + test('it tolerates null, undefined and missing arguments', async function (assert) { + this.set('nullObject', null); - await render(hbs`{{set-object-kv this.inputValue}}`); + await render(hbs` + {{set-object-kv this.nullObject "b" 2}}{{set-object-kv this.missing "b" 2}}{{set-object-kv (hash) this.missingKey this.missingValue}} + `); - assert.dom().hasText('1234'); + assert.dom('.output').hasNoText('no output and no error for empty input'); }); }); diff --git a/tests/integration/helpers/smart-humanize-test.js b/tests/integration/helpers/smart-humanize-test.js index 8a4e8e6f..87845b77 100644 --- a/tests/integration/helpers/smart-humanize-test.js +++ b/tests/integration/helpers/smart-humanize-test.js @@ -6,12 +6,102 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | smart-humanize', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it title cases an underscored string', async function (assert) { + this.set('value', 'first_name'); - await render(hbs`{{smart-humanize this.inputValue}}`); + await render(hbs`{{smart-humanize this.value}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('First Name'); + }); + + test('it decamelizes camel case input', async function (assert) { + this.set('value', 'trackingNumber'); + + await render(hbs`{{smart-humanize this.value}}`); + + assert.dom(this.element).hasText('Tracking Number'); + }); + + test('it uppercases known acronyms', async function (assert) { + this.set('apiKey', 'api_key'); + this.set('vatNumber', 'vat_number'); + this.set('uuid', 'uuid'); + + await render(hbs`{{smart-humanize this.apiKey}}|{{smart-humanize this.vatNumber}}|{{smart-humanize this.uuid}}`); + + assert.dom(this.element).hasText('API Key|VAT Number|UUID'); + }); + + test('it uppercases acronyms discovered through decamelization', async function (assert) { + this.set('value', 'orderEta'); + + await render(hbs`{{smart-humanize this.value}}`); + + assert.dom(this.element).hasText('Order ETA'); + }); + + test('it only uppercases whole word acronyms', async function (assert) { + this.set('value', 'rapid_transit'); + + await render(hbs`{{smart-humanize this.value}}`); + + assert.dom(this.element).hasText('Rapid Transit'); + }); + + test('it normalizes already uppercase input', async function (assert) { + this.set('value', 'ORDER_STATUS'); + + await render(hbs`{{smart-humanize this.value}}`); + + assert.dom(this.element).hasText('Order Status'); + }); + + test('it capitalizes every word of a dasherized string', async function (assert) { + this.set('value', 'order-status-code'); + + await render(hbs`{{smart-humanize this.value}}`); + + assert.dom(this.element).hasText('Order Status Code'); + }); + + test('it renders nothing for an empty string', async function (assert) { + this.set('value', ''); + + await render(hbs`{{smart-humanize this.value}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it returns non string input unchanged', async function (assert) { + this.set('number', 42); + this.set('bool', true); + + await render(hbs`{{smart-humanize this.number}}|{{smart-humanize this.bool}}`); + + assert.dom(this.element).hasText('42|true'); + }); + + test('it renders nothing for null and undefined', async function (assert) { + this.set('nullValue', null); + + await render(hbs`{{smart-humanize this.nullValue}}{{smart-humanize this.missing}}`); + + assert.dom(this.element).hasNoText(); + }); + + test('it preserves unicode words', async function (assert) { + this.set('value', 'straße_städte'); + + await render(hbs`{{smart-humanize this.value}}`); + + assert.dom(this.element).hasText('Straße Städte'); + }); + + test('it handles numeric segments', async function (assert) { + this.set('value', 'address_line_2'); + + await render(hbs`{{smart-humanize this.value}}`); + + assert.dom(this.element).hasText('Address Line 2'); }); }); diff --git a/tests/integration/helpers/spread-widget-options-test.js b/tests/integration/helpers/spread-widget-options-test.js index c17f35ef..cddec198 100644 --- a/tests/integration/helpers/spread-widget-options-test.js +++ b/tests/integration/helpers/spread-widget-options-test.js @@ -2,16 +2,102 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; + +function registerCapture(owner, sink) { + owner.register( + 'helper:capture-value', + helper(function ([value]) { + sink.push(value); + return ''; + }) + ); +} module('Integration | Helper | spread-widget-options', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it merges the widget id into the grid options', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('params', { id: 'widget_1', options: { x: 0, y: 2, w: 4, h: 3 } }); + + await render(hbs`{{capture-value (spread-widget-options this.params)}}`); + + assert.deepEqual(captured[0], { id: 'widget_1', x: 0, y: 2, w: 4, h: 3 }, 'the id and options are flattened into one object'); + }); + + test('it exposes the merged options to the template', async function (assert) { + this.set('params', { id: 'widget_1', options: { x: 0, y: 2 } }); + + await render(hbs`{{get (spread-widget-options this.params) "id"}}|{{get (spread-widget-options this.params) "x"}}|{{get (spread-widget-options this.params) "y"}}`); + + assert.dom(this.element).hasText('widget_1|0|2'); + }); + + test('it works with a hash built inline in the template', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('gridOptions', { w: 6, h: 2 }); + + await render(hbs`{{capture-value (spread-widget-options (hash id="widget_2" options=this.gridOptions))}}`); + + assert.deepEqual(captured[0], { id: 'widget_2', w: 6, h: 2 }); + }); + + test('an id inside options overrides the top level id', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('params', { id: 'outer', options: { id: 'inner', w: 1 } }); + + await render(hbs`{{capture-value (spread-widget-options this.params)}}`); + + assert.strictEqual(captured[0].id, 'inner', 'the spread options win over the leading id'); + }); + + test('it returns just the id when there are no options', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('params', { id: 'widget_3' }); + + await render(hbs`{{capture-value (spread-widget-options this.params)}}`); + + assert.deepEqual(captured[0], { id: 'widget_3' }, 'missing options spread to nothing'); + }); + + test('it tolerates null options and a missing id', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('nullOptions', { id: 'widget_4', options: null }); + this.set('noId', { options: { w: 2 } }); + + await render(hbs`{{capture-value (spread-widget-options this.nullOptions)}}{{capture-value (spread-widget-options this.noId)}}`); + + assert.deepEqual(captured[0], { id: 'widget_4' }, 'null options spread to nothing'); + assert.deepEqual(captured[1], { id: undefined, w: 2 }, 'a missing id is carried through as undefined'); + }); + + test('it returns a new object and does not mutate the source options', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + const options = { x: 1 }; + const params = { id: 'widget_5', options }; + this.set('params', params); + + await render(hbs`{{capture-value (spread-widget-options this.params)}}`); + + assert.notStrictEqual(captured[0], options, 'a fresh object is returned'); + assert.notStrictEqual(captured[0], params, 'the params object is not returned directly'); + assert.deepEqual(options, { x: 1 }, 'the source options are not mutated'); + }); + + test('it preserves falsy option values', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('params', { id: 'widget_6', options: { x: 0, autoPosition: false, label: '' } }); - await render(hbs`{{spread-widget-options this.inputValue}}`); + await render(hbs`{{capture-value (spread-widget-options this.params)}}`); - assert.dom().hasText('1234'); + assert.deepEqual(captured[0], { id: 'widget_6', x: 0, autoPosition: false, label: '' }); }); }); diff --git a/tests/integration/helpers/string-starts-with-test.js b/tests/integration/helpers/string-starts-with-test.js new file mode 100644 index 00000000..322f6794 --- /dev/null +++ b/tests/integration/helpers/string-starts-with-test.js @@ -0,0 +1,76 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | string-starts-with', function (hooks) { + setupRenderingTest(hooks); + + async function renderWith(context, str, prefix) { + context.set('str', str); + context.set('prefix', prefix); + + await render(hbs`{{string-starts-with this.str this.prefix}}`); + + return context.element.textContent.trim(); + } + + test('it is true when the string starts with the prefix', async function (assert) { + assert.strictEqual(await renderWith(this, '_new_report', '_new_'), 'true'); + }); + + test('it is false when the string does not start with the prefix', async function (assert) { + assert.strictEqual(await renderWith(this, 'report_new_', '_new_'), 'false', 'the prefix must be at the start, not anywhere'); + }); + + test('an empty prefix always matches', async function (assert) { + assert.strictEqual(await renderWith(this, 'anything', ''), 'true'); + }); + + test('an empty string only matches an empty prefix', async function (assert) { + assert.strictEqual(await renderWith(this, '', ''), 'true'); + assert.strictEqual(await renderWith(this, '', 'x'), 'false'); + }); + + test('it is false when either argument is not a string', async function (assert) { + assert.strictEqual(await renderWith(this, null, '_new_'), 'false'); + assert.strictEqual(await renderWith(this, undefined, '_new_'), 'false'); + assert.strictEqual(await renderWith(this, '_new_x', null), 'false'); + assert.strictEqual(await renderWith(this, '_new_x', undefined), 'false'); + assert.strictEqual(await renderWith(this, 123, '1'), 'false', 'a number is not coerced to a string'); + assert.strictEqual(await renderWith(this, ['_new_'], '_new_'), 'false'); + assert.strictEqual(await renderWith(this, {}, '_new_'), 'false'); + }); + + test('it is false when both arguments are missing', async function (assert) { + await render(hbs`{{string-starts-with}}`); + + assert.dom(this.element).hasText('false', 'no arguments is safe rather than a crash'); + }); + + test('matching is case sensitive', async function (assert) { + assert.strictEqual(await renderWith(this, 'Report', 'report'), 'false'); + assert.strictEqual(await renderWith(this, 'Report', 'Report'), 'true'); + }); + + test('it handles whitespace and unicode prefixes literally', async function (assert) { + assert.strictEqual(await renderWith(this, ' spaced', ' '), 'true'); + assert.strictEqual(await renderWith(this, '🚚 truck', '🚚'), 'true'); + assert.strictEqual(await renderWith(this, 'truck 🚚', '🚚'), 'false'); + }); + + test('a prefix longer than the string does not match', async function (assert) { + assert.strictEqual(await renderWith(this, 'ab', 'abc'), 'false'); + }); + + test('it recomputes when the arguments change', async function (assert) { + this.set('str', '_new_a'); + this.set('prefix', '_new_'); + + await render(hbs`{{string-starts-with this.str this.prefix}}`); + assert.dom(this.element).hasText('true'); + + this.set('str', 'old_a'); + assert.dom(this.element).hasText('false', 'the helper is reactive to its arguments'); + }); +}); diff --git a/tests/integration/helpers/to-int-test.js b/tests/integration/helpers/to-int-test.js index f3d849dd..2f6458cd 100644 --- a/tests/integration/helpers/to-int-test.js +++ b/tests/integration/helpers/to-int-test.js @@ -6,12 +6,98 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | to-int', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it parses a numeric string', async function (assert) { + this.set('value', '42'); - await render(hbs`{{to-int this.inputValue}}`); + await render(hbs`{{to-int this.value}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('42'); + }); + + test('it strips non numeric characters before parsing', async function (assert) { + this.set('currency', '$1,234'); + this.set('mixed', '12 units'); + + await render(hbs`{{to-int this.currency}}|{{to-int this.mixed}}`); + + assert.dom(this.element).hasText('1234|12'); + }); + + test('it drops decimal points so the fraction is concatenated', async function (assert) { + this.set('value', '10.50'); + + await render(hbs`{{to-int this.value}}`); + + assert.dom(this.element).hasText('1050'); + }); + + test('it drops the minus sign so negative strings become positive', async function (assert) { + this.set('value', '-5'); + + await render(hbs`{{to-int this.value}}`); + + assert.dom(this.element).hasText('5'); + }); + + test('it strips leading zeros', async function (assert) { + this.set('value', '007'); + + await render(hbs`{{to-int this.value}}`); + + assert.dom(this.element).hasText('7'); + }); + + test('it passes numbers straight through to parseInt', async function (assert) { + this.set('integer', 42); + this.set('float', 10.9); + this.set('zero', 0); + this.set('negative', -7); + + await render(hbs`{{to-int this.integer}}|{{to-int this.float}}|{{to-int this.zero}}|{{to-int this.negative}}`); + + assert.dom(this.element).hasText('42|10|0|-7'); + }); + + test('it yields NaN for strings without digits', async function (assert) { + this.set('word', 'abc'); + this.set('empty', ''); + this.set('whitespace', ' '); + + await render(hbs`{{to-int this.word}}|{{to-int this.empty}}|{{to-int this.whitespace}}`); + + assert.dom(this.element).hasText('NaN|NaN|NaN'); + }); + + test('it yields NaN for null, undefined and booleans', async function (assert) { + this.set('nullValue', null); + this.set('bool', true); + + await render(hbs`{{to-int this.nullValue}}|{{to-int this.missing}}|{{to-int this.bool}}`); + + assert.dom(this.element).hasText('NaN|NaN|NaN'); + }); + + test('it parses digits out of exponential notation as plain digits', async function (assert) { + this.set('value', '1e3'); + + await render(hbs`{{to-int this.value}}`); + + assert.dom(this.element).hasText('13'); + }); + + test('it handles digit strings beyond the safe integer range', async function (assert) { + this.set('value', '9007199254740993'); + + await render(hbs`{{to-int this.value}}`); + + assert.dom(this.element).hasText('9007199254740992', 'the value is clamped to the nearest representable number'); + }); + + test('it extracts digits from unicode and formatted strings', async function (assert) { + this.set('value', '第4号-2024'); + + await render(hbs`{{to-int this.value}}`); + + assert.dom(this.element).hasText('42024'); }); }); diff --git a/tests/integration/helpers/transition-to-test.js b/tests/integration/helpers/transition-to-test.js index 056062e5..8f545840 100644 --- a/tests/integration/helpers/transition-to-test.js +++ b/tests/integration/helpers/transition-to-test.js @@ -1,17 +1,141 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; + +/** + * The helper transitions through `router:main`, so record the calls on the real router instance + * instead of letting it attempt a routing transition inside a rendering test. + */ +function stubRouter(owner) { + const calls = []; + // eslint-disable-next-line ember/no-private-routing-service -- the helper under test transitions through `router:main` itself + const router = owner.lookup('router:main'); + router.transitionTo = function (...args) { + calls.push(args); + }; + + return calls; +} module('Integration | Helper | transition-to', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it does not transition until the returned closure is invoked', async function (assert) { + const calls = stubRouter(this.owner); + + await render(hbs``); + + assert.deepEqual(calls, [], 'rendering alone does not transition'); + + await click('.go'); + + assert.deepEqual(calls, [['orders']], 'clicking transitions to the route'); + }); + + test('it forwards dynamic segments as positional arguments', async function (assert) { + const calls = stubRouter(this.owner); + this.set('id', 'order_1'); + + await render(hbs``); + await click('.go'); + + assert.deepEqual(calls, [['orders.view', 'order_1', 'activity']], 'every positional argument is forwarded'); + }); + + test('it appends query params as a trailing options object', async function (assert) { + const calls = stubRouter(this.owner); + + await render(hbs``); + await click('.go'); + + assert.strictEqual(calls.length, 1, 'one transition happened'); + assert.strictEqual(calls[0][0], 'orders', 'the route name is first'); + assert.deepEqual(calls[0][1], { queryParams: { page: 2, status: 'active' } }, 'the query params are passed as the trailing options'); + }); + + test('it appends query params after dynamic segments', async function (assert) { + const calls = stubRouter(this.owner); + + await render(hbs``); + await click('.go'); + + assert.deepEqual(calls[0], ['orders.view', 'order_1', { queryParams: { tab: 'activity' } }], 'segments come before the query params'); + }); + + test('it can be invoked more than once', async function (assert) { + const calls = stubRouter(this.owner); + + await render(hbs``); + await click('.go'); + await click('.go'); + + assert.strictEqual(calls.length, 2, 'each invocation transitions again'); + }); + + test('it prefixes the route with the owner mount point when mounted in an engine', async function (assert) { + const calls = stubRouter(this.owner); + this.owner.mountPoint = 'console.fleet-ops'; + + await render(hbs``); + await click('.go'); + + assert.deepEqual(calls, [['console.fleet-ops.orders.index']], 'the mount point prefixes the route name'); + }); + + test('it maps the application route to the mount point itself', async function (assert) { + const calls = stubRouter(this.owner); + this.owner.mountPoint = 'console.fleet-ops'; + + await render(hbs``); + await click('.go'); + + assert.deepEqual(calls, [['console.fleet-ops']], 'the application route resolves to the mount point'); + }); + + test('it ignores a blank mount point', async function (assert) { + const calls = stubRouter(this.owner); + this.owner.mountPoint = ' '; + + await render(hbs``); + await click('.go'); + + assert.deepEqual(calls, [['orders']], 'a blank mount point does not prefix the route name'); + }); + + test('it reads the route name lazily so the latest argument value is used', async function (assert) { + const calls = stubRouter(this.owner); + this.set('route', 'orders'); + + await render(hbs``); + await click('.go'); + + this.set('route', 'places'); + await settled(); + await click('.go'); + + assert.deepEqual(calls, [['orders'], ['places']], 'the second click uses the updated route name'); + }); + // The closure is captured and called directly: thrown from inside `{{on "click"}}` the + // assertion escapes as an uncaught global error that assert.throws cannot intercept. + test('a non-string route name is rejected rather than interpolated', async function (assert) { + const calls = stubRouter(this.owner); + this.owner.mountPoint = 'console.fleet-ops'; + this.set('route', 404); + + const captured = []; + this.owner.register( + 'helper:capture-value', + helper(function ([value]) { + captured.push(value); + return ''; + }) + ); - await render(hbs`{{transition-to this.inputValue}}`); + await render(hbs`{{capture-value (transition-to this.route)}}`); - assert.dom(this.element).hasText('1234'); + assert.throws(captured[0], /propValue argument must be an string/, 'the caller is told about its own bug'); + assert.deepEqual(calls, [], 'and no transition is attempted'); }); }); diff --git a/tests/integration/helpers/truncate-filename-test.js b/tests/integration/helpers/truncate-filename-test.js index 4c278b38..6379e520 100644 --- a/tests/integration/helpers/truncate-filename-test.js +++ b/tests/integration/helpers/truncate-filename-test.js @@ -6,12 +6,106 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | truncate-filename', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it leaves a short filename untouched', async function (assert) { + this.set('filename', 'invoice.pdf'); - await render(hbs`{{truncate-filename this.inputValue}}`); + await render(hbs`{{truncate-filename this.filename}}`); - assert.dom().hasText('1234'); + assert.dom(this.element).hasText('invoice.pdf'); + }); + + test('it leaves a filename of exactly the maximum length untouched', async function (assert) { + this.set('filename', 'abcdefghijklmnop.txt'); + + await render(hbs`{{truncate-filename this.filename}}`); + + assert.strictEqual(this.element.textContent.trim(), 'abcdefghijklmnop.txt'); + assert.strictEqual('abcdefghijklmnop.txt'.length, 20, 'the fixture is exactly at the default limit'); + }); + + test('it truncates the base name and keeps the extension within the default limit', async function (assert) { + this.set('filename', 'abcdefghijklmnopq.txt'); + + await render(hbs`{{truncate-filename this.filename}}`); + + assert.strictEqual(this.element.textContent.trim(), 'abcdefghijklm....txt'); + assert.strictEqual(this.element.textContent.trim().length, 20, 'the result respects the maximum length'); + }); + + test('it honours a custom maximum length', async function (assert) { + this.set('filename', 'abcdefghijklmnopq.txt'); + + await render(hbs`{{truncate-filename this.filename 10}}`); + + assert.strictEqual(this.element.textContent.trim(), 'abc....txt'); + }); + + test('it truncates a filename with no extension', async function (assert) { + this.set('filename', 'thisisaverylongfilenamewithoutanyextension'); + + await render(hbs`{{truncate-filename this.filename}}`); + + assert.strictEqual(this.element.textContent.trim(), 'thisisaverylongfi...'); + assert.strictEqual(this.element.textContent.trim().length, 20, 'the result respects the maximum length'); + }); + + test('it treats everything after the first dot as the extension', async function (assert) { + this.set('filename', 'my.backup.archive.tar.gz'); + + await render(hbs`{{truncate-filename this.filename}}`); + + assert.strictEqual(this.element.textContent.trim(), '....backup.archive.tar.gz'); + }); + + test('it returns the extension alone when the limit cannot fit it', async function (assert) { + this.set('filename', 'photograph.jpeg'); + + await render(hbs`{{truncate-filename this.filename 4}}`); + + assert.strictEqual(this.element.textContent.trim(), '....jpeg'); + }); + + test('it handles a dotfile whose whole name reads as an extension', async function (assert) { + this.set('filename', '.gitignore'); + + await render(hbs`{{truncate-filename this.filename 5}}`); + + assert.strictEqual(this.element.textContent.trim(), '....gitignore'); + }); + + test('it handles a zero maximum length', async function (assert) { + this.set('filename', 'report.csv'); + + await render(hbs`{{truncate-filename this.filename 0}}`); + + assert.strictEqual(this.element.textContent.trim(), '....csv'); + }); + + test('it returns falsy and non string input unchanged', async function (assert) { + this.set('nullValue', null); + this.set('empty', ''); + this.set('number', 12345); + + await render(hbs`{{truncate-filename this.nullValue}}|{{truncate-filename this.missing}}|{{truncate-filename this.empty}}|{{truncate-filename this.number}}`); + + assert.strictEqual(this.element.textContent.trim(), '|||12345'); + }); + + test('it truncates unicode filenames', async function (assert) { + this.set('filename', 'файл-очень-длинное-имя.txt'); + + await render(hbs`{{truncate-filename this.filename}}`); + + assert.strictEqual(this.element.textContent.trim(), 'файл-очень-дл....txt'); + }); + + test('it truncates a very long filename to the requested length', async function (assert) { + this.set('filename', `${'a'.repeat(500)}.png`); + + await render(hbs`{{truncate-filename this.filename 30}}`); + + const output = this.element.textContent.trim(); + assert.strictEqual(output.length, 30, 'the output is capped at the requested length'); + assert.true(output.endsWith('....png'), 'the extension is preserved'); }); }); diff --git a/tests/integration/helpers/unwrap-coordinates-test.js b/tests/integration/helpers/unwrap-coordinates-test.js index 181515fa..9b744965 100644 --- a/tests/integration/helpers/unwrap-coordinates-test.js +++ b/tests/integration/helpers/unwrap-coordinates-test.js @@ -2,16 +2,210 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; +import { leafletWrapCoordinates } from '@fleetbase/ember-ui/helpers/unwrap-coordinates'; + +const TOLERANCE = 1e-6; + +function registerCapture(owner, sink) { + owner.register( + 'helper:capture-value', + helper(function ([value]) { + sink.push(value); + return ''; + }) + ); +} + +function assertLatLng(assert, actual, expectedLat, expectedLng, label) { + assert.strictEqual(typeof actual?.lat, 'number', `${label}: has a numeric lat`); + assert.strictEqual(typeof actual?.lng, 'number', `${label}: has a numeric lng`); + assert.true(Math.abs(actual.lat - expectedLat) < TOLERANCE, `${label}: lat is ${expectedLat} (got ${actual.lat})`); + assert.true(Math.abs(actual.lng - expectedLng) < TOLERANCE, `${label}: lng is ${expectedLng} (got ${actual.lng})`); +} module('Integration | Helper | unwrap-coordinates', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it projects a geojson point and preserves the other members', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + const point = { type: 'Point', coordinates: [-80.191788, 25.761681], id: 'place_1' }; + this.set('input', point); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + const result = captured[0]; + assert.notStrictEqual(result, point, 'a new geometry object is returned'); + assert.strictEqual(result.type, 'Point', 'the geometry type is preserved'); + assert.strictEqual(result.id, 'place_1', 'unrelated members are preserved'); + assert.deepEqual(point.coordinates, [-80.191788, 25.761681], 'the source geometry is not mutated'); + assertLatLng(assert, result.coordinates, 25.761681, -80.191788, 'point'); + }); + + test('it wraps out of range longitudes back into the canonical range', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('input', { type: 'Point', coordinates: [200, 45] }); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + assertLatLng(assert, captured[0].coordinates, 45, -160, 'wrapped point'); + }); + + test('it wraps longitudes below negative 180', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('input', { type: 'Point', coordinates: [-190, -33.8688] }); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + assertLatLng(assert, captured[0].coordinates, -33.8688, 170, 'wrapped point'); + }); + + test('it projects every vertex of a geojson line string', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('input', { + type: 'LineString', + coordinates: [ + [-80, 26], + [-80.1, 26.1], + [-80.2, 26.2], + ], + }); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + const coordinates = captured[0].coordinates; + assert.strictEqual(coordinates.length, 3, 'every vertex is returned'); + assertLatLng(assert, coordinates[0], 26, -80, 'vertex 0'); + assertLatLng(assert, coordinates[1], 26.1, -80.1, 'vertex 1'); + assertLatLng(assert, coordinates[2], 26.2, -80.2, 'vertex 2'); + }); + + test('it projects every ring of a geojson polygon', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('input', { + type: 'Polygon', + coordinates: [ + [ + [-80, 26], + [-80.1, 26.1], + [-80.2, 26.2], + [-80, 26], + ], + ], + }); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + const rings = captured[0].coordinates; + assert.strictEqual(rings.length, 1, 'the ring nesting is preserved'); + assert.strictEqual(rings[0].length, 4, 'every vertex of the ring is returned'); + assertLatLng(assert, rings[0][0], 26, -80, 'ring vertex 0'); + assertLatLng(assert, rings[0][3], 26, -80, 'closing vertex'); + }); + + test('it clamps latitudes beyond the web mercator limit', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('input', { type: 'Point', coordinates: [10, 89] }); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + const coordinates = captured[0].coordinates; + assert.true(coordinates.lat < 85.06, `the latitude is clamped below the mercator maximum (got ${coordinates.lat})`); + assert.true(coordinates.lat > 85.04, `and not past it (got ${coordinates.lat})`); + assert.true(Math.abs(coordinates.lng - 10) < TOLERANCE, 'the longitude is untouched'); + }); + + test('it converts a bare list of latitude longitude pairs', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('input', [ + [26, -80], + [26.1, -80.1], + ]); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + const coordinates = captured[0]; + assert.strictEqual(coordinates.length, 2, 'each pair is converted'); + assertLatLng(assert, coordinates[0], 26, -80, 'pair 0'); + assertLatLng(assert, coordinates[1], 26.1, -80.1, 'pair 1'); + }); + + test('it converts a single latitude longitude pair', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('input', [26, -80]); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + assertLatLng(assert, captured[0], 26, -80, 'single pair'); + }); + + test('it returns falsy input unchanged', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('nullValue', null); + this.set('zero', 0); + this.set('empty', ''); + + await render( + hbs`{{capture-value (unwrap-coordinates this.nullValue)}}{{capture-value (unwrap-coordinates this.missing)}}{{capture-value (unwrap-coordinates this.zero)}}{{capture-value (unwrap-coordinates this.empty)}}` + ); + + assert.deepEqual(captured, [null, undefined, 0, ''], 'falsy values are passed straight through'); + }); + + test('it returns non geometry input unchanged', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + const plain = { type: 'Point', id: 'no-coordinates' }; + this.set('object', plain); + this.set('string', 'not coordinates'); + + await render(hbs`{{capture-value (unwrap-coordinates this.object)}}{{capture-value (unwrap-coordinates this.string)}}`); + + assert.strictEqual(captured[0], plain, 'an object without coordinates is returned by identity'); + assert.strictEqual(captured[1], 'not coordinates', 'a string is returned unchanged'); + }); + + test('it returns an empty coordinate list unchanged', async function (assert) { + const captured = []; + registerCapture(this.owner, captured); + this.set('input', []); + + await render(hbs`{{capture-value (unwrap-coordinates this.input)}}`); + + assert.deepEqual(captured[0], [], 'an empty array stays empty'); + }); + module('leafletWrapCoordinates', function () { + test('a longitude past the antimeridian is wrapped back into range', function (assert) { + assert.deepEqual(leafletWrapCoordinates([200, 10]), [-160, 10]); + assert.deepEqual(leafletWrapCoordinates([-200, 10, 55]), [160, 10, 55], 'extra dimensions are preserved'); + }); - await render(hbs`{{unwrap-coordinates this.inputValue}}`); + test('nested rings are wrapped element by element', function (assert) { + assert.deepEqual( + leafletWrapCoordinates([ + [200, 10], + [-200, 20], + ]), + [ + [-160, 10], + [160, 20], + ] + ); + }); - assert.dom().hasText('1234'); + test('anything that is not an array is handed straight back', function (assert) { + assert.strictEqual(leafletWrapCoordinates('not coordinates'), 'not coordinates'); + assert.strictEqual(leafletWrapCoordinates(null), null); + assert.deepEqual(leafletWrapCoordinates({ lat: 1, lng: 2 }), { lat: 1, lng: 2 }); + }); }); }); diff --git a/tests/integration/modifiers/background-url-test.js b/tests/integration/modifiers/background-url-test.js index 8082b01d..7730addb 100644 --- a/tests/integration/modifiers/background-url-test.js +++ b/tests/integration/modifiers/background-url-test.js @@ -1,15 +1,105 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +const PHOTO = 'https://cdn.fleetbase.test/photo.png'; +const OTHER_PHOTO = 'https://cdn.fleetbase.test/other.png'; + module('Integration | Modifier | background-url', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it sets the background image from the url and defaults the size to cover', async function (assert) { + this.set('url', PHOTO); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + + assert.strictEqual(element.style.backgroundImage, `url("${PHOTO}")`, 'background image is the provided url'); + assert.strictEqual(element.style.backgroundSize, 'cover', 'background size defaults to cover'); + assert.notOk(element.style.backgroundImage.includes('linear-gradient'), 'no overlay gradient is applied by default'); + }); + + test('it honors the size option', async function (assert) { + this.set('url', PHOTO); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.backgroundSize, 'contain', 'background size uses the provided size option'); + }); + + test('it layers the gradient overlay above the url when overlay is true', async function (assert) { + this.set('url', PHOTO); + this.set('gradient', 'linear-gradient(rgb(255, 0, 0), rgb(0, 0, 255))'); + + await render(hbs`
      `); + + const backgroundImage = find('[data-test-el]').style.backgroundImage; + + assert.ok(backgroundImage.includes('linear-gradient'), `overlay gradient is applied (got "${backgroundImage}")`); + assert.ok(backgroundImage.includes(`url("${PHOTO}")`), 'url is still applied alongside the gradient'); + assert.ok(backgroundImage.indexOf('linear-gradient') < backgroundImage.indexOf('url('), 'gradient is layered above the image'); + assert.ok(backgroundImage.includes('rgb(255, 0, 0)'), 'the custom gradient value is used'); + }); + + test('it uses the default overlay gradient when only overlay is passed', async function (assert) { + this.set('url', PHOTO); + + await render(hbs`
      `); + + const backgroundImage = find('[data-test-el]').style.backgroundImage; + + assert.ok(backgroundImage.includes('linear-gradient'), 'default gradient is applied'); + assert.ok(backgroundImage.includes('rgba(0, 0, 0, 0.3)'), `default gradient uses the black 30% overlay (got "${backgroundImage}")`); + }); + + test('it updates the background when the url changes', async function (assert) { + this.set('url', PHOTO); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.backgroundImage, `url("${PHOTO}")`, 'initial url is applied'); + + this.set('url', OTHER_PHOTO); + await settled(); + + assert.strictEqual(element.style.backgroundImage, `url("${OTHER_PHOTO}")`, 'background image tracks the updated url'); + }); + + test('it does not produce a url() for null, undefined or empty urls', async function (assert) { + this.set('url', null); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + + assert.strictEqual(element.style.backgroundImage, '', 'no background image is set for a null url'); + assert.notOk(element.style.background.includes('null'), 'the literal string "null" is never used as a url'); + assert.strictEqual(element.style.backgroundSize, 'cover', 'background size is still applied'); + + this.set('url', ''); + await settled(); + assert.strictEqual(element.style.backgroundImage, '', 'no background image is set for an empty url'); + + this.set('url', ' '); + await settled(); + assert.strictEqual(element.style.backgroundImage, '', 'no background image is set for a whitespace only url'); + }); + + test('it clears the background image when the url is removed', async function (assert) { + this.set('url', PHOTO); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.backgroundImage, `url("${PHOTO}")`, 'initial url is applied'); + + this.set('url', undefined); + await settled(); - assert.ok(true); + assert.strictEqual(element.style.backgroundImage, '', 'background image is cleared when the url goes away'); + assert.notOk(element.style.background.includes('undefined'), 'the literal string "undefined" is never used as a url'); }); }); diff --git a/tests/integration/modifiers/constrain-view-section-width-test.js b/tests/integration/modifiers/constrain-view-section-width-test.js index 06b38491..4b732b94 100644 --- a/tests/integration/modifiers/constrain-view-section-width-test.js +++ b/tests/integration/modifiers/constrain-view-section-width-test.js @@ -1,15 +1,159 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +// Chrome re-serialises `calc(100vw - Npx)` (e.g. as `calc(0px + 100vw)`), so assert on +// the operands rather than the exact string the browser chose to emit. +function assertViewportMinus(assert, element, pixels, message) { + const width = element.style.width; + assert.true(width.startsWith('calc('), `${message} (a calc expression is applied)`); + assert.true(width.includes('100vw'), `${message} (relative to the viewport)`); + assert.true(width.includes(`${pixels}px`), `${message} (reserving ${pixels}px)`); +} + +// Wait for a ResizeObserver delivery (which happens after layout, before paint). +async function nextFrames(frames = 2) { + for (let i = 0; i < frames; i++) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + + await settled(); +} + module('Integration | Modifier | constrain-view-section-width', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + let sidebar; + let section; + + hooks.beforeEach(function () { + // fixed + off-screen so these fixtures never influence the page width + sidebar = document.createElement('nav'); + sidebar.className = 'next-sidebar'; + sidebar.style.cssText = 'position: fixed; top: -9999px; left: 0; box-sizing: border-box; width: 200px; height: 10px;'; + document.body.appendChild(sidebar); + + section = document.createElement('section'); + section.className = 'next-view-section'; + section.style.cssText = 'position: fixed; top: -9999px; left: 0; height: 10px;'; + document.body.appendChild(section); + }); + + hooks.afterEach(function () { + sidebar.remove(); + section.remove(); + }); + + test('it constrains the view section and the tablist to the space beside the sidebar', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + const pageWidth = document.body.offsetWidth; + assert.ok(pageWidth > 200, 'sanity: the page is wider than the fixture sidebar'); + assertViewportMinus(assert, section, 200, 'the view section reserves the sidebar width'); + assert.strictEqual(find('[data-test-tablist]').style.maxWidth, `${pageWidth - 200}px`, 'the tablist max width is the page width minus the sidebar width'); + }); + + test('it recalculates on window resize', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + const tablist = find('[data-test-tablist]'); + const initialMaxWidth = parseInt(tablist.style.maxWidth, 10); + assertViewportMinus(assert, section, 200, 'initial section width applied'); + + sidebar.style.width = '120px'; + window.dispatchEvent(new Event('resize')); + await settled(); + + assertViewportMinus(assert, section, 120, 'the section width follows the new sidebar width'); + assert.strictEqual(parseInt(tablist.style.maxWidth, 10) - initialMaxWidth, 80, 'the tablist gained exactly the 80px the sidebar lost'); + }); + + test('it recalculates when the sidebar itself is resized', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + const tablist = find('[data-test-tablist]'); + const initialMaxWidth = parseInt(tablist.style.maxWidth, 10); + + sidebar.style.width = '260px'; + await nextFrames(); + + assertViewportMinus(assert, section, 260, 'the resize observer picked up the sidebar width change'); + assert.strictEqual(initialMaxWidth - parseInt(tablist.style.maxWidth, 10), 60, 'the tablist lost exactly the 60px the sidebar gained'); + }); + + test('it treats a missing sidebar as zero width', async function (assert) { + sidebar.remove(); + + await render(hbs` +
      +
      +
      + `); + + assertViewportMinus(assert, section, 0, 'the section takes the full viewport width'); + assert.strictEqual(find('[data-test-tablist]').style.maxWidth, `${document.body.offsetWidth}px`, 'the tablist takes the full page width'); + }); + + test('it only touches the tablist inside its own element', async function (assert) { + await render(hbs` +
      +
      + `); + + assert.strictEqual(find('[data-test-outside]').style.maxWidth, '', 'a tablist outside the modified element is untouched'); + assertViewportMinus(assert, section, 200, 'the section is still constrained when there is no tablist to size'); + }); + + test('it removes its resize listener and observer when destroyed', async function (assert) { + this.set('show', true); + + await render(hbs` + {{#if this.show}} +
      +
      +
      + {{/if}} + `); + + assertViewportMinus(assert, section, 200, 'the section was constrained while rendered'); + + this.set('show', false); + await settled(); + + assert.dom('[data-test-container]').doesNotExist('the modified element was torn down'); + + sidebar.style.width = '50px'; + window.dispatchEvent(new Event('resize')); + await nextFrames(); + + assertViewportMinus(assert, section, 200, 'no listener or observer is left behind after teardown'); + }); + test('with no view section on the page only the tablist is constrained', async function (assert) { + section.remove(); + + await render(hbs` +
      +
      +
      + `); + + const pageWidth = document.body.offsetWidth; + assert.strictEqual(find('[data-test-tablist]').style.maxWidth, `${pageWidth - 200}px`, 'the tablist is still sized'); + assert.strictEqual(section.style.width, '', 'and the detached section is left untouched'); - assert.ok(true); + document.body.appendChild(section); }); }); diff --git a/tests/integration/modifiers/fallback-img-src-test.js b/tests/integration/modifiers/fallback-img-src-test.js index e630a522..7d4fec8d 100644 --- a/tests/integration/modifiers/fallback-img-src-test.js +++ b/tests/integration/modifiers/fallback-img-src-test.js @@ -1,15 +1,133 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +// Same-origin urls keep the test off the network; the images are lazy loaded from an +// off-screen wrapper and every "error" is dispatched synthetically (non bubbling, exactly +// like a real image load failure) so nothing is ever fetched. +const FALLBACK = `${window.location.origin}/fleetbase-fallback.png`; +const OTHER_FALLBACK = `${window.location.origin}/fleetbase-other-fallback.png`; + module('Integration | Modifier | fallback-img-src', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it swaps in the fallback url when the image errors', async function (assert) { + this.set('fallback', FALLBACK); + + await render(hbs` +
      + +
      + `); + + const image = find('[data-test-img]'); + assert.strictEqual(image.getAttribute('src'), null, 'no src is applied before an error occurs'); + assert.strictEqual(image.getAttribute('fallback-url'), null, 'no fallback marker before an error occurs'); + + image.dispatchEvent(new Event('error')); + await settled(); + + assert.strictEqual(image.getAttribute('src'), FALLBACK, 'the fallback url is applied as the src'); + assert.strictEqual(image.getAttribute('fallback-url'), FALLBACK, 'the fallback-url attribute records the fallback'); + }); + + test('it normalizes the fallback url before applying it', async function (assert) { + this.set('fallback', `${window.location.origin}/images/../fleetbase-fallback.png`); + + await render(hbs` +
      + +
      + `); + + const image = find('[data-test-img]'); + image.dispatchEvent(new Event('error')); + await settled(); + + assert.strictEqual(image.getAttribute('src'), FALLBACK, 'the url is normalized through the URL constructor'); + }); + + test('it uses the updated fallback url after the argument changes', async function (assert) { + this.set('fallback', FALLBACK); + + await render(hbs` +
      + +
      + `); + + const image = find('[data-test-img]'); + image.dispatchEvent(new Event('error')); + await settled(); + assert.strictEqual(image.getAttribute('fallback-url'), FALLBACK, 'the original fallback is applied first'); + + this.set('fallback', OTHER_FALLBACK); + await settled(); + image.dispatchEvent(new Event('error')); + await settled(); + + assert.strictEqual(image.getAttribute('src'), OTHER_FALLBACK, 'the src uses the updated fallback'); + assert.strictEqual(image.getAttribute('fallback-url'), OTHER_FALLBACK, 'the fallback-url attribute uses the updated fallback'); + }); + + test('it ignores fallback urls that are not http or https', async function (assert) { + this.set('fallback', 'ftp://example.com/fleetbase-fallback.png'); + + await render(hbs` +
      + +
      + `); + + const image = find('[data-test-img]'); + image.dispatchEvent(new Event('error')); + await settled(); + + assert.strictEqual(image.getAttribute('src'), null, 'a non http(s) fallback is never applied as a src'); + assert.strictEqual(image.getAttribute('fallback-url'), null, 'a non http(s) fallback does not set the marker attribute'); + }); + + test('it ignores fallback values that are not strings', async function (assert) { + this.set('fallback', null); + + await render(hbs` +
      + +
      + `); + + const image = find('[data-test-img]'); + image.dispatchEvent(new Event('error')); + await settled(); + + assert.strictEqual(image.getAttribute('src'), null, 'a null fallback is a no-op'); + assert.strictEqual(image.getAttribute('fallback-url'), null, 'a null fallback does not set the marker attribute'); + }); + + test('it removes the error listener when the element is destroyed', async function (assert) { + this.set('fallback', FALLBACK); + this.set('show', true); + + await render(hbs` +
      + {{#if this.show}} + + {{/if}} +
      + `); + + const image = find('[data-test-img]'); + + this.set('show', false); + await settled(); + + assert.dom('[data-test-img]').doesNotExist('the image was torn down'); + + image.dispatchEvent(new Event('error')); + await settled(); - assert.ok(true); + assert.strictEqual(image.getAttribute('src'), null, 'the detached image no longer receives the fallback src'); + assert.strictEqual(image.getAttribute('fallback-url'), null, 'the error listener was removed on teardown'); }); }); diff --git a/tests/integration/modifiers/imask-test.js b/tests/integration/modifiers/imask-test.js index 06128f8f..e32a3ffb 100644 --- a/tests/integration/modifiers/imask-test.js +++ b/tests/integration/modifiers/imask-test.js @@ -1,15 +1,105 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, fillIn, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Modifier | imask', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it masks what is typed into the input', async function (assert) { + this.set('options', { mask: '000-000' }); - assert.ok(true); + await render(hbs``); + + await fillIn('[data-test-input]', '123456'); + + assert.dom('[data-test-input]').hasValue('123-456', 'the mask inserts the literal separator'); + }); + + test('it drops characters that do not fit the mask', async function (assert) { + this.set('options', { mask: '000-000' }); + + await render(hbs``); + + await fillIn('[data-test-input]', 'ab12cd34ef56gh'); + + assert.dom('[data-test-input]').hasValue('123-456', 'non digits are stripped and the mask is applied'); + }); + + test('it truncates input that overflows the mask', async function (assert) { + this.set('options', { mask: '000-000' }); + + await render(hbs``); + + await fillIn('[data-test-input]', '1234567890'); + + assert.dom('[data-test-input]').hasValue('123-456', 'input longer than the mask is truncated'); + }); + + test('it masks a value that is already present when the modifier installs', async function (assert) { + this.set('options', { mask: '000-000' }); + + await render(hbs``); + + assert.dom('[data-test-input]').hasValue('987-654', 'the pre-existing value is masked on install'); + }); + + test('it re-masks the input when the mask options change', async function (assert) { + this.set('options', { mask: '000-000' }); + + await render(hbs``); + + await fillIn('[data-test-input]', '123456'); + assert.dom('[data-test-input]').hasValue('123-456', 'the original mask is applied'); + + await fillIn('[data-test-input]', ''); + assert.dom('[data-test-input]').hasValue('', 'the masked input can be cleared'); + + this.set('options', { mask: '00/00/0000' }); + await settled(); + + await fillIn('[data-test-input]', '12312021'); + assert.dom('[data-test-input]').hasValue('12/31/2021', 'the updated mask is applied'); + }); + + test('it does nothing when no mask is configured', async function (assert) { + this.set('options', {}); + + await render(hbs``); + + await fillIn('[data-test-input]', 'plain text 123'); + + assert.dom('[data-test-input]').hasValue('plain text 123', 'the value is left untouched when the mask is empty'); + }); + + test('it does nothing when no options are passed at all', async function (assert) { + await render(hbs``); + + await fillIn('[data-test-input]', 'plain text 123'); + + assert.dom('[data-test-input]').hasValue('plain text 123', 'the value is left untouched with no options'); + }); + + test('it destroys the mask instance when the element is torn down', async function (assert) { + this.set('options', { mask: '000-000' }); + this.set('show', true); + + await render(hbs`{{#if this.show}}{{/if}}`); + + await fillIn('[data-test-input]', '123456'); + const input = find('[data-test-input]'); + assert.strictEqual(input.value, '123-456', 'the mask is active while rendered'); + + this.set('show', false); + await settled(); + + assert.dom('[data-test-input]').doesNotExist('the input was torn down'); + + input.value = '999888'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + await settled(); + + assert.strictEqual(input.value, '999888', 'the destroyed mask no longer reformats input events'); }); }); diff --git a/tests/integration/modifiers/increase-height-by-test.js b/tests/integration/modifiers/increase-height-by-test.js index 4b14cdb9..ae01bd69 100644 --- a/tests/integration/modifiers/increase-height-by-test.js +++ b/tests/integration/modifiers/increase-height-by-test.js @@ -1,15 +1,104 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +// The measured element is given an explicit 100px height so offsetHeight is deterministic. module('Integration | Modifier | increase-height-by', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it increases the measured height by a numeric amount', async function (assert) { + this.set('increaseBy', 50); - assert.ok(true); + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.height, '150px', 'height is the original offsetHeight plus the increase'); + assert.dom('[data-test-el]').hasStyle({ height: '150px' }, 'the element actually renders taller'); + }); + + test('it increases the height by unitless and px string amounts', async function (assert) { + this.set('increaseBy', '50'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.height, '150px', 'a unitless string is added numerically, not concatenated'); + }); + + test('it treats a px string as pixels', async function (assert) { + this.set('increaseBy', '25px'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.height, '125px', 'a px string is added numerically'); + }); + + test('it converts em, rem, pt and pc units before increasing the height', async function (assert) { + this.set('increaseBy', '2em'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, '132px', 'em is converted at 16px per em'); + + // each re-run measures the already-increased element, so reset it between assertions + element.style.height = '100px'; + this.set('increaseBy', '3rem'); + await settled(); + assert.strictEqual(element.style.height, '148px', 'rem is converted at 16px per rem'); + + element.style.height = '100px'; + this.set('increaseBy', '4pt'); + await settled(); + assert.strictEqual(element.style.height, '105.32px', 'pt is converted at 1.33px per pt'); + + element.style.height = '100px'; + this.set('increaseBy', '2pc'); + await settled(); + assert.strictEqual(element.style.height, '132px', 'pc is converted at 16px per pc'); + }); + + test('it does nothing for undefined and null', async function (assert) { + this.set('increaseBy', undefined); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, '100px', 'undefined leaves the height untouched'); + + this.set('increaseBy', null); + await settled(); + assert.strictEqual(element.style.height, '100px', 'null leaves the height untouched'); + }); + + test('it never produces NaN or a malformed height for garbage input', async function (assert) { + this.set('increaseBy', 'not-a-number'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, '100px', 'a non numeric string increases the height by zero'); + assert.notOk(element.style.height.includes('NaN'), 'no NaN is written to the style'); + }); + + test('it re-measures and re-applies when the amount changes', async function (assert) { + this.set('increaseBy', 50); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, '150px', 'initial increase applied'); + + this.set('increaseBy', 25); + await settled(); + + assert.strictEqual(element.style.height, '175px', 'the new amount is added to the current measured height'); + }); + + test('it applies zero as a no-op increase', async function (assert) { + this.set('increaseBy', 0); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.height, '100px', 'a zero increase pins the height to the measured height'); }); }); diff --git a/tests/integration/modifiers/set-container-dimensions-test.js b/tests/integration/modifiers/set-container-dimensions-test.js index c9306ec2..6df5e269 100644 --- a/tests/integration/modifiers/set-container-dimensions-test.js +++ b/tests/integration/modifiers/set-container-dimensions-test.js @@ -1,15 +1,127 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +// A detached, off-screen fixture keeps the measured container independent of the +// rendered element's own parent, and independent of the browser window size. +function createFixtureContainer(id, width, height) { + const container = document.createElement('div'); + + container.id = id; + container.style.cssText = `position: fixed; top: -9999px; left: 0; box-sizing: border-box; width: ${width}px; height: ${height}px;`; + document.body.appendChild(container); + + return container; +} + module('Integration | Modifier | set-container-dimensions', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + let fixture; + + hooks.afterEach(function () { + if (fixture) { + fixture.remove(); + fixture = undefined; + } + }); + + test('it sizes the element to its parent by default', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '300px', 'width matches the parent offsetWidth'); + assert.strictEqual(element.style.height, '150px', 'height matches the parent offsetHeight'); + assert.strictEqual(element.offsetWidth, 300, 'element actually renders at the parent width'); + assert.strictEqual(element.offsetHeight, 150, 'element actually renders at the parent height'); + }); + + test('it subtracts the horizontal and vertical padding options', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '270px', 'width is the container width minus both horizontal paddings'); + assert.strictEqual(element.style.height, '130px', 'height is the container height minus both vertical paddings'); + }); + + test('it measures a container passed as an element', async function (assert) { + fixture = createFixtureContainer('scd-element-container', 420, 220); + this.set('container', fixture); + + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '420px', 'width comes from the supplied container element, not the parent'); + assert.strictEqual(element.style.height, '220px', 'height comes from the supplied container element, not the parent'); + }); + + test('it measures a container passed as a selector', async function (assert) { + fixture = createFixtureContainer('scd-selector-container', 360, 180); + + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '360px', 'width comes from the selected container'); + assert.strictEqual(element.style.height, '180px', 'height comes from the selected container'); + }); + + test('it falls back to the parent when the container selector matches nothing', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '300px', 'width falls back to the parent width'); + assert.strictEqual(element.style.height, '150px', 'height falls back to the parent height'); + }); + + test('it recalculates when the options change', async function (assert) { + this.set('leftPadding', 0); + + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '300px', 'initial width uses no padding'); + + this.set('leftPadding', 40); + await settled(); + + assert.strictEqual(element.style.width, '260px', 'width tracks the updated padding option'); + assert.strictEqual(element.style.height, '150px', 'height is unchanged by a horizontal padding update'); + }); + + test('it treats missing padding options as zero', async function (assert) { + await render(hbs` +
      +
      +
      + `); - assert.ok(true); + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '300px', 'omitted horizontal paddings do not produce NaN'); + assert.strictEqual(element.style.height, '150px', 'omitted vertical paddings do not produce NaN'); }); }); diff --git a/tests/integration/modifiers/set-height-test.js b/tests/integration/modifiers/set-height-test.js index 908a999a..604d260a 100644 --- a/tests/integration/modifiers/set-height-test.js +++ b/tests/integration/modifiers/set-height-test.js @@ -1,15 +1,121 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Modifier | set-height', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it sets a pixel height from a number', async function (assert) { + this.set('height', 120); - assert.ok(true); + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.height, '120px', 'inline height is set in pixels'); + assert.dom('[data-test-el]').hasStyle({ height: '120px' }, 'element actually renders 120px tall'); + }); + + test('it sets a pixel height from unitless and px strings', async function (assert) { + this.set('height', '150'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, '150px', 'a unitless string is treated as pixels'); + + this.set('height', '175px'); + await settled(); + assert.strictEqual(element.style.height, '175px', 'a px string keeps its pixel value'); + }); + + test('it converts em, rem, pt and pc units to pixels', async function (assert) { + this.set('height', '2em'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, '32px', 'em is converted at 16px per em'); + + this.set('height', '3rem'); + await settled(); + assert.strictEqual(element.style.height, '48px', 'rem is converted at 16px per rem'); + + this.set('height', '4pt'); + await settled(); + assert.strictEqual(element.style.height, '5.32px', 'pt is converted at 1.33px per pt'); + + this.set('height', '2pc'); + await settled(); + assert.strictEqual(element.style.height, '32px', 'pc is converted at 16px per pc'); + }); + + test('it passes the value straight through when calculated is true', async function (assert) { + this.set('height', 'calc(100% - 20px)'); + + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, 'calc(100% - 20px)', 'the calc expression is applied verbatim'); + assert.strictEqual(element.offsetHeight, 180, 'the calc expression resolves against the 200px parent'); + }); + + test('it updates the height when the argument changes', async function (assert) { + this.set('height', 100); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, '100px', 'initial height applied'); + + this.set('height', 260); + await settled(); + + assert.strictEqual(element.style.height, '260px', 'inline height tracks the updated argument'); + assert.dom('[data-test-el]').hasStyle({ height: '260px' }, 'rendered height tracks the updated argument'); + }); + + test('it does nothing for undefined or null and still handles zero', async function (assert) { + this.set('height', undefined); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.height, '80px', 'undefined leaves the existing height alone'); + + this.set('height', null); + await settled(); + assert.strictEqual(element.style.height, '80px', 'null leaves the existing height alone'); + + this.set('height', 0); + await settled(); + assert.strictEqual(element.style.height, '0px', 'zero is applied rather than skipped as falsy'); + assert.strictEqual(element.offsetHeight, 0, 'element collapses to zero height'); + }); + + test('it rejects negative heights', async function (assert) { + this.set('height', -50); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.height, '', 'a negative height is invalid css and is not applied'); + }); + test('a keyword height is applied verbatim', async function (assert) { + this.set('height', 'auto'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.height, 'auto', 'a value with no numeric part is passed straight through'); + }); + + test('a percentage height is applied verbatim', async function (assert) { + this.set('height', '100%'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.height, '100%'); }); }); diff --git a/tests/integration/modifiers/set-max-height-test.js b/tests/integration/modifiers/set-max-height-test.js index 87e9ad0e..c5764e9a 100644 --- a/tests/integration/modifiers/set-max-height-test.js +++ b/tests/integration/modifiers/set-max-height-test.js @@ -1,15 +1,111 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Modifier | set-max-height', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it sets a pixel max height from a number and actually constrains the element', async function (assert) { + this.set('maxHeight', 60); - assert.ok(true); + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.maxHeight, '60px', 'inline max-height is set in pixels'); + assert.strictEqual(element.offsetHeight, 60, 'the 500px tall child is clamped to the max height'); + }); + + test('it sets a pixel max height from unitless and px strings', async function (assert) { + this.set('maxHeight', '150'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.maxHeight, '150px', 'a unitless string is treated as pixels'); + + this.set('maxHeight', '175px'); + await settled(); + assert.strictEqual(element.style.maxHeight, '175px', 'a px string keeps its pixel value'); + }); + + test('it converts em, rem, pt and pc units to pixels', async function (assert) { + this.set('maxHeight', '2em'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.maxHeight, '32px', 'em is converted at 16px per em'); + + this.set('maxHeight', '3rem'); + await settled(); + assert.strictEqual(element.style.maxHeight, '48px', 'rem is converted at 16px per rem'); + + this.set('maxHeight', '4pt'); + await settled(); + assert.strictEqual(element.style.maxHeight, '5.32px', 'pt is converted at 1.33px per pt'); + + this.set('maxHeight', '2pc'); + await settled(); + assert.strictEqual(element.style.maxHeight, '32px', 'pc is converted at 16px per pc'); + }); + + test('it updates the max height when the argument changes', async function (assert) { + this.set('maxHeight', 100); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.maxHeight, '100px', 'initial max height applied'); + + this.set('maxHeight', 320); + await settled(); + + assert.strictEqual(element.style.maxHeight, '320px', 'inline max height tracks the updated argument'); + assert.dom('[data-test-el]').hasStyle({ maxHeight: '320px' }, 'computed max height tracks the updated argument'); + }); + + test('it does nothing for undefined or null and still handles zero', async function (assert) { + this.set('maxHeight', undefined); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.maxHeight, '40px', 'undefined leaves the existing max height alone'); + + this.set('maxHeight', null); + await settled(); + assert.strictEqual(element.style.maxHeight, '40px', 'null leaves the existing max height alone'); + + this.set('maxHeight', 0); + await settled(); + assert.strictEqual(element.style.maxHeight, '0px', 'zero is applied rather than skipped as falsy'); + }); + + test('it rejects negative max heights', async function (assert) { + this.set('maxHeight', -25); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.maxHeight, '', 'a negative max height is invalid css and is not applied'); + assert.dom('[data-test-el]').hasStyle({ maxHeight: 'none' }, 'element keeps the default max height'); + }); + test('a keyword max height is applied verbatim', async function (assert) { + this.set('maxHeight', 'none'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.maxHeight, 'none', 'a value with no numeric part is passed straight through'); + }); + test('a percentage max height is applied verbatim', async function (assert) { + this.set('maxHeight', '80%'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.maxHeight, '80%', 'a unit the modifier cannot convert is honoured, not turned into px'); }); }); diff --git a/tests/integration/modifiers/set-max-width-test.js b/tests/integration/modifiers/set-max-width-test.js index a6118e61..4408dc6b 100644 --- a/tests/integration/modifiers/set-max-width-test.js +++ b/tests/integration/modifiers/set-max-width-test.js @@ -1,15 +1,78 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +// The measured target uses content-box with a known padding/margin so that +// offsetWidth is deterministic: 300 (content) + 10 (padding-left) = 310. + module('Integration | Modifier | set-max-width', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it defaults to a max width of 100%', async function (assert) { + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.maxWidth, '100%', 'max width defaults to 100%'); + }); + + test('it uses a literal value when the argument starts with a digit', async function (assert) { + this.set('to', '250px'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.maxWidth, '250px', 'literal pixel values are used as-is'); + assert.dom('[data-test-el]').hasStyle({ maxWidth: '250px' }, 'computed max width matches the literal value'); + }); + + test('it measures another element when the argument is a selector, excluding spacing by default', async function (assert) { + this.set('to', '#smw-target'); + + await render(hbs` +
      +
      + `); + + assert.strictEqual(find('#smw-target').offsetWidth, 310, 'sanity: measured target is 310px wide including padding'); + assert.strictEqual(find('[data-test-el]').style.maxWidth, '295px', 'max width is the target offsetWidth minus its left padding and left margin'); + }); + + test('it includes padding and margin when includeSpacing is true', async function (assert) { + this.set('to', '#smw-target'); + + await render(hbs` +
      +
      + `); + + assert.strictEqual(find('[data-test-el]').style.maxWidth, '310px', 'max width is the full offsetWidth of the target'); + }); + + test('it updates when the argument changes', async function (assert) { + this.set('to', '120px'); + + await render(hbs` +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.maxWidth, '120px', 'initial literal value applied'); + + this.set('to', '#smw-target'); + await settled(); + assert.strictEqual(element.style.maxWidth, '295px', 'switching to a selector measures the target element'); + + this.set('to', '60%'); + await settled(); + assert.strictEqual(element.style.maxWidth, '60%', 'switching back to a literal value applies it'); + }); + + test('it leaves the max width unset when the selector matches nothing', async function (assert) { + this.set('to', '#smw-does-not-exist'); + + await render(hbs`
      `); - assert.ok(true); + assert.strictEqual(find('[data-test-el]').style.maxWidth, '', 'no max width is applied when the target is missing'); + assert.dom('[data-test-el]').hasStyle({ maxWidth: 'none' }, 'element keeps the default max width'); }); }); diff --git a/tests/integration/modifiers/set-width-test.js b/tests/integration/modifiers/set-width-test.js index 80c61cfb..2374cb9c 100644 --- a/tests/integration/modifiers/set-width-test.js +++ b/tests/integration/modifiers/set-width-test.js @@ -1,15 +1,139 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Modifier | set-width', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it sets a pixel width from a number', async function (assert) { + this.set('width', 120); - assert.ok(true); + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.width, '120px', 'inline width is set in pixels'); + assert.dom('[data-test-el]').hasStyle({ width: '120px' }, 'element actually renders 120px wide'); + }); + + test('it sets a pixel width from unitless and px strings', async function (assert) { + this.set('width', '150'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '150px', 'a unitless string is treated as pixels'); + + this.set('width', '175px'); + await settled(); + + assert.strictEqual(element.style.width, '175px', 'a px string keeps its pixel value'); + }); + + test('it converts em, rem, pt and pc units to pixels', async function (assert) { + this.set('width', '2em'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '32px', 'em is converted at 16px per em'); + + this.set('width', '3rem'); + await settled(); + assert.strictEqual(element.style.width, '48px', 'rem is converted at 16px per rem'); + + this.set('width', '4pt'); + await settled(); + assert.strictEqual(element.style.width, '5.32px', 'pt is converted at 1.33px per pt'); + + this.set('width', '2pc'); + await settled(); + assert.strictEqual(element.style.width, '32px', 'pc is converted at 16px per pc'); + }); + + test('it updates the width when the argument changes', async function (assert) { + this.set('width', 100); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '100px', 'initial width applied'); + + this.set('width', 260); + await settled(); + + assert.strictEqual(element.style.width, '260px', 'inline width tracks the updated argument'); + assert.dom('[data-test-el]').hasStyle({ width: '260px' }, 'rendered width tracks the updated argument'); + }); + + test('it does nothing for undefined or null and still handles zero', async function (assert) { + this.set('width', undefined); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.width, '80px', 'undefined leaves the existing width alone'); + + this.set('width', null); + await settled(); + assert.strictEqual(element.style.width, '80px', 'null leaves the existing width alone'); + + this.set('width', 0); + await settled(); + assert.strictEqual(element.style.width, '0px', 'zero is applied rather than skipped as falsy'); + }); + + test('it handles very large values', async function (assert) { + this.set('width', 100000); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.width, '100000px', 'very large widths are applied verbatim'); + }); + + test('it rejects negative widths', async function (assert) { + this.set('width', -50); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.width, '', 'a negative width is invalid css and is not applied'); + }); + + // Viewport and percentage units cannot be converted to a pixel count here, so they are + // handed to CSS untouched. Emitting the bare number as px is what turned the coordinates + // picker's `100vw` fullscreen size into a 100-PIXEL overlay. + test('percentage and viewport units are passed through untouched', async function (assert) { + for (const width of ['100vw', '50%', '75vh', '10vmin', '20vmax', '8ch', '3ex']) { + this.set('width', width); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.width, width, `${width} survives verbatim`); + } + }); + + test('absolute css units other than pt and pc are passed through too', async function (assert) { + for (const width of ['5cm', '30mm', '2in']) { + this.set('width', width); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.width, width); + } + }); + + test('a keyword width is handed straight to css', async function (assert) { + this.set('width', 'auto'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.width, 'auto'); + }); + + test('a calc() expression is handed straight to css', async function (assert) { + this.set('width', 'calc(100% - 20px)'); + + await render(hbs`
      `); + + assert.true(find('[data-test-el]').style.width.startsWith('calc('), 'the expression reaches CSS intact'); }); }); diff --git a/tests/integration/modifiers/set-z-index-test.js b/tests/integration/modifiers/set-z-index-test.js index 300ba3e8..709481a5 100644 --- a/tests/integration/modifiers/set-z-index-test.js +++ b/tests/integration/modifiers/set-z-index-test.js @@ -1,15 +1,69 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Modifier | set-z-index', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it sets the z-index from a number', async function (assert) { + this.set('zIndex', 5); - assert.ok(true); + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.zIndex, '5', 'inline z-index is set'); + assert.dom('[data-test-el]').hasStyle({ zIndex: '5' }, 'computed z-index is applied'); + }); + + test('it sets the z-index from a numeric string', async function (assert) { + this.set('zIndex', '42'); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.zIndex, '42', 'string values are accepted'); + }); + + test('it supports zero and negative stacking values', async function (assert) { + this.set('zIndex', 0); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.zIndex, '0', 'zero is applied rather than skipped as falsy'); + + this.set('zIndex', -1); + await settled(); + + assert.strictEqual(element.style.zIndex, '-1', 'negative values are applied'); + assert.dom('[data-test-el]').hasStyle({ zIndex: '-1' }, 'computed z-index reflects the negative value'); + }); + + test('it updates the z-index when the argument changes', async function (assert) { + this.set('zIndex', 10); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.zIndex, '10', 'initial value applied'); + + this.set('zIndex', 999); + await settled(); + + assert.strictEqual(element.style.zIndex, '999', 'value tracks the updated argument'); + }); + + test('it leaves the z-index unset for undefined and null values', async function (assert) { + this.set('zIndex', undefined); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.zIndex, '', 'undefined does not produce an invalid z-index'); + + this.set('zIndex', null); + await settled(); + + assert.strictEqual(element.style.zIndex, '', 'null does not produce an invalid z-index'); + assert.dom('[data-test-el]').hasStyle({ zIndex: 'auto' }, 'element keeps the default auto stacking'); }); }); diff --git a/tests/integration/modifiers/translate-x-test.js b/tests/integration/modifiers/translate-x-test.js index 7c76663a..b80d581d 100644 --- a/tests/integration/modifiers/translate-x-test.js +++ b/tests/integration/modifiers/translate-x-test.js @@ -1,15 +1,75 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Modifier | translate-x', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it translates using rem as the default unit', async function (assert) { + this.set('x', 2); - assert.ok(true); + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.transform, 'translateX(2rem)', 'transform uses the rem default unit'); + }); + + test('it uses the provided unit and produces a real transform', async function (assert) { + this.set('x', 50); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.transform, 'translateX(50px)', 'inline transform uses the supplied unit'); + assert.dom('[data-test-el]').hasStyle({ transform: 'matrix(1, 0, 0, 1, 50, 0)' }, 'element is actually translated 50px on the x axis'); + }); + + test('it supports percentage units and negative offsets', async function (assert) { + this.set('x', 50); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.transform, 'translateX(50%)', 'percentage unit is applied'); + + this.set('x', -10); + await settled(); + + assert.strictEqual(element.style.transform, 'translateX(-10%)', 'negative offsets are applied'); + }); + + test('it applies a zero translation instead of skipping it', async function (assert) { + this.set('x', 0); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.transform, 'translateX(0px)', 'zero is not treated as "no value"'); + assert.dom('[data-test-el]').hasStyle({ transform: 'matrix(1, 0, 0, 1, 0, 0)' }, 'computed transform is the identity translation'); + }); + + test('it updates the transform when the offset or unit changes', async function (assert) { + this.set('x', 1); + this.set('unit', 'rem'); + + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.transform, 'translateX(1rem)', 'initial transform applied'); + + this.set('x', 4); + await settled(); + assert.strictEqual(element.style.transform, 'translateX(4rem)', 'transform tracks the updated offset'); + + this.set('unit', 'px'); + await settled(); + assert.strictEqual(element.style.transform, 'translateX(4px)', 'transform tracks the updated unit'); + }); + + test('it does not apply an invalid transform for a null offset', async function (assert) { + this.set('x', null); + + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.transform, '', 'an invalid translateX value is rejected by the CSSOM'); + assert.dom('[data-test-el]').hasStyle({ transform: 'none' }, 'element is left untransformed'); }); }); diff --git a/tests/integration/modifiers/vertical-offset-by-test.js b/tests/integration/modifiers/vertical-offset-by-test.js index 54b2634a..e9ecbc7a 100644 --- a/tests/integration/modifiers/vertical-offset-by-test.js +++ b/tests/integration/modifiers/vertical-offset-by-test.js @@ -1,15 +1,131 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled, find } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Modifier | vertical-offset-by', function (hooks) { setupRenderingTest(hooks); - // Replace this with your real tests. - test('it renders', async function (assert) { - await render(hbs`
      `); + test('it offsets the given direction by a numeric offset', async function (assert) { + await render(hbs`
      `); - assert.ok(true); + assert.strictEqual(find('[data-test-el]').style.bottom, '20px', 'bottom is offset by the given amount'); + }); + + test('it offsets the top when top is the direction', async function (assert) { + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.top, '35px', 'top is offset by the given amount'); + assert.strictEqual(element.style.bottom, '', 'the other direction is untouched'); + }); + + test('it strips units from a string offset', async function (assert) { + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.bottom, '45px', 'a px string offset is normalized to pixels'); + }); + + test('it adds the height of an element matched by a selector', async function (assert) { + await render(hbs` +
      +
      + `); + + assert.strictEqual(find('[data-test-el]').style.bottom, '40px', 'offset is the base offset plus the matched element height'); + }); + + test('it sums the heights of comma separated selectors', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + assert.strictEqual(find('[data-test-el]').style.bottom, '50px', 'both matched element heights are summed'); + }); + + test('it sums the heights of pipe separated selectors', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + assert.strictEqual(find('[data-test-el]').style.bottom, '55px', 'pipe separated selectors are summed with the base offset'); + }); + + test('it accepts an array of element references', async function (assert) { + this.set('elements', null); + + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.bottom, '', 'nothing is applied while there are no elements'); + + this.set('elements', [find('#vob-header'), find('#vob-subheader')]); + await settled(); + + assert.strictEqual(element.style.bottom, '50px', 'a plain array of html elements is measured'); + }); + + test('it ignores array entries that are neither elements nor selectors', async function (assert) { + this.set('elements', null); + + await render(hbs` +
      +
      + `); + + this.set('elements', [find('#vob-header'), 42, '#vob-does-not-exist', null]); + await settled(); + + assert.strictEqual(find('[data-test-el]').style.bottom, '30px', 'only the real element contributes to the offset'); + }); + + test('it does nothing when both offset and elements are blank', async function (assert) { + await render(hbs`
      `); + + assert.strictEqual(find('[data-test-el]').style.bottom, '7px', 'the existing bottom value is left alone'); + }); + + test('it recalculates when the offset changes', async function (assert) { + this.set('offset', 10); + + await render(hbs` +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.bottom, '40px', 'initial offset applied'); + + this.set('offset', 100); + await settled(); + + assert.strictEqual(element.style.bottom, '130px', 'offset tracks the updated argument'); + }); + + test('it applies the offset as a real layout position', async function (assert) { + await render(hbs` +
      +
      +
      + `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.bottom, '40px', 'bottom offset is applied'); + assert.strictEqual(element.offsetTop, 150, 'the element is positioned 40px above the bottom of its 200px container'); + }); + test('with no direction at all it offsets from the bottom', async function (assert) { + await render(hbs`
      `); + + const element = find('[data-test-el]'); + assert.strictEqual(element.style.bottom, '25px', 'bottom is the default direction'); + assert.strictEqual(element.style.top, '', 'and the top is left alone'); }); }); diff --git a/tests/test-helper.js b/tests/test-helper.js index 4efd6e58..d2fd7dc0 100644 --- a/tests/test-helper.js +++ b/tests/test-helper.js @@ -4,9 +4,40 @@ import * as QUnit from 'qunit'; import { setApplication } from '@ember/test-helpers'; import { setup } from 'qunit-dom'; import { start } from 'ember-qunit'; +import { forceModulesToBeLoaded, sendCoverage } from 'ember-cli-code-coverage/test-support'; setApplication(Application.create(config.APP)); setup(QUnit.assert); +// Evaluate every bundled module after the suite finishes so files no test imported still appear +// in the coverage denominator, then post the collected coverage to the reporting middleware. +// +// WHY THIS IS NOT JUST `QUnit.done`, which is what the addon's README shows: +// +// The POST to /write-coverage carries ~1.9 MB for this addon, because a per-file 100% gate needs +// every module force-loaded. In CI mode testem tears the browser down as soon as QUnit reports the +// run finished, which truncates that upload mid-body — the server logs +// `BadRequestError: request aborted` from raw-body and writes nothing at all. It is size-dependent, +// so it looks like flakiness: measured here, fast filtered runs produced an artifact about 2 times +// in 9 while full runs always did. +// +// `Testem.afterTests` hands us a callback that testem WAITS for, so the upload completes before +// teardown. It does not fire under `--server`, hence the branch. +// +// Upstream: https://github.com/ember-cli-code-coverage/ember-cli-code-coverage/issues/420 +// https://github.com/testem/testem/issues/1577 +if (config.APP.isRunningWithServerArgs) { + QUnit.done(async function () { + forceModulesToBeLoaded(); + await sendCoverage(); + }); +} else { + // eslint-disable-next-line no-undef + Testem.afterTests(function (testemConfig, data, callback) { + forceModulesToBeLoaded(); + sendCoverage(callback); + }); +} + start(); diff --git a/tests/unit/initializers/define-task-test-waiter-modifier-test.js b/tests/unit/initializers/define-task-test-waiter-modifier-test.js index 7e034c3e..f5a368fc 100644 --- a/tests/unit/initializers/define-task-test-waiter-modifier-test.js +++ b/tests/unit/initializers/define-task-test-waiter-modifier-test.js @@ -1,11 +1,14 @@ import Application from '@ember/application'; - import config from 'dummy/config/environment'; -import { initialize } from 'dummy/initializers/define-task-test-waiter-modifier'; +import initializer, { initialize } from 'dummy/initializers/define-task-test-waiter-modifier'; import { module, test } from 'qunit'; import Resolver from 'ember-resolver'; import { run } from '@ember/runloop'; +// The initializer body is currently commented out upstream (the +// ember-concurrency test-waiter modifier is not registered), so these tests pin +// the contract that actually matters to consumers: the module exports a valid, +// side-effect-free initializer that an application can boot with repeatedly. module('Unit | Initializer | define-task-test-waiter-modifier', function (hooks) { hooks.beforeEach(function () { this.TestApplication = class TestApplication extends Application { @@ -19,19 +22,48 @@ module('Unit | Initializer | define-task-test-waiter-modifier', function (hooks) initialize, }); - this.application = this.TestApplication.create({ - autoboot: false, - }); + this.application = this.TestApplication.create({ autoboot: false }); }); hooks.afterEach(function () { run(this.application, 'destroy'); }); - // TODO: Replace this with your real tests. - test('it works', async function (assert) { + test('it exports an initializer object exposing the initialize function', function (assert) { + assert.strictEqual(typeof initialize, 'function', 'the named export is callable'); + assert.strictEqual(initializer.initialize, initialize, 'the default export reuses the same function'); + }); + + test('the application boots with the initializer registered', async function (assert) { + await this.application.boot(); + + assert.true(this.application._booted, 'booting completes rather than throwing'); + }); + + test('initialize accepts an application and returns nothing', function (assert) { + assert.strictEqual(initialize(this.application), undefined); + }); + + test('initialize is safe to call with no arguments', function (assert) { + assert.strictEqual(initialize(), undefined, 'it does not read its arguments'); + }); + + test('it is idempotent across repeated invocations', function (assert) { + initialize(this.application); + initialize(this.application); + initialize(this.application); + + assert.true(true, 'repeated initialization neither throws nor accumulates state'); + }); + + test('it registers no container entries as a side effect', async function (assert) { await this.application.boot(); + const owner = this.application.buildInstance(); - assert.ok(true); + try { + assert.false(owner.hasRegistration('modifier:task-test-waiter'), 'the modifier registration is currently disabled upstream'); + } finally { + run(owner, 'destroy'); + } }); }); diff --git a/tests/unit/playground/allowlist-test.js b/tests/unit/playground/allowlist-test.js new file mode 100644 index 00000000..77bbc62c --- /dev/null +++ b/tests/unit/playground/allowlist-test.js @@ -0,0 +1,99 @@ +import { module, test } from 'qunit'; +import { DOCUMENTED_COMPONENTS, CATEGORIES, REMOVED_FROM_ADDON_STILL_IN_DOCS, DOCS_ROOT, slugFor } from 'dummy/playground/allowlist'; + +/** + * The allowlist is the playground's scope authority: it records the component surface documented + * at fleetbase.io/docs/ui. These tests hold it to that job — they do NOT compare it against + * `app/components`, because the addon exports far more than the documentation covers. + */ +module('Unit | playground | allowlist', function () { + test('it covers the documented component surface', function (assert) { + assert.strictEqual(DOCUMENTED_COMPONENTS.length, 63, 'the documented surface is 63 components'); + }); + + test('every entry carries the metadata the playground needs', function (assert) { + const incomplete = DOCUMENTED_COMPONENTS.filter((entry) => !entry.path || !entry.name || !entry.category || !entry.docsUrl); + + assert.deepEqual(incomplete, [], 'no entry is missing path, name, category or docsUrl'); + }); + + test('every documentation URL points at the official documentation', function (assert) { + const offsite = DOCUMENTED_COMPONENTS.filter((entry) => !entry.docsUrl.startsWith(`${DOCS_ROOT}/`)); + + assert.deepEqual(offsite, [], `every docsUrl is under ${DOCS_ROOT}`); + }); + + test('resolution paths are paths, not display names', function (assert) { + // `Layout::Resource::Tabular` resolves through `layout/resource/tabular`, not + // `layout-resource-tabular` or `layoutResourceTabular`. + const byName = Object.fromEntries(DOCUMENTED_COMPONENTS.map((entry) => [entry.name, entry.path])); + + assert.strictEqual(byName['Layout::Resource::Tabular'], 'layout/resource/tabular'); + assert.strictEqual(byName['Attach::Tooltip'], 'attach/tooltip'); + assert.strictEqual(byName['Modal::Layouts::Confirm'], 'modal/layouts/confirm'); + assert.strictEqual(byName['RegistryYield'], 'registry-yield'); + }); + + test('no path is listed twice', function (assert) { + const paths = DOCUMENTED_COMPONENTS.map((entry) => entry.path); + + assert.strictEqual(new Set(paths).size, paths.length, 'paths are unique'); + }); + + test('slugs are unique and URL-safe', function (assert) { + const slugs = DOCUMENTED_COMPONENTS.map((entry) => slugFor(entry.path)); + + assert.strictEqual(new Set(slugs).size, slugs.length, 'slugs are unique'); + assert.deepEqual( + slugs.filter((slug) => !/^[a-z0-9-]+$/.test(slug)), + [], + 'slugs contain only lowercase letters, digits and hyphens' + ); + }); + + test('every category is one the documentation navigation uses', function (assert) { + const unknown = DOCUMENTED_COMPONENTS.filter((entry) => !CATEGORIES.includes(entry.category)); + + assert.deepEqual(unknown, [], 'no entry invents a category'); + }); + + test('out-of-scope public components are absent', function (assert) { + // Each of these is a real public export the documentation does not cover. Adding one here + // would be adding scope the documentation does not authorise. + const outOfScope = ['aside-item-scroller', 'chat-container', 'chat-tray', 'metadata-editor', 'autocomplete-input', 'bulk-search-dropdown']; + const paths = DOCUMENTED_COMPONENTS.map((entry) => entry.path); + const leaked = outOfScope.filter((path) => paths.includes(path)); + + assert.deepEqual(leaked, [], 'no undocumented public component is allowlisted'); + }); + + test('table cells and other internals are not allowlisted', function (assert) { + const internal = DOCUMENTED_COMPONENTS.filter( + (entry) => entry.path.startsWith('table/') || entry.path.startsWith('template-builder/') || entry.path.startsWith('report-builder/') || entry.path.startsWith('chat-tray/') + ); + + assert.deepEqual(internal, [], 'sub-components of documented components get no page of their own'); + }); + + module('the ScheduleCalendar documentation mismatch', function () { + test('ScheduleCalendar and ScheduleItemCard are deliberately absent', function (assert) { + const names = DOCUMENTED_COMPONENTS.map((entry) => entry.name); + + // Both were deleted from the addon as confirmed dead code (commit e6a3903, PR #143), + // but /docs/ui/scheduling/event-calendar still names ScheduleCalendar. The playground + // documents what exists; the official documentation is what needs correcting. + assert.notOk(names.includes('ScheduleCalendar'), 'ScheduleCalendar is not restored'); + assert.notOk(names.includes('ScheduleItemCard'), 'ScheduleItemCard is not restored'); + }); + + test('the mismatch is recorded rather than silently dropped', function (assert) { + assert.deepEqual(REMOVED_FROM_ADDON_STILL_IN_DOCS, ['ScheduleCalendar', 'ScheduleItemCard'], 'the discrepancy is stated in the allowlist itself'); + }); + + test('EventCalendar is included', function (assert) { + const names = DOCUMENTED_COMPONENTS.map((entry) => entry.name); + + assert.ok(names.includes('EventCalendar'), 'the component that does exist is covered'); + }); + }); +}); diff --git a/tests/unit/playground/controls-test.js b/tests/unit/playground/controls-test.js new file mode 100644 index 00000000..5895daee --- /dev/null +++ b/tests/unit/playground/controls-test.js @@ -0,0 +1,167 @@ +import { module, test } from 'qunit'; +import { control, coerce, defaultsFor, validateControl, CONTROL_TYPES } from 'dummy/playground/controls'; + +module('Unit | playground | controls', function () { + module('definition', function () { + test('a control gets a humanized label and a type-appropriate default', function (assert) { + assert.strictEqual(control('isLoading', 'boolean').label, 'Is Loading'); + assert.false(control('isLoading', 'boolean').default); + assert.strictEqual(control('count', 'number').default, 0); + assert.strictEqual(control('text', 'text').default, ''); + assert.strictEqual(control('data', 'json').default, null); + }); + + test('explicit labels, defaults and help win', function (assert) { + const definition = control('type', 'select', { label: 'Kind', options: ['a', 'b'], default: 'b', help: 'pick one' }); + + assert.strictEqual(definition.label, 'Kind'); + assert.strictEqual(definition.default, 'b'); + assert.strictEqual(definition.help, 'pick one'); + }); + + test('select options are normalized, including a null option', function (assert) { + const definition = control('prefix', 'select', { options: [null, 'fas'], default: null }); + + assert.deepEqual(definition.options, [ + { value: null, label: '(none)' }, + { value: 'fas', label: 'fas' }, + ]); + }); + + test('controls are serializable unless opted out', function (assert) { + assert.true(control('text', 'text').serializable); + assert.false(control('text', 'text', { serializable: false }).serializable); + }); + }); + + module('coercion', function () { + test('booleans accept real booleans and their string forms', function (assert) { + const definition = control('on', 'boolean'); + + assert.true(coerce(definition, true).value); + assert.true(coerce(definition, 'true').value); + assert.false(coerce(definition, 'false').value); + }); + + test('a non-boolean falls back to the default and reports why', function (assert) { + const { value, error } = coerce(control('on', 'boolean', { default: false }), 'maybe'); + + assert.false(value, 'the default is used'); + assert.strictEqual(error, 'Expected true or false.'); + }); + + test('numbers are parsed and bounded', function (assert) { + const definition = control('percent', 'number', { default: 10, min: 0, max: 100 }); + + assert.strictEqual(coerce(definition, '42').value, 42); + assert.strictEqual(coerce(definition, 42).value, 42); + assert.strictEqual(coerce(definition, '-1').error, 'Must be at least 0.'); + assert.strictEqual(coerce(definition, '101').error, 'Must be at most 100.'); + assert.strictEqual(coerce(definition, 'abc').error, 'Enter a number.'); + assert.strictEqual(coerce(definition, 'abc').value, 10, 'an unparseable number falls back'); + }); + + test('selects only accept declared options', function (assert) { + const definition = control('type', 'select', { options: ['default', 'primary'], default: 'default' }); + + assert.strictEqual(coerce(definition, 'primary').value, 'primary'); + assert.strictEqual(coerce(definition, 'nope').error, 'Not one of the allowed options.'); + assert.strictEqual(coerce(definition, 'nope').value, 'default', 'an unknown option falls back'); + }); + + test('a select matches an option through its string form', function (assert) { + const definition = control('size', 'select', { options: [1, 2], default: 1 }); + + assert.strictEqual(coerce(definition, '2').value, 2, 'the declared value type is preserved'); + }); + + test('colours must be hex', function (assert) { + const definition = control('tint', 'color', { default: '#000000' }); + + assert.strictEqual(coerce(definition, '#3b82f6').value, '#3b82f6'); + assert.strictEqual(coerce(definition, 'blue').error, 'Expected a hex colour such as #3b82f6.'); + }); + + test('dates must parse, and empty means unset', function (assert) { + const definition = control('when', 'date', { default: '' }); + + assert.strictEqual(coerce(definition, '2026-03-16').value, '2026-03-16'); + assert.strictEqual(coerce(definition, '').value, ''); + assert.strictEqual(coerce(definition, 'not-a-date').error, 'Not a valid date.'); + }); + + test('json is parsed, and invalid json reports rather than throws', function (assert) { + const definition = control('rows', 'json', { default: null }); + + assert.deepEqual(coerce(definition, '{"a":1}').value, { a: 1 }); + assert.deepEqual(coerce(definition, { a: 1 }).value, { a: 1 }, 'an object passes through'); + assert.strictEqual(coerce(definition, '{oops').error, 'Not valid JSON.'); + assert.strictEqual(coerce(definition, '{oops').value, null, 'the default is used'); + }); + + test('text coerces anything to a string and never errors', function (assert) { + const definition = control('label', 'text'); + + assert.strictEqual(coerce(definition, 42).value, '42'); + assert.strictEqual(coerce(definition, null).value, ''); + assert.strictEqual(coerce(definition, 'hi').error, null); + }); + }); + + module('defaults', function () { + test('defaultsFor collects every documented default', function (assert) { + const controls = [control('text', 'text', { default: 'Save' }), control('on', 'boolean', { default: true }), control('n', 'number', { default: 3 })]; + + assert.deepEqual(defaultsFor(controls), { text: 'Save', on: true, n: 3 }); + }); + + test('defaultsFor of nothing is an empty set', function (assert) { + assert.deepEqual(defaultsFor([]), {}); + assert.deepEqual(defaultsFor(), {}); + }); + }); + + module('validation', function () { + test('a well-formed control has no problems', function (assert) { + assert.deepEqual(validateControl(control('text', 'text', { default: 'x' })), []); + }); + + test('it catches the mistakes that would break a page', function (assert) { + assert.deepEqual(validateControl(null), ['control is not an object']); + assert.ok(validateControl({ key: '', type: 'text', label: 'x', default: '' }).includes('missing key')); + assert.ok(validateControl({ key: 'a', type: 'nope', label: 'x', default: '' }).includes('unknown type "nope"')); + assert.ok(validateControl({ key: 'a', type: 'text', label: '', default: '' }).includes('missing label')); + assert.ok(validateControl({ key: 'a', type: 'text', label: 'x' }).includes('missing default')); + }); + + test('a select must have options and a default among them', function (assert) { + assert.ok(validateControl({ key: 'a', type: 'select', label: 'A', default: 'x', options: [] }).includes('select control needs options')); + assert.ok( + validateControl({ + key: 'a', + type: 'select', + label: 'A', + default: 'z', + options: [{ value: 'x', label: 'x' }], + }).includes('select default is not among its options') + ); + }); + + test('a number control cannot have min above max', function (assert) { + assert.ok(validateControl({ key: 'a', type: 'number', label: 'A', default: 5, min: 10, max: 1 }).includes('min is greater than max')); + }); + + test('a default that fails its own validation is caught', function (assert) { + const problems = validateControl({ key: 'a', type: 'number', label: 'A', default: 500, min: 0, max: 100 }); + + assert.ok( + problems.some((problem) => problem.startsWith('default fails its own validation')), + 'Reset would otherwise produce a validation error' + ); + }); + + test('the supported type list is what the control component renders', function (assert) { + assert.deepEqual(CONTROL_TYPES, ['boolean', 'text', 'number', 'select', 'color', 'date', 'datetime', 'json']); + }); + }); +}); diff --git a/tests/unit/playground/event-sanitizer-test.js b/tests/unit/playground/event-sanitizer-test.js new file mode 100644 index 00000000..9f6a4e73 --- /dev/null +++ b/tests/unit/playground/event-sanitizer-test.js @@ -0,0 +1,170 @@ +import { module, test } from 'qunit'; +import { summarize, summarizeArguments } from 'dummy/playground/event-sanitizer'; + +/** + * The event log shows what a component handed a callback. It must never render a live object + * graph, never walk a record's relationships, and never throw — a callback that cannot be + * summarized must not take the page down. + */ +module('Unit | playground | event-sanitizer', function () { + module('primitives', function () { + test('it summarizes primitives', function (assert) { + assert.deepEqual(summarize(null), { kind: 'null', text: 'null' }); + assert.deepEqual(summarize(undefined), { kind: 'undefined', text: 'undefined' }); + assert.deepEqual(summarize(42), { kind: 'number', text: '42' }); + assert.deepEqual(summarize(true), { kind: 'boolean', text: 'true' }); + assert.deepEqual(summarize('hi'), { kind: 'string', text: '"hi"' }); + }); + + test('long strings are truncated', function (assert) { + const summary = summarize('x'.repeat(500)); + + assert.true(summary.text.length < 200, 'the log does not carry a 500-character argument'); + assert.true(summary.text.endsWith('…'), 'truncation is visible'); + }); + + test('functions are named, not invoked', function (assert) { + assert.strictEqual(summarize(function save() {}).text, 'function save()'); + assert.strictEqual(summarize(() => {}).kind, 'function'); + }); + }); + + module('DOM values', function () { + test('a DOM event is reduced to safe fields', function (assert) { + const event = new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true }); + const summary = summarize(event); + + assert.strictEqual(summary.kind, 'event'); + assert.strictEqual(summary.text, 'keydown event'); + assert.strictEqual(summary.fields.key, 'Enter'); + assert.strictEqual(summary.fields.ctrlKey, 'true'); + }); + + test('an input event exposes value and checked, and nothing else off target', function (assert) { + const input = document.createElement('input'); + + input.type = 'checkbox'; + input.checked = true; + input.value = 'on'; + + const event = new Event('change'); + + Object.defineProperty(event, 'target', { value: input }); + + const summary = summarize(event); + + assert.strictEqual(summary.fields['target.checked'], 'true'); + assert.strictEqual(summary.fields['target.value'], 'on'); + assert.strictEqual(summary.fields.target, 'input'); + assert.notOk('innerHTML' in summary.fields, 'the element itself is not walked'); + }); + + test('a DOM node is described by its tag only', function (assert) { + assert.deepEqual(summarize(document.createElement('div')), { kind: 'node', text: '
      ' }); + }); + + test('a File reports name, type and size but never contents', function (assert) { + const file = new File(['secret contents'], 'report.pdf', { type: 'application/pdf' }); + const summary = summarize(file); + + assert.strictEqual(summary.kind, 'file'); + assert.strictEqual(summary.text, 'File "report.pdf"'); + assert.strictEqual(summary.fields.type, 'application/pdf'); + assert.notOk(JSON.stringify(summary).includes('secret contents'), 'contents are never read'); + }); + }); + + module('application values', function () { + test('an Error is summarized by name and message', function (assert) { + assert.deepEqual(summarize(new TypeError('bad thing')), { kind: 'error', text: 'TypeError: bad thing' }); + }); + + test('a Date is summarized in ISO form', function (assert) { + assert.strictEqual(summarize(new Date('2026-03-16T00:00:00Z')).text, '2026-03-16T00:00:00.000Z'); + }); + + test('an invalid Date does not throw', function (assert) { + assert.strictEqual(summarize(new Date('nonsense')).text, 'Invalid Date'); + }); + + test('an ember-data-shaped record is reduced to its identity', function (assert) { + const record = { id: 'order_1', isDestroyed: false, currentState: {}, secretToken: 'sk_live_xyz' }; + const summary = summarize(record); + + assert.strictEqual(summary.kind, 'record'); + assert.notOk(JSON.stringify(summary).includes('sk_live_xyz'), 'record attributes are never walked'); + }); + + test('a service is not walked', function (assert) { + assert.deepEqual(summarize({ isServiceFactory: true, apiKey: 'secret' }), { kind: 'service', text: '(service)' }); + }); + }); + + module('hostile structures', function () { + test('a cyclic object is summarized rather than exploding', function (assert) { + const node = { name: 'a' }; + + node.self = node; + + const summary = summarize(node); + + assert.strictEqual(summary.fields.self, '(circular)'); + }); + + test('two objects referencing each other are handled', function (assert) { + const a = {}; + const b = { a }; + + a.b = b; + + assert.ok(summarize(a), 'no infinite recursion'); + }); + + test('deep nesting stops at a bounded depth', function (assert) { + const deep = { a: { b: { c: { d: { e: 'too far' } } } } }; + + assert.notOk(JSON.stringify(summarize(deep)).includes('too far'), 'the log does not descend forever'); + }); + + test('long arrays are truncated with a count', function (assert) { + const summary = summarize(Array.from({ length: 50 }, (_, i) => i)); + + assert.strictEqual(summary.text, 'Array(50)'); + assert.strictEqual(summary.items.length, 10, 'only the first few are shown'); + assert.strictEqual(summary.truncated, 40); + }); + + test('wide objects are truncated with a count', function (assert) { + const wide = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`k${i}`, i])); + + assert.strictEqual(summarize(wide).truncated, 18); + }); + + test('a value whose getters throw is reported, not propagated', function (assert) { + const hostile = {}; + + Object.defineProperty(hostile, 'boom', { + enumerable: true, + get() { + throw new Error('nope'); + }, + }); + + assert.strictEqual(summarize(hostile).kind, 'unavailable', 'the log degrades instead of crashing'); + }); + }); + + module('argument lists', function () { + test('summarizeArguments maps every argument', function (assert) { + const summaries = summarizeArguments(['a', 1, null]); + + assert.strictEqual(summaries.length, 3); + assert.strictEqual(summaries[0].text, '"a"'); + }); + + test('an empty argument list is fine', function (assert) { + assert.deepEqual(summarizeArguments([]), []); + assert.deepEqual(summarizeArguments(), []); + }); + }); +}); diff --git a/tests/unit/playground/registry-test.js b/tests/unit/playground/registry-test.js new file mode 100644 index 00000000..4328a1fc --- /dev/null +++ b/tests/unit/playground/registry-test.js @@ -0,0 +1,176 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { getOwner } from '@ember/application'; +import REGISTRY, { findBySlug, categories } from 'dummy/playground/registry'; +import { DOCUMENTED_COMPONENTS, slugFor } from 'dummy/playground/allowlist'; +import { validateControl } from 'dummy/playground/controls'; + +/** + * Scope completeness, both directions: every allowlisted documented component has a registry + * entry, and every registry entry is allowlisted. Deliberately never compared against the full + * `app/components` inventory — that is the comparison the playground exists to avoid. + */ +module('Unit | playground | registry', function (hooks) { + setupTest(hooks); + + module('scope completeness', function () { + test('every documented component has a registry entry', function (assert) { + const slugs = new Set(REGISTRY.map((entry) => entry.slug)); + const missing = DOCUMENTED_COMPONENTS.filter((documented) => !slugs.has(slugFor(documented.path))); + + assert.deepEqual( + missing.map((entry) => entry.path), + [], + 'no documented component is left without a playground page' + ); + }); + + test('every registry entry is an allowlisted documented component', function (assert) { + const documented = new Set(DOCUMENTED_COMPONENTS.map((entry) => slugFor(entry.path))); + const extra = REGISTRY.filter((entry) => !documented.has(entry.slug)); + + assert.deepEqual( + extra.map((entry) => entry.slug), + [], + 'the registry does not exceed the documented scope' + ); + }); + + test('the counts agree', function (assert) { + assert.strictEqual(REGISTRY.length, DOCUMENTED_COMPONENTS.length, 'registry and allowlist are the same size'); + assert.strictEqual(REGISTRY.length, 63, '63 documented components are represented'); + }); + }); + + module('entry integrity', function () { + test('slugs are unique', function (assert) { + const slugs = REGISTRY.map((entry) => entry.slug); + + assert.strictEqual(new Set(slugs).size, slugs.length, 'no slug is duplicated'); + }); + + test('required metadata is present on every entry', function (assert) { + const required = ['slug', 'name', 'category', 'description', 'docsUrl', 'component', 'sourcePath', 'testPaths', 'example']; + const incomplete = REGISTRY.filter((entry) => required.some((key) => !entry[key] || (Array.isArray(entry[key]) && entry[key].length === 0))); + + assert.deepEqual( + incomplete.map((entry) => entry.slug), + [], + 'every entry carries the metadata the component page renders' + ); + }); + + test('public component identifiers are derived from the resolution path', function (assert) { + assert.strictEqual(findBySlug('layout-resource-tabular').component, 'Layout::Resource::Tabular'); + assert.strictEqual(findBySlug('attach-tooltip').component, 'Attach::Tooltip'); + assert.strictEqual(findBySlug('modal-layouts-option-prompt').component, 'Modal::Layouts::OptionPrompt'); + assert.strictEqual(findBySlug('registry-yield').component, 'RegistryYield'); + }); + + test('every entry points at a component that actually resolves', function (assert) { + const owner = getOwner(this); + const unresolvable = REGISTRY.filter((entry) => !owner.factoryFor(`component:${entry.path}`)); + + assert.deepEqual( + unresolvable.map((entry) => entry.path), + [], + 'no documented component points at a missing source component' + ); + }); + + test('every example adapter resolves', function (assert) { + const owner = getOwner(this); + const unresolvable = REGISTRY.filter((entry) => !owner.factoryFor(`component:${entry.example}`)); + + assert.deepEqual( + unresolvable.map((entry) => entry.example), + [], + 'every registry entry has a rendering adapter' + ); + }); + + test('categories come from the documentation navigation', function (assert) { + assert.deepEqual(categories(), [ + 'Layout & Structure', + 'Navigation', + 'Buttons & Actions', + 'Forms & Inputs', + 'Data Display', + 'Calendars & Boards', + 'Modals', + 'Dashboard', + 'Builders', + 'Registry & Slots', + ]); + }); + + test('findBySlug returns null for anything not documented', function (assert) { + assert.strictEqual(findBySlug('aside-item-scroller'), null, 'an undocumented public component does not resolve'); + assert.strictEqual(findBySlug('not-a-component'), null); + }); + }); + + module('control metadata', function () { + test('every control on every entry is valid', function (assert) { + const problems = []; + + for (const entry of REGISTRY) { + for (const definition of entry.controls) { + const found = validateControl(definition); + + if (found.length > 0) { + problems.push(`${entry.slug}.${definition.key}: ${found.join('; ')}`); + } + } + } + + assert.deepEqual(problems, [], 'no control is malformed'); + }); + + test('control keys are unique within an entry', function (assert) { + const clashes = REGISTRY.filter((entry) => { + const keys = entry.controls.map((definition) => definition.key); + + return new Set(keys).size !== keys.length; + }); + + assert.deepEqual( + clashes.map((entry) => entry.slug), + [], + 'no entry declares the same control twice' + ); + }); + + test('scenario ids are unique within an entry', function (assert) { + const clashes = REGISTRY.filter((entry) => { + const ids = entry.scenarios.map((scenario) => scenario.id); + + return new Set(ids).size !== ids.length; + }); + + assert.deepEqual( + clashes.map((entry) => entry.slug), + [], + 'no entry declares the same scenario twice' + ); + }); + + test('preset scenarios only set controls that exist', function (assert) { + const problems = []; + + for (const entry of REGISTRY) { + const keys = new Set(entry.controls.map((definition) => definition.key)); + + for (const scenario of entry.scenarios) { + for (const key of Object.keys(scenario.values ?? {})) { + if (!keys.has(key)) { + problems.push(`${entry.slug}/${scenario.id} sets unknown control "${key}"`); + } + } + } + } + + assert.deepEqual(problems, [], 'no preset writes a control the entry does not declare'); + }); + }); +}); diff --git a/tests/unit/playground/state-codec-test.js b/tests/unit/playground/state-codec-test.js new file mode 100644 index 00000000..8e32617e --- /dev/null +++ b/tests/unit/playground/state-codec-test.js @@ -0,0 +1,146 @@ +import { module, test } from 'qunit'; +import { encodeState, decodeState } from 'dummy/playground/state-codec'; +import { control } from 'dummy/playground/controls'; + +const CONTROLS = [ + control('text', 'text', { default: 'Save' }), + control('type', 'select', { options: ['default', 'primary'], default: 'default' }), + control('count', 'number', { default: 1, min: 0, max: 10 }), + control('on', 'boolean', { default: false }), +]; + +function encodeRaw(payload) { + return btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +module('Unit | playground | state-codec', function () { + module('defaults', function () { + test('no state decodes to the documented defaults', function (assert) { + const { values, warnings } = decodeState(CONTROLS, null); + + assert.deepEqual(values, { text: 'Save', type: 'default', count: 1, on: false }); + assert.deepEqual(warnings, [], 'an absent parameter is not a problem worth reporting'); + }); + + test('an empty state decodes to defaults too', function (assert) { + assert.deepEqual(decodeState(CONTROLS, '').values, { text: 'Save', type: 'default', count: 1, on: false }); + }); + + test('values equal to their defaults are not encoded', function (assert) { + assert.strictEqual(encodeState(CONTROLS, { text: 'Save', type: 'default', count: 1, on: false }), null, 'the query parameter stays absent rather than present-and-empty'); + }); + }); + + module('round trips', function () { + test('non-default values survive an encode/decode round trip', function (assert) { + const values = { text: 'Dispatch', type: 'primary', count: 7, on: true }; + const { values: decoded, warnings } = decodeState(CONTROLS, encodeState(CONTROLS, values)); + + assert.deepEqual(decoded, values); + assert.deepEqual(warnings, []); + }); + + test('non-ASCII text survives', function (assert) { + const encoded = encodeState(CONTROLS, { text: 'Överför — 送信' }); + + assert.strictEqual(decodeState(CONTROLS, encoded).values.text, 'Överför — 送信'); + }); + + test('only what differs from the default is carried', function (assert) { + const encoded = encodeState(CONTROLS, { text: 'Save', type: 'primary', count: 1, on: false }); + + assert.deepEqual(JSON.parse(atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))), { type: 'primary' }, 'shared URLs stay short'); + }); + }); + + module('hostile input', function () { + test('malformed encoding falls back to defaults with one warning', function (assert) { + const { values, warnings } = decodeState(CONTROLS, '!!!not-base64!!!'); + + assert.deepEqual(values, { text: 'Save', type: 'default', count: 1, on: false }); + assert.strictEqual(warnings.length, 1); + assert.ok(warnings[0].includes('could not be read'), 'the fallback is reported, not silent'); + }); + + test('a payload that is not an object falls back', function (assert) { + const { values, warnings } = decodeState(CONTROLS, encodeRaw([1, 2, 3])); + + assert.deepEqual(values, { text: 'Save', type: 'default', count: 1, on: false }); + assert.ok(warnings[0].includes('not a set of control values')); + }); + + test('unknown keys are ignored and reported', function (assert) { + const { values, warnings } = decodeState(CONTROLS, encodeRaw({ text: 'Kept', nope: 'dropped' })); + + assert.strictEqual(values.text, 'Kept', 'known keys still apply'); + assert.notOk('nope' in values, 'the unknown key does not reach the component'); + assert.ok(warnings.some((warning) => warning.includes('nope'))); + }); + + test('wrong types fall back per control and report', function (assert) { + const { values, warnings } = decodeState(CONTROLS, encodeRaw({ count: 'abc', type: 'nope', text: 'fine' })); + + assert.strictEqual(values.count, 1, 'the bad number fell back to its default'); + assert.strictEqual(values.type, 'default', 'the bad option fell back to its default'); + assert.strictEqual(values.text, 'fine', 'the good value was kept'); + assert.strictEqual(warnings.length, 2, 'each bad value is reported'); + }); + + test('an out-of-range number falls back', function (assert) { + const { values, warnings } = decodeState(CONTROLS, encodeRaw({ count: 999 })); + + assert.strictEqual(values.count, 1); + assert.ok(warnings[0].includes('at most 10')); + }); + + test('decoding never throws', function (assert) { + for (const hostile of ['', '=', '???', 'e30', btoa('not json'), 'null', undefined]) { + assert.ok(decodeState(CONTROLS, hostile).values, `survived ${JSON.stringify(hostile)}`); + } + }); + }); + + module('what is never serialized', function () { + test('functions are dropped', function (assert) { + const encoded = encodeState(CONTROLS, { text: () => 'nope', type: 'primary' }); + + assert.deepEqual(decodeState(CONTROLS, encoded).values.type, 'primary'); + assert.strictEqual(decodeState(CONTROLS, encoded).values.text, 'Save', 'the function never made it into the URL'); + }); + + test('class instances such as records and services are dropped', function (assert) { + class Record { + id = 'order_1'; + } + + const encoded = encodeState([control('data', 'json', { default: null })], { data: new Record() }); + + assert.strictEqual(encoded, null, 'a non-plain object is not URL material'); + }); + + test('Dates and Errors are dropped', function (assert) { + const definition = [control('data', 'json', { default: null })]; + + assert.strictEqual(encodeState(definition, { data: new Date() }), null); + assert.strictEqual(encodeState(definition, { data: new Error('boom') }), null); + }); + + test('controls marked unserializable are skipped and reported on the way back', function (assert) { + const controls = [control('secret', 'text', { default: '', serializable: false })]; + + assert.strictEqual(encodeState(controls, { secret: 'value' }), null, 'never encoded'); + + const { values, warnings } = decodeState(controls, encodeRaw({ secret: 'injected' })); + + assert.strictEqual(values.secret, '', 'an injected value is refused'); + assert.ok(warnings.some((warning) => warning.includes('not shareable'))); + }); + + test('plain nested structures are allowed', function (assert) { + const controls = [control('data', 'json', { default: null })]; + const data = { rows: [{ id: 1, ok: true }], label: 'x' }; + + assert.deepEqual(decodeState(controls, encodeState(controls, { data })).values.data, data); + }); + }); +}); diff --git a/tests/unit/services/dashboard-test.js b/tests/unit/services/dashboard-test.js index 3612fe3a..b62229ae 100644 --- a/tests/unit/services/dashboard-test.js +++ b/tests/unit/services/dashboard-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { settled } from '@ember/test-helpers'; class StubStore { records = new Map(); @@ -79,6 +80,9 @@ module('Unit | Service | dashboard', function (hooks) { this.owner.register('service:notifications', {}, { instantiate: false }); this.owner.register('service:intl', { t: (k) => k }, { instantiate: false }); this.owner.register('service:universe', new StubUniverse(), { instantiate: false }); + // The addon ships a real universe/widget-service; it must be unregistered before a + // stub can take its place, otherwise the resolver-provided one is used. + this.owner.unregister('service:universe/widget-service'); this.owner.register('service:universe/widget-service', this.widgetService, { instantiate: false }); }); @@ -177,3 +181,596 @@ module('Unit | Service | dashboard', function (hooks) { assert.strictEqual(service.currentWidgetSourceDashboardId, 'dashboard', 'saved dashboard keeps its persisted widget catalog source'); }); }); + +// A second module covering the parts of the service the original tests do not reach: +// dashboard selection, creation, deletion, the editing/adding flags and the widget +// source resolution. Kept separate so its stubs can record calls without complicating +// the fixtures above. +class RecordingFetch { + posts = []; + responses = new Map(); + failWith = null; + + post(path, body, options) { + this.posts.push({ path, body, options }); + + if (this.failWith) { + return Promise.reject(this.failWith); + } + + return Promise.resolve(this.responses.get(path) ?? null); + } +} + +class RecordingNotifications { + successes = []; + serverErrors = []; + + success(message) { + this.successes.push(message); + } + serverError(error) { + this.serverErrors.push(error); + } +} + +module('Unit | Service | dashboard selection and lifecycle', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.store = new StubStore(); + this.widgetService = new StubWidgetService(); + this.fetch = new RecordingFetch(); + this.notifications = new RecordingNotifications(); + + this.owner.register('service:store', this.store, { instantiate: false }); + this.owner.register('service:fetch', this.fetch, { instantiate: false }); + this.owner.register('service:notifications', this.notifications, { instantiate: false }); + this.owner.register('service:intl', { t: (key, values) => `${key}:${values?.dashboardName ?? ''}` }, { instantiate: false }); + this.owner.register('service:universe', new StubUniverse(), { instantiate: false }); + this.owner.unregister('service:universe/widget-service'); + this.owner.register('service:universe/widget-service', this.widgetService, { instantiate: false }); + + this.service = this.owner.lookup('service:dashboard'); + }); + + module('editing flags', function () { + test('the editing flag can be raised and lowered', function (assert) { + assert.false(this.service.isEditingDashboard); + + this.service.onChangeEdit(); + assert.true(this.service.isEditingDashboard, 'it defaults to enabling'); + + this.service.onChangeEdit(false); + assert.false(this.service.isEditingDashboard); + }); + + test('the adding-widget flag can be raised and lowered', function (assert) { + assert.false(this.service.isAddingWidget); + + this.service.onAddingWidget(); + assert.true(this.service.isAddingWidget); + + this.service.onAddingWidget(false); + assert.false(this.service.isAddingWidget); + }); + }); + + module('selecting a dashboard', function () { + test('choosing a system dashboard resets the server default', async function (assert) { + const dashboard = { id: 'dashboard', user_uuid: 'system' }; + + await this.service.selectDashboard.perform(dashboard); + + assert.strictEqual(this.service.currentDashboard, dashboard); + assert.deepEqual( + this.fetch.posts.map((post) => post.path), + ['dashboards/reset-default'], + 'the default is reset rather than switched' + ); + }); + + test('choosing a saved dashboard switches to it and adopts the response', async function (assert) { + const saved = { id: 'dash_1', user_uuid: 'user_1' }; + const returned = { id: 'dash_1', name: 'From server' }; + this.fetch.responses.set('dashboards/switch', returned); + + await this.service.selectDashboard.perform(saved); + + assert.deepEqual(this.fetch.posts[0].body, { dashboard_uuid: 'dash_1' }); + assert.true(this.fetch.posts[0].options.normalizeToEmberData); + assert.strictEqual(this.service.currentDashboard, returned, 'the server response becomes current'); + }); + + test('a failed switch is reported and leaves the current dashboard alone', async function (assert) { + const error = new Error('switch failed'); + this.fetch.failWith = error; + this.service.currentDashboard = { id: 'unchanged' }; + + await this.service.selectDashboard.perform({ id: 'dash_1', user_uuid: 'user_1' }); + + assert.deepEqual(this.notifications.serverErrors, [error]); + assert.strictEqual(this.service.currentDashboard.id, 'unchanged'); + }); + + test('setCurrentDashboard switches and adopts the response', async function (assert) { + const returned = { id: 'dash_2', name: 'Second' }; + this.fetch.responses.set('dashboards/switch', returned); + + await this.service.setCurrentDashboard.perform({ id: 'dash_2' }); + + assert.strictEqual(this.service.currentDashboard, returned); + }); + + test('a failed setCurrentDashboard is reported', async function (assert) { + const error = new Error('nope'); + this.fetch.failWith = error; + + await this.service.setCurrentDashboard.perform({ id: 'dash_2' }); + + assert.deepEqual(this.notifications.serverErrors, [error]); + assert.strictEqual(this.service.currentDashboard, undefined); + }); + }); + + module('creating a dashboard', function () { + test('a created dashboard is announced, selected and added to the list', async function (assert) { + this.store.createRecord = (modelName, data) => ({ + ...data, + id: 'dash_new', + save: () => Promise.resolve({ id: 'dash_new', name: data.name, user_uuid: 'user_1' }), + }); + + await this.service.createDashboard.perform('Operations'); + + assert.deepEqual(this.notifications.successes, ['services.dashboard-service.create-dashboard-success-notification:Operations']); + assert.deepEqual( + this.service.dashboards.map((dashboard) => dashboard.id), + ['dash_new'], + 'it joins the list' + ); + assert.deepEqual( + this.fetch.posts.map((post) => post.path), + ['dashboards/switch'], + 'it is also selected' + ); + }); + + test('extra attributes are passed through to the new record', async function (assert) { + let created; + this.store.createRecord = (modelName, data) => { + created = data; + return { ...data, save: () => Promise.resolve({ id: 'dash_new', name: data.name }) }; + }; + + await this.service.createDashboard.perform('Operations', { extension: 'fleet-ops' }); + + assert.strictEqual(created.name, 'Operations'); + assert.true(created.is_default, 'new dashboards are created as the default'); + assert.strictEqual(created.extension, 'fleet-ops'); + }); + + test('a failed save is reported and nothing is added', async function (assert) { + const error = new Error('save failed'); + this.store.createRecord = () => ({ save: () => Promise.reject(error) }); + + await this.service.createDashboard.perform('Operations'); + + assert.deepEqual(this.notifications.serverErrors, [error]); + assert.deepEqual(this.service.dashboards, []); + }); + + test('a save that resolves to nothing announces nothing', async function (assert) { + this.store.createRecord = () => ({ save: () => Promise.resolve(null) }); + + await this.service.createDashboard.perform('Operations'); + + assert.deepEqual(this.notifications.successes, []); + assert.deepEqual(this.service.dashboards, []); + }); + }); + + module('the widget source dashboard', function () { + test('with nothing selected it falls back to the plain default', function (assert) { + assert.strictEqual(this.service.currentWidgetSourceDashboardId, 'dashboard'); + }); + + test('a system dashboard sources widgets from itself', function (assert) { + this.service.currentDashboard = { id: 'fleet-ops', user_uuid: 'system' }; + + assert.strictEqual(this.service.currentWidgetSourceDashboardId, 'fleet-ops'); + }); + + test('a saved dashboard can pin its widget source', function (assert) { + this.service.currentDashboard = { id: 'dash_1', user_uuid: 'user_1', options: { widget_source_dashboard_id: 'fleet-ops' } }; + + assert.strictEqual(this.service.currentWidgetSourceDashboardId, 'fleet-ops'); + }); + + test('an unpinned saved dashboard falls back to the last load scope', async function (assert) { + this.store.queryRecords = []; + await this.service.loadDashboards.perform({ defaultDashboardId: 'storefront', extension: 'storefront' }); + this.service.currentDashboard = { id: 'dash_1', user_uuid: 'user_1' }; + + assert.strictEqual(this.service.currentWidgetSourceDashboardId, 'storefront'); + }); + }); + + module('loading', function () { + test('a non-array query result leaves the dashboards untouched', async function (assert) { + this.store.query = () => Promise.resolve(null); + + await this.service.loadDashboards.perform(); + + assert.deepEqual(this.service.dashboards, []); + assert.strictEqual(this.service.currentDashboard, undefined); + }); + + test('the load options are remembered for later reloads', async function (assert) { + await this.service.loadDashboards.perform({ defaultDashboardId: 'storefront', defaultDashboardName: 'Storefront', extension: 'storefront' }); + + assert.deepEqual(this.service.lastLoadOptions, { + defaultDashboardId: 'storefront', + defaultDashboardName: 'Storefront', + extension: 'storefront', + slot: null, + }); + }); + + test('an empty dashboard leaves the widget panel closed by default', async function (assert) { + await this.service.loadDashboards.perform(); + + assert.false(this.service.isAddingWidget); + }); + + test('an empty dashboard opens the widget panel when the service is configured to', async function (assert) { + this.service.showPanelWhenZeroWidgets = true; + + await this.service.loadDashboards.perform(); + + assert.true(this.service.isAddingWidget, 'the panel opens for a dashboard with no widgets'); + }); + + test('a store result exposing toArray is unwrapped', async function (assert) { + const saved = { id: 'dash_1', user_uuid: 'user_1', is_default: false, widgets: [] }; + const resultSet = [saved]; + resultSet.toArray = () => [saved]; + this.store.query = () => Promise.resolve(resultSet); + + await this.service.loadDashboards.perform(); + + assert.true( + this.service.dashboards.some((dashboard) => dashboard.id === 'dash_1'), + 'the saved dashboard is included alongside the system one' + ); + }); + }); + + module('resetting', function () { + test('reset clears the current dashboard and the list immediately', async function (assert) { + this.service.currentDashboard = { id: 'dash_1' }; + this.service.dashboards = [{ id: 'dash_1' }]; + + this.service.reset(); + + assert.strictEqual(this.service.currentDashboard, null); + assert.deepEqual(this.service.dashboards, []); + + // reset() defers the store unloads to the next runloop tick; let them run + // inside the test rather than after the owner is torn down. + await settled(); + assert.strictEqual(this.store.records.size, 0, 'the deferred unload also runs'); + }); + }); + + module('deleting a dashboard', function () { + function deletableDashboard(overrides = {}) { + const dashboard = { + id: 'custom', + name: 'Custom Dashboard', + user_uuid: 'user_1', + is_default: true, + destroyCount: 0, + destroyRecord() { + dashboard.destroyCount++; + return Promise.resolve(dashboard); + }, + ...overrides, + }; + + return dashboard; + } + + test('it destroys the record and announces the deletion', async function (assert) { + const dashboard = deletableDashboard(); + + await this.service.deleteDashboard.perform(dashboard); + + assert.strictEqual(dashboard.destroyCount, 1, 'the record is destroyed'); + assert.deepEqual( + this.notifications.successes, + ['services.dashboard-service.delete-dashboard-success-notification:Custom Dashboard'], + 'the deletion is announced with the dashboard name' + ); + }); + + test('it reloads and reselects, then runs the caller callback', async function (assert) { + const called = []; + + await this.service.deleteDashboard.perform(deletableDashboard(), { callback: (current) => called.push(current) }); + + assert.strictEqual(called.length, 1, 'the callback runs once'); + assert.strictEqual(called[0], this.service.currentDashboard, 'it receives whatever is current afterwards'); + }); + + test('deleting without any options does not throw', async function (assert) { + await this.service.deleteDashboard.perform(deletableDashboard()); + + assert.deepEqual(this.notifications.serverErrors, [], 'nothing is reported as an error'); + }); + + test('a failed destroy reports the error and runs the caller onError', async function (assert) { + const dashboard = deletableDashboard({ destroyRecord: () => Promise.reject(new Error('cannot delete')) }); + const errors = []; + + await this.service.deleteDashboard.perform(dashboard, { onError: (error, record) => errors.push({ error, record }) }); + + assert.strictEqual(errors.length, 1, 'the caller is told about the failure'); + assert.strictEqual(errors[0].record, dashboard, 'the failing record travels with the error'); + assert.strictEqual(this.notifications.serverErrors.length, 1, 'the real failure is reported'); + assert.strictEqual(this.notifications.serverErrors[0].message, 'cannot delete'); + assert.deepEqual(this.notifications.successes, [], 'nothing is announced as deleted'); + }); + + test('a failed destroy still reports even with no onError hook', async function (assert) { + await this.service.deleteDashboard.perform(deletableDashboard({ destroyRecord: () => Promise.reject(new Error('nope')) })); + + assert.strictEqual(this.notifications.serverErrors.length, 1, 'the failure is surfaced'); + assert.deepEqual(this.notifications.successes, []); + }); + }); + + module('choosing the next dashboard', function () { + test('a user default outranks every system dashboard', function (assert) { + const service = this.service; + service.dashboards = [ + { id: 'dashboard', user_uuid: 'system', is_default: false }, + { id: 'mine', user_uuid: 'user-1', is_default: true }, + ]; + + assert.strictEqual(service._getNextDashboard({}).id, 'mine'); + }); + + test('the slot default is preferred over the plain default', function (assert) { + this.widgetService.slotDefault = 'alrashd'; + const service = this.service; + service.dashboards = [ + { id: 'dashboard', user_uuid: 'system', is_default: false }, + { id: 'alrashd', user_uuid: 'system', is_default: false }, + ]; + + assert.strictEqual(service._getNextDashboard({ slot: 'console.home' }).id, 'alrashd'); + }); + + test('with no slot the configured default id is used', function (assert) { + const service = this.service; + service.dashboards = [ + { id: 'other', user_uuid: 'system', is_default: false }, + { id: 'dashboard', user_uuid: 'system', is_default: false }, + ]; + + assert.strictEqual(service._getNextDashboard({}).id, 'dashboard'); + }); + + test('anything else falls back to the first dashboard', function (assert) { + const service = this.service; + service.dashboards = [ + { id: 'first', user_uuid: 'user-1', is_default: false }, + { id: 'second', user_uuid: 'user-1', is_default: false }, + ]; + + assert.strictEqual(service._getNextDashboard({}).id, 'first'); + }); + + test('an empty dashboard list yields nothing', function (assert) { + const service = this.service; + service.dashboards = []; + + assert.strictEqual(service._getNextDashboard({}), undefined); + }); + }); + + module('the default dashboard record', function () { + test('an existing usable record is reused rather than recreated', function (assert) { + const service = this.service; + const existing = { id: 'dashboard', name: 'Default Dashboard', isDeleted: false, isDestroying: false, isDestroyed: false }; + this.store.records.set('dashboard:dashboard', existing); + + const created = this.store.createdPayloads.length; + + assert.strictEqual(service._createDefaultDashboard('dashboard', 'Default Dashboard'), existing); + assert.strictEqual(this.store.createdPayloads.length, created, 'no new record is created'); + }); + + test('a deleted record is replaced', function (assert) { + const service = this.service; + this.store.records.set('dashboard:dashboard', { id: 'dashboard', isDeleted: true }); + this.store.createRecord = (modelName, data) => ({ ...data, replaced: true }); + + const dashboard = service._createDefaultDashboard('dashboard', 'Default Dashboard'); + + assert.true(dashboard.replaced, 'a fresh record is created over the deleted one'); + assert.strictEqual(dashboard.name, 'Default Dashboard'); + assert.strictEqual(dashboard.user_uuid, 'system'); + }); + + test('an identity-map race is resolved by re-peeking', function (assert) { + const service = this.service; + const raced = { id: 'dashboard', name: 'Raced Dashboard' }; + + this.store.createRecord = () => { + // Mirror the race: the record appears between the peek and the create. + this.store.records.set('dashboard:dashboard', raced); + throw new Error("The id dashboard has already been used with another 'dashboard' record."); + }; + + assert.strictEqual(service._createDefaultDashboard('dashboard', 'Default Dashboard'), raced, 'the winner of the race is adopted'); + }); + + test('any other creation failure is re-thrown', function (assert) { + const service = this.service; + this.store.createRecord = () => { + throw new Error('database is on fire'); + }; + + assert.throws(() => service._createDefaultDashboard('dashboard', 'Default Dashboard'), /database is on fire/); + }); + }); + + module('load state helpers', function () { + test('the default dashboard is reported loaded only when present', function (assert) { + const service = this.service; + + service.dashboards = []; + assert.false(service._isDefaultDashboardLoaded('dashboard')); + assert.true(service._isDefaultDashboardNotLoaded('dashboard')); + + service.dashboards = [{ id: 'dashboard' }]; + assert.true(service._isDefaultDashboardLoaded('dashboard')); + assert.false(service._isDefaultDashboardNotLoaded('dashboard')); + }); + + test('a null entry in the list is tolerated', function (assert) { + const service = this.service; + service.dashboards = [null, { id: 'dashboard' }]; + + assert.true(service._isDefaultDashboardLoaded('dashboard'), 'a null dashboard does not throw'); + }); + }); + // Six private entry points carry defaulted parameters, and every existing caller passes them + // explicitly. Calling them bare is the only way those defaults run. + module('called with no arguments', function () { + test('the default dashboard is named and keyed from the built-in defaults', function (assert) { + const service = this.owner.lookup('service:dashboard'); + + const dashboard = service._createDefaultDashboard(); + + assert.strictEqual(dashboard.id, 'dashboard'); + assert.strictEqual(dashboard.name, 'Default Dashboard'); + assert.strictEqual(dashboard.user_uuid, 'system'); + }); + + test('the default widgets are built for the default dashboard', function (assert) { + this.widgetService.widgets = [{ id: 'kpi', name: 'KPI', component: 'widget/kpi', default: true }]; + const service = this.owner.lookup('service:dashboard'); + + assert.strictEqual(service._createDefaultDashboardWidgets().length, 1); + }); + + test('default widgets are laid out by their order, then in registration order', function (assert) { + // As registered by extensions that booted in some arbitrary order. + this.widgetService.widgets = [ + { id: 'ledger-expenses', name: 'Expenses', component: 'widget/kpi', default: true }, + { id: 'map', name: 'Map', component: 'widget/map', default: true, order: 50 }, + { id: 'blog', name: 'Blog', component: 'widget/blog', default: true }, + { id: 'ledger-revenue', name: 'Revenue', component: 'widget/kpi', default: true, order: 20 }, + { id: 'radar', name: 'Radar', component: 'widget/radar', default: true, order: 10 }, + { id: 'active-orders', name: 'Active Orders', component: 'widget/kpi', default: true, order: 20 }, + { id: 'not-a-number', name: 'Odd', component: 'widget/kpi', default: true, order: '5' }, + ]; + const service = this.owner.lookup('service:dashboard'); + + assert.deepEqual( + service._createDefaultDashboardWidgets().map((widget) => widget.options.widget_key), + ['radar', 'ledger-revenue', 'active-orders', 'map', 'ledger-expenses', 'blog', 'not-a-number'], + 'ordered widgets first (ties keep registration order), then the rest as registered' + ); + }); + + test('the system dashboards fall back to the single default', function (assert) { + const service = this.owner.lookup('service:dashboard'); + + const dashboards = service._createSystemDashboards(); + + assert.strictEqual(dashboards.length, 1, 'with no slot there is nothing to expand'); + assert.strictEqual(dashboards[0].id, 'dashboard'); + }); + + test('the load-state helpers answer for the default id', function (assert) { + const service = this.owner.lookup('service:dashboard'); + service.dashboards = []; + + assert.false(service._isDefaultDashboardLoaded(), 'nothing is loaded yet'); + assert.true(service._isDefaultDashboardNotLoaded(), 'and the inverse agrees'); + + service.dashboards = [{ id: 'dashboard' }]; + + assert.true(service._isDefaultDashboardLoaded()); + assert.false(service._isDefaultDashboardNotLoaded()); + }); + + test('the next dashboard is chosen against the default id', function (assert) { + const service = this.owner.lookup('service:dashboard'); + service.dashboards = [ + { id: 'other', user_uuid: 'system' }, + { id: 'dashboard', user_uuid: 'system' }, + ]; + + assert.strictEqual(service._getNextDashboard().id, 'dashboard'); + }); + }); + + module('system dashboards registered against a slot', function () { + test('a slot with no default of its own has one prepended', function (assert) { + this.widgetService.slotDashboards = [{ dashboardId: 'fleet', name: 'Fleet' }]; + const service = this.owner.lookup('service:dashboard'); + + const dashboards = service._createSystemDashboards({ slot: 'console' }); + + assert.deepEqual( + dashboards.map((dashboard) => dashboard.id), + ['dashboard', 'fleet'], + 'the built-in default leads the list' + ); + assert.strictEqual(dashboards[1].name, 'Fleet'); + }); + + test('a slot that already declares the default is used as-is', function (assert) { + this.widgetService.slotDashboards = [ + { dashboardId: 'dashboard', name: 'Main' }, + { dashboardId: 'fleet', name: 'Fleet' }, + ]; + const service = this.owner.lookup('service:dashboard'); + + const dashboards = service._createSystemDashboards({ slot: 'console' }); + + assert.deepEqual( + dashboards.map((dashboard) => dashboard.id), + ['dashboard', 'fleet'], + 'nothing is prepended' + ); + assert.strictEqual(dashboards[0].name, 'Main', 'the slot definition wins over the built-in name'); + }); + + test('a slot dashboard identified only by id, and with no name, keys on that id', function (assert) { + this.widgetService.slotDashboards = [{ id: 'ops' }]; + const service = this.owner.lookup('service:dashboard'); + + const dashboards = service._createSystemDashboards({ slot: 'console' }); + + const ops = dashboards.find((dashboard) => dashboard.id === 'ops'); + assert.ok(ops, 'the id stands in for a missing dashboardId'); + assert.strictEqual(ops.name, 'ops', 'and for a missing name'); + }); + }); + + test('an identity-map race that re-peeks to nothing re-throws', function (assert) { + const service = this.owner.lookup('service:dashboard'); + this.store.createRecord = () => { + throw new Error(`The id dashboard has already been used with another 'dashboard' record.`); + }; + this.store.peekRecord = () => null; + + assert.throws(() => service._createDefaultDashboard(), /has already been used/, 'the error is not swallowed when there is nothing to recover'); + }); +}); diff --git a/tests/unit/services/docs-panel-test.js b/tests/unit/services/docs-panel-test.js index 6361f18c..cc192b51 100644 --- a/tests/unit/services/docs-panel-test.js +++ b/tests/unit/services/docs-panel-test.js @@ -4,6 +4,20 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Service | docs-panel', function (hooks) { setupTest(hooks); + // Every other test calls open() first, which assigns all of these — this is the only look at + // the service before anything has happened to it. + test('a freshly looked-up service starts closed and empty', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.false(service.isOpen, 'closed'); + assert.strictEqual(service.url, null, 'with nothing to show'); + assert.strictEqual(service.title, 'Documentation', 'and a generic title'); + assert.strictEqual(service.source, null); + assert.false(service.iframeFailed); + assert.false(service.isIframeLoading); + assert.strictEqual(service.iframeTheme, 'light'); + }); + test('it normalizes documentation slugs and urls', function (assert) { const service = this.owner.lookup('service:docs-panel'); @@ -59,4 +73,213 @@ module('Unit | Service | docs-panel', function (hooks) { service.markIframeFailed(); assert.false(service.isIframeLoading, 'error clears loading state'); }); + + module('opening and closing', function () { + test('opening records the title, source and open state', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/overview', { title: 'Fleet Ops', source: 'user-menu' }); + + assert.true(service.isOpen); + assert.strictEqual(service.title, 'Fleet Ops'); + assert.strictEqual(service.source, 'user-menu'); + assert.false(service.iframeFailed); + }); + + test('opening without options falls back to a generic title and no source', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/overview'); + + assert.strictEqual(service.title, 'Documentation'); + assert.strictEqual(service.source, null); + }); + + test('reopening clears a previous failure', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/overview'); + service.markIframeFailed(); + assert.true(service.iframeFailed); + + service.open('fleet-ops/overview'); + assert.false(service.iframeFailed, 'the new document starts clean'); + }); + + test('closing clears the open and loading state but keeps the url', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/overview'); + service.close(); + + assert.false(service.isOpen); + assert.false(service.isIframeLoading); + assert.strictEqual(service.url, 'https://www.fleetbase.io/docs/fleet-ops/overview?embed=console&theme=light', 'the url survives so the panel can be reopened'); + }); + }); + + module('embedding', function () { + test('nothing is embeddable before a document is opened', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.false(service.canEmbed); + }); + + test('every official docs host is embeddable', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + for (const url of ['https://www.fleetbase.io/docs/a', 'https://fleetbase.io/docs/a', 'https://docs.fleetbase.io/anything']) { + service.url = url; + assert.true(service.canEmbed, `${url} is embeddable`); + } + }); + + test('an official host outside /docs is not embeddable', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.url = 'https://www.fleetbase.io/pricing'; + + assert.false(service.canEmbed, 'only the docs path is embeddable on the marketing host'); + }); + + test('a third-party url is not embeddable', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.url = 'https://example.com/help'; + + assert.false(service.canEmbed); + }); + + test('an unparseable url is not embeddable', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.url = 'http://[::bad::]/docs'; + + assert.false(service.canEmbed, 'a malformed url is rejected rather than throwing'); + }); + }); + + module('theming', function () { + test('the theme drives the body wrapper class', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/overview', { theme: 'dark' }); + assert.true(service.isIframeThemeDark); + assert.strictEqual(service.bodyWrapperClass, 'fleetbase-docs-panel-body fleetbase-docs-panel-body-dark'); + + service.open('fleet-ops/overview', { theme: 'light' }); + assert.false(service.isIframeThemeDark); + assert.strictEqual(service.bodyWrapperClass, 'fleetbase-docs-panel-body fleetbase-docs-panel-body-light'); + }); + + test('an unsupported theme falls back to the theme service', function (assert) { + const themeService = this.owner.lookup('service:theme'); + themeService.currentTheme = 'dark'; + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/overview', { theme: 'neon' }); + + assert.strictEqual(service.iframeTheme, 'dark', 'the console theme wins over an unrecognised value'); + }); + + test('with no theme at all the theme service is consulted', function (assert) { + const themeService = this.owner.lookup('service:theme'); + themeService.currentTheme = 'dark'; + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/overview'); + + assert.strictEqual(service.iframeTheme, 'dark'); + assert.true(service.url.includes('theme=dark')); + }); + + test('a theme service reporting nothing usable falls back to light', function (assert) { + const themeService = this.owner.lookup('service:theme'); + themeService.currentTheme = undefined; + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/overview'); + + assert.strictEqual(service.iframeTheme, 'light'); + }); + + test('sanitizeTheme accepts only the supported themes', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.strictEqual(service.sanitizeTheme('light'), 'light'); + assert.strictEqual(service.sanitizeTheme('dark'), 'dark'); + assert.strictEqual(service.sanitizeTheme('neon'), undefined); + assert.strictEqual(service.sanitizeTheme(undefined), undefined); + }); + }); + + module('url classification', function () { + test('isOfficialDocsUrl recognises the docs hosts', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.true(service.isOfficialDocsUrl('https://www.fleetbase.io/docs/a')); + assert.true(service.isOfficialDocsUrl('https://fleetbase.io/docs/a')); + assert.true(service.isOfficialDocsUrl('https://docs.fleetbase.io/a')); + assert.false(service.isOfficialDocsUrl('https://www.fleetbase.io/pricing')); + assert.false(service.isOfficialDocsUrl('https://example.com/docs/a')); + assert.false(service.isOfficialDocsUrl('http://[::bad::]/docs'), 'a malformed url is rejected rather than throwing'); + }); + + test('withDocsEmbedParams leaves third-party urls untouched', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.strictEqual(service.withDocsEmbedParams('https://example.com/help', 'dark'), 'https://example.com/help'); + }); + + test('withDocsEmbedParams omits the theme when none is given', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + const url = service.withDocsEmbedParams('https://www.fleetbase.io/docs/a', undefined); + + assert.true(url.includes('embed=console')); + assert.false(url.includes('theme='), 'no theme parameter is added'); + }); + + test('withDocsEmbedParams returns a malformed url unchanged', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.strictEqual(service.withDocsEmbedParams('http://[::bad::]/docs', 'light'), 'http://[::bad::]/docs'); + }); + + test('a protocol-relative url is left alone', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.strictEqual(service.normalizeUrl('//example.com/help'), '//example.com/help'); + }); + }); + + test('openExternal opens the current url in a docs window', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + const opened = []; + const originalOpen = window.open; + window.open = (...args) => opened.push(args); + + try { + service.openExternal(); + assert.deepEqual(opened, [], 'with no url nothing is opened'); + + service.open('fleet-ops/overview'); + service.openExternal(); + + assert.strictEqual(opened.length, 1); + assert.strictEqual(opened[0][0], service.url); + assert.strictEqual(opened[0][1], '_docs', 'it reuses a named docs window'); + } finally { + window.open = originalOpen; + } + }); + // `resolveTheme` asks the owner whether a theme service exists before looking it up, so a host + // application that ships none falls back to light rather than throwing. + test('with no theme service registered the iframe theme falls back to light', function (assert) { + this.owner.unregister('service:theme'); + const service = this.owner.lookup('service:docs-panel'); + + assert.strictEqual(service.resolveTheme(), 'light', 'the registration check short-circuits the lookup'); + assert.strictEqual(service.resolveTheme('dark'), 'dark', 'an explicit theme still wins'); + }); }); diff --git a/tests/unit/services/leaflet-test.js b/tests/unit/services/leaflet-test.js index 553d3a26..fefe2cfa 100644 --- a/tests/unit/services/leaflet-test.js +++ b/tests/unit/services/leaflet-test.js @@ -1,12 +1,247 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +// A stand-in for the real Leaflet global. No script is ever loaded and no tile is +// ever requested; the service only cares that `window.L` is a non-null object. +function fakeLeaflet(name) { + return { name, map: () => ({}) }; +} + module('Unit | Service | leaflet', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. - test('it exists', function (assert) { - let service = this.owner.lookup('service:leaflet'); - assert.ok(service); + hooks.beforeEach(function () { + this.originalL = window.L; + this.originalLeaflet = window.leaflet; + this.originalSetInterval = window.setInterval; + this.originalClearTimeout = window.clearTimeout; + + delete window.L; + delete window.leaflet; + + this.ticks = []; + this.cleared = []; + + // Replace the timer boundary so the polling loop is driven by the test. + window.setInterval = (callback) => { + this.ticks.push(callback); + return 4242; + }; + window.clearTimeout = (id) => { + this.cleared.push(id); + }; + + this.service = this.owner.lookup('service:leaflet'); + + // Drives the most recently registered polling callback. + this.tick = (times = 1) => { + const callback = this.ticks[this.ticks.length - 1]; + for (let i = 0; i < times; i++) { + callback(); + } + }; + }); + + hooks.afterEach(function () { + window.setInterval = this.originalSetInterval; + window.clearTimeout = this.originalClearTimeout; + + if (this.originalL === undefined) { + delete window.L; + } else { + window.L = this.originalL; + } + + if (this.originalLeaflet === undefined) { + delete window.leaflet; + } else { + window.leaflet = this.originalLeaflet; + } + }); + + test('it starts uninitialized with no instances', function (assert) { + assert.deepEqual(this.service.instances, []); + assert.false(this.service.initialized); + assert.strictEqual(this.service.instance, undefined); + assert.strictEqual(this.service.initializationId, undefined); + }); + + test('setInstance publishes the instance on the service and both globals', function (assert) { + const leaflet = fakeLeaflet('primary'); + + this.service.setInstance(leaflet); + + assert.strictEqual(this.service.instance, leaflet); + assert.strictEqual(window.L, leaflet, 'window.L is aliased'); + assert.strictEqual(window.leaflet, leaflet, 'window.leaflet is aliased'); + }); + + test('getInstance prefers the tracked instance', function (assert) { + const tracked = fakeLeaflet('tracked'); + this.service.instance = tracked; + window.L = fakeLeaflet('global'); + + assert.strictEqual(this.service.getInstance(), tracked); + }); + + test('getInstance falls back to the window globals', function (assert) { + const global = fakeLeaflet('global'); + window.L = global; + + assert.strictEqual(this.service.getInstance(), global, 'window.L is used when nothing is tracked'); + + delete window.L; + window.leaflet = global; + + assert.strictEqual(this.service.getInstance(), global, 'window.leaflet is the last resort'); + }); + + test('getInstance returns undefined when Leaflet is absent everywhere', function (assert) { + assert.strictEqual(this.service.getInstance(), undefined); + }); + + test('load starts a polling interval and records its id', function (assert) { + this.service.load(); + + assert.strictEqual(this.ticks.length, 1, 'exactly one interval is started'); + assert.strictEqual(this.service.initializationId, 4242, 'the timer id is retained for cleanup'); + assert.false(this.service.initialized, 'nothing is initialized before the first tick'); + }); + + test('polling stays uninitialized while no Leaflet global exists', function (assert) { + this.service.load(); + this.tick(5); + + assert.false(this.service.initialized); + assert.strictEqual(this.service.instance, undefined); + assert.deepEqual(this.service.instances, []); + }); + + test('the first tick that sees a Leaflet global initializes the service', function (assert) { + this.service.load(); + window.L = fakeLeaflet('first'); + + this.tick(); + + assert.true(this.service.initialized); + assert.strictEqual(this.service.instance.name, 'first'); + assert.strictEqual(window.leaflet, this.service.instance, 'both globals are aliased to the instance'); + assert.deepEqual(this.service.instances, [], 'the first load is not recorded as a re-initialization'); + }); + + test('a non-object Leaflet global is ignored', function (assert) { + this.service.load(); + window.L = function LeafletFn() {}; + + this.tick(); + + assert.false(this.service.initialized, 'only object globals count as Leaflet'); + }); + + test('further ticks with the same global change nothing', function (assert) { + this.service.load(); + const leaflet = fakeLeaflet('first'); + window.L = leaflet; + + this.tick(4); + + assert.true(this.service.initialized); + assert.strictEqual(this.service.instance, leaflet, 'the instance is stable'); + assert.deepEqual(this.service.instances, [], 'no re-initializations are recorded'); + }); + + test('a replacement global is recorded and the original instance is re-pinned', function (assert) { + this.service.load(); + const first = fakeLeaflet('first'); + window.L = first; + this.tick(); + + window.L = fakeLeaflet('second'); + this.tick(); + + assert.strictEqual(this.service.instance, first, 'the service keeps the instance it initialized with'); + assert.strictEqual(window.L, first, 'and restores window.L to it'); + assert.strictEqual(this.service.instances.length, 1, 'the re-initialization is recorded once'); + }); + + test('onReady fires once after twenty ticks and clears the timer', function (assert) { + const readyWith = []; + this.service.load({ onReady: (instance) => readyWith.push(instance) }); + + const leaflet = fakeLeaflet('first'); + window.L = leaflet; + + this.tick(19); + assert.deepEqual(readyWith, [], 'onReady has not fired yet at nineteen ticks'); + assert.deepEqual(this.cleared, [], 'and the timer is still running'); + + this.tick(); + + assert.strictEqual(readyWith.length, 1, 'onReady fires exactly once'); + assert.strictEqual(readyWith[0], leaflet, 'it receives the resolved Leaflet instance'); + assert.deepEqual(this.cleared, [4242], 'the polling timer is cleared by its id'); + }); + + test('onReady receives undefined when Leaflet never appears', function (assert) { + const readyWith = []; + this.service.load({ onReady: (instance) => readyWith.push(instance) }); + + this.tick(20); + + assert.strictEqual(readyWith.length, 1); + assert.strictEqual(readyWith[0], undefined, 'the callback still runs so callers can handle failure'); + assert.deepEqual(this.cleared, [4242]); + }); + + test('load tolerates a missing options object and a non-function onReady', function (assert) { + this.service.load(); + window.L = fakeLeaflet('first'); + + this.tick(20); + assert.deepEqual(this.cleared, [4242], 'the timer is still cleared without an onReady callback'); + + this.cleared.length = 0; + this.service.load({ onReady: 'not a function' }); + this.tick(20); + + assert.strictEqual(this.cleared.length, 1, 'a non-callable onReady is skipped rather than invoked'); + }); + + test('the counter is per-load, so a second load gets its own countdown', function (assert) { + const readyWith = []; + this.service.load({ onReady: () => readyWith.push('first-load') }); + this.tick(20); + + assert.deepEqual(readyWith, ['first-load']); + + this.service.load({ onReady: () => readyWith.push('second-load') }); + + this.tick(19); + assert.deepEqual(readyWith, ['first-load'], 'the second load restarts the count'); + + this.tick(); + assert.deepEqual(readyWith, ['first-load', 'second-load']); + }); + + test('ticks past the twentieth do not re-fire onReady', function (assert) { + let readyCount = 0; + this.service.load({ onReady: () => readyCount++ }); + + this.tick(25); + + assert.strictEqual(readyCount, 1, 'the callback is guarded by an exact tick count'); + }); + test('an instance already set is kept, and the service still finishes initializing', function (assert) { + const existing = fakeLeaflet('preset'); + this.service.instance = existing; + this.service.load(); + window.L = fakeLeaflet('later'); + + this.tick(); + this.tick(); + + assert.strictEqual(this.service.instance, existing, 'the preset instance is kept'); + assert.true(this.service.initialized, 'and the poll is marked done rather than running forever'); + assert.strictEqual(this.service.instances.length, 1, 'a later global is now noticed as a re-initialization instead of being ignored forever'); }); }); diff --git a/tests/unit/services/modals-manager-test.js b/tests/unit/services/modals-manager-test.js index 27df7f99..2c8a891a 100644 --- a/tests/unit/services/modals-manager-test.js +++ b/tests/unit/services/modals-manager-test.js @@ -1,23 +1,782 @@ import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; +import { setupTest } from 'dummy/tests/helpers'; import Service from '@ember/service'; +import { settled } from '@ember/test-helpers'; module('Unit | Service | modals-manager', function (hooks) { setupTest(hooks); hooks.beforeEach(function () { - class EventsStub extends Service { - on() {} - off() {} - trigger() {} - emit() {} - } + this.manager = this.owner.lookup('service:modals-manager'); + }); + + function open(manager, options = {}) { + manager.show('modal/layouts/alert', options); + + return manager.getTopModal(); + } + + module('stack basics', function () { + test('it starts empty', function (assert) { + assert.deepEqual(this.manager.modals, []); + assert.false(this.manager.modalIsOpened); + assert.strictEqual(this.manager.getTopModal(), null); + assert.strictEqual(this.manager.componentToRender, null); + assert.deepEqual(this.manager.options, {}); + assert.strictEqual(this.manager.modalDefer, null); + }); + + test('show pushes a modal and reports it as opened', function (assert) { + this.manager.show('my-component', { title: 'Hello' }); + + assert.true(this.manager.modalIsOpened); + assert.strictEqual(this.manager.modals.length, 1); + assert.strictEqual(this.manager.componentToRender, 'my-component'); + assert.strictEqual(this.manager.options.title, 'Hello'); + assert.ok(this.manager.modalDefer, 'a deferred is attached'); + }); + + test('show returns a promise that resolves when the modal is done', async function (assert) { + const promise = this.manager.show('my-component'); + const modal = this.manager.getTopModal(); + + await this.manager.done(modal.id); + const resolved = await promise; + + assert.strictEqual(resolved, this.manager, 'the promise resolves with the service'); + }); + + test('options merge over the defaults', function (assert) { + this.manager.show('c', { title: 'Custom', size: 'lg' }); + + assert.strictEqual(this.manager.options.title, 'Custom', 'the caller wins'); + assert.strictEqual(this.manager.options.size, 'lg'); + assert.true(this.manager.options.backdrop, 'untouched defaults are preserved'); + assert.strictEqual(this.manager.options.confirmButtonDefaultText, 'Yes'); + }); + + test('modals stack and the top modal is the most recent', function (assert) { + this.manager.show('first'); + this.manager.show('second'); + + assert.strictEqual(this.manager.modals.length, 2); + assert.strictEqual(this.manager.componentToRender, 'second'); + }); + + test('each stacked modal gets a higher z-index', function (assert) { + this.manager.show('first'); + this.manager.show('second'); + this.manager.show('third'); + + const [a, b, c] = this.manager.modals; + assert.strictEqual(a.options._zIndex, 1060); + assert.strictEqual(b.options._zIndex, 1070); + assert.strictEqual(c.options._zIndex, 1080); + }); + + test('getModalZIndex reflects stack position', function (assert) { + this.manager.show('first'); + this.manager.show('second'); + const [a, b] = this.manager.modals; + + assert.strictEqual(this.manager.getModalZIndex(a.id), 1060); + assert.strictEqual(this.manager.getModalZIndex(b.id), 1070); + assert.strictEqual(this.manager.getModalZIndex('nope'), 1060, 'an unknown id falls back to the base z-index'); + }); + + test('modals get distinct ids and a creation timestamp', function (assert) { + this.manager.show('a'); + this.manager.show('b'); + const [a, b] = this.manager.modals; + + assert.notStrictEqual(a.id, b.id); + assert.true(a.createdAt instanceof Date); + assert.true(a.isOpen); + }); + + test('getModalById finds a modal or returns null', function (assert) { + const modal = open(this.manager); + + assert.strictEqual(this.manager.getModalById(modal.id), modal); + assert.strictEqual(this.manager.getModalById('missing'), null); + }); + }); + + module('convenience dialogs', function () { + test('confirm renders the confirm layout with its modal class', function (assert) { + this.manager.confirm({ title: 'Sure?' }); + + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/confirm'); + assert.strictEqual(this.manager.options.modalClass, 'flb--confirm-modal modal-sm'); + assert.true(this.manager.options.hideTitle); + }); + + test('confirm appends a caller-supplied modal class', function (assert) { + this.manager.confirm({ modalClass: 'extra' }); + + assert.strictEqual(this.manager.options.modalClass, 'flb--confirm-modal extra'); + }); + + test('confirm works with no options', function (assert) { + this.manager.confirm(); + + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/confirm'); + }); + + test('alert hides the accept button and relabels decline', function (assert) { + this.manager.alert({ title: 'Heads up' }); + + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/alert'); + assert.true(this.manager.options.hideAcceptButton); + assert.strictEqual(this.manager.options.declineButtonText, 'OK'); + assert.strictEqual(this.manager.options.modalClass, 'flb--alert-modal modal-sm'); + }); + + test('alert appends a caller-supplied modal class', function (assert) { + this.manager.alert({ modalClass: 'extra' }); + + assert.strictEqual(this.manager.options.modalClass, 'flb--alert-modal extra'); + }); + + test('prompt and bulk render their layouts', function (assert) { + this.manager.prompt({ title: 'Name?' }); + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/prompt'); + + this.manager.bulk({ title: 'Bulk' }); + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/bulk-action'); + }); + + test('progress requires an array of promises', function (assert) { + this.manager.progress({ promises: [Promise.resolve()] }); + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/progress'); + + assert.throws(() => this.manager.progress({}), /`options.promises` must be an array/); + assert.throws(() => this.manager.progress({ promises: 'nope' }), /`options.promises` must be an array/); + }); + + test('process requires a process option', function (assert) { + this.manager.process({ process: () => {} }); + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/process'); + + assert.throws(() => this.manager.process({}), /`options.process` must be defined/); + }); + + test('displayLoader closes any open modal then shows the loader', async function (assert) { + this.manager.show('something-else'); + + await this.manager.displayLoader(); + + assert.strictEqual(this.manager.modals.length, 1, 'the previous modal was closed first'); + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/loading'); + assert.strictEqual(this.manager.options.title, 'Loading...'); + }); + + test('displayLoader accepts a custom title', async function (assert) { + await this.manager.displayLoader({ title: 'Crunching' }); + + assert.strictEqual(this.manager.options.title, 'Crunching'); + }); + + test('loader is an alias for displayLoader', async function (assert) { + await this.manager.loader({ title: 'Via alias' }); + + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/loading'); + assert.strictEqual(this.manager.options.title, 'Via alias'); + }); + }); + + module('userSelectOption', function () { + test('confirming resolves with the selected value', async function (assert) { + const promise = this.manager.userSelectOption('Pick', ['a', 'b']); + await settled(); + + const modal = this.manager.getTopModal(); + assert.strictEqual(modal.options.title, 'Pick'); + assert.deepEqual(modal.options.promptOptions, ['a', 'b']); + + modal.options.selectOption({ target: { value: 'b' } }); + modal.options.confirm(); + + assert.strictEqual(await promise, 'b'); + assert.strictEqual(this.manager.modals.length, 0, 'the modal is closed'); + }); + + test('declining resolves with null', async function (assert) { + const promise = this.manager.userSelectOption('Pick', ['a']); + await settled(); + + this.manager.getTopModal().options.decline(); + + assert.strictEqual(await promise, null); + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('confirming without choosing resolves with the initial null selection', async function (assert) { + const promise = this.manager.userSelectOption('Pick', ['a']); + await settled(); + + this.manager.getTopModal().options.confirm(); + + assert.strictEqual(await promise, null); + }); + + test('extra modal options are merged in', async function (assert) { + this.manager.userSelectOption('Pick', ['a'], { modalClass: 'custom' }); + await settled(); + + assert.strictEqual(this.manager.getTopModal().options.modalClass, 'custom'); + }); + }); + + module('confirm and decline handlers', function () { + test('onClickConfirmWithDone closes the modal when there is no confirm handler', async function (assert) { + const modal = open(this.manager); + + await this.manager.onClickConfirmWithDone(modal.id); + + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('onClickConfirmWithDone is a no-op for an unknown modal id', function (assert) { + open(this.manager); + + assert.strictEqual(this.manager.onClickConfirmWithDone('nope'), undefined); + assert.strictEqual(this.manager.modals.length, 1, 'nothing is closed'); + }); + + test('a synchronous confirm handler runs and leaves closing to it', function (assert) { + const calls = []; + const modal = open(this.manager, { confirm: (manager, done) => calls.push([manager, done]) }); + + this.manager.onClickConfirmWithDone(modal.id); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0][0], this.manager, 'the handler receives the service'); + assert.strictEqual(typeof calls[0][1], 'function', 'and a done callback'); + assert.strictEqual(this.manager.modals.length, 1, 'a sync handler must close the modal itself'); + }); + + test('the done callback handed to a confirm handler closes the modal', async function (assert) { + let doneFn; + const modal = open(this.manager, { confirm: (manager, done) => (doneFn = done) }); + + this.manager.onClickConfirmWithDone(modal.id); + await doneFn(); + + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('a promise-returning confirm handler closes the modal when it settles', async function (assert) { + const modal = open(this.manager, { confirm: () => Promise.resolve('ok') }); + + await this.manager.onClickConfirmWithDone(modal.id); + + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('a rejected confirm promise still closes the modal', async function (assert) { + const modal = open(this.manager, { confirm: () => Promise.reject(new Error('nope')) }); + + try { + await this.manager.onClickConfirmWithDone(modal.id); + } catch { + // the rejection propagates through finally; the modal must still close + } + await settled(); + + assert.strictEqual(this.manager.modals.length, 0, 'finally() closes regardless of outcome'); + }); + + test('keepOpen prevents the modal closing after confirm', async function (assert) { + const modal = open(this.manager, { keepOpen: true, confirm: () => Promise.resolve() }); + + await this.manager.onClickConfirmWithDone(modal.id); + await settled(); + + assert.strictEqual(this.manager.modals.length, 1, 'the dialog is held open deliberately'); + }); + + test('onClickDeclineWithDone closes when there is no decline handler', async function (assert) { + const modal = open(this.manager); + + await this.manager.onClickDeclineWithDone(modal.id); + + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('onClickDeclineWithDone is a no-op for an unknown modal id', function (assert) { + open(this.manager); + + assert.strictEqual(this.manager.onClickDeclineWithDone('nope'), undefined); + assert.strictEqual(this.manager.modals.length, 1); + }); + + test('a synchronous decline handler runs and leaves closing to it', function (assert) { + let called = 0; + const modal = open(this.manager, { decline: () => called++ }); + + this.manager.onClickDeclineWithDone(modal.id); + + assert.strictEqual(called, 1); + assert.strictEqual(this.manager.modals.length, 1); + }); + + test('a promise-returning decline handler closes the modal when it settles', async function (assert) { + const modal = open(this.manager, { decline: () => Promise.resolve() }); + + await this.manager.onClickDeclineWithDone(modal.id); + + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('keepOpen prevents the modal closing after decline', async function (assert) { + const modal = open(this.manager, { keepOpen: true, decline: () => Promise.resolve() }); + + await this.manager.onClickDeclineWithDone(modal.id); + await settled(); + + assert.strictEqual(this.manager.modals.length, 1); + }); + }); + + module('done and closing', function () { + test('done closes the top modal when no id is given', async function (assert) { + this.manager.show('first'); + this.manager.show('second'); + + await this.manager.done(); + + assert.strictEqual(this.manager.modals.length, 1); + assert.strictEqual(this.manager.componentToRender, 'first', 'the top modal was the one closed'); + }); + + test('done closes a specific modal from the middle of the stack', async function (assert) { + this.manager.show('first'); + this.manager.show('second'); + const [first] = this.manager.modals; + + await this.manager.done(first.id); + + assert.strictEqual(this.manager.modals.length, 1); + assert.strictEqual(this.manager.componentToRender, 'second'); + }); + + test('done resolves true when there is nothing to close', async function (assert) { + assert.true(await this.manager.done()); + assert.true(await this.manager.done('missing')); + }); + + test('done invokes the named action callback with the options', async function (assert) { + const calls = []; + const modal = open(this.manager, { onConfirm: (options) => calls.push(options) }); + + await this.manager.done(modal.id, 'onConfirm'); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0], modal.options, 'the callback receives the modal options'); + assert.true(calls[0].backdrop, 'the options include the merged defaults'); + }); + + test('done invokes onFinish regardless of the action', async function (assert) { + let finished = 0; + const modal = open(this.manager, { onFinish: () => finished++ }); + + await this.manager.done(modal.id); + + assert.strictEqual(finished, 1); + }); + + test('done runs both the action callback and onFinish', async function (assert) { + const order = []; + const modal = open(this.manager, { + onDecline: () => order.push('action'), + onFinish: () => order.push('finish'), + }); + + await this.manager.done(modal.id, 'onDecline'); - this.owner.register('service:events', EventsStub); + assert.deepEqual(order, ['action', 'finish']); + }); + + test('closeAll empties the stack', async function (assert) { + this.manager.show('a'); + this.manager.show('b'); + this.manager.show('c'); + + this.manager.closeAll(); + await settled(); + + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('closeTop closes only the top modal', async function (assert) { + this.manager.show('a'); + this.manager.show('b'); + + this.manager.closeTop(); + await settled(); + + assert.strictEqual(this.manager.modals.length, 1); + assert.strictEqual(this.manager.componentToRender, 'a'); + }); + + test('closeTop is a no-op on an empty stack', async function (assert) { + this.manager.closeTop(); + await settled(); + + assert.strictEqual(this.manager.modals.length, 0); + }); + }); + + module('option access', function () { + test('getOption reads from the top modal', function (assert) { + open(this.manager, { title: 'Top' }); + + assert.strictEqual(this.manager.getOption('title'), 'Top'); + }); + + test('getOption returns the default for a missing key', function (assert) { + open(this.manager); + + assert.strictEqual(this.manager.getOption('nope', 'fallback'), 'fallback'); + assert.strictEqual(this.manager.getOption('nope'), null, 'the default default is null'); + }); + + test('getOption returns the default when there is no modal', function (assert) { + assert.strictEqual(this.manager.getOption('title', 'fallback'), 'fallback'); + }); + + test('getOption with an array key delegates to getOptions', function (assert) { + open(this.manager, { title: 'T', size: 'lg' }); + + assert.deepEqual(this.manager.getOption(['title', 'size']), { title: 'T', size: 'lg' }); + }); + + test('getOptionForModal targets a specific modal', function (assert) { + const first = open(this.manager, { title: 'First' }); + open(this.manager, { title: 'Second' }); + + assert.strictEqual(this.manager.getOptionForModal(first.id, 'title'), 'First'); + assert.strictEqual(this.manager.getOption('title'), 'Second', 'the top modal is unaffected'); + }); + + test('getOptions returns the whole options hash when given no keys', function (assert) { + const modal = open(this.manager, { title: 'T' }); + + assert.strictEqual(this.manager.getOptions([]), modal.options); + }); + + // `show()` is normally called with an options hash; a caller that passes none leaves the + // modal without one at all. + test('getOptions returns an empty object for a modal shown with no options', function (assert) { + this.manager.show('modal/layouts/alert'); + const modal = this.manager.getTopModal(); + modal.options = undefined; + + assert.deepEqual(this.manager.getOptions([]), {}, 'an absent options hash reads as empty'); + }); + + test('getOptions returns an empty object when there is no modal', function (assert) { + assert.deepEqual(this.manager.getOptions(['title']), {}); + }); + + test('setOption updates the top modal', function (assert) { + open(this.manager, { title: 'Before' }); + + this.manager.setOption('title', 'After'); + + assert.strictEqual(this.manager.getOption('title'), 'After'); + }); + + test('setOption is a no-op when there is no modal', function (assert) { + this.manager.setOption('title', 'x'); + + assert.strictEqual(this.manager.modals.length, 0, 'nothing is created'); + }); + + test('setOptionForModal targets a specific modal', function (assert) { + const first = open(this.manager, { title: 'First' }); + open(this.manager, { title: 'Second' }); + + this.manager.setOptionForModal(first.id, 'title', 'Changed'); + + assert.strictEqual(this.manager.getOptionForModal(first.id, 'title'), 'Changed'); + assert.strictEqual(this.manager.getOption('title'), 'Second'); + }); + + test('setOptions updates several keys at once', function (assert) { + open(this.manager, { title: 'T' }); + + this.manager.setOptions({ title: 'New', size: 'sm' }); + + assert.strictEqual(this.manager.getOption('title'), 'New'); + assert.strictEqual(this.manager.getOption('size'), 'sm'); + }); + + test('setOptions is a no-op when there is no modal', function (assert) { + this.manager.setOptions({ title: 'x' }); + + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('clearOptions empties the options of the target modal', function (assert) { + const modal = open(this.manager, { title: 'T' }); + + this.manager.clearOptions(modal.id); + + assert.deepEqual(modal.options, {}); + }); + + test('clearOptions defaults to the top modal and is safe when empty', function (assert) { + const modal = open(this.manager, { title: 'T' }); + this.manager.clearOptions(); + assert.deepEqual(modal.options, {}); + + this.manager.modals = []; + this.manager.clearOptions(); + assert.strictEqual(this.manager.modals.length, 0, 'no modal to clear is not an error'); + }); + }); + + module('invoke and loading', function () { + test('invoke calls a function stored in the options', function (assert) { + open(this.manager, { doThing: (a, b) => a + b }); + + assert.strictEqual(this.manager.invoke('doThing', null, 2, 3), 5); + }); + + test('invoke returns null when the option is not a function', function (assert) { + open(this.manager, { notAFunction: 'x' }); + + assert.strictEqual(this.manager.invoke('notAFunction'), null); + assert.strictEqual(this.manager.invoke('missing'), null); + }); + + test('invoke returns null when there is no modal', function (assert) { + assert.strictEqual(this.manager.invoke('anything'), null); + }); + + test('invoke can target a specific modal', function (assert) { + const first = open(this.manager, { doThing: () => 'from first' }); + open(this.manager, { doThing: () => 'from second' }); + + assert.strictEqual(this.manager.invoke('doThing', first.id), 'from first'); + }); + + test('startLoading and stopLoading toggle the flag on the top modal', function (assert) { + open(this.manager); + + this.manager.startLoading(); + assert.true(this.manager.getOption('isLoading')); + + this.manager.stopLoading(); + assert.false(this.manager.getOption('isLoading')); + }); + + test('the ForModal loading aliases target a specific modal', function (assert) { + const first = open(this.manager); + open(this.manager); + + this.manager.startLoadingForModal(first.id); + assert.true(this.manager.getOptionForModal(first.id, 'isLoading')); + assert.strictEqual(this.manager.getOption('isLoading'), null, 'the top modal is untouched'); + + this.manager.stopLoadingForModal(first.id); + assert.false(this.manager.getOptionForModal(first.id, 'isLoading')); + }); }); - test('it exists', function (assert) { - let service = this.owner.lookup('service:modals-manager'); - assert.ok(service); + module('keyboard', function () { + test('Escape closes the top modal', async function (assert) { + this.manager.show('a'); + + this.manager.handleKeyboardEvent({ key: 'Escape' }); + await settled(); + + assert.strictEqual(this.manager.modals.length, 0); + }); + + test('Escape is ignored when the modal opts out of keyboard handling', async function (assert) { + open(this.manager, { keyboard: false }); + + this.manager.handleKeyboardEvent({ key: 'Escape' }); + await settled(); + + assert.strictEqual(this.manager.modals.length, 1); + }); + + test('other keys do nothing', async function (assert) { + this.manager.show('a'); + + this.manager.handleKeyboardEvent({ key: 'Enter' }); + await settled(); + + assert.strictEqual(this.manager.modals.length, 1); + }); + + test('Escape on an empty stack is safe', async function (assert) { + this.manager.handleKeyboardEvent({ key: 'Escape' }); + await settled(); + + assert.strictEqual(this.manager.modals.length, 0); + }); + }); + + module('events integration', function (hooks) { + let tracked; + + hooks.beforeEach(function () { + tracked = []; + this.owner.register( + 'service:events', + class extends Service { + trackEvent(...args) { + tracked.push(args); + } + } + ); + this.manager = this.owner.lookup('service:modals-manager'); + }); + + test('opening a modal is tracked', function (assert) { + this.manager.show('my-component', { title: 'T' }); + + assert.strictEqual(tracked.length, 1); + assert.strictEqual(tracked[0][0], 'ui.modal.opened'); + assert.strictEqual(tracked[0][1], 'my-component'); + }); + + test('closing a modal is tracked with the action', async function (assert) { + const modal = open(this.manager); + tracked.length = 0; + + await this.manager.done(modal.id, 'onConfirm'); + + assert.strictEqual(tracked.length, 1); + assert.strictEqual(tracked[0][0], 'ui.modal.closed'); + assert.strictEqual(tracked[0][2], 'onConfirm'); + }); + + test('the service works when no events service is registered', function (assert) { + this.owner.unregister('service:events'); + const manager = this.owner.lookup('service:modals-manager'); + + manager.show('c'); + + assert.strictEqual(manager.modals.length, 1, 'tracking is optional'); + }); + }); + // Every one of these takes a defaulted `options = {}`; a caller that passes nothing is the + // only way those defaults run. + module('the convenience dialogs with no options at all', function () { + test('alert, prompt and bulk each render their own layout', function (assert) { + this.manager.alert(); + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/alert'); + assert.strictEqual(this.manager.options.modalClass, 'flb--alert-modal modal-sm', 'the base class is applied'); + + this.manager.prompt(); + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/prompt'); + + this.manager.bulk(); + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/bulk-action'); + }); + + test('progress and process still refuse to open', function (assert) { + assert.throws(() => this.manager.progress(), /`options.promises` must be an array/); + assert.throws(() => this.manager.process(), /`options.process` must be defined/); + }); + + test('loader opens the loading layout', async function (assert) { + await this.manager.loader(); + + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/loading'); + }); + }); + + module('userSelectOption callbacks', function () { + test('it offers the prompt with no options and resolves null when declined', async function (assert) { + const selection = this.manager.userSelectOption('Pick a warehouse'); + await settled(); + + assert.strictEqual(this.manager.componentToRender, 'modal/layouts/option-prompt'); + assert.strictEqual(this.manager.options.title, 'Pick a warehouse'); + assert.deepEqual(this.manager.options.promptOptions, [], 'an empty option list is the default'); + assert.strictEqual(this.manager.options.selected, null); + + this.manager.options.decline(); + + assert.strictEqual(await selection, null, 'declining resolves with nothing selected'); + }); + + test('choosing an option records it and confirming resolves it', async function (assert) { + const selection = this.manager.userSelectOption('Pick a warehouse', ['north', 'south']); + await settled(); + + assert.deepEqual(this.manager.options.promptOptions, ['north', 'south']); + + this.manager.options.selectOption({ target: { value: 'south' } }); + assert.strictEqual(this.manager.options.selected, 'south', 'the choice is stored on the modal'); + + this.manager.options.confirm(); + + assert.strictEqual(await selection, 'south'); + }); + + test('caller-supplied modal options override the defaults', async function (assert) { + this.manager.userSelectOption('Pick a warehouse', ['north'], { title: 'Override', acceptButtonText: 'Go' }); + await settled(); + + assert.strictEqual(this.manager.options.title, 'Override', 'the spread happens last'); + assert.strictEqual(this.manager.options.acceptButtonText, 'Go'); + }); + }); + + module('reading and writing options by modal', function () { + test('getOptions with no arguments returns every option of the top modal', function (assert) { + open(this.manager, { title: 'Top', size: 'lg' }); + + const options = this.manager.getOptions(); + + assert.strictEqual(options.title, 'Top'); + assert.strictEqual(options.size, 'lg'); + }); + + test('getOptions returns nothing when there is no modal at all', function (assert) { + assert.deepEqual(this.manager.getOptions(), {}, 'with no arguments'); + assert.deepEqual(this.manager.getOptions(['title']), {}, 'and with props'); + }); + + test('getOptions can be pointed at a modal that is no longer on top', function (assert) { + const first = open(this.manager, { title: 'First' }); + open(this.manager, { title: 'Second' }); + + assert.strictEqual(this.manager.getOptions(['title']).title, 'Second', 'the top modal by default'); + assert.strictEqual(this.manager.getOptions(['title'], first.id).title, 'First', 'and an explicit id wins'); + assert.deepEqual(this.manager.getOptions(['title'], 'no-such-modal'), {}, 'an unknown id answers empty'); + }); + + test('setOptions writes to the top modal, or to the one named', function (assert) { + const first = open(this.manager, { title: 'First' }); + const second = open(this.manager, { title: 'Second' }); + + this.manager.setOptions({ title: 'Renamed' }); + assert.strictEqual(second.options.title, 'Renamed', 'the top modal is the default target'); + assert.strictEqual(first.options.title, 'First', 'and the one below is untouched'); + + this.manager.setOptions({ title: 'Also renamed' }, first.id); + assert.strictEqual(first.options.title, 'Also renamed'); + }); + + test('setOptions with nothing to write is harmless', function (assert) { + const modal = open(this.manager, { title: 'Top' }); + + this.manager.setOptions(); + + assert.strictEqual(modal.options.title, 'Top', 'the modal is left alone'); + + this.manager.setOptions({ title: 'Ignored' }, 'no-such-modal'); + + assert.strictEqual(modal.options.title, 'Top', 'and an unknown id writes nowhere'); + }); }); }); diff --git a/tests/unit/services/resource-context-panel-test.js b/tests/unit/services/resource-context-panel-test.js index 9efd4584..cf2e0597 100644 --- a/tests/unit/services/resource-context-panel-test.js +++ b/tests/unit/services/resource-context-panel-test.js @@ -1,14 +1,926 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +function contentOverlay(overrides = {}) { + return { content: 'my-panel', ...overrides }; +} + +function tabbedOverlay(overrides = {}) { + return { + tabs: [ + { key: 'details', title: 'Details', component: 'panel/details' }, + { key: 'history', label: 'History', render: () => 'history' }, + ], + ...overrides, + }; +} module('Unit | Service | resource-context-panel', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. - test('it exists', function (assert) { - // let service = this.owner.lookup('service:resource-context-panel'); - // to do fix depn service injection of notificatinos, cannoy inject notifications service here - let service = true; - assert.ok(service); + hooks.beforeEach(function () { + const routerEvents = []; + const transitions = []; + + this.routerEvents = routerEvents; + this.transitions = transitions; + + // Local router stub so no real transition is ever attempted. + this.owner.register( + 'service:router', + class RouterStub extends Service { + on(eventName, handler) { + routerEvents.push(['on', eventName, handler]); + } + + off(eventName, handler) { + routerEvents.push(['off', eventName, handler]); + } + + transitionTo(options) { + transitions.push(options); + } + } + ); + + this.service = this.owner.lookup('service:resource-context-panel'); + }); + + module('initial state', function () { + test('it starts with no overlays and no active tabs', function (assert) { + assert.deepEqual(this.service.overlays, []); + assert.deepEqual(this.service.activeTabs, {}); + assert.strictEqual(this.service.getActive(), null); + assert.false(this.service.isOpen(), 'nothing is open'); + assert.false(this.service.isOpen('anything')); + assert.deepEqual(this.service.stack(), []); + assert.strictEqual(this.service.getById('missing'), null); + assert.strictEqual(this.service.getActiveTab('missing'), null); + }); + + test('stack() returns a defensive copy', function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + + const stack = this.service.stack(); + stack.push('junk'); + + assert.strictEqual(this.service.overlays.length, 1, 'mutating the returned array does not affect the service'); + }); + }); + + module('open', function () { + test('it pushes the overlay and returns its id', function (assert) { + const id = this.service.open(contentOverlay({ id: 'panel-1' })); + + assert.strictEqual(id, 'panel-1'); + assert.strictEqual(this.service.overlays.length, 1); + assert.true(this.service.isOpen('panel-1')); + assert.true(this.service.isOpen()); + assert.strictEqual(this.service.getById('panel-1').content, 'my-panel'); + }); + + test('it generates an id and defaults the size when omitted', function (assert) { + const definition = contentOverlay(); + const id = this.service.open(definition); + + assert.true(id.startsWith('overlay-'), `generated id ${id} is namespaced`); + assert.strictEqual(definition.id, id, 'the definition is mutated in place with its id'); + assert.strictEqual(definition.size, 'sm', 'size defaults to sm'); + }); + + test('generated ids are unique per overlay', function (assert) { + const first = this.service.open(contentOverlay()); + const second = this.service.open(contentOverlay()); + + assert.notStrictEqual(first, second); + assert.strictEqual(this.service.overlays.length, 2); + }); + + test('an explicit size is preserved', function (assert) { + const definition = contentOverlay({ size: 'lg' }); + this.service.open(definition); + + assert.strictEqual(definition.size, 'lg'); + }); + + test('overlays stack in open order and getActive returns the top-most', function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + this.service.open(contentOverlay({ id: 'b' })); + + assert.deepEqual( + this.service.overlays.map((overlay) => overlay.id), + ['a', 'b'] + ); + assert.strictEqual(this.service.getActive().id, 'b', 'the last opened overlay is active'); + }); + + test('it replaces the overlays array rather than mutating it (tracking safety)', function (assert) { + const before = this.service.overlays; + this.service.open(contentOverlay({ id: 'a' })); + + assert.notStrictEqual(this.service.overlays, before, 'a new array instance is assigned'); + assert.strictEqual(before.length, 0, 'the previous array is left untouched'); + }); + + test('a tabbed overlay activates its first tab', function (assert) { + this.service.open(tabbedOverlay({ id: 'tabbed' })); + + assert.strictEqual(this.service.getActiveTab('tabbed'), 'details'); + assert.deepEqual(this.service.activeTabs, { tabbed: 'details' }); + }); + + test('initialTab overrides the first tab', function (assert) { + this.service.open(tabbedOverlay({ id: 'tabbed', initialTab: 'history' })); + + assert.strictEqual(this.service.getActiveTab('tabbed'), 'history'); + }); + + test('a content overlay registers no active tab', function (assert) { + this.service.open(contentOverlay({ id: 'plain' })); + + assert.deepEqual(this.service.activeTabs, {}); + assert.strictEqual(this.service.getActiveTab('plain'), null); + }); + + test('the onOpen hook receives the resource, model and a close callback', async function (assert) { + const resource = { id: 'record-1' }; + let received; + + this.service.open( + contentOverlay({ + id: 'hooked', + resource, + onOpen(context) { + received = context; + }, + }) + ); + + assert.strictEqual(received.resource, resource); + assert.strictEqual(received.model, resource, 'model mirrors resource when only resource is given'); + assert.strictEqual(typeof received.close, 'function'); + + await received.close(); + assert.false(this.service.isOpen('hooked'), 'the provided close callback closes this overlay'); + }); + + test('onOpen falls back to the model when no resource is given', function (assert) { + const model = { id: 'model-1' }; + let received; + + this.service.open(contentOverlay({ id: 'hooked', model, onOpen: (context) => (received = context) })); + + assert.strictEqual(received.resource, model); + assert.strictEqual(received.model, model); + }); + + test('onOpen runs only after the overlay is on the stack', function (assert) { + let openCountAtHookTime; + + this.service.open(contentOverlay({ id: 'hooked', onOpen: () => (openCountAtHookTime = this.service.overlays.length) })); + + assert.strictEqual(openCountAtHookTime, 1); + }); + }); + + module('validation', function () { + test('an overlay with neither tabs nor content is rejected', function (assert) { + assert.throws(() => this.service.open({ id: 'bad' }), /must have either tabs or content/); + assert.strictEqual(this.service.overlays.length, 0, 'nothing is added on a failed open'); + }); + + test('an overlay with both tabs and content is rejected', function (assert) { + assert.throws(() => this.service.open(tabbedOverlay({ id: 'bad', content: 'x' })), /cannot have both tabs and content/); + }); + + test('an empty tabs array is rejected', function (assert) { + assert.throws(() => this.service.open({ id: 'bad', tabs: [] }), /Tabs must be a non-empty array/); + }); + + test('a non-array tabs value is rejected', function (assert) { + assert.throws(() => this.service.open({ id: 'bad', tabs: { key: 'x' } }), /Tabs must be a non-empty array/); + }); + + test('a tab without a title or label is rejected', function (assert) { + assert.throws(() => this.service.open({ id: 'bad', tabs: [{ component: 'x' }] }), /Each tab must have/); + }); + + test('a tab without a component or render is rejected', function (assert) { + assert.throws(() => this.service.open({ id: 'bad', tabs: [{ title: 'Details' }] }), /Each tab must have/); + }); + + test('label plus render is an acceptable tab', function (assert) { + this.service.open({ id: 'ok', tabs: [{ label: 'Details', render: () => 'x' }] }); + + assert.true(this.service.isOpen('ok')); + }); + }); + + module('update', function () { + test('it merges the partial into the existing overlay', function (assert) { + this.service.open(contentOverlay({ id: 'a', size: 'sm' })); + this.service.update('a', { size: 'lg', title: 'Updated' }); + + const overlay = this.service.getById('a'); + assert.strictEqual(overlay.size, 'lg'); + assert.strictEqual(overlay.title, 'Updated'); + assert.strictEqual(overlay.content, 'my-panel', 'untouched keys survive'); + }); + + test('it preserves stack position', function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + this.service.open(contentOverlay({ id: 'b' })); + this.service.update('a', { size: 'lg' }); + + assert.deepEqual( + this.service.overlays.map((overlay) => overlay.id), + ['a', 'b'], + 'updating does not reorder the stack' + ); + }); + + test('it replaces the overlay object rather than mutating it', function (assert) { + const definition = contentOverlay({ id: 'a' }); + this.service.open(definition); + this.service.update('a', { size: 'lg' }); + + assert.notStrictEqual(this.service.getById('a'), definition, 'a new object is stored'); + assert.strictEqual(definition.size, 'sm', 'the original definition is not mutated by update'); + }); + + test('it throws for an unknown id', function (assert) { + assert.throws(() => this.service.update('nope', { size: 'lg' }), /Overlay with ID nope not found/); + }); + + test('it revalidates the merged definition', function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + + assert.throws(() => this.service.update('a', { tabs: [{ title: 'T', component: 'c' }] }), /cannot have both tabs and content/); + }); + }); + + module('close', function () { + test('it removes the overlay by id', async function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + this.service.open(contentOverlay({ id: 'b' })); + + await this.service.close('a'); + + assert.deepEqual( + this.service.overlays.map((overlay) => overlay.id), + ['b'] + ); + assert.false(this.service.isOpen('a')); + }); + + test('without an id it closes the top-most overlay', async function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + this.service.open(contentOverlay({ id: 'b' })); + + await this.service.close(); + + assert.deepEqual( + this.service.overlays.map((overlay) => overlay.id), + ['a'], + 'the most recently opened overlay is closed' + ); + }); + + test('closing an unknown id is a no-op', async function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + + await this.service.close('does-not-exist'); + + assert.strictEqual(this.service.overlays.length, 1, 'nothing is removed and nothing throws'); + }); + + test('closing with an empty stack is a no-op', async function (assert) { + await this.service.close(); + + assert.deepEqual(this.service.overlays, []); + }); + + test('it clears the active tab entry for the closed overlay only', async function (assert) { + this.service.open(tabbedOverlay({ id: 'a' })); + this.service.open(tabbedOverlay({ id: 'b' })); + + await this.service.close('a'); + + assert.deepEqual(this.service.activeTabs, { b: 'details' }); + }); + + test('it awaits an async onClose hook before removing the overlay', async function (assert) { + const order = []; + + this.service.open( + contentOverlay({ + id: 'a', + async onClose() { + order.push('hook-start'); + await Promise.resolve(); + order.push('hook-end'); + }, + }) + ); + + await this.service.close('a'); + order.push('closed'); + + assert.deepEqual(order, ['hook-start', 'hook-end', 'closed'], 'the hook completes before close resolves'); + assert.false(this.service.isOpen('a')); + }); + + test('the onClose hook receives the model and a close callback', async function (assert) { + const model = { id: 1 }; + let received; + + this.service.open(contentOverlay({ id: 'a', model, onClose: (context) => (received = context) })); + await this.service.close('a'); + + assert.strictEqual(received.model, model); + assert.strictEqual(typeof received.close, 'function'); + }); + + test('closeAll empties the stack and the tab map', async function (assert) { + this.service.open(tabbedOverlay({ id: 'a' })); + this.service.open(contentOverlay({ id: 'b' })); + this.service.open(contentOverlay({ id: 'c' })); + + await this.service.closeAll(); + + assert.deepEqual(this.service.overlays, []); + assert.deepEqual(this.service.activeTabs, {}); + assert.false(this.service.isOpen()); + }); + + test('closeAll closes from the top of the stack down', async function (assert) { + const closed = []; + const track = (id) => contentOverlay({ id, onClose: () => closed.push(id) }); + + this.service.open(track('a')); + this.service.open(track('b')); + this.service.open(track('c')); + + await this.service.closeAll(); + + assert.deepEqual(closed, ['c', 'b', 'a']); + }); + + test('closeAll on an empty stack resolves without error', async function (assert) { + await this.service.closeAll(); + + assert.deepEqual(this.service.overlays, []); + }); + }); + + module('setActiveTab', function () { + test('it switches the active tab', function (assert) { + this.service.open(tabbedOverlay({ id: 'a' })); + this.service.setActiveTab('a', 'history'); + + assert.strictEqual(this.service.getActiveTab('a'), 'history'); + }); + + test('it matches a tab by id as well as by key', function (assert) { + this.service.open({ + id: 'a', + tabs: [ + { key: 'one', title: 'One', component: 'c' }, + { id: 'two', title: 'Two', component: 'c' }, + ], + }); + this.service.setActiveTab('a', 'two'); + + assert.strictEqual(this.service.getActiveTab('a'), 'two'); + }); + + test('it throws for an unknown overlay, a tabless overlay and an unknown tab', function (assert) { + this.service.open(contentOverlay({ id: 'plain' })); + this.service.open(tabbedOverlay({ id: 'tabbed' })); + + assert.throws(() => this.service.setActiveTab('nope', 'details'), /Overlay with ID nope not found/); + assert.throws(() => this.service.setActiveTab('plain', 'details'), /does not have tabs/); + assert.throws(() => this.service.setActiveTab('tabbed', 'nope'), /Tab with key nope not found/); + assert.strictEqual(this.service.getActiveTab('tabbed'), 'details', 'the active tab is unchanged after a failed switch'); + }); + + test('a beforeLeave guard returning false blocks the switch', function (assert) { + this.service.open({ + id: 'a', + tabs: [ + { key: 'one', title: 'One', component: 'c', beforeLeave: () => false }, + { key: 'two', title: 'Two', component: 'c' }, + ], + }); + + this.service.setActiveTab('a', 'two'); + + assert.strictEqual(this.service.getActiveTab('a'), 'one', 'the guard prevented the change'); + }); + + test('a beforeLeave guard returning true allows the switch and receives context', function (assert) { + let received; + this.service.open({ + id: 'a', + model: { id: 7 }, + tabs: [ + { + key: 'one', + title: 'One', + component: 'c', + beforeLeave(context) { + received = context; + return true; + }, + }, + { key: 'two', title: 'Two', component: 'c' }, + ], + }); + + this.service.setActiveTab('a', 'two'); + + assert.strictEqual(this.service.getActiveTab('a'), 'two'); + assert.strictEqual(received.model.id, 7); + assert.strictEqual(typeof received.close, 'function'); + }); + + test('an async beforeLeave defers the switch until it resolves', async function (assert) { + let resolveGuard; + this.service.open({ + id: 'a', + tabs: [ + { key: 'one', title: 'One', component: 'c', beforeLeave: () => new Promise((resolve) => (resolveGuard = resolve)) }, + { key: 'two', title: 'Two', component: 'c' }, + ], + }); + + this.service.setActiveTab('a', 'two'); + assert.strictEqual(this.service.getActiveTab('a'), 'one', 'the tab does not change while the guard is pending'); + + resolveGuard(true); + await Promise.resolve(); + await Promise.resolve(); + + assert.strictEqual(this.service.getActiveTab('a'), 'two', 'it switches once the guard resolves truthy'); + }); + + test('an async beforeLeave resolving false keeps the current tab', async function (assert) { + this.service.open({ + id: 'a', + tabs: [ + { key: 'one', title: 'One', component: 'c', beforeLeave: () => Promise.resolve(false) }, + { key: 'two', title: 'Two', component: 'c' }, + ], + }); + + this.service.setActiveTab('a', 'two'); + await Promise.resolve(); + await Promise.resolve(); + + assert.strictEqual(this.service.getActiveTab('a'), 'one'); + }); + + test('the guard is skipped when re-selecting the already active tab', function (assert) { + let guardCalls = 0; + this.service.open({ + id: 'a', + tabs: [ + { + key: 'one', + title: 'One', + component: 'c', + beforeLeave() { + guardCalls++; + return false; + }, + }, + { key: 'two', title: 'Two', component: 'c' }, + ], + }); + + this.service.setActiveTab('a', 'one'); + + assert.strictEqual(guardCalls, 0, 'no guard runs when the tab does not change'); + assert.strictEqual(this.service.getActiveTab('a'), 'one'); + }); + + test('other overlays keep their own active tab', function (assert) { + this.service.open(tabbedOverlay({ id: 'a' })); + this.service.open(tabbedOverlay({ id: 'b' })); + + this.service.setActiveTab('b', 'history'); + + assert.strictEqual(this.service.getActiveTab('a'), 'details'); + assert.strictEqual(this.service.getActiveTab('b'), 'history'); + }); + }); + + module('bringToFront', function () { + test('it moves the overlay to the end of the stack', function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + this.service.open(contentOverlay({ id: 'b' })); + this.service.open(contentOverlay({ id: 'c' })); + + this.service.bringToFront('a'); + + assert.deepEqual( + this.service.overlays.map((overlay) => overlay.id), + ['b', 'c', 'a'] + ); + assert.strictEqual(this.service.getActive().id, 'a'); + }); + + test('bringing the front overlay forward is a no-op', function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + this.service.open(contentOverlay({ id: 'b' })); + const before = this.service.overlays; + + this.service.bringToFront('b'); + + assert.strictEqual(this.service.overlays, before, 'the array is not even reassigned'); + }); + + test('it throws for an unknown id', function (assert) { + assert.throws(() => this.service.bringToFront('nope'), /Overlay with ID nope not found/); + }); + }); + + module('closeOnTransition', function () { + test('it subscribes to routeWillChange when requested', function (assert) { + this.service.open(contentOverlay({ id: 'a', closeOnTransition: true })); + + const subscriptions = this.routerEvents.filter(([kind, event]) => kind === 'on' && event === 'routeWillChange'); + assert.strictEqual(subscriptions.length, 1, 'exactly one listener is attached'); + }); + + test('it does not subscribe otherwise', function (assert) { + this.service.open(contentOverlay({ id: 'a' })); + this.service.open(contentOverlay({ id: 'b', closeOnTransition: false })); + + assert.strictEqual(this.routerEvents.length, 0); + }); + + test('firing the handler closes the overlay and detaches the listener', async function (assert) { + this.service.open(contentOverlay({ id: 'a', closeOnTransition: true })); + const [, , handler] = this.routerEvents[0]; + + handler(); + await Promise.resolve(); + + assert.false(this.service.isOpen('a'), 'the overlay closed on transition'); + assert.true( + this.routerEvents.some(([kind, , fn]) => kind === 'off' && fn === handler), + 'the same handler is detached' + ); + }); + + test('closing normally detaches the transition listener', async function (assert) { + this.service.open(contentOverlay({ id: 'a', closeOnTransition: true })); + const [, , handler] = this.routerEvents[0]; + + await this.service.close('a'); + + assert.deepEqual( + this.routerEvents.filter(([kind]) => kind === 'off').map(([, , fn]) => fn), + [handler], + 'the listener is unregistered exactly once' + ); + }); + }); + + module('routeSync', function () { + test('opening a synced overlay writes the panel id to query params', function (assert) { + this.service.open(contentOverlay({ id: 'a', routeSync: true })); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'a' } }]); + }); + + test('opening a synced tabbed overlay includes the active tab', function (assert) { + this.service.open(tabbedOverlay({ id: 'a', routeSync: true })); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'a', panel_tab: 'details' } }]); + }); + + test('switching tabs on a synced overlay updates the query params', function (assert) { + this.service.open(tabbedOverlay({ id: 'a', routeSync: true })); + this.transitions.length = 0; + + this.service.setActiveTab('a', 'history'); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'a', panel_tab: 'history' } }]); + }); + + test('closing a synced overlay clears the query params', async function (assert) { + this.service.open(contentOverlay({ id: 'a', routeSync: true })); + this.transitions.length = 0; + + await this.service.close('a'); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: null, panel_tab: null } }]); + }); + + test('an unsynced overlay never touches the router', async function (assert) { + this.service.open(tabbedOverlay({ id: 'a' })); + this.service.setActiveTab('a', 'history'); + await this.service.close('a'); + + assert.deepEqual(this.transitions, [], 'no transitions are attempted'); + }); + }); + + test('the service survives a full open/close/reopen cycle', async function (assert) { + const id = this.service.open(tabbedOverlay()); + + this.service.setActiveTab(id, 'history'); + await this.service.close(id); + + assert.deepEqual(this.service.overlays, []); + assert.deepEqual(this.service.activeTabs, {}); + + const second = this.service.open(tabbedOverlay()); + + assert.notStrictEqual(second, id, 'a fresh id is generated'); + assert.strictEqual(this.service.getActiveTab(second), 'details', 'tab state is rebuilt from scratch'); + assert.strictEqual(this.service.overlays.length, 1); + }); + + module('route synchronisation', function () { + test('opening a route-synced overlay writes its id to the query params', function (assert) { + const id = this.service.open(contentOverlay({ id: 'panel-1', routeSync: true })); + + assert.strictEqual(id, 'panel-1'); + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'panel-1' } }], 'only the id is synced for a content overlay'); + }); + + test('a route-synced tabbed overlay also writes its active tab', function (assert) { + this.service.open(tabbedOverlay({ id: 'panel-2', routeSync: true })); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'panel-2', panel_tab: 'details' } }]); + }); + + test('a route-synced overlay opened on a named initial tab syncs that tab', function (assert) { + this.service.open(tabbedOverlay({ id: 'panel-3', routeSync: true, initialTab: 'history' })); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'panel-3', panel_tab: 'history' } }]); + }); + + test('updating a route-synced overlay re-syncs it', function (assert) { + this.service.open(contentOverlay({ id: 'panel-4', routeSync: true })); + this.transitions.length = 0; + + this.service.update('panel-4', { title: 'Renamed' }); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'panel-4' } }]); + assert.strictEqual(this.service.getById('panel-4').title, 'Renamed'); + }); + + test('updating an overlay that is not route-synced transitions nothing', function (assert) { + this.service.open(contentOverlay({ id: 'panel-5' })); + + this.service.update('panel-5', { title: 'Renamed' }); + + assert.deepEqual(this.transitions, []); + }); + + test('changing tab on a route-synced overlay syncs the new tab', function (assert) { + this.service.open(tabbedOverlay({ id: 'panel-6', routeSync: true })); + this.transitions.length = 0; + + this.service.setActiveTab('panel-6', 'history'); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'panel-6', panel_tab: 'history' } }]); + assert.strictEqual(this.service.getActiveTab('panel-6'), 'history'); + }); + + test('changing tab on an unsynced overlay transitions nothing', function (assert) { + this.service.open(tabbedOverlay({ id: 'panel-7' })); + + this.service.setActiveTab('panel-7', 'history'); + + assert.deepEqual(this.transitions, []); + assert.strictEqual(this.service.getActiveTab('panel-7'), 'history'); + }); + + test('closing a route-synced overlay clears both query params', async function (assert) { + this.service.open(contentOverlay({ id: 'panel-8', routeSync: true })); + this.transitions.length = 0; + + await this.service.close('panel-8'); + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: null, panel_tab: null } }]); + }); + + test('closing an unsynced overlay clears nothing', async function (assert) { + this.service.open(contentOverlay({ id: 'panel-9' })); + + await this.service.close('panel-9'); + + assert.deepEqual(this.transitions, []); + }); + }); + + module('asynchronous hooks', function () { + test('close waits for a promise-returning onClose before removing the overlay', async function (assert) { + const order = []; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + + this.service.open( + contentOverlay({ + id: 'panel-async', + onClose: () => { + order.push('onClose'); + return gate; + }, + }) + ); + + const closing = this.service.close('panel-async'); + + assert.deepEqual(order, ['onClose'], 'the hook has been called'); + assert.strictEqual(this.service.overlays.length, 1, 'but the overlay is still open while the hook is pending'); + + release(); + await closing; + + assert.deepEqual(this.service.overlays, [], 'it is removed once the hook settles'); + }); + + test('onClose receives the model and a working close callback', async function (assert) { + const received = []; + const model = { id: 'ord_1' }; + + this.service.open(contentOverlay({ id: 'panel-hook', model, onClose: (context) => received.push(context) })); + + await this.service.close('panel-hook'); + + assert.strictEqual(received.length, 1); + assert.strictEqual(received[0].model, model); + assert.strictEqual(typeof received[0].close, 'function'); + }); + + test('an async beforeLeave guard switches the tab once it resolves true', async function (assert) { + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + + this.service.open({ + id: 'panel-guard', + tabs: [ + { key: 'details', title: 'Details', component: 'panel/details', beforeLeave: () => gate }, + { key: 'history', title: 'History', component: 'panel/history' }, + ], + }); + + this.service.setActiveTab('panel-guard', 'history'); + assert.strictEqual(this.service.getActiveTab('panel-guard'), 'details', 'the tab does not change while the guard is pending'); + + release(true); + await gate; + + assert.strictEqual(this.service.getActiveTab('panel-guard'), 'history', 'it changes once the guard allows it'); + }); + + test('an async beforeLeave guard resolving false keeps the current tab', async function (assert) { + this.service.open({ + id: 'panel-blocked', + tabs: [ + { key: 'details', title: 'Details', component: 'panel/details', beforeLeave: () => Promise.resolve(false) }, + { key: 'history', title: 'History', component: 'panel/history' }, + ], + }); + + this.service.setActiveTab('panel-blocked', 'history'); + await Promise.resolve(); + await Promise.resolve(); + + assert.strictEqual(this.service.getActiveTab('panel-blocked'), 'details', 'the guard refused the change'); + }); + + test('an async guard on a route-synced overlay syncs only after it resolves', async function (assert) { + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + + this.service.open({ + id: 'panel-guard-sync', + routeSync: true, + tabs: [ + { key: 'details', title: 'Details', component: 'panel/details', beforeLeave: () => gate }, + { key: 'history', title: 'History', component: 'panel/history' }, + ], + }); + this.transitions.length = 0; + + this.service.setActiveTab('panel-guard-sync', 'history'); + assert.deepEqual(this.transitions, [], 'nothing is synced while the guard is pending'); + + release(true); + await gate; + + assert.deepEqual(this.transitions, [{ queryParams: { panel_id: 'panel-guard-sync', panel_tab: 'history' } }]); + }); + }); + // The route-sync helpers all begin `if (!this.router) return;`. That guard exists for hosts + // that never inject a router, and every test so far has had one. + module('with no router injected', function (hooks) { + hooks.beforeEach(function () { + this.service = this.owner.lookup('service:resource-context-panel'); + this.service.router = null; + }); + + test('opening a route-synced overlay still records it and reports no transition', function (assert) { + const id = this.service.open(contentOverlay({ id: 'panel-1', title: 'Details', routeSync: true })); + + assert.strictEqual(id, 'panel-1', 'the overlay is opened'); + assert.strictEqual(this.service.overlays.length, 1); + assert.deepEqual(this.transitions, [], 'nothing is pushed into the URL'); + }); + + test('setting the active tab still records it', function (assert) { + const id = this.service.open(tabbedOverlay({ id: 'panel-2', routeSync: true })); + + this.service.setActiveTab(id, 'history'); + + assert.strictEqual(this.service.getActiveTab(id), 'history'); + assert.deepEqual(this.transitions, []); + }); + + test('closing still removes the overlay', async function (assert) { + const id = this.service.open(contentOverlay({ id: 'panel-3', routeSync: true })); + + await this.service.close(id); + + assert.strictEqual(this.service.overlays.length, 0); + assert.deepEqual(this.transitions, []); + }); + }); + // Both hooks are handed a `close` callback so they can dismiss the overlay themselves; neither + // callback had ever been invoked. + module('the close callback handed to the lifecycle hooks', function () { + test('onClose receives one that closes the overlay it belongs to', async function (assert) { + const service = this.owner.lookup('service:resource-context-panel'); + let handed; + + const id = service.open( + contentOverlay({ + id: 'panel-close', + onClose: ({ close }) => { + handed = close; + }, + }) + ); + + await service.close(id); + + assert.strictEqual(typeof handed, 'function', 'the hook is given a close callback'); + assert.strictEqual(service.overlays.length, 0, 'the overlay is already gone'); + + handed(); + + assert.strictEqual(service.overlays.length, 0, 'calling it again is harmless'); + }); + + test('a tab beforeLeave guard receives one that closes the overlay', function (assert) { + const service = this.owner.lookup('service:resource-context-panel'); + let handed; + + const id = service.open({ + id: 'panel-tabs', + tabs: [ + { + key: 'details', + title: 'Details', + component: 'panel/details', + beforeLeave: ({ close }) => { + handed = close; + return true; + }, + }, + { key: 'history', title: 'History', component: 'panel/history' }, + ], + }); + + service.setActiveTab(id, 'history'); + + assert.strictEqual(typeof handed, 'function', 'the guard is given a close callback'); + assert.strictEqual(service.getActiveTab(id), 'history', 'and the tab change went ahead'); + + handed(); + + assert.strictEqual(service.overlays.length, 0, 'invoking it dismisses the overlay'); + }); + }); + test('opening nothing at all is refused with a useful error', function (assert) { + const service = this.owner.lookup('service:resource-context-panel'); + + assert.throws(() => service.open(), /Overlay definition is required/, 'with no argument'); + assert.throws(() => service.open(null), /Overlay definition is required/, 'and with an explicit null'); + assert.strictEqual(service.overlays.length, 0, 'nothing is left behind'); }); }); diff --git a/tests/unit/services/resource-registry-test.js b/tests/unit/services/resource-registry-test.js index 10baa7ba..e38f6d52 100644 --- a/tests/unit/services/resource-registry-test.js +++ b/tests/unit/services/resource-registry-test.js @@ -13,7 +13,14 @@ module('Unit | Service | resource-registry', function (hooks) { const [vehicle] = service.registerDescriptors([{ key: 'vehicle', aliases: ['attachable-vehicle'], identifier: (r) => r.plate }]); assert.strictEqual(vehicle.key, 'vehicle'); assert.strictEqual(service.register({ key: 'driver' }).key, 'driver'); - assert.ok(service.descriptors.some((d) => d.key === 'vehicle') && service.descriptors.some((d) => d.key === 'driver')); + assert.ok( + service.descriptors.some((d) => d.key === 'vehicle'), + 'the vehicle descriptor is registered' + ); + assert.ok( + service.descriptors.some((d) => d.key === 'driver'), + 'the driver descriptor is registered' + ); assert.strictEqual(service.resolveKey('attachable-vehicle'), 'vehicle'); assert.strictEqual(service.getDescriptor('vehicle').key, 'vehicle'); assert.strictEqual(service.componentName('pill', 'vehicle'), 'vehicle/pill'); diff --git a/tests/unit/services/sidebar-navigator-test.js b/tests/unit/services/sidebar-navigator-test.js new file mode 100644 index 00000000..a3023307 --- /dev/null +++ b/tests/unit/services/sidebar-navigator-test.js @@ -0,0 +1,292 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class AbilitiesStub extends Service { + denied = new Set(); + throwFor = new Set(); + + can(permission) { + if (this.throwFor.has(permission)) { + throw new Error(`Permission check failed for ${permission}`); + } + + return !this.denied.has(permission); + } +} + +module('Unit | Service | sidebar-navigator', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:abilities', AbilitiesStub); + this.service = this.owner.lookup('service:sidebar-navigator'); + }); + + // Every method takes defaulted parameters. A component test always supplies them; calling the + // service directly with no arguments at all is the only way those defaults run. + module('called with no arguments at all', function () { + test('every entry point answers safely', function (assert) { + assert.deepEqual(this.service.normalizeItems(), [], 'normalizeItems'); + assert.true(this.service.isVisible(), 'isVisible treats a missing item as visible'); + assert.false(this.service.hasVisibleTarget(), 'hasVisibleTarget'); + assert.true(this.service.hasRequiredVisibleChildren(), 'hasRequiredVisibleChildren'); + assert.deepEqual(this.service.flattenItems(), [], 'flattenItems'); + assert.deepEqual(this.service.searchItems(), [], 'searchItems'); + assert.deepEqual(this.service.normalizeSearchResults(), [], 'normalizeSearchResults'); + assert.deepEqual(this.service.activePath(), [], 'activePath'); + assert.false(this.service.isActive(), 'isActive'); + assert.strictEqual(this.service.breadcrumb(), '', 'breadcrumb'); + }); + }); + + module('normalizeItems', function () { + test('anything that is not an array is normalised to an empty one', function (assert) { + assert.deepEqual(this.service.normalizeItems('not an array'), []); + assert.deepEqual(this.service.normalizeItems(null), []); + assert.deepEqual(this.service.normalizeItems({ label: 'Orders' }), []); + }); + + test('an item with no navigable target is dropped', function (assert) { + const items = this.service.normalizeItems([{ label: 'Orders', route: 'console.orders' }, { label: 'Nowhere' }]); + + assert.deepEqual( + items.map((item) => item.label), + ['Orders'] + ); + }); + + test('a url, an onClick or children each count as a target', function (assert) { + const items = this.service.normalizeItems([ + { label: 'Docs', url: 'https://example.test' }, + { label: 'Invite', onClick() {} }, + { label: 'Settings', children: [{ label: 'General', route: 'console.settings' }] }, + ]); + + assert.deepEqual( + items.map((item) => item.label), + ['Docs', 'Invite', 'Settings'] + ); + }); + + test('children are normalised recursively', function (assert) { + const [settings] = this.service.normalizeItems([ + { + label: 'Settings', + children: [{ label: 'General', route: 'console.settings' }, { label: 'Nowhere' }], + }, + ]); + + assert.deepEqual( + settings.children.map((child) => child.label), + ['General'], + 'the targetless child is dropped too' + ); + }); + + test('an item marked not visible is dropped', function (assert) { + const items = this.service.normalizeItems([{ label: 'Orders', route: 'console.orders', visible: false }]); + + assert.deepEqual(items, []); + }); + + test('requiresVisibleChildren keeps a branch only when it has a non-hub child', function (assert) { + const items = this.service.normalizeItems([ + { + label: 'Hub only', + requiresVisibleChildren: true, + children: [{ label: 'Hub', isNavigationHub: true, route: 'console.hub' }], + }, + { + label: 'Has content', + requiresVisibleChildren: true, + children: [ + { label: 'Hub', isNavigationHub: true, route: 'console.hub' }, + { label: 'Orders', route: 'console.orders' }, + ], + }, + ]); + + assert.deepEqual( + items.map((item) => item.label), + ['Has content'] + ); + }); + }); + + module('permissions', function () { + test('a denied visiblePermission hides the item', function (assert) { + const abilities = this.owner.lookup('service:abilities'); + abilities.denied.add('see orders'); + + assert.false(this.service.isVisible({ label: 'Orders', visiblePermission: 'see orders' })); + assert.true(this.service.isVisible({ label: 'Orders', visiblePermission: 'see drivers' })); + }); + + test('an explicit permission decides visibility on its own', function (assert) { + const abilities = this.owner.lookup('service:abilities'); + abilities.denied.add('list orders'); + + assert.false(this.service.isVisible({ label: 'Orders', permission: 'list orders' })); + assert.true(this.service.isVisible({ label: 'Orders', permission: 'list drivers' })); + }); + + test('a permission check that throws fails closed', function (assert) { + const abilities = this.owner.lookup('service:abilities'); + abilities.throwFor.add('list orders'); + + assert.false(this.service.can('list orders'), 'the error is swallowed as a refusal'); + assert.false(this.service.isVisible({ label: 'Orders', permission: 'list orders' })); + }); + }); + + module('searchItems', function () { + const ITEMS = [ + { + label: 'Settings', + children: [{ label: 'Service Rates', description: 'Pricing rules', keywords: ['pricing'] }], + }, + { title: 'Reports', url: '/reports' }, + ]; + + test('a blank query matches nothing rather than everything', function (assert) { + assert.deepEqual(this.service.searchItems(ITEMS, ''), [], 'an empty query'); + assert.deepEqual(this.service.searchItems(ITEMS, ' '), [], 'and whitespace alone'); + }); + + test('it matches a label, a title, a description, a url and a keyword', function (assert) { + const labelsFor = (query) => this.service.searchItems(ITEMS, query).map(({ item }) => item.label ?? item.title); + + assert.deepEqual(labelsFor('service'), ['Service Rates'], 'by label'); + assert.deepEqual(labelsFor('reports'), ['Reports'], 'by title'); + assert.deepEqual(labelsFor('pricing rules'), ['Service Rates'], 'by description'); + assert.deepEqual(labelsFor('/reports'), ['Reports'], 'by url'); + assert.deepEqual(labelsFor('pricing'), ['Service Rates'], 'by keyword'); + }); + + test('a child is found through its ancestor label', function (assert) { + const found = this.service.searchItems(ITEMS, 'settings').map(({ item }) => item.label); + + assert.true(found.includes('Service Rates'), 'the parent name reaches its children'); + }); + + test('each result carries the path that leads to it', function (assert) { + const [result] = this.service.searchItems(ITEMS, 'service rates'); + + assert.deepEqual( + result.path.map((item) => item.label), + ['Settings', 'Service Rates'] + ); + }); + }); + + module('normalizeSearchResults', function () { + test('anything that is not an array becomes an empty one', function (assert) { + assert.deepEqual(this.service.normalizeSearchResults('nope'), []); + assert.deepEqual(this.service.normalizeSearchResults(null), []); + }); + + test('empty entries are dropped', function (assert) { + const results = this.service.normalizeSearchResults([null, undefined, { label: 'Kept' }]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].label, 'Kept'); + }); + + test('a bare result becomes its own item and falls back to its title', function (assert) { + const [result] = this.service.normalizeSearchResults([{ title: 'Tyler Demo' }]); + + assert.strictEqual(result.label, 'Tyler Demo', 'the title stands in for a missing label'); + assert.deepEqual(result.item, { title: 'Tyler Demo' }, 'and the result is its own item'); + assert.strictEqual(result.type, 'Result', 'with a default type'); + }); + + test('an explicit item, label and type are all kept', function (assert) { + const item = { id: 'user_1' }; + const [result] = this.service.normalizeSearchResults([{ item, label: 'Ada', type: 'User', breadcrumb: 'People > Ada' }]); + + assert.strictEqual(result.item, item); + assert.strictEqual(result.label, 'Ada'); + assert.strictEqual(result.type, 'User', 'a supplied type overrides the default'); + assert.strictEqual(result.breadcrumb, 'People > Ada'); + }); + }); + + module('isActive and activePath', function () { + test('a route matches by prefix', function (assert) { + assert.true(this.service.isActive({ route: 'console.orders' }, 'console.orders.index')); + assert.false(this.service.isActive({ route: 'console.orders' }, 'console.drivers.index')); + assert.false(this.service.isActive({ route: 'console.orders' }, undefined), 'with no current route, nothing is active'); + }); + + test('a url matches exactly', function (assert) { + assert.true(this.service.isActive({ url: '/reports' }, undefined, '/reports')); + assert.false(this.service.isActive({ url: '/reports' }, undefined, '/reports/daily'), 'a url is not a prefix match'); + }); + + test('an activeWhen predicate wins when it returns true', function (assert) { + const item = { route: 'console.orders', activeWhen: () => true }; + + assert.true(this.service.isActive(item, 'somewhere.else'), 'the predicate decides'); + }); + + test('a predicate returning false falls through to the route match', function (assert) { + const item = { route: 'console.orders', activeWhen: () => false }; + + assert.true(this.service.isActive(item, 'console.orders.index'), 'the route still matches'); + assert.false(this.service.isActive(item, 'console.drivers.index'), 'and still fails when it should'); + }); + + test('a predicate that throws falls through rather than failing', function (assert) { + const item = { + route: 'console.orders', + activeWhen: () => { + throw new Error('the host application blew up deciding'); + }, + }; + + assert.true(this.service.isActive(item, 'console.orders.index'), 'the route match takes over'); + }); + + test('activePath returns the trail down to the active descendant', function (assert) { + const items = [ + { label: 'Orders', route: 'console.orders' }, + { + label: 'Settings', + children: [{ label: 'General', route: 'console.settings.general' }], + }, + ]; + + const path = this.service.activePath(items, 'console.settings.general.index'); + + assert.deepEqual( + path.map((item) => item.label), + ['Settings', 'General'] + ); + }); + + test('activePath is empty when nothing matches', function (assert) { + const items = [{ label: 'Orders', route: 'console.orders' }]; + + assert.deepEqual(this.service.activePath(items, 'console.drivers.index'), []); + }); + }); + + module('flattenItems and breadcrumb', function () { + test('an item with no children flattens to itself', function (assert) { + const flattened = this.service.flattenItems([{ label: 'Orders' }]); + + assert.strictEqual(flattened.length, 1); + assert.deepEqual( + flattened[0].path.map((item) => item.label), + ['Orders'] + ); + }); + + test('a breadcrumb joins the labels, falling back to titles and skipping blanks', function (assert) { + const path = [{ label: 'Settings' }, { title: 'Service Rates' }, { icon: 'gear' }]; + + assert.strictEqual(this.service.breadcrumb(path), 'Settings > Service Rates'); + }); + }); +}); diff --git a/tests/unit/services/sidebar-test.js b/tests/unit/services/sidebar-test.js index 682f468d..d368b28f 100644 --- a/tests/unit/services/sidebar-test.js +++ b/tests/unit/services/sidebar-test.js @@ -131,4 +131,149 @@ module('Unit | Service | sidebar', function (hooks) { service.hideNow(); assert.strictEqual(service.state, 'hidden'); }); + // Every one of these entry points has a defaulted parameter, and a caller that supplies + // nothing is the only way those defaults run. + module('called with no arguments', function () { + test('the setters fall back to their defaults', function (assert) { + const service = this.owner.lookup('service:sidebar'); + + service.setVisualState('minimized'); + service.setVisualState(); + assert.strictEqual(service.state, 'visible', 'setVisualState defaults to visible'); + + service.setEnabled(false); + service.setEnabled(); + assert.true(service.isEnabled, 'setEnabled defaults to true'); + }); + + test('transitionTo defaults to visible and syncContextToState to the current state', function (assert) { + const service = this.owner.lookup('service:sidebar'); + const calls = []; + service.registerContext({ + show: () => calls.push('show'), + hide: (immediate) => calls.push(`hide:${immediate}`), + minimize: () => calls.push('minimize'), + }); + + service.setVisualState('minimized'); + service.syncContextToState(); + assert.deepEqual(calls, ['minimize'], 'with no state it syncs the state it already has'); + + service.transitionTo(); + assert.strictEqual(service.state, 'visible'); + assert.deepEqual(calls, ['minimize', 'show'], 'and transitionTo lands on visible'); + }); + }); + + module('while the sidebar is disabled', function () { + test('an ordinary transition is refused', function (assert) { + const service = this.owner.lookup('service:sidebar'); + const calls = []; + service.registerContext({ show: () => calls.push('show'), hide: () => calls.push('hide'), minimize: () => calls.push('minimize') }); + + service.disable(); + const stateAfterDisable = service.state; + calls.length = 0; + + service.show(); + service.minimize(); + + assert.strictEqual(service.state, stateAfterDisable, 'the state is untouched'); + assert.deepEqual(calls, [], 'and the context is never told to move'); + }); + + test('disabling twice is a no-op that preserves the remembered state', function (assert) { + const service = this.owner.lookup('service:sidebar'); + + service.setVisualState('minimized'); + service.disable(); + assert.strictEqual(service.previousState, 'minimized', 'the state before disabling is remembered'); + + service.disable(); + + assert.strictEqual(service.previousState, 'minimized', 'the second call does not overwrite it with "hidden"'); + assert.true(service.isHidden); + }); + + test('toggling is refused', function (assert) { + const service = this.owner.lookup('service:sidebar'); + + service.disable(); + const before = service.state; + service.toggle(); + + assert.strictEqual(service.state, before); + }); + + test('hiding re-enables the sidebar unless asked to preserve the disabled flag', function (assert) { + const service = this.owner.lookup('service:sidebar'); + + service.disable(); + assert.true(service.isDisabled); + + service.hide(); + + assert.true(service.isEnabled, 'an explicit hide takes the sidebar back out of the disabled state'); + assert.true(service.isHidden); + + service.disable(); + service.hide({ preserveDisabled: true }); + + assert.true(service.isDisabled, 'unless the caller says otherwise'); + }); + + test('enable with no remembered state falls back to visible', function (assert) { + const service = this.owner.lookup('service:sidebar'); + + service.previousState = null; + service.setEnabled(false); + service.enable(); + + assert.strictEqual(service.state, 'visible'); + assert.true(service.isEnabled); + }); + }); + + test('hide accepts a bare boolean as the immediate flag', function (assert) { + const service = this.owner.lookup('service:sidebar'); + const hides = []; + service.registerContext({ show() {}, minimize() {}, hide: (immediate) => hides.push(immediate) }); + + service.hide(true); + service.hide(false); + service.hide(); + + assert.deepEqual(hides, [true, false, false], 'the boolean form is passed straight through to the context'); + }); + + module('reaching into the registered context', function () { + test('with no context the accessors answer undefined rather than throwing', function (assert) { + const service = this.owner.lookup('service:sidebar'); + + assert.strictEqual(service.getComponent(), undefined); + assert.strictEqual(service.getElement(), undefined); + assert.strictEqual(service.getGutterElement(), undefined); + }); + + test('with a context they reach through to the component and its nodes', function (assert) { + const service = this.owner.lookup('service:sidebar'); + const sidebarNode = document.createElement('div'); + const gutterNode = document.createElement('div'); + const component = { sidebarNode, gutterNode }; + service.registerContext({ component }); + + assert.strictEqual(service.getComponent(), component); + assert.strictEqual(service.getElement(), sidebarNode); + assert.strictEqual(service.getGutterElement(), gutterNode); + }); + + test('a context with no component still answers safely', function (assert) { + const service = this.owner.lookup('service:sidebar'); + service.registerContext({}); + + assert.strictEqual(service.getComponent(), undefined); + assert.strictEqual(service.getElement(), undefined, 'the second hop is guarded too'); + assert.strictEqual(service.getGutterElement(), undefined); + }); + }); }); diff --git a/tests/unit/services/table-context-test.js b/tests/unit/services/table-context-test.js new file mode 100644 index 00000000..cc79eea3 --- /dev/null +++ b/tests/unit/services/table-context-test.js @@ -0,0 +1,96 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import TableContextService from '@fleetbase/ember-ui/services/table-context'; + +// @fleetbase/ember-core ships its own `app/services/table-context.js`, which +// shadows this addon's re-export — `owner.lookup('service:table-context')` +// resolves to ember-core's older, non-null-safe copy. Register this addon's +// class under its own name so these tests exercise (and cover) our source. +module('Unit | Service | table-context', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:ui-table-context', TableContextService); + this.service = this.owner.lookup('service:ui-table-context'); + }); + + test('it starts with no node and no table', function (assert) { + assert.strictEqual(this.service.node, undefined); + assert.strictEqual(this.service.table, undefined); + }); + + test('getSelectedIds returns an empty array when no table is registered', function (assert) { + assert.deepEqual(this.service.getSelectedIds(), [], 'a missing table is not an error'); + }); + + test('getSelectedRows returns an empty array when no table is registered', function (assert) { + assert.deepEqual(this.service.getSelectedRows(), []); + }); + + test('untoggleSelectAll is a no-op when no table is registered', function (assert) { + assert.strictEqual(this.service.untoggleSelectAll(), undefined); + }); + + test('getSelectedIds maps the ids of the selected rows', function (assert) { + this.service.table = { selectedRows: [{ id: 'a' }, { id: 'b' }] }; + + assert.deepEqual(this.service.getSelectedIds(), ['a', 'b']); + }); + + test('getSelectedIds returns an empty array when the table has no selectedRows', function (assert) { + this.service.table = {}; + + assert.deepEqual(this.service.getSelectedIds(), []); + }); + + test('getSelectedIds preserves undefined ids rather than dropping rows', function (assert) { + this.service.table = { selectedRows: [{ id: 'a' }, {}] }; + + assert.deepEqual(this.service.getSelectedIds(), ['a', undefined], 'the mapping is 1:1 with the rows'); + }); + + test('getSelectedRows returns the live selection', function (assert) { + const selectedRows = [{ id: 'a' }]; + this.service.table = { selectedRows }; + + assert.strictEqual(this.service.getSelectedRows(), selectedRows, 'the same array instance is handed back'); + }); + + test('getSelectedRows falls back to an empty array when selectedRows is absent', function (assert) { + this.service.table = {}; + + assert.deepEqual(this.service.getSelectedRows(), []); + }); + + test('untoggleSelectAll delegates to the registered table', function (assert) { + let calls = 0; + this.service.table = { untoggleSelectAll: () => ++calls }; + + const result = this.service.untoggleSelectAll(); + + assert.strictEqual(calls, 1, 'the table method is invoked'); + assert.strictEqual(result, 1, 'its return value is passed through'); + }); + + test('untoggleSelectAll is safe when the table does not implement it', function (assert) { + this.service.table = {}; + + assert.strictEqual(this.service.untoggleSelectAll(), undefined); + }); + + test('node and table are tracked and can be reassigned', function (assert) { + const node = document.createElement('table'); + this.service.node = node; + this.service.table = { selectedRows: [{ id: 'x' }] }; + + assert.strictEqual(this.service.node, node); + assert.deepEqual(this.service.getSelectedIds(), ['x']); + + this.service.table = { selectedRows: [] }; + assert.deepEqual(this.service.getSelectedIds(), [], 'replacing the table replaces the selection'); + }); + + test('the service is a singleton within one owner', function (assert) { + assert.strictEqual(this.owner.lookup('service:ui-table-context'), this.service); + }); +}); diff --git a/tests/unit/services/template-builder-test.js b/tests/unit/services/template-builder-test.js new file mode 100644 index 00000000..908c1f35 --- /dev/null +++ b/tests/unit/services/template-builder-test.js @@ -0,0 +1,142 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +function type(label, value, icon = 'box') { + return { label, value, icon }; +} + +module('Unit | Service | template-builder', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.service = this.owner.lookup('service:template-builder'); + }); + + test('it starts with no registered resource types', function (assert) { + assert.deepEqual(this.service.resourceTypes, []); + }); + + test('registering adds the types in order', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order'), type('Driver', 'Models\\Driver')]); + + assert.deepEqual( + this.service.resourceTypes.map((t) => t.label), + ['Order', 'Driver'] + ); + }); + + test('registering appends rather than replacing', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + this.service.registerResourceTypes([type('Driver', 'Models\\Driver')]); + + assert.strictEqual(this.service.resourceTypes.length, 2, 'a second extension adds to the first'); + }); + + test('duplicate values are ignored', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + this.service.registerResourceTypes([type('Order Renamed', 'Models\\Order')]); + + assert.strictEqual(this.service.resourceTypes.length, 1, 'the value is the identity'); + assert.strictEqual(this.service.resourceTypes[0].label, 'Order', 'the first registration wins'); + }); + + test('duplicates within a single call are still added once each', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order'), type('Driver', 'Models\\Driver')]); + + assert.strictEqual(this.service.resourceTypes.length, 2); + }); + + test('types without a value are rejected', function (assert) { + this.service.registerResourceTypes([{ label: 'No value', icon: 'box' }, type('Ok', 'Models\\Ok')]); + + assert.deepEqual( + this.service.resourceTypes.map((t) => t.value), + ['Models\\Ok'], + 'a type with no value cannot be identified so it is dropped' + ); + }); + + test('registering an empty list is a no-op', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + this.service.registerResourceTypes([]); + + assert.strictEqual(this.service.resourceTypes.length, 1); + }); + + test('registering with no argument is a no-op', function (assert) { + this.service.registerResourceTypes(); + + assert.deepEqual(this.service.resourceTypes, []); + }); + + test('registering only duplicates leaves the list identical', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + const before = this.service.resourceTypes; + + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + + assert.strictEqual(this.service.resourceTypes, before, 'the tracked array is not reassigned when nothing is new'); + }); + + test('unregistering removes the named values', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order'), type('Driver', 'Models\\Driver')]); + + this.service.unregisterResourceTypes(['Models\\Order']); + + assert.deepEqual( + this.service.resourceTypes.map((t) => t.label), + ['Driver'] + ); + }); + + test('unregistering several values at once', function (assert) { + this.service.registerResourceTypes([type('A', 'a'), type('B', 'b'), type('C', 'c')]); + + this.service.unregisterResourceTypes(['a', 'c']); + + assert.deepEqual( + this.service.resourceTypes.map((t) => t.value), + ['b'] + ); + }); + + test('unregistering an unknown value leaves the list untouched', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + + this.service.unregisterResourceTypes(['Models\\Nope']); + + assert.strictEqual(this.service.resourceTypes.length, 1); + }); + + test('unregistering with no argument is a no-op', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + + this.service.unregisterResourceTypes(); + + assert.strictEqual(this.service.resourceTypes.length, 1); + }); + + test('a value can be re-registered after being unregistered', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + this.service.unregisterResourceTypes(['Models\\Order']); + this.service.registerResourceTypes([type('Order Again', 'Models\\Order')]); + + assert.deepEqual( + this.service.resourceTypes.map((t) => t.label), + ['Order Again'], + 'removal clears the duplicate guard' + ); + }); + + test('resourceTypes reflects the backing list without copying it', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + + assert.strictEqual(this.service.resourceTypes, this.service._resourceTypes); + }); + + test('it is a singleton within one owner', function (assert) { + this.service.registerResourceTypes([type('Order', 'Models\\Order')]); + + assert.strictEqual(this.owner.lookup('service:template-builder').resourceTypes.length, 1, 'a second lookup sees the same registrations'); + }); +}); diff --git a/tests/unit/utils/decorators/arg-test.js b/tests/unit/utils/decorators/arg-test.js index ad233b72..badd11f8 100644 --- a/tests/unit/utils/decorators/arg-test.js +++ b/tests/unit/utils/decorators/arg-test.js @@ -1,7 +1,112 @@ +import arg from '@fleetbase/ember-ui/utils/decorators/arg'; import { module, test } from 'qunit'; +class Widget { + constructor(args = {}) { + this.args = args; + } + + @arg open = true; + @arg title = 'Default title'; + @arg count = 0; + @arg missing; + @arg computed = Math.max(2, 3); +} + +class SubWidget extends Widget { + @arg title = 'Subclass title'; +} + module('Unit | Utility | decorators/arg', function () { - test('it works', function (assert) { - assert.ok(true); + test('it falls back to the field initializer when the arg is absent', function (assert) { + const widget = new Widget({}); + + assert.true(widget.open); + assert.strictEqual(widget.title, 'Default title'); + assert.strictEqual(widget.count, 0); + assert.strictEqual(widget.computed, 3, 'the initializer expression is evaluated, not just a literal'); + }); + + test('it returns undefined when there is no arg and no initializer', function (assert) { + assert.strictEqual(new Widget({}).missing, undefined); + }); + + test('a provided arg overrides the initializer', function (assert) { + const widget = new Widget({ open: false, title: 'Given', count: 42 }); + + assert.false(widget.open, 'an explicit false arg wins over a true default'); + assert.strictEqual(widget.title, 'Given'); + assert.strictEqual(widget.count, 42); + }); + + test('only undefined triggers the default — every other falsy arg is honored', function (assert) { + assert.strictEqual(new Widget({ title: null }).title, null, 'null is a real value'); + assert.strictEqual(new Widget({ title: '' }).title, '', 'an empty string is a real value'); + assert.strictEqual(new Widget({ title: 0 }).title, 0); + assert.true(Number.isNaN(new Widget({ open: NaN }).open), 'NaN is honored rather than replaced by the default'); + assert.strictEqual(new Widget({ title: undefined }).title, 'Default title', 'an explicitly undefined arg falls back'); + }); + + test('the decorated property is a live getter, not a snapshot', function (assert) { + const args = { title: 'First' }; + const widget = new Widget(args); + + assert.strictEqual(widget.title, 'First'); + + args.title = 'Second'; + assert.strictEqual(widget.title, 'Second', 'later arg changes are visible'); + + delete args.title; + assert.strictEqual(widget.title, 'Default title', 'removing the arg restores the default'); + }); + + test('repeated access is stable and side-effect free', function (assert) { + const widget = new Widget({ count: 7 }); + + assert.strictEqual(widget.count, 7); + assert.strictEqual(widget.count, 7); + assert.false(Object.prototype.hasOwnProperty.call(widget, 'count'), 'reading never installs an own property that would shadow the getter'); + }); + + test('the decorated property is read-only', function (assert) { + const widget = new Widget({}); + + assert.throws( + () => { + widget.title = 'nope'; + }, + TypeError, + 'the descriptor exposes a getter with no setter' + ); + }); + + test('instances do not share state', function (assert) { + const a = new Widget({ title: 'A' }); + const b = new Widget({ title: 'B' }); + const c = new Widget({}); + + assert.strictEqual(a.title, 'A'); + assert.strictEqual(b.title, 'B'); + assert.strictEqual(c.title, 'Default title'); + }); + + test('a subclass may override the default while inheriting the rest', function (assert) { + const sub = new SubWidget({}); + + assert.strictEqual(sub.title, 'Subclass title', 'the subclass initializer wins'); + assert.true(sub.open, 'inherited decorated args still work'); + assert.strictEqual(new SubWidget({ title: 'Passed' }).title, 'Passed', 'args still override the subclass default'); + }); + + test('it throws when the instance has no args object', function (assert) { + const orphan = Object.create(Widget.prototype); + + assert.throws(() => orphan.title, TypeError, 'the getter reads this.args unconditionally'); + }); + + test('it reads the arg under the same key as the decorated property', function (assert) { + const widget = new Widget({ Title: 'wrong case', title: 'right case' }); + + assert.strictEqual(widget.title, 'right case', 'lookup is case-sensitive and exact'); }); }); diff --git a/tests/unit/utils/decorators/uses-transition-test.js b/tests/unit/utils/decorators/uses-transition-test.js index ee6099ef..3d4680e6 100644 --- a/tests/unit/utils/decorators/uses-transition-test.js +++ b/tests/unit/utils/decorators/uses-transition-test.js @@ -1,7 +1,114 @@ +import usesTransition from '@fleetbase/ember-ui/utils/decorators/uses-transition'; +import Service from '@ember/service'; +import { setOwner } from '@ember/application'; import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; -module('Unit | Utility | decorators/uses-transition', function () { - test('it works', function (assert) { - assert.ok(true); +class Panel { + @usesTransition('fade') usesTransition; + @usesTransition('backdropFade') usesBackdropTransition; + + constructor(owner, args = {}) { + setOwner(this, owner); + this.args = args; + } +} + +module('Unit | Utility | decorators/uses-transition', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.build = (args) => new Panel(this.owner, args); + }); + + test('it asserts that a fade property name is provided', function (assert) { + assert.throws(() => usesTransition(), /fadeProperty/, 'no argument at all'); + assert.throws(() => usesTransition(undefined), /fadeProperty/); + assert.throws(() => usesTransition(null), /fadeProperty/); + assert.throws(() => usesTransition(123), /fadeProperty/, 'a non-string is rejected'); + assert.throws(() => usesTransition({ fade: true }), /fadeProperty/); + }); + + test('it accepts an empty string because only the type is asserted', function (assert) { + assert.strictEqual(typeof usesTransition(''), 'function', 'the factory returns a decorator'); + assert.strictEqual(typeof usesTransition('fade'), 'function'); + }); + + test('transitions are enabled by default when the arg is absent', function (assert) { + assert.true(this.build({}).usesTransition); + assert.true(this.build({}).usesBackdropTransition); + }); + + test('only an explicit false disables the transition', function (assert) { + assert.false(this.build({ fade: false }).usesTransition, 'false disables'); + assert.true(this.build({ fade: true }).usesTransition); + assert.true(this.build({ fade: 0 }).usesTransition, '0 is not === false'); + assert.true(this.build({ fade: null }).usesTransition, 'null is not === false'); + assert.true(this.build({ fade: '' }).usesTransition, 'an empty string is not === false'); + assert.true(this.build({ fade: 'false' }).usesTransition, 'the string "false" is not === false'); + assert.true(this.build({ fade: undefined }).usesTransition, 'undefined falls back to enabled'); + }); + + test('each decorated property reads its own configured arg', function (assert) { + const panel = this.build({ fade: false, backdropFade: true }); + + assert.false(panel.usesTransition, 'the fade arg drives usesTransition'); + assert.true(panel.usesBackdropTransition, 'the backdropFade arg drives usesBackdropTransition independently'); + }); + + test('it always returns a boolean, never the raw arg value', function (assert) { + const value = this.build({ fade: 'truthy string' }).usesTransition; + + assert.strictEqual(typeof value, 'boolean'); + assert.true(value); + }); + + test('transitions are disabled entirely under FastBoot', function (assert) { + this.owner.register( + 'service:fastboot', + class FastBootStub extends Service { + isFastBoot = true; + } + ); + + assert.false(this.build({}).usesTransition, 'FastBoot short-circuits before the arg is consulted'); + assert.false(this.build({ fade: true }).usesTransition, 'even an explicit true is ignored'); + }); + + test('transitions stay enabled when the fastboot service reports false', function (assert) { + this.owner.register( + 'service:fastboot', + class FastBootStub extends Service { + isFastBoot = false; + } + ); + + assert.true(this.build({}).usesTransition); + assert.false(this.build({ fade: false }).usesTransition, 'the arg still wins when not in FastBoot'); + }); + + test('it is a live getter that re-evaluates on every access', function (assert) { + const args = {}; + const panel = this.build(args); + + assert.true(panel.usesTransition); + + args.fade = false; + assert.false(panel.usesTransition, 'the second read reflects the updated arg'); + + args.fade = true; + assert.true(panel.usesTransition, 'and the third read reflects it again'); + }); + + test('the decorated property is read-only', function (assert) { + const panel = this.build({}); + + assert.throws( + () => { + panel.usesTransition = false; + }, + TypeError, + 'no setter is installed' + ); }); }); diff --git a/tests/unit/utils/deprecate-subclassing-test.js b/tests/unit/utils/deprecate-subclassing-test.js index 1ae1717f..d77a2ad4 100644 --- a/tests/unit/utils/deprecate-subclassing-test.js +++ b/tests/unit/utils/deprecate-subclassing-test.js @@ -1,7 +1,87 @@ +import deprecateSubclassing from '@fleetbase/ember-ui/utils/deprecate-subclassing'; import { module, test } from 'qunit'; +import { settled } from '@ember/test-helpers'; module('Unit | Utility | deprecate-subclassing', function () { - test('it works', function (assert) { - assert.ok(true); + test('it returns a subclass of the target in a debug build', function (assert) { + class Widget {} + + const Wrapped = deprecateSubclassing(Widget); + + assert.notStrictEqual(Wrapped, Widget, 'a new class is returned'); + assert.true(Object.prototype.isPrototypeOf.call(Widget, Wrapped), 'the returned class extends the target'); + }); + + test('instances of the returned class are instances of the target', function (assert) { + class Widget {} + + const instance = new (deprecateSubclassing(Widget))(); + + assert.true(instance instanceof Widget); + }); + + test('it preserves constructor arguments and target behaviour', function (assert) { + class Widget { + constructor(a, b) { + this.sum = a + b; + } + + describe() { + return `sum:${this.sum}`; + } + } + + const instance = new (deprecateSubclassing(Widget))(2, 3); + + assert.strictEqual(instance.sum, 5, 'arguments reach the target constructor'); + assert.strictEqual(instance.describe(), 'sum:5', 'prototype methods are inherited'); + }); + + // The deprecation check runs in a `next()` callback and reports through + // Ember's deprecation channel, so these cases assert that each variant + // constructs cleanly and settles — exercising all three states of the + // `wrapperClass === this.constructor || this[flag] === true` predicate. + test('constructing the wrapper directly settles cleanly', async function (assert) { + class Widget {} + const Wrapped = deprecateSubclassing(Widget); + + const instance = new Wrapped(); + await settled(); + + assert.strictEqual(instance.constructor, Wrapped, 'the direct-construction predicate branch holds'); + }); + + test('a subclass of the wrapper still constructs and settles', async function (assert) { + class Widget {} + const Wrapped = deprecateSubclassing(Widget); + + class Subclass extends Wrapped {} + + const instance = new Subclass(); + await settled(); + + assert.true(instance instanceof Widget, 'subclassing is deprecated but not prevented'); + assert.notStrictEqual(instance.constructor, Wrapped, 'the deprecated branch is the one taken'); + }); + + test('the __ember-bootstrap_subclass escape hatch marks a privileged subclass', async function (assert) { + class Widget {} + const Wrapped = deprecateSubclassing(Widget); + + class PrivilegedSubclass extends Wrapped { + '__ember-bootstrap_subclass' = true; + } + + const instance = new PrivilegedSubclass(); + await settled(); + + assert.true(instance['__ember-bootstrap_subclass'], 'the flag is set by the time the deferred check runs'); + assert.true(instance instanceof Widget); + }); + + test('each call produces an independent wrapper', function (assert) { + class Widget {} + + assert.notStrictEqual(deprecateSubclassing(Widget), deprecateSubclassing(Widget), 'wrappers are not cached or shared'); }); }); diff --git a/tests/unit/utils/dom-test.js b/tests/unit/utils/dom-test.js index 901d0559..4f229694 100644 --- a/tests/unit/utils/dom-test.js +++ b/tests/unit/utils/dom-test.js @@ -1,7 +1,557 @@ import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { settled } from '@ember/test-helpers'; +import { setOwner } from '@ember/application'; +import { + findElementById, + findElemementByIdInShadowDom, + getDOM, + getDestinationElement, + unwrapChildren, + afterRender, + afterPaint, + renderCompleted, + waitForInsertedAndSized, + createElement, +} from '@fleetbase/ember-ui/utils/dom'; -module('Unit | Utility | dom', function () { - test('it works', function (assert) { - assert.ok(true); +// A minimal stand-in for a SimpleDOM document: it has no getElementById, so +// findElementById has to walk the tree by hand. +function documentWithoutGetElementById(rootChildren) { + return { firstChild: rootChildren }; +} + +function fakeNode(id, { children = null, next = null, hasGetAttribute = true } = {}) { + const node = { firstChild: children, nextSibling: next }; + + if (hasGetAttribute) { + node.getAttribute = (name) => (name === 'id' ? id : null); + } + + return node; +} + +module('Unit | Utility | dom', function (hooks) { + // A rendering test is used throughout because several of these helpers + // resolve their document through the owner and its root element. + setupRenderingTest(hooks); + + // Build a plain object that Ember's getOwner() can resolve, so the + // owner-dependent helpers can be exercised without a real component. + function ownedContext(owner, properties = {}) { + const context = { ...properties }; + setOwner(context, owner); + + return context; + } + + module('findElementById', function () { + test('it delegates to getElementById when the document supports it', function (assert) { + const el = document.createElement('div'); + el.id = 'find-me'; + document.body.appendChild(el); + + try { + assert.strictEqual(findElementById(document, 'find-me'), el); + } finally { + el.remove(); + } + }); + + test('it returns null from getElementById for a missing id', function (assert) { + assert.strictEqual(findElementById(document, 'definitely-not-present'), null); + }); + + test('it walks the tree when the document has no getElementById', function (assert) { + const target = fakeNode('wanted'); + const doc = documentWithoutGetElementById(fakeNode('outer', { children: target })); + + assert.strictEqual(findElementById(doc, 'wanted'), target, 'the nested node is found depth-first'); + }); + + test('it skips nodes without getAttribute while walking', function (assert) { + const target = fakeNode('wanted'); + const textish = fakeNode(null, { hasGetAttribute: false, next: target }); + const doc = documentWithoutGetElementById(textish); + + assert.strictEqual(findElementById(doc, 'wanted'), target, 'text nodes do not abort the walk'); + }); + + test('it returns undefined when the walk exhausts the tree', function (assert) { + const doc = documentWithoutGetElementById(fakeNode('a', { next: fakeNode('b') })); + + assert.strictEqual(findElementById(doc, 'missing'), undefined); + }); + + test('it returns undefined for a document with no children at all', function (assert) { + assert.strictEqual(findElementById(documentWithoutGetElementById(null), 'anything'), undefined); + }); + }); + + module('getDOM', function () { + test('it returns the document from a renderer that already exposes _dom', function (assert) { + const stubDocument = { marker: 'from-renderer' }; + const context = { renderer: { _dom: { document: stubDocument } } }; + + assert.strictEqual(getDOM(context), stubDocument); + }); + + test('it falls back to the -document service when the renderer has no _dom', function (assert) { + const stubDocument = { marker: 'from-service' }; + this.owner.unregister('service:-document'); + this.owner.register('service:-document', stubDocument, { instantiate: false }); + + assert.strictEqual(getDOM(ownedContext(this.owner, { renderer: undefined })), stubDocument); + }); + + test('it looks up renderer:-dom when there is no -document service', function (assert) { + const stubDocument = { marker: 'from-renderer-lookup' }; + this.owner.unregister('service:-document'); + this.owner.unregister('renderer:-dom'); + this.owner.register('renderer:-dom', { _dom: { document: stubDocument } }, { instantiate: false }); + + assert.strictEqual(getDOM(ownedContext(this.owner, { renderer: null })), stubDocument); + }); + + test('it throws when the looked-up renderer has no document either', function (assert) { + this.owner.unregister('service:-document'); + this.owner.unregister('renderer:-dom'); + this.owner.register('renderer:-dom', { _dom: {} }, { instantiate: false }); + + assert.throws(() => getDOM(ownedContext(this.owner, { renderer: null })), /Could not get DOM/); + }); + + test('it prefers the context renderer over any owner lookup', function (assert) { + const stubDocument = { marker: 'from-renderer' }; + this.owner.unregister('service:-document'); + this.owner.register('service:-document', { marker: 'from-service' }, { instantiate: false }); + + const context = ownedContext(this.owner, { renderer: { _dom: { document: stubDocument } } }); + + assert.strictEqual(getDOM(context), stubDocument, 'the owner is never consulted when the renderer already has a _dom'); + }); + + test('it throws when nothing can provide a document', function (assert) { + const context = { renderer: { _dom: {} } }; + + assert.throws(() => getDOM(context), /Could not get DOM/); + }); + }); + + module('findElemementByIdInShadowDom', function () { + // The helper searches owner.rootElement directly, so these tests swap in + // a real element for the duration of the test and restore it afterwards. + function withRootElement(owner, element, callback) { + const original = owner.rootElement; + owner.rootElement = element; + + try { + return callback(); + } finally { + owner.rootElement = original; + } + } + + test('it finds an element by id inside the owner root element', function (assert) { + const root = document.createElement('div'); + const target = document.createElement('span'); + target.id = 'shadow-target'; + root.appendChild(target); + + withRootElement(this.owner, root, () => { + assert.strictEqual(findElemementByIdInShadowDom(ownedContext(this.owner), 'shadow-target'), target); + }); + }); + + test('it returns null when the id is not inside the root element', function (assert) { + withRootElement(this.owner, document.createElement('div'), () => { + assert.strictEqual(findElemementByIdInShadowDom(ownedContext(this.owner), 'nowhere-to-be-found'), null); + }); + }); + + test('it scopes the search to the root element rather than the whole document', function (assert) { + const outside = document.createElement('span'); + outside.id = 'outside-the-root'; + document.body.appendChild(outside); + + try { + withRootElement(this.owner, document.createElement('div'), () => { + assert.strictEqual(findElemementByIdInShadowDom(ownedContext(this.owner), 'outside-the-root'), null, 'elements elsewhere in the document are not matched'); + }); + } finally { + outside.remove(); + } + }); + + test('it yields undefined when the root element is a selector string rather than an element', function (assert) { + // This is the guard the implementation makes explicit with + // `rootElement.querySelector && ...` — a string root has no + // querySelector, so the shadow-DOM lookup is skipped entirely. + withRootElement(this.owner, '#ember-testing', () => { + assert.strictEqual(findElemementByIdInShadowDom(ownedContext(this.owner), 'anything'), undefined); + }); + }); + }); + + module('unwrapChildren', function () { + test('it hoists every child into the parent and removes the wrapper', function (assert) { + const parent = document.createElement('div'); + const wrapper = document.createElement('section'); + wrapper.innerHTML = 'onetwo'; + parent.appendChild(document.createElement('hr')); + parent.appendChild(wrapper); + + unwrapChildren(wrapper); + + assert.strictEqual(wrapper.parentNode, null, 'the wrapper is detached'); + assert.strictEqual(wrapper.childNodes.length, 0, 'the wrapper keeps none of its children'); + assert.strictEqual(parent.innerHTML, '
      onetwo', 'children keep their order and land where the wrapper was'); + }); + + test('it removes an empty wrapper without error', function (assert) { + const parent = document.createElement('div'); + const wrapper = document.createElement('section'); + parent.appendChild(wrapper); + + unwrapChildren(wrapper); + + assert.strictEqual(parent.childNodes.length, 0, 'nothing is left behind'); + }); + }); + + module('render timing helpers', function () { + test('afterRender resolves on the afterRender queue', async function (assert) { + let resolved = false; + + const promise = afterRender().then(() => (resolved = true)); + assert.false(resolved, 'it does not resolve synchronously'); + + await promise; + await settled(); + assert.true(resolved); + }); + + test('afterPaint resolves on the next animation frame', async function (assert) { + let resolved = false; + + const promise = afterPaint().then(() => (resolved = true)); + assert.false(resolved, 'it does not resolve synchronously'); + + await promise; + assert.true(resolved); + }); + + test('renderCompleted waits for both render and paint', async function (assert) { + let paintDone = false; + requestAnimationFrame(() => (paintDone = true)); + + await renderCompleted(); + await settled(); + + assert.true(paintDone, 'a frame elapsed before renderCompleted resolved'); + }); + }); + + module('waitForInsertedAndSized', function () { + test('it resolves immediately for an element that is already sized', async function (assert) { + const el = document.createElement('div'); + el.style.cssText = 'width: 10px; height: 10px;'; + document.body.appendChild(el); + + try { + assert.strictEqual(await waitForInsertedAndSized(el), el); + } finally { + el.remove(); + } + }); + + test('it accepts a getter function as well as an element', async function (assert) { + const el = document.createElement('div'); + el.style.cssText = 'width: 10px; height: 10px;'; + document.body.appendChild(el); + + try { + assert.strictEqual(await waitForInsertedAndSized(() => el), el); + } finally { + el.remove(); + } + }); + + test('it resolves once a later insertion gives the element a size', async function (assert) { + const el = document.createElement('div'); + el.style.cssText = 'width: 10px; height: 10px;'; + + const promise = waitForInsertedAndSized(() => el, { timeoutMs: 2000 }); + document.body.appendChild(el); + + try { + assert.strictEqual(await promise, el, 'the mutation observer picked up the insertion'); + } finally { + el.remove(); + } + }); + + // A negative timeout means "wait indefinitely": no timer is armed, so there is none for + // cleanup to clear either. + test('a negative timeout arms no deadline', async function (assert) { + const el = document.createElement('div'); + el.style.cssText = 'width: 10px; height: 10px;'; + + const promise = waitForInsertedAndSized(() => el, { timeoutMs: -1 }); + document.body.appendChild(el); + + try { + assert.strictEqual(await promise, el, 'it still resolves once the element is there'); + } finally { + el.remove(); + } + }); + + test('it rejects when the element never becomes sized', async function (assert) { + const el = document.createElement('div'); + document.body.appendChild(el); + + try { + await waitForInsertedAndSized(el, { timeoutMs: 50 }); + assert.true(false, 'expected a rejection'); + } catch (error) { + assert.strictEqual(error.message, 'Element was not inserted/sized in time'); + } finally { + el.remove(); + } + }); + + test('it treats a zero-sized but present element as not ready', async function (assert) { + const el = document.createElement('div'); + el.style.cssText = 'width: 0; height: 0;'; + document.body.appendChild(el); + + try { + await waitForInsertedAndSized(el, { timeoutMs: 50 }); + assert.true(false, 'expected a rejection'); + } catch (error) { + assert.strictEqual(error.message, 'Element was not inserted/sized in time'); + } finally { + el.remove(); + } + }); + + test('it rejects when the getter never returns an element', async function (assert) { + try { + await waitForInsertedAndSized(() => null, { timeoutMs: 50 }); + assert.true(false, 'expected a rejection'); + } catch (error) { + assert.strictEqual(error.message, 'Element was not inserted/sized in time'); + } + }); + + test('it rejects for a sized element that is detached from the document', async function (assert) { + const el = document.createElement('div'); + el.style.cssText = 'width: 10px; height: 10px;'; + + try { + await waitForInsertedAndSized(el, { timeoutMs: 50 }); + assert.true(false, 'expected a rejection'); + } catch (error) { + assert.strictEqual(error.message, 'Element was not inserted/sized in time', 'being in the document is required, not just having a size'); + } + }); + }); + + module('createElement', function () { + test('it creates a bare element with no options', function (assert) { + const el = createElement('span'); + + assert.strictEqual(el.tagName, 'SPAN'); + assert.strictEqual(el.attributes.length, 0, 'no attributes are added'); + assert.strictEqual(el.childNodes.length, 0); + }); + + test('it accepts class names as an array or a space-separated string', function (assert) { + assert.deepEqual([...createElement('div', { classNames: ['a', 'b'] }).classList], ['a', 'b']); + assert.deepEqual([...createElement('div', { classNames: 'a b' }).classList], ['a', 'b']); + }); + + test('it drops empty class name segments', function (assert) { + assert.deepEqual([...createElement('div', { classNames: 'a b ' }).classList], ['a', 'b'], 'double spaces do not produce empty classes'); + }); + + test('it applies styles', function (assert) { + const el = createElement('div', { styles: { color: 'rgb(1, 2, 3)', zIndex: '5' } }); + + assert.strictEqual(el.style.color, 'rgb(1, 2, 3)'); + assert.strictEqual(el.style.zIndex, '5'); + }); + + test('it ignores a non-object styles option', function (assert) { + assert.strictEqual(createElement('div', { styles: 'color: red' }).getAttribute('style'), null); + }); + + test('it sets attributes and renders true as an empty value', function (assert) { + const el = createElement('input', { attrs: { type: 'text', disabled: true, role: 'textbox' } }); + + assert.strictEqual(el.getAttribute('type'), 'text'); + assert.strictEqual(el.getAttribute('disabled'), '', 'true becomes a valueless attribute'); + assert.strictEqual(el.getAttribute('role'), 'textbox'); + }); + + test('it omits attributes that are false, null or undefined', function (assert) { + const el = createElement('input', { attrs: { disabled: false, hidden: null, title: undefined } }); + + assert.false(el.hasAttribute('disabled')); + assert.false(el.hasAttribute('hidden')); + assert.false(el.hasAttribute('title')); + }); + + test('it keeps a zero or empty-string attribute value', function (assert) { + const el = createElement('div', { attrs: { tabindex: 0, 'data-empty': '' } }); + + assert.strictEqual(el.getAttribute('tabindex'), '0', 'zero is not treated as absent'); + assert.strictEqual(el.getAttribute('data-empty'), ''); + }); + + test('it writes dataset entries', function (assert) { + const el = createElement('div', { dataset: { testId: 'x', count: 2 } }); + + assert.strictEqual(el.dataset.testId, 'x'); + assert.strictEqual(el.getAttribute('data-test-id'), 'x', 'camelCase becomes dash-case'); + assert.strictEqual(el.dataset.count, '2'); + }); + + test('it attaches event listeners and ignores non-function handlers', function (assert) { + let clicks = 0; + const el = createElement('button', { on: { click: () => clicks++, focus: 'not-a-function' } }); + + el.dispatchEvent(new MouseEvent('click')); + assert.strictEqual(clicks, 1); + + el.dispatchEvent(new FocusEvent('focus')); + assert.strictEqual(clicks, 1, 'the string handler was skipped rather than throwing'); + }); + + test('it sets text content, escaping markup', function (assert) { + const el = createElement('div', { text: 'hi' }); + + assert.strictEqual(el.textContent, 'hi'); + assert.strictEqual(el.querySelector('b'), null, 'text is not parsed as html'); + }); + + test('it accepts innerText as an alias for text', function (assert) { + assert.strictEqual(createElement('div', { innerText: 'aliased' }).textContent, 'aliased'); + }); + + test('it sets html content via html or innerHTML', function (assert) { + assert.strictEqual(createElement('div', { html: 'bold' }).querySelector('b').textContent, 'bold'); + assert.strictEqual(createElement('div', { innerHTML: 'it' }).querySelector('i').textContent, 'it'); + }); + + test('it throws when both text and html are supplied', function (assert) { + assert.throws(() => createElement('div', { text: 'a', html: 'b' }), /use either text OR html, not both/); + }); + + test('it treats an empty string as content rather than as absent', function (assert) { + const el = createElement('div', { text: '' }, 'child-should-be-ignored'); + + assert.strictEqual(el.textContent, '', 'text: "" wins over children because it is not null'); + }); + + test('it appends string, node and nested-array children', function (assert) { + const node = document.createElement('b'); + node.textContent = 'B'; + const el = createElement('div', {}, ['A', node, ['C', 'D']]); + + assert.strictEqual(el.textContent, 'ABCD', 'nested arrays are flattened in order'); + }); + + test('it stringifies non-node children', function (assert) { + assert.strictEqual(createElement('div', {}, [0, false, 'x']).textContent, '0falsex', 'zero and false are rendered, not skipped'); + }); + + test('it skips null and undefined children', function (assert) { + assert.strictEqual(createElement('div', {}, [null, 'a', undefined]).textContent, 'a'); + }); + + test('it accepts a single non-array child', function (assert) { + assert.strictEqual(createElement('div', {}, 'solo').textContent, 'solo'); + }); + + test('it mounts into an element target', function (assert) { + const host = document.createElement('div'); + document.body.appendChild(host); + + try { + const el = createElement('span', { mount: host }); + assert.strictEqual(el.parentNode, host); + } finally { + host.remove(); + } + }); + + test('it mounts into a selector target', function (assert) { + const host = document.createElement('div'); + host.id = 'mount-host'; + document.body.appendChild(host); + + try { + const el = createElement('span', { mount: '#mount-host' }); + assert.strictEqual(el.parentNode, host); + } finally { + host.remove(); + } + }); + + test('it warns and stays detached when the mount target cannot be found', function (assert) { + const originalWarn = console.warn; + const warnings = []; + console.warn = (...args) => warnings.push(args); + + try { + const el = createElement('span', { mount: '#no-such-host' }); + + assert.strictEqual(el.parentNode, null, 'the element is returned unmounted'); + assert.strictEqual(warnings.length, 1, 'the caller is warned'); + assert.strictEqual(warnings[0][0], 'createElement: mount target not found'); + } finally { + console.warn = originalWarn; + } + }); + }); + + module('getDestinationElement', function () { + test('it returns the wormhole element when one exists in the document', function (assert) { + const wormhole = document.createElement('div'); + wormhole.id = 'ember-bootstrap-wormhole'; + document.body.appendChild(wormhole); + + try { + const context = ownedContext(this.owner, { renderer: { _dom: { document } } }); + assert.strictEqual(getDestinationElement(context), wormhole); + } finally { + wormhole.remove(); + } + }); + + test('it falls back to the test root element when no wormhole exists', function (assert) { + assert.dom('#ember-bootstrap-wormhole').doesNotExist('precondition: no wormhole is present'); + + const context = ownedContext(this.owner, { renderer: { _dom: { document } } }); + const destination = getDestinationElement(context); + + assert.strictEqual(destination, document.querySelector('#ember-testing'), 'the test container stands in for the wormhole'); + }); + }); + test('waitForInsertedAndSized can be asked never to time out', async function (assert) { + const element = document.createElement('div'); + element.style.width = '10px'; + element.style.height = '10px'; + document.getElementById('ember-testing').appendChild(element); + + // A negative timeout skips the timer entirely; the element is already sized, so the + // fast path resolves immediately and nothing is left pending. + const resolved = await waitForInsertedAndSized(element, { timeoutMs: -1 }); + + assert.strictEqual(resolved, element); + + element.remove(); }); }); diff --git a/tests/unit/utils/fetch-file-as-data-url-test.js b/tests/unit/utils/fetch-file-as-data-url-test.js new file mode 100644 index 00000000..8b696f99 --- /dev/null +++ b/tests/unit/utils/fetch-file-as-data-url-test.js @@ -0,0 +1,54 @@ +import fetchFileAsDataUrl, { blobToDataUrl } from 'dummy/utils/fetch-file-as-data-url'; +import { module, test } from 'qunit'; + +module('Unit | Utility | fetch-file-as-data-url', function (hooks) { + let originalFetch; + let requests; + const fetchService = { host: 'https://api.test', namespace: 'int/v1', credentials: 'include', getHeaders: () => ({ Authorization: 'Bearer token' }) }; + + hooks.beforeEach(function () { + originalFetch = window.fetch; + requests = []; + }); + + hooks.afterEach(function () { + window.fetch = originalFetch; + }); + + test('it downloads the file through the API with the console credentials and returns a data url', async function (assert) { + window.fetch = (url, options) => { + requests.push({ url, options }); + return Promise.resolve(new Response(new Blob(['png-bytes'], { type: 'image/png' }), { status: 200 })); + }; + + const dataUrl = await fetchFileAsDataUrl(fetchService, 'file_9'); + + assert.strictEqual(requests[0].url, 'https://api.test/int/v1/files/download?file=file_9'); + assert.strictEqual(requests[0].options.credentials, 'include'); + assert.strictEqual(requests[0].options.mode, 'cors'); + assert.deepEqual(requests[0].options.headers, { Authorization: 'Bearer token' }); + assert.strictEqual(dataUrl, `data:image/png;base64,${btoa('png-bytes')}`); + }); + + test('a service without a namespace or credentials still builds a request', async function (assert) { + window.fetch = (url, options) => { + requests.push({ url, options }); + return Promise.resolve(new Response(new Blob(['x']), { status: 200 })); + }; + + await fetchFileAsDataUrl({ host: 'https://api.test', getHeaders: () => ({}) }, 'file_1'); + + assert.strictEqual(requests[0].url, 'https://api.test/files/download?file=file_1'); + assert.strictEqual(requests[0].options.credentials, 'include', 'credentials default to include'); + }); + + test('a failed download rejects with the status', async function (assert) { + window.fetch = () => Promise.resolve(new Response('nope', { status: 404 })); + + await assert.rejects(fetchFileAsDataUrl(fetchService, 'file_9'), /file_9 \(404\)/); + }); + + test('blobToDataUrl encodes a blob', async function (assert) { + assert.strictEqual(await blobToDataUrl(new Blob(['hi'], { type: 'text/plain' })), 'data:text/plain;base64,aGk='); + }); +}); diff --git a/tests/unit/utils/file-sentinel-id-test.js b/tests/unit/utils/file-sentinel-id-test.js new file mode 100644 index 00000000..8f95afaf --- /dev/null +++ b/tests/unit/utils/file-sentinel-id-test.js @@ -0,0 +1,16 @@ +import fileSentinelId from 'dummy/utils/file-sentinel-id'; +import { module, test } from 'qunit'; + +module('Unit | Utility | file-sentinel-id', function () { + test('it extracts the uuid from a file reference', function (assert) { + assert.strictEqual(fileSentinelId('file:226307ff-ddd5-49b2-9ad7-e100f968f270'), '226307ff-ddd5-49b2-9ad7-e100f968f270'); + }); + + test('anything else is not a reference', function (assert) { + assert.strictEqual(fileSentinelId('file:'), null, 'an empty reference'); + assert.strictEqual(fileSentinelId('{"uuid":"x"}'), null, 'expanded json'); + assert.strictEqual(fileSentinelId('data:image/png;base64,AAAA'), null, 'a data url'); + assert.strictEqual(fileSentinelId({ uuid: 'x' }), null, 'an object'); + assert.strictEqual(fileSentinelId(null), null); + }); +}); diff --git a/tests/unit/utils/find-custom-field-value-test.js b/tests/unit/utils/find-custom-field-value-test.js new file mode 100644 index 00000000..568eaddc --- /dev/null +++ b/tests/unit/utils/find-custom-field-value-test.js @@ -0,0 +1,36 @@ +import findCustomFieldValue from 'dummy/utils/find-custom-field-value'; +import { module, test } from 'qunit'; + +module('Unit | Utility | find-custom-field-value', function () { + const field = { id: 'cf_1' }; + + test('it finds the record for the field on a plain subject', function (assert) { + const record = { custom_field_uuid: 'cf_1', value: 'High' }; + assert.strictEqual(findCustomFieldValue({ custom_field_values: [null, { custom_field_uuid: 'cf_2' }, record] }, field), record); + }); + + test('it reads through get() when the subject offers one', function (assert) { + const record = { custom_field_uuid: 'cf_1', value: 'High' }; + const subject = { get: (key) => (key === 'custom_field_values' ? [record] : undefined) }; + assert.strictEqual(findCustomFieldValue(subject, field), record); + }); + + test('a persisted record wins over the unsaved twin left behind by a save', function (assert) { + const unsaved = { custom_field_uuid: 'cf_1', value: 'file:abc', isNew: true }; + const persisted = { custom_field_uuid: 'cf_1', value: '{"id":"abc"}', isNew: false }; + assert.strictEqual(findCustomFieldValue({ custom_field_values: [unsaved, persisted] }, field), persisted); + }); + + test('an unsaved record is still used when it is the only one', function (assert) { + const unsaved = { custom_field_uuid: 'cf_1', value: 'draft', isNew: true }; + assert.strictEqual(findCustomFieldValue({ custom_field_values: [unsaved] }, field), unsaved); + }); + + test('it returns null with nothing to read', function (assert) { + assert.strictEqual(findCustomFieldValue({ custom_field_values: [] }, field), null); + assert.strictEqual(findCustomFieldValue({ get: () => undefined }, field), null); + assert.strictEqual(findCustomFieldValue(null, field), null); + assert.strictEqual(findCustomFieldValue({ custom_field_values: [null, { custom_field_uuid: 'cf_1' }] }, undefined), null); + assert.strictEqual(findCustomFieldValue({ custom_field_values: [{}] }, {}), null); + }); +}); diff --git a/tests/unit/utils/floating-test.js b/tests/unit/utils/floating-test.js index 3cba6860..9edab41b 100644 --- a/tests/unit/utils/floating-test.js +++ b/tests/unit/utils/floating-test.js @@ -1,10 +1,166 @@ -import floating from 'dummy/utils/floating'; +import floating, { Tooltip } from '@fleetbase/ember-ui/utils/floating'; import { module, test } from 'qunit'; +import { settled } from '@ember/test-helpers'; -module('Unit | Utility | floating', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = typeof floating === 'object' && typeof floating.createTooltip === 'function'; - assert.ok(result); +function mountTarget() { + const el = document.createElement('button'); + el.textContent = 'trigger'; + el.style.cssText = 'position: absolute; top: 50px; left: 50px; width: 40px; height: 20px;'; + document.body.appendChild(el); + + return el; +} + +function tooltipEls() { + return [...document.body.querySelectorAll('[role="tooltip"]')]; +} + +module('Unit | Utility | floating', function (hooks) { + let mountEl; + let tooltip; + + hooks.beforeEach(function () { + mountEl = mountTarget(); + }); + + hooks.afterEach(function () { + tooltip?.destroy(); + tooltip = undefined; + mountEl.remove(); + // Belt and braces: no tooltip may outlive its test. + tooltipEls().forEach((el) => el.remove()); + }); + + test('createTooltip returns a Tooltip and mounts it on the body', function (assert) { + tooltip = floating.createTooltip(mountEl, { text: 'Hello' }); + + assert.true(tooltip instanceof Tooltip); + assert.strictEqual(tooltip.tooltipEl.parentNode, document.body, 'the tooltip is mounted on the body, not inside the trigger'); + assert.strictEqual(tooltip.mountEl, mountEl); + }); + + test('it renders the text and the tooltip role', function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Save changes' }); + + assert.strictEqual(tooltip.tooltipEl.textContent, 'Save changes'); + assert.strictEqual(tooltip.tooltipEl.getAttribute('role'), 'tooltip', 'assistive tech can identify it'); + }); + + test('it starts hidden and non-interactive', function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Hi' }); + + assert.strictEqual(tooltip.tooltipEl.style.opacity, '0', 'it is transparent until hovered'); + assert.strictEqual(tooltip.tooltipEl.style.pointerEvents, 'none', 'it never swallows pointer events'); + assert.strictEqual(tooltip.tooltipEl.style.position, 'absolute'); + }); + + test('it always carries the base classes', function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Hi' }); + + assert.true(tooltip.tooltipEl.classList.contains('ui-input-info')); + assert.true(tooltip.tooltipEl.classList.contains('text-xs')); + }); + + test('it accepts extra class names as an array', function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Hi', classNames: ['a', 'b'] }); + + assert.true(tooltip.tooltipEl.classList.contains('a')); + assert.true(tooltip.tooltipEl.classList.contains('b')); + }); + + test('it accepts extra class names as a space-separated string', function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Hi', classNames: 'c d' }); + + assert.true(tooltip.tooltipEl.classList.contains('c')); + assert.true(tooltip.tooltipEl.classList.contains('d')); + }); + + test('it renders with no options at all', function (assert) { + tooltip = new Tooltip(mountEl); + + assert.strictEqual(tooltip.tooltipEl.textContent, '', 'missing text renders empty rather than "undefined"'); + assert.true(tooltip.tooltipEl.classList.contains('ui-input-info')); + }); + + test('hovering positions the tooltip and reveals it', async function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Hi' }); + + mountEl.dispatchEvent(new MouseEvent('mouseenter')); + await settled(); + + assert.strictEqual(tooltip.tooltipEl.style.opacity, '1', 'it becomes visible'); + assert.notStrictEqual(tooltip.tooltipEl.style.left, '', 'a computed left offset was applied'); + assert.notStrictEqual(tooltip.tooltipEl.style.top, '', 'a computed top offset was applied'); + }); + + test('leaving the trigger hides it again', async function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Hi' }); + + mountEl.dispatchEvent(new MouseEvent('mouseenter')); + await settled(); + mountEl.dispatchEvent(new MouseEvent('mouseleave')); + + assert.strictEqual(tooltip.tooltipEl.style.opacity, '0'); + }); + + test('focus and blur mirror hover, so it is keyboard accessible', async function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Hi' }); + + mountEl.dispatchEvent(new FocusEvent('focus')); + await settled(); + assert.strictEqual(tooltip.tooltipEl.style.opacity, '1', 'focus shows the tooltip'); + + mountEl.dispatchEvent(new FocusEvent('blur')); + assert.strictEqual(tooltip.tooltipEl.style.opacity, '0', 'blur hides it'); + }); + + test('it honours an explicit placement and offset', async function (assert) { + tooltip = new Tooltip(mountEl, { text: 'Hi', placement: 'bottom', offset: 20 }); + + mountEl.dispatchEvent(new MouseEvent('mouseenter')); + await settled(); + + const triggerRect = mountEl.getBoundingClientRect(); + assert.true(parseFloat(tooltip.tooltipEl.style.top) > triggerRect.top, 'a bottom placement sits below the trigger'); + }); + + test('destroy removes the element and detaches every listener', async function (assert) { + const instance = new Tooltip(mountEl, { text: 'Hi' }); + const { tooltipEl } = instance; + + instance.destroy(); + + assert.strictEqual(tooltipEl.parentNode, null, 'the element is removed from the body'); + assert.deepEqual(instance.cleanupFns, [], 'the cleanup list is emptied'); + + mountEl.dispatchEvent(new MouseEvent('mouseenter')); + await settled(); + + assert.strictEqual(tooltipEl.style.opacity, '0', 'the detached listeners no longer reveal it'); + }); + + test('destroy is idempotent', function (assert) { + const instance = new Tooltip(mountEl, { text: 'Hi' }); + + instance.destroy(); + instance.destroy(); + + assert.deepEqual(instance.cleanupFns, [], 'a second destroy is a no-op rather than an error'); + }); + + test('multiple tooltips on one trigger are independent', function (assert) { + const first = new Tooltip(mountEl, { text: 'One' }); + const second = new Tooltip(mountEl, { text: 'Two' }); + + try { + assert.strictEqual(tooltipEls().length, 2, 'both are mounted'); + + first.destroy(); + + assert.strictEqual(tooltipEls().length, 1, 'destroying one leaves the other'); + assert.strictEqual(tooltipEls()[0].textContent, 'Two'); + } finally { + second.destroy(); + } }); }); diff --git a/tests/unit/utils/format-bytes-test.js b/tests/unit/utils/format-bytes-test.js index 3a41c9ea..1ab076d5 100644 --- a/tests/unit/utils/format-bytes-test.js +++ b/tests/unit/utils/format-bytes-test.js @@ -1,10 +1,61 @@ -import formatBytes from 'dummy/utils/format-bytes'; +import formatBytes from '@fleetbase/ember-ui/utils/format-bytes'; import { module, test } from 'qunit'; module('Unit | Utility | format-bytes', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = formatBytes(); - assert.ok(result); + test('it returns the literal "0 Bytes" for exactly zero', function (assert) { + assert.strictEqual(formatBytes(0), '0 Bytes'); + assert.strictEqual(formatBytes(0, 5), '0 Bytes', 'decimals are irrelevant for the zero short-circuit'); + }); + + test('it keeps values under 1 KB in bytes', function (assert) { + assert.strictEqual(formatBytes(1), '1 Bytes'); + assert.strictEqual(formatBytes(500), '500 Bytes'); + assert.strictEqual(formatBytes(1023), '1023 Bytes', '1023 is still below the 1024 boundary'); + }); + + test('it steps up a unit at each 1024 boundary', function (assert) { + assert.strictEqual(formatBytes(1024), '1 KB'); + assert.strictEqual(formatBytes(1024 * 1024), '1 MB'); + assert.strictEqual(formatBytes(1024 ** 3), '1 GB'); + assert.strictEqual(formatBytes(1024 ** 4), '1 TB'); + assert.strictEqual(formatBytes(1024 ** 5), '1 PB'); + }); + + test('it defaults to two decimals and strips insignificant trailing zeros', function (assert) { + assert.strictEqual(formatBytes(1536), '1.5 KB', '1.50 is normalized to 1.5 by parseFloat'); + assert.strictEqual(formatBytes(1234567, 1), '1.2 MB'); + assert.strictEqual(formatBytes(1234567), '1.18 MB'); + }); + + test('it honors an explicit decimals argument', function (assert) { + assert.strictEqual(formatBytes(1536, 0), '2 KB', 'rounds when no decimals are requested'); + assert.strictEqual(formatBytes(1536, 3), '1.5 KB'); + assert.strictEqual(formatBytes(1234567, 4), '1.1774 MB'); + }); + + test('it clamps negative decimals to zero rather than throwing', function (assert) { + assert.strictEqual(formatBytes(1536, -2), '2 KB'); + assert.strictEqual(formatBytes(1024, -100), '1 KB'); + }); + + test('it produces "NaN undefined" for inputs with no logarithm', function (assert) { + assert.strictEqual(formatBytes(-1), 'NaN undefined', 'negative sizes have no defined unit index'); + assert.strictEqual(formatBytes(NaN), 'NaN undefined'); + assert.strictEqual(formatBytes(undefined), 'NaN undefined'); + assert.strictEqual(formatBytes(null), 'NaN undefined', 'null is not === 0 so it does not hit the short-circuit'); + assert.strictEqual(formatBytes(Infinity), 'NaN undefined'); + }); + + test('it under-indexes for fractional byte counts', function (assert) { + assert.strictEqual(formatBytes(0.5), '512 undefined', 'sub-byte values compute a negative unit index'); + }); + + test('it is pure and stable across repeated invocations', function (assert) { + const first = formatBytes(2048); + const second = formatBytes(2048); + + assert.strictEqual(first, '2 KB'); + assert.strictEqual(second, first, 'repeated calls with the same input return the same string'); + assert.strictEqual(typeof first, 'string', 'the return value is always a string'); }); }); diff --git a/tests/unit/utils/format-currency-test.js b/tests/unit/utils/format-currency-test.js index 93d50d2e..0aa4de16 100644 --- a/tests/unit/utils/format-currency-test.js +++ b/tests/unit/utils/format-currency-test.js @@ -16,4 +16,15 @@ module('Unit | Utility | format-currency', function () { let result = formatCurrency(500, 'DEMOX'); assert.strictEqual(result, 'DEMOX 5.00'); }); + test('a zero-decimal currency is not divided into cents', function (assert) { + // JPY has no decimal separator, so the amount is already in its main unit. + const yen = formatCurrency(1500, 'JPY'); + + assert.true(yen.includes('1,500'), `the amount is used as-is (${yen})`); + assert.false(yen.includes('15.00'), 'rather than treated as cents'); + }); + + test('it formats nothing at all as zero in US dollars', function (assert) { + assert.strictEqual(formatCurrency(), '$0.00', 'both arguments default'); + }); }); diff --git a/tests/unit/utils/format-date-test.js b/tests/unit/utils/format-date-test.js index 3b3a75cb..957266f1 100644 --- a/tests/unit/utils/format-date-test.js +++ b/tests/unit/utils/format-date-test.js @@ -1,10 +1,60 @@ -import formatDate from 'dummy/utils/format-date'; +import formatDate from '@fleetbase/ember-ui/utils/format-date'; import { module, test } from 'qunit'; +// All dates are constructed from explicit local-time components so the assertions +// never depend on the system clock or the machine timezone. +const FIXED_DATE = new Date(2020, 0, 15, 13, 45, 30); + module('Unit | Utility | format-date', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = formatDate(new Date()); - assert.ok(result); + test('it formats with the default "PPP p" pattern when no format string is given', function (assert) { + assert.strictEqual(formatDate(FIXED_DATE), 'January 15th, 2020 1:45 PM'); + }); + + test('it honors an explicit format string', function (assert) { + assert.strictEqual(formatDate(FIXED_DATE, 'yyyy-MM-dd'), '2020-01-15'); + assert.strictEqual(formatDate(FIXED_DATE, 'HH:mm:ss'), '13:45:30'); + assert.strictEqual(formatDate(FIXED_DATE, "EEEE 'at' h:mm a"), 'Wednesday at 1:45 PM'); + }); + + test('it formats midnight and end-of-day boundaries', function (assert) { + assert.strictEqual(formatDate(new Date(2020, 0, 1, 0, 0, 0), 'yyyy-MM-dd HH:mm'), '2020-01-01 00:00'); + assert.strictEqual(formatDate(new Date(2020, 11, 31, 23, 59, 59), 'yyyy-MM-dd HH:mm:ss'), '2020-12-31 23:59:59'); + assert.strictEqual(formatDate(new Date(1999, 11, 31, 23, 59, 59)), 'December 31st, 1999 11:59 PM'); + }); + + test('it formats leap days and single-digit components with padding', function (assert) { + assert.strictEqual(formatDate(new Date(2020, 1, 29, 5, 7, 9), 'yyyy-MM-dd HH:mm:ss'), '2020-02-29 05:07:09'); + assert.strictEqual(formatDate(new Date(2020, 1, 29), 'd/M/yyyy'), '29/2/2020', 'unpadded tokens stay unpadded'); + }); + + test('it accepts a numeric timestamp as well as a Date instance', function (assert) { + const timestamp = FIXED_DATE.getTime(); + + assert.strictEqual(formatDate(timestamp, 'yyyy-MM-dd HH:mm:ss'), '2020-01-15 13:45:30', 'timestamps and Dates format identically'); + }); + + test('it throws a RangeError for unrepresentable dates', function (assert) { + assert.throws(() => formatDate(new Date('not-a-date'), 'yyyy'), RangeError, 'an Invalid Date throws'); + assert.throws(() => formatDate(null, 'yyyy'), RangeError, 'null throws'); + assert.throws(() => formatDate(undefined, 'yyyy'), RangeError, 'undefined throws'); + assert.throws(() => formatDate(NaN, 'yyyy'), RangeError, 'NaN throws'); + }); + + test('it does not mutate the date instance it is given', function (assert) { + const date = new Date(2020, 0, 15, 13, 45, 30); + const before = date.getTime(); + + formatDate(date); + formatDate(date, 'yyyy'); + + assert.strictEqual(date.getTime(), before, 'the input Date is left untouched'); + }); + + test('it returns identical output for repeated invocations', function (assert) { + const first = formatDate(FIXED_DATE, 'yyyy-MM-dd'); + const second = formatDate(FIXED_DATE, 'yyyy-MM-dd'); + + assert.strictEqual(first, '2020-01-15'); + assert.strictEqual(second, first); }); }); diff --git a/tests/unit/utils/format-meters-test.js b/tests/unit/utils/format-meters-test.js index b7ee88e4..485d0972 100644 --- a/tests/unit/utils/format-meters-test.js +++ b/tests/unit/utils/format-meters-test.js @@ -1,10 +1,50 @@ -import formatMeters from 'dummy/utils/format-meters'; +import formatMeters from '@fleetbase/ember-ui/utils/format-meters'; import { module, test } from 'qunit'; module('Unit | Utility | format-meters', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = formatMeters(); - assert.ok(result); + test('it converts whole kilometers', function (assert) { + assert.strictEqual(formatMeters(0), '0km'); + assert.strictEqual(formatMeters(1000), '1km'); + assert.strictEqual(formatMeters(25000), '25km'); + }); + + test('it rounds to the nearest kilometer', function (assert) { + assert.strictEqual(formatMeters(1500), '2km', 'half rounds up'); + assert.strictEqual(formatMeters(1499), '1km'); + assert.strictEqual(formatMeters(999), '1km'); + assert.strictEqual(formatMeters(500), '1km'); + assert.strictEqual(formatMeters(499), '0km', 'sub-half-kilometer collapses to zero'); + assert.strictEqual(formatMeters(1234567), '1235km'); + }); + + test('it rounds negative distances toward positive infinity', function (assert) { + assert.strictEqual(formatMeters(-1000), '-1km'); + assert.strictEqual(formatMeters(-1500), '-1km', 'Math.round(-1.5) is -1, not -2'); + assert.strictEqual(formatMeters(-1600), '-2km'); + assert.strictEqual(formatMeters(-400), '0km', 'negative zero stringifies without a sign'); + }); + + test('it propagates non-numeric input into the output string', function (assert) { + assert.strictEqual(formatMeters(undefined), 'NaNkm'); + assert.strictEqual(formatMeters(NaN), 'NaNkm'); + assert.strictEqual(formatMeters({}), 'NaNkm'); + assert.strictEqual(formatMeters(Infinity), 'Infinitykm'); + }); + + test('it coerces numeric strings and null through division', function (assert) { + assert.strictEqual(formatMeters('2000'), '2km', 'numeric strings divide cleanly'); + assert.strictEqual(formatMeters(null), '0km', 'null coerces to 0'); + }); + + test('it handles very large values without switching to exponential notation', function (assert) { + assert.strictEqual(formatMeters(1e12), '1000000000km'); + }); + + test('it always returns a km-suffixed string and is stable across calls', function (assert) { + const first = formatMeters(3000); + + assert.strictEqual(first, '3km'); + assert.strictEqual(formatMeters(3000), first, 'repeated calls are identical'); + assert.true(first.endsWith('km'), 'the unit suffix is always appended'); }); }); diff --git a/tests/unit/utils/generate-uuid-test.js b/tests/unit/utils/generate-uuid-test.js new file mode 100644 index 00000000..c82db03f --- /dev/null +++ b/tests/unit/utils/generate-uuid-test.js @@ -0,0 +1,62 @@ +import { module, test } from 'qunit'; +import generateUuid from '@fleetbase/ember-ui/utils/generate-uuid'; + +const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +module('Unit | Utility | generate-uuid', function (hooks) { + let nativeRandomUUID; + + hooks.beforeEach(function () { + nativeRandomUUID = crypto.randomUUID; + }); + + hooks.afterEach(function () { + // `randomUUID` is a plain data property on the crypto instance in the browsers we + // target, so restoring it is enough; delete it when it was never there to begin with. + if (nativeRandomUUID === undefined) { + delete crypto.randomUUID; + } else { + crypto.randomUUID = nativeRandomUUID; + } + }); + + test('it uses crypto.randomUUID when the platform offers it', function (assert) { + crypto.randomUUID = () => 'ffffffff-ffff-4fff-bfff-ffffffffffff'; + + assert.strictEqual(generateUuid(), 'ffffffff-ffff-4fff-bfff-ffffffffffff', 'the platform generator is used as given'); + }); + + test('without randomUUID it builds a v4 uuid from getRandomValues', function (assert) { + crypto.randomUUID = undefined; + + const uuid = generateUuid(); + + assert.ok(V4.test(uuid), `${uuid} is a well-formed v4 uuid`); + }); + + test('the fallback sets the version and variant bits whatever the random bytes are', function (assert) { + crypto.randomUUID = undefined; + + const nativeGetRandomValues = crypto.getRandomValues; + + try { + // All-zero bytes: only the version and variant nibbles may come back non-zero. + crypto.getRandomValues = (array) => array.fill(0x00); + assert.strictEqual(generateUuid(), '00000000-0000-4000-8000-000000000000', 'version 4 and variant 8 are forced into all-zero bytes'); + + // All-ones bytes: the same two nibbles are masked down rather than left as f. + crypto.getRandomValues = (array) => array.fill(0xff); + assert.strictEqual(generateUuid(), 'ffffffff-ffff-4fff-bfff-ffffffffffff', 'version 4 and variant b are masked into all-one bytes'); + } finally { + crypto.getRandomValues = nativeGetRandomValues; + } + }); + + test('it does not repeat itself', function (assert) { + crypto.randomUUID = undefined; + + const seen = new Set(Array.from({ length: 50 }, () => generateUuid())); + + assert.strictEqual(seen.size, 50, 'fifty draws from the fallback are all distinct'); + }); +}); diff --git a/tests/unit/utils/get-active-url-param-test.js b/tests/unit/utils/get-active-url-param-test.js index 6b7d6579..49579132 100644 --- a/tests/unit/utils/get-active-url-param-test.js +++ b/tests/unit/utils/get-active-url-param-test.js @@ -1,7 +1,37 @@ +import getActiveUrlParam from '@fleetbase/ember-ui/utils/get-active-url-param'; import { module, test } from 'qunit'; -module('Unit | Utility | get-active-url-param', function () { - test('it works', function (assert) { - assert.ok(true); +// NOTE: the current implementation is an unconditional `return true` — it takes +// no arguments and reads no state. These tests pin that contract exactly so the +// stub cannot change behaviour silently; they are deliberately narrow rather +// than asserting URL behaviour the function does not actually implement. +module('Unit | Utility | get-active-url-param', function (hooks) { + let originalUrl; + + hooks.beforeEach(function () { + originalUrl = window.location.href; + }); + + hooks.afterEach(function () { + window.history.replaceState(null, '', originalUrl); + }); + + test('it returns boolean true', function (assert) { + assert.true(getActiveUrlParam(), 'the value is exactly true, not merely truthy'); + }); + + test('it ignores any arguments it is given', function (assert) { + assert.true(getActiveUrlParam('anything')); + assert.true(getActiveUrlParam(null, undefined, 0)); + }); + + test('it does not depend on the current query string', function (assert) { + window.history.replaceState(null, '', '?active=false&other=1'); + + assert.true(getActiveUrlParam(), 'the result is independent of the URL'); + }); + + test('it is stable across repeated calls', function (assert) { + assert.strictEqual(getActiveUrlParam(), getActiveUrlParam()); }); }); diff --git a/tests/unit/utils/get-currency-test.js b/tests/unit/utils/get-currency-test.js index 9e470418..7e732201 100644 --- a/tests/unit/utils/get-currency-test.js +++ b/tests/unit/utils/get-currency-test.js @@ -4,10 +4,31 @@ import { module, test } from 'qunit'; module('Unit | Utility | get-currency', function () { test('it returns normal currencies by code', function (assert) { let result = getCurrency('SGD'); - assert.ok(result); + assert.strictEqual(result.code, 'SGD'); + assert.strictEqual(result.title, 'Singapore Dollar'); + }); + + test('a code is matched case-insensitively', function (assert) { + assert.strictEqual(getCurrency('sgd').code, 'SGD'); + assert.strictEqual(getCurrency('Sgd').code, 'SGD'); + }); + + test('a two-letter code is matched against the country iso2', function (assert) { + const result = getCurrency('SG'); + assert.strictEqual(result.iso2, 'SG'); assert.strictEqual(result.code, 'SGD'); }); + test('a longer string is matched against the country name', function (assert) { + const result = getCurrency('Singapore'); + assert.strictEqual(result.code, 'SGD'); + }); + + test('an unknown code returns nothing', function (assert) { + assert.strictEqual(getCurrency('NOTACURRENCY'), undefined); + assert.strictEqual(getCurrency('ZZ'), undefined); + }); + test('it returns KUDOS by code', function (assert) { let result = getCurrency('KUDOS'); assert.ok(result); @@ -18,5 +39,15 @@ module('Unit | Utility | get-currency', function () { test('it hides KUDOS from the default currency list', function (assert) { let result = getCurrency(); assert.false(result.some((currency) => currency.code === 'KUDOS')); + assert.true(result.length > 100, 'the rest of the list is still returned'); + }); + + test('hidden currencies can be opted into', function (assert) { + const result = getCurrency(null, { includeHidden: true }); + assert.true(result.some((currency) => currency.code === 'KUDOS')); + }); + + test('an empty code is treated as no code at all', function (assert) { + assert.true(Array.isArray(getCurrency('')), 'an empty string returns the whole list'); }); }); diff --git a/tests/unit/utils/get-custom-field-type-map-test.js b/tests/unit/utils/get-custom-field-type-map-test.js index 5fec3555..819ebb95 100644 --- a/tests/unit/utils/get-custom-field-type-map-test.js +++ b/tests/unit/utils/get-custom-field-type-map-test.js @@ -1,10 +1,79 @@ -import getCustomFieldTypeMap from 'dummy/utils/get-custom-field-type-map'; +import getCustomFieldTypeMap from '@fleetbase/ember-ui/utils/get-custom-field-type-map'; import { module, test } from 'qunit'; +import { camelize, dasherize } from '@ember/string'; module('Unit | Utility | get-custom-field-type-map', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = getCustomFieldTypeMap(); - assert.ok(result); + test('it returns the full set of supported custom field types', function (assert) { + const map = getCustomFieldTypeMap(); + + assert.deepEqual( + Object.keys(map), + ['input', 'phoneInput', 'moneyInput', 'dateTimeInput', 'datePicker', 'radioButton', 'select', 'fileUpload', 'signaturePad'], + 'the key set and its order are part of the contract' + ); + }); + + test('each type maps to its rendering component', function (assert) { + const map = getCustomFieldTypeMap(); + + assert.strictEqual(map.input.component, 'input'); + assert.strictEqual(map.phoneInput.component, 'phone-input'); + assert.strictEqual(map.moneyInput.component, 'money-input'); + assert.strictEqual(map.dateTimeInput.component, 'date-time-input'); + assert.strictEqual(map.datePicker.component, 'date-picker'); + assert.strictEqual(map.radioButton.component, 'radio-button-select'); + assert.strictEqual(map.select.component, 'select'); + assert.strictEqual(map.fileUpload.component, 'file-upload'); + assert.strictEqual(map.signaturePad.component, 'signature-pad'); + }); + + test('only the option-driven types declare hasOptions', function (assert) { + const map = getCustomFieldTypeMap(); + const withOptions = Object.keys(map).filter((key) => map[key].hasOptions === true); + + assert.deepEqual(withOptions, ['radioButton', 'select']); + assert.strictEqual(map.input.hasOptions, undefined, 'plain inputs do not declare hasOptions at all'); + assert.false('hasOptions' in map.fileUpload, 'the key is absent rather than false'); + assert.false('hasOptions' in map.signaturePad, 'a signature is not an option-driven field'); + }); + + test('commented-out types are not exposed', function (assert) { + const map = getCustomFieldTypeMap(); + + assert.false('modelSelect' in map, 'modelSelect is disabled in source'); + assert.false('dropzone' in map, 'dropzone is disabled in source'); + }); + + test('every key survives the dasherize/camelize round trip', function (assert) { + // `custom-field/form.js` persists `dasherize(key)` as the field type and looks the + // field map back up with `camelize(type)`, so a key that does not round trip would + // silently render the wrong component. + for (const key of Object.keys(getCustomFieldTypeMap())) { + assert.strictEqual(camelize(dasherize(key)), key, `${key} round trips`); + } + }); + + test('signaturePad dasherizes to the persisted field type', function (assert) { + assert.strictEqual(dasherize('signaturePad'), 'signature-pad', 'the persisted custom field type is signature-pad'); + }); + + test('it returns a fresh object graph on every call', function (assert) { + const first = getCustomFieldTypeMap(); + const second = getCustomFieldTypeMap(); + + assert.notStrictEqual(first, second, 'callers get their own top-level object'); + assert.notStrictEqual(first.input, second.input, 'nested descriptors are fresh too'); + assert.deepEqual(first, second, 'but the contents are identical'); + }); + + test('mutating the returned map does not leak into later calls', function (assert) { + const first = getCustomFieldTypeMap(); + first.input.component = 'hacked'; + delete first.select; + + const second = getCustomFieldTypeMap(); + + assert.strictEqual(second.input.component, 'input', 'the source map is not shared state'); + assert.true('select' in second, 'deleted keys reappear on the next call'); }); }); diff --git a/tests/unit/utils/get-url-param-test.js b/tests/unit/utils/get-url-param-test.js index 5402eaba..fbd9bc4e 100644 --- a/tests/unit/utils/get-url-param-test.js +++ b/tests/unit/utils/get-url-param-test.js @@ -1,7 +1,84 @@ +import getUrlParam from '@fleetbase/ember-ui/utils/get-url-param'; import { module, test } from 'qunit'; -module('Unit | Utility | getUrlParam', function () { - test('it works', function (assert) { - assert.ok(true); +module('Unit | Utility | get-url-param', function (hooks) { + let originalUrl; + + hooks.beforeEach(function () { + originalUrl = window.location.href; + }); + + hooks.afterEach(function () { + window.history.replaceState(null, '', originalUrl); + }); + + function withSearch(search) { + window.history.replaceState(null, '', search); + } + + test('it returns a single value as a string', function (assert) { + withSearch('?status=active'); + + assert.strictEqual(getUrlParam('status'), 'active'); + }); + + test('it returns an array when a key repeats', function (assert) { + withSearch('?tag=a&tag=b&tag=c'); + + assert.deepEqual(getUrlParam('tag'), ['a', 'b', 'c'], 'every repeated value is preserved in order'); + }); + + test('it returns an array for the bracket form when it repeats', function (assert) { + withSearch('?tag[]=a&tag[]=b'); + + assert.deepEqual(getUrlParam('tag'), ['a', 'b'], 'tag[]=a&tag[]=b is read through the bare key'); + }); + + test('it prefers the bare key over the bracket key when both repeat', function (assert) { + withSearch('?tag=a&tag=b&tag[]=c&tag[]=d'); + + assert.deepEqual(getUrlParam('tag'), ['a', 'b'], 'the bare-key branch is checked first'); + }); + + test('it falls back to the bracket form for a single value', function (assert) { + withSearch('?tag[]=only'); + + assert.strictEqual(getUrlParam('tag'), 'only', 'a lone tag[]=x still resolves through the bare key'); + }); + + test('it returns undefined for a missing key', function (assert) { + withSearch('?other=1'); + + assert.strictEqual(getUrlParam('status'), undefined); + }); + + test('it returns undefined for a present but empty value', function (assert) { + withSearch('?status='); + + assert.strictEqual(getUrlParam('status'), undefined, 'an empty string is treated as absent, not as ""'); + }); + + test('it returns undefined when the query string is empty', function (assert) { + withSearch(window.location.pathname); + + assert.strictEqual(getUrlParam('status'), undefined); + }); + + test('it decodes percent-encoded and unicode values', function (assert) { + withSearch(`?q=${encodeURIComponent('a b/&é 🚚')}`); + + assert.strictEqual(getUrlParam('q'), 'a b/&é 🚚'); + }); + + test('it keeps a single repeated-looking value as a string', function (assert) { + withSearch('?tag=a'); + + assert.strictEqual(getUrlParam('tag'), 'a', 'one value is a string, not a one-element array'); + }); + + test('it preserves empty entries inside a repeated key', function (assert) { + withSearch('?tag=a&tag='); + + assert.deepEqual(getUrlParam('tag'), ['a', ''], 'the empty-value check only applies to the single-value path'); }); }); diff --git a/tests/unit/utils/is-empty-object-test.js b/tests/unit/utils/is-empty-object-test.js index eb8285d4..87be0a4e 100644 --- a/tests/unit/utils/is-empty-object-test.js +++ b/tests/unit/utils/is-empty-object-test.js @@ -1,10 +1,65 @@ -import isEmptyObject from 'dummy/utils/is-empty-object'; +import isEmptyObject from '@fleetbase/ember-ui/utils/is-empty-object'; import { module, test } from 'qunit'; module('Unit | Utility | is-empty-object', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = isEmptyObject(); - assert.ok(result); + test('it reports true for an object literal with no keys', function (assert) { + assert.true(isEmptyObject({})); + assert.true(isEmptyObject(new Object())); + }); + + test('it reports false for an object with any own enumerable key', function (assert) { + assert.false(isEmptyObject({ a: 1 })); + assert.false(isEmptyObject({ a: undefined }), 'an explicitly undefined value is still a key'); + assert.false(isEmptyObject({ a: null })); + assert.false(isEmptyObject({ '': 'blank key' })); + assert.false(isEmptyObject({ 'ünïcödé-🚚': true }), 'unicode keys count'); + }); + + test('it treats blank values as empty', function (assert) { + assert.true(isEmptyObject(null)); + assert.true(isEmptyObject(undefined)); + assert.true(isEmptyObject('')); + assert.true(isEmptyObject(' '), 'whitespace-only strings are blank'); + assert.true(isEmptyObject([]), 'an empty array is blank'); + }); + + test('it reports false for non-plain objects and non-blank primitives', function (assert) { + assert.false(isEmptyObject([1, 2]), 'a populated array is not a plain empty object'); + assert.false(isEmptyObject('text')); + assert.false(isEmptyObject(0), 'zero is neither blank nor a plain object'); + assert.false(isEmptyObject(false)); + assert.false(isEmptyObject(NaN)); + assert.false(isEmptyObject(new Date(2020, 0, 1)), 'a Date has a non-Object constructor'); + assert.false(isEmptyObject(() => {})); + }); + + test('it reports false for empty class instances because the constructor differs', function (assert) { + class Empty {} + + assert.false(isEmptyObject(new Empty()), 'only plain object literals qualify'); + }); + + test('it reports false for a null-prototype object with no constructor', function (assert) { + assert.false(isEmptyObject(Object.create(null)), 'no constructor means the Object check cannot pass'); + }); + + test('it ignores inherited and non-enumerable properties', function (assert) { + const parent = { inherited: true }; + const child = Object.create(parent); + + assert.true(isEmptyObject(child), 'inherited keys do not count — only own enumerable keys do'); + + const hidden = {}; + Object.defineProperty(hidden, 'secret', { value: 1, enumerable: false }); + + assert.true(isEmptyObject(hidden), 'non-enumerable own keys are invisible to Object.keys'); + }); + + test('it does not mutate the object it inspects and is repeatable', function (assert) { + const subject = { a: 1 }; + + assert.false(isEmptyObject(subject)); + assert.false(isEmptyObject(subject), 'repeated calls agree'); + assert.deepEqual(subject, { a: 1 }, 'the input is untouched'); }); }); diff --git a/tests/unit/utils/is-fast-boot-test.js b/tests/unit/utils/is-fast-boot-test.js index 33101d78..5cb62017 100644 --- a/tests/unit/utils/is-fast-boot-test.js +++ b/tests/unit/utils/is-fast-boot-test.js @@ -1,7 +1,74 @@ +import isFastBoot from '@fleetbase/ember-ui/utils/is-fastboot'; import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { setOwner } from '@ember/application'; +import Service from '@ember/service'; -module('Unit | Utility | is-fast-boot', function () { - test('it works', function (assert) { - assert.ok(true); +function ownedContext(owner) { + const context = {}; + setOwner(context, owner); + + return context; +} + +module('Unit | Utility | is-fastboot', function (hooks) { + setupTest(hooks); + + test('it returns false when no fastboot service is registered', function (assert) { + assert.false(isFastBoot(ownedContext(this.owner)), 'a browser app has no fastboot service'); + }); + + test('it reports true when the fastboot service says so', function (assert) { + this.owner.register( + 'service:fastboot', + class extends Service { + isFastBoot = true; + } + ); + + assert.true(isFastBoot(ownedContext(this.owner))); + }); + + test('it reports false when the fastboot service says so', function (assert) { + this.owner.register( + 'service:fastboot', + class extends Service { + isFastBoot = false; + } + ); + + assert.false(isFastBoot(ownedContext(this.owner))); + }); + + test('it reads the flag through get, so a computed value is honoured', function (assert) { + this.owner.register( + 'service:fastboot', + class extends Service { + get isFastBoot() { + return true; + } + } + ); + + assert.true(isFastBoot(ownedContext(this.owner))); + }); + + test('it returns undefined when the service exists but exposes no flag', function (assert) { + this.owner.register('service:fastboot', class extends Service {}); + + assert.strictEqual(isFastBoot(ownedContext(this.owner)), undefined, 'the service is trusted to define the flag'); + }); + + test('it accepts any owner-bearing object as its context', function (assert) { + this.owner.register( + 'service:fastboot', + class extends Service { + isFastBoot = true; + } + ); + + const service = this.owner.lookup('service:fastboot'); + + assert.true(isFastBoot(service), 'a real owned object works as the context'); }); }); diff --git a/tests/unit/utils/is-image-file-test.js b/tests/unit/utils/is-image-file-test.js index 142e8c07..806c66c2 100644 --- a/tests/unit/utils/is-image-file-test.js +++ b/tests/unit/utils/is-image-file-test.js @@ -1,28 +1,259 @@ -import isImageFile from 'dummy/utils/is-image-file'; +import isImageFile, { getFileExtension, toUint8, looksLikeImageBytes } from '@fleetbase/ember-ui/utils/is-image-file'; import { module, test } from 'qunit'; +function bytes(...values) { + return new Uint8Array(values); +} + +// Pad to at least 12 bytes so the length guard never masks a signature check. +function signature(...values) { + const u8 = new Uint8Array(12); + u8.set(values.slice(0, 12)); + + return u8; +} + +const PNG_SIGNATURE = signature(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a); + module('Unit | Utility | is-image-file', function () { - test('it works', function (assert) { - const file = { - subject_uuid: null, - caption: null, - url: null, - path: '7edXyrWQo.png', - bucket: null, - folder: null, - etag: null, - original_filename: 'Screenshot 2025-11-11 at 12.11.48.png', - type: 'file', - content_type: 'image/png', - subject_type: null, - file_size: 254603, - slug: 'screenshot-2025-11-11-at-121148png-3', - permalink: null, - deleted_at: null, - created_at: '2025-11-12T07:42:51.000Z', - updated_at: '2025-11-12T07:42:51.000Z', - }; - let result = isImageFile(file); - assert.ok(result); + module('isImageFile', function () { + test('it rejects nullish and falsy input', function (assert) { + assert.false(isImageFile(null)); + assert.false(isImageFile(undefined)); + assert.false(isImageFile(0)); + assert.false(isImageFile('')); + assert.false(isImageFile(false)); + assert.false(isImageFile()); + }); + + test('it accepts anything with an image content_type', function (assert) { + assert.true(isImageFile({ content_type: 'image/png' })); + assert.true(isImageFile({ content_type: 'image/svg+xml' })); + }); + + test('it falls back to the type field when content_type is absent', function (assert) { + assert.true(isImageFile({ type: 'image/jpeg' })); + }); + + test('it matches the MIME type case-insensitively', function (assert) { + assert.true(isImageFile({ content_type: 'IMAGE/PNG' })); + }); + + test('it ignores the generic "file" type used by the API payload', function (assert) { + assert.false(isImageFile({ type: 'file' }), '"file" is not a MIME type and must not short-circuit'); + }); + + test('it recognises a real uploaded-image payload by extension', function (assert) { + const file = { + path: '7edXyrWQo.png', + original_filename: 'Screenshot 2025-11-11 at 12.11.48.png', + type: 'file', + content_type: 'image/png', + file_size: 254603, + }; + + assert.true(isImageFile(file)); + }); + + test('it still detects an image when the service mislabels the MIME type', function (assert) { + const file = { content_type: 'application/octet-stream', original_filename: 'photo.JPG' }; + + assert.true(isImageFile(file), 'the extension rescues a mislabelled octet-stream'); + }); + + test('it accepts an inline data: image URL', function (assert) { + assert.true(isImageFile({ url: 'data:image/png;base64,iVBORw0KGgo=' })); + }); + + test('it accepts a data: image URL supplied as the path', function (assert) { + assert.true(isImageFile({ path: 'data:image/gif;base64,R0lGODlh' })); + }); + + test('it rejects a non-image data URL', function (assert) { + assert.false(isImageFile({ url: 'data:text/plain;base64,aGk=' })); + }); + + test('it accepts every extension in the allow list', function (assert) { + for (const ext of ['jpg', 'jpeg', 'jfif', 'png', 'gif', 'bmp', 'tif', 'tiff', 'webp', 'svg', 'heic', 'heif', 'avif', 'ico', 'cur']) { + assert.true(isImageFile({ original_filename: `asset.${ext}` }), `.${ext} is recognised`); + } + }); + + test('it rejects non-image extensions', function (assert) { + for (const ext of ['pdf', 'docx', 'mp4', 'zip', 'txt']) { + assert.false(isImageFile({ original_filename: `asset.${ext}` }), `.${ext} is not an image`); + } + }); + + test('it sniffs raw bytes when MIME and extension are both unhelpful', function (assert) { + assert.true(isImageFile({ original_filename: 'mystery', bytes: PNG_SIGNATURE })); + }); + + test('it sniffs a base64 payload', function (assert) { + const base64 = btoa(String.fromCharCode(...PNG_SIGNATURE)); + + assert.true(isImageFile({ original_filename: 'mystery', base64 })); + }); + + test('it returns false when the sniffed bytes are not an image', function (assert) { + assert.false(isImageFile({ original_filename: 'mystery', bytes: signature(0x00, 0x01, 0x02) })); + }); + + test('it swallows a malformed base64 payload rather than throwing', function (assert) { + assert.false(isImageFile({ original_filename: 'mystery', base64: 'not valid base64!!!' })); + }); + + test('it falls through to false when nothing identifies the file', function (assert) { + assert.false(isImageFile({})); + assert.false(isImageFile({ original_filename: 'README' })); + }); + }); + + module('getFileExtension', function () { + test('it returns an empty string for nullish or non-string input', function (assert) { + assert.strictEqual(getFileExtension(null), ''); + assert.strictEqual(getFileExtension(undefined), ''); + assert.strictEqual(getFileExtension(''), ''); + assert.strictEqual(getFileExtension(42), ''); + assert.strictEqual(getFileExtension({}), ''); + }); + + test('it reports the sentinel extension for data image URLs', function (assert) { + assert.strictEqual(getFileExtension('data:image/png;base64,AAAA'), 'dataurl'); + }); + + test('it lowercases the extension', function (assert) { + assert.strictEqual(getFileExtension('PHOTO.JPEG'), 'jpeg'); + }); + + test('it strips a query string and a fragment', function (assert) { + assert.strictEqual(getFileExtension('photo.png?size=large'), 'png'); + assert.strictEqual(getFileExtension('photo.png#preview'), 'png'); + assert.strictEqual(getFileExtension('photo.png?a=1#b'), 'png'); + }); + + test('it reads the extension from a full URL path', function (assert) { + assert.strictEqual(getFileExtension('https://cdn.example.com/a/b/photo.webp'), 'webp'); + }); + + test('it reads the extension from a relative path', function (assert) { + assert.strictEqual(getFileExtension('a/b/photo.gif'), 'gif'); + }); + + test('it uses only the last dot', function (assert) { + assert.strictEqual(getFileExtension('archive.tar.gz'), 'gz'); + assert.strictEqual(getFileExtension('Screenshot 2025-11-11 at 12.11.48.png'), 'png'); + }); + + test('it returns an empty string when there is no usable extension', function (assert) { + assert.strictEqual(getFileExtension('README'), '', 'no dot at all'); + assert.strictEqual(getFileExtension('.gitignore'), '', 'a leading dot is not an extension'); + assert.strictEqual(getFileExtension('trailing.'), '', 'a trailing dot is not an extension'); + assert.strictEqual(getFileExtension('a/b/'), '', 'a directory path has no filename'); + }); + }); + + module('toUint8', function () { + test('it passes a Uint8Array straight through', function (assert) { + const input = bytes(1, 2, 3); + + assert.strictEqual(toUint8(input), input, 'the same instance is returned, not a copy'); + }); + + test('it wraps an ArrayBuffer', function (assert) { + const result = toUint8(new Uint8Array([4, 5, 6]).buffer); + + assert.true(result instanceof Uint8Array); + assert.deepEqual([...result], [4, 5, 6]); + }); + + test('it decodes a plain base64 string', function (assert) { + assert.deepEqual([...toUint8(null, btoa('hi'))], [104, 105]); + }); + + test('it strips a data: prefix before decoding', function (assert) { + assert.deepEqual([...toUint8(null, `data:image/png;base64,${btoa('hi')}`)], [104, 105]); + }); + + test('it decodes an empty payload after a bare data: prefix', function (assert) { + assert.deepEqual([...toUint8(null, 'data:image/png;base64')], [], 'a missing comma yields an empty buffer rather than throwing'); + }); + + test('it returns null when given neither bytes nor base64', function (assert) { + assert.strictEqual(toUint8(null, null), null); + assert.strictEqual(toUint8(undefined, undefined), null); + assert.strictEqual(toUint8({}, 42), null, 'a non-string base64 value is ignored'); + }); + + test('it throws on an undecodable base64 string', function (assert) { + assert.throws(() => toUint8(null, 'not valid base64!!!')); + }); + }); + + module('looksLikeImageBytes', function () { + test('it rejects buffers shorter than the minimum header', function (assert) { + assert.false(looksLikeImageBytes(bytes())); + assert.false(looksLikeImageBytes(bytes(0xff, 0xd8, 0xff)), 'even a valid JPEG prefix needs 12 bytes to be considered'); + assert.false(looksLikeImageBytes(new Uint8Array(11))); + }); + + test('it recognises a JPEG signature', function (assert) { + assert.true(looksLikeImageBytes(signature(0xff, 0xd8, 0xff))); + }); + + test('it recognises a PNG signature', function (assert) { + assert.true(looksLikeImageBytes(PNG_SIGNATURE)); + }); + + test('it recognises both GIF versions', function (assert) { + assert.true(looksLikeImageBytes(signature(0x47, 0x49, 0x46, 0x38, 0x37, 0x61)), 'GIF87a'); + assert.true(looksLikeImageBytes(signature(0x47, 0x49, 0x46, 0x38, 0x39, 0x61)), 'GIF89a'); + }); + + test('it rejects a GIF-like header with an unknown version byte', function (assert) { + assert.false(looksLikeImageBytes(signature(0x47, 0x49, 0x46, 0x38, 0x35, 0x61))); + }); + + test('it recognises WEBP by its RIFF container', function (assert) { + assert.true(looksLikeImageBytes(signature(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50))); + }); + + test('it rejects a RIFF container that is not WEBP', function (assert) { + assert.false(looksLikeImageBytes(signature(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x41, 0x56, 0x49, 0x20)), 'RIFF/AVI is not an image'); + }); + + test('it recognises BMP', function (assert) { + assert.true(looksLikeImageBytes(signature(0x42, 0x4d))); + }); + + test('it recognises both TIFF byte orders', function (assert) { + assert.true(looksLikeImageBytes(signature(0x49, 0x49, 0x2a, 0x00)), 'little-endian'); + assert.true(looksLikeImageBytes(signature(0x4d, 0x4d, 0x00, 0x2a)), 'big-endian'); + }); + + test('it recognises ISOBMFF image brands', function (assert) { + const ftyp = [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70]; + + for (const brand of ['heic', 'heix', 'mif1', 'hevc', 'avif', 'avis']) { + const brandBytes = [...brand].map((character) => character.charCodeAt(0)); + assert.true(looksLikeImageBytes(signature(...ftyp, ...brandBytes)), `${brand} is an image brand`); + } + }); + + test('it rejects an ISOBMFF file with a video brand', function (assert) { + const ftyp = [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70]; + const brandBytes = [...'mp42'].map((character) => character.charCodeAt(0)); + + assert.false(looksLikeImageBytes(signature(...ftyp, ...brandBytes)), 'mp42 is video, not an image'); + }); + + test('it rejects unknown bytes', function (assert) { + assert.false(looksLikeImageBytes(new Uint8Array(16))); + assert.false(looksLikeImageBytes(signature(0x25, 0x50, 0x44, 0x46)), 'a PDF header is not an image'); + }); + }); + test('bytes that cannot be read fall through to a negative answer', function (assert) { + assert.false(isImageFile({ bytes: 12345 }), 'a number is not convertible to a byte view'); + assert.false(isImageFile({ base64: '!!!not base64!!!' }), 'nor is an unparseable base64 string'); }); }); diff --git a/tests/unit/utils/is-menu-item-active-test.js b/tests/unit/utils/is-menu-item-active-test.js index 917071d2..304829df 100644 --- a/tests/unit/utils/is-menu-item-active-test.js +++ b/tests/unit/utils/is-menu-item-active-test.js @@ -1,9 +1,99 @@ import isMenuItemActive from 'dummy/utils/is-menu-item-active'; import { module, test } from 'qunit'; +import { setupWindowMock } from 'ember-window-mock/test-support'; +import window from 'ember-window-mock'; -module('Unit | Utility | is-menu-item-active', function () { - test('it works', function (assert) { - let result = isMenuItemActive(); - assert.strictEqual(typeof result, 'boolean'); +// Every decision this helper makes comes from the current URL, so each test states the path it is +// answering for. +function at(path) { + window.location.href = `http://localhost${path}`; +} + +module('Unit | Utility | is-menu-item-active', function (hooks) { + setupWindowMock(hooks); + + test('with no arguments at all it reports nothing active', function (assert) { + at('/fleet-ops/orders'); + + assert.false(isMenuItemActive(), 'an undefined slug is not in the path'); + }); + + module('a section and a slug', function () { + test('both must appear in the path', function (assert) { + at('/storefront/products'); + + assert.true(isMenuItemActive('storefront', 'products'), 'the section leads and the slug follows'); + assert.false(isMenuItemActive('storefront', 'orders'), 'a slug that is not there fails'); + assert.false(isMenuItemActive('iam', 'products'), 'and so does the wrong section'); + }); + + test('the section must be the first segment, not merely present', function (assert) { + at('/storefront/products/iam'); + + assert.false(isMenuItemActive('iam', 'products'), 'iam appears in the path but does not lead it'); + }); + }); + + module('the fleet-ops three-segment path', function () { + test('the leading fleet-ops segment is dropped before matching', function (assert) { + at('/fleet-ops/operations/orders'); + + assert.true(isMenuItemActive('operations', 'orders'), 'the section is matched against the second segment'); + assert.false(isMenuItemActive('fleet-ops', 'orders'), 'and no longer against fleet-ops itself'); + }); + + test('a two-segment fleet-ops path is left alone', function (assert) { + at('/fleet-ops/orders'); + + assert.true(isMenuItemActive('fleet-ops', 'orders'), 'the hack only applies at three segments'); + }); + + test('a three-segment path under another extension is left alone', function (assert) { + at('/storefront/operations/orders'); + + assert.true(isMenuItemActive('storefront', 'orders')); + assert.false(isMenuItemActive('operations', 'orders'), 'only fleet-ops is stripped'); + }); + }); + + module('a slug that is also its own section', function () { + test('with no view it only has to appear in the path', function (assert) { + at('/dashboard/widgets'); + + assert.true(isMenuItemActive('dashboard', 'dashboard'), 'the section leads the path'); + }); + + // `slugOnly` is defined as `… && view === null`, so asking for a view takes the + // section-and-view path instead. + test('asking for a view falls through to the section rules', function (assert) { + at('/dashboard/analytics'); + + assert.true(isMenuItemActive('dashboard', 'dashboard', 'analytics'), 'the section, slug and view all appear'); + assert.false(isMenuItemActive('dashboard', 'dashboard', 'reports'), 'a view that is absent still fails'); + }); + }); + + module('a view', function () { + test('it can be satisfied by a path segment', function (assert) { + at('/storefront/products/grid'); + + assert.true(isMenuItemActive('storefront', 'products', 'grid')); + assert.false(isMenuItemActive('storefront', 'products', 'list'), 'a different view does not match'); + }); + + test('with no section the slug and the view are both still required', function (assert) { + at('/storefront/products/grid'); + + assert.true(isMenuItemActive(null, 'products', 'grid')); + assert.false(isMenuItemActive(null, 'orders', 'grid'), 'the slug still has to be there'); + assert.false(isMenuItemActive(null, 'products', 'list'), 'and so does the view'); + }); + }); + + test('with neither a section nor a view only the slug matters', function (assert) { + at('/storefront/products/grid'); + + assert.true(isMenuItemActive(null, 'grid')); + assert.false(isMenuItemActive(null, 'invoices')); }); }); diff --git a/tests/unit/utils/is-upload-file-test.js b/tests/unit/utils/is-upload-file-test.js index 6a498318..bcae3211 100644 --- a/tests/unit/utils/is-upload-file-test.js +++ b/tests/unit/utils/is-upload-file-test.js @@ -1,9 +1,51 @@ -// import isUploadFile from 'dummy/utils/is-upload-file'; +import isUploadFile from '@fleetbase/ember-ui/utils/is-upload-file'; +import { UploadFile } from 'ember-file-upload'; import { module, test } from 'qunit'; +function uploadFileFrom(file) { + return new UploadFile(file); +} + module('Unit | Utility | is-upload-file', function () { - test('it works', function (assert) { - // let result = isUploadFile(); - assert.ok(true); + test('it recognises an UploadFile instance', function (assert) { + const uploadFile = uploadFileFrom(new File(['data'], 'report.pdf', { type: 'application/pdf' })); + + assert.true(isUploadFile(uploadFile)); + }); + + test('it rejects the raw File the UploadFile wraps', function (assert) { + const file = new File(['data'], 'report.pdf', { type: 'application/pdf' }); + + assert.false(isUploadFile(file), 'a browser File is not an UploadFile'); + }); + + test('it rejects a Blob', function (assert) { + assert.false(isUploadFile(new Blob(['data']))); + }); + + test('it rejects nullish values', function (assert) { + assert.false(isUploadFile(null)); + assert.false(isUploadFile(undefined)); + }); + + test('it rejects primitives', function (assert) { + assert.false(isUploadFile('')); + assert.false(isUploadFile('report.pdf')); + assert.false(isUploadFile(0)); + assert.false(isUploadFile(false)); + assert.false(isUploadFile(NaN)); + }); + + test('it rejects plain objects that merely look like an upload', function (assert) { + assert.false(isUploadFile({ name: 'report.pdf', size: 4, type: 'application/pdf' }), 'duck typing is not enough — the check is instanceof'); + }); + + test('it rejects arrays and empty objects', function (assert) { + assert.false(isUploadFile([])); + assert.false(isUploadFile({})); + }); + + test('it is callable with no argument', function (assert) { + assert.false(isUploadFile()); }); }); diff --git a/tests/unit/utils/load-assets-test.js b/tests/unit/utils/load-assets-test.js index f4859639..4e094a24 100644 --- a/tests/unit/utils/load-assets-test.js +++ b/tests/unit/utils/load-assets-test.js @@ -1,10 +1,178 @@ -// import loadAssets from 'dummy/utils/load-assets'; +import loadAssets from '@fleetbase/ember-ui/utils/load-assets'; +import { settled } from '@ember/test-helpers'; import { module, test } from 'qunit'; -module('Unit | Utility | load-assets', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - // let result = loadAssets(); - assert.ok(true); +const INDICATOR_KEY = '__loadAssetsTestIndicator'; + +module('Unit | Utility | load-assets', function (hooks) { + hooks.beforeEach(function () { + this.appended = []; + + // Intercept the DOM injection boundary: elements are recorded but never + // attached, so no script is executed and no stylesheet is fetched. + this.originalAppendChild = document.body.appendChild; + document.body.appendChild = (node) => { + this.appended.push(node); + return node; + }; + + this.scripts = () => this.appended.filter((node) => node.tagName === 'SCRIPT' && node.src); + this.links = () => this.appended.filter((node) => node.tagName === 'LINK'); + }); + + hooks.afterEach(async function () { + document.body.appendChild = this.originalAppendChild; + await settled(); + delete window[INDICATOR_KEY]; + }); + + test('it always injects the window.exports bootstrap first', async function (assert) { + loadAssets(); + + assert.strictEqual(this.appended.length, 1, 'only the bootstrap is injected with no assets'); + assert.strictEqual(this.appended[0].tagName, 'SCRIPT'); + assert.strictEqual(this.appended[0].innerHTML, 'window.exports = window.exports || {};'); + assert.strictEqual(this.appended[0].src, '', 'the bootstrap is inline, not a remote script'); + + await settled(); + }); + + test('it injects scripts in order under the base path', async function (assert) { + loadAssets({ basePath: 'engines-dist/leaflet', scripts: ['leaflet.js', 'plugins/heat.js'], stylesheets: [] }); + + assert.deepEqual( + this.scripts().map((node) => node.getAttribute('src')), + ['/engines-dist/leaflet/leaflet.js', '/engines-dist/leaflet/plugins/heat.js'], + 'each script is prefixed with a leading slash and the base path' + ); + + await settled(); + }); + + test('it injects stylesheets as rel=stylesheet links after the scripts', async function (assert) { + loadAssets({ basePath: 'assets', scripts: ['a.js'], stylesheets: ['a.css', 'b.css'] }); + + assert.deepEqual( + this.appended.map((node) => node.tagName), + ['SCRIPT', 'SCRIPT', 'LINK', 'LINK'], + 'bootstrap, then scripts, then stylesheets' + ); + assert.deepEqual( + this.links().map((node) => node.getAttribute('href')), + ['/assets/a.css', '/assets/b.css'] + ); + assert.true( + this.links().every((node) => node.rel === 'stylesheet'), + 'links are marked as stylesheets' + ); + + await settled(); + }); + + test('an empty or missing base path resolves to the site root', async function (assert) { + loadAssets({ basePath: '', scripts: ['a.js'], stylesheets: ['a.css'] }); + + assert.strictEqual(this.scripts()[0].getAttribute('src'), '/a.js'); + assert.strictEqual(this.links()[0].getAttribute('href'), '/a.css'); + + await settled(); + }); + + test('it preserves query strings and special characters in asset paths', async function (assert) { + loadAssets({ basePath: 'dist', scripts: ['bundle.js?v=1.2.3', 'ünïcödé file.js'], stylesheets: [] }); + + assert.deepEqual( + this.scripts().map((node) => node.getAttribute('src')), + ['/dist/bundle.js?v=1.2.3', '/dist/ünïcödé file.js'], + 'paths are passed through verbatim' + ); + + await settled(); + }); + + test('empty asset lists inject nothing beyond the bootstrap', async function (assert) { + loadAssets({ basePath: 'dist', scripts: [], stylesheets: [] }); + + assert.strictEqual(this.scripts().length, 0); + assert.strictEqual(this.links().length, 0); + assert.strictEqual(this.appended.length, 1); + + await settled(); + }); + + test('it flags the global indicator false immediately and true once loading settles', async function (assert) { + window[INDICATOR_KEY] = 'stale value'; + + loadAssets({ basePath: 'dist', scripts: ['a.js'], stylesheets: [], globalIndicatorKey: INDICATOR_KEY }); + + assert.false(window[INDICATOR_KEY], 'the indicator is reset before injection'); + + await settled(); + + assert.true(window[INDICATOR_KEY], 'and flipped to true after the load window elapses'); + }); + + test('the callback runs once, after the indicator is set', async function (assert) { + const observed = []; + + loadAssets({ basePath: 'dist', scripts: [], stylesheets: [], globalIndicatorKey: INDICATOR_KEY }, () => observed.push(window[INDICATOR_KEY])); + + assert.deepEqual(observed, [], 'the callback is deferred, not synchronous'); + + await settled(); + + assert.deepEqual(observed, [true], 'it fires exactly once and sees the indicator already set'); + }); + + test('a non-string or absent indicator key touches no globals', async function (assert) { + loadAssets({ basePath: 'dist', scripts: [], stylesheets: [], globalIndicatorKey: 123 }); + + await settled(); + + assert.false(INDICATOR_KEY in window, 'nothing is written for a non-string key'); + assert.strictEqual(window[123], undefined, 'and the numeric key is not used either'); + }); + + test('a non-function callback is ignored rather than invoked', async function (assert) { + loadAssets({ basePath: 'dist', scripts: [], stylesheets: [], globalIndicatorKey: INDICATOR_KEY }, 'not-a-function'); + + await settled(); + + assert.true(window[INDICATOR_KEY], 'the rest of the completion work still runs'); + }); + + test('it throws when the stylesheets list is missing, after injecting the scripts', function (assert) { + assert.throws(() => loadAssets({ basePath: 'dist', scripts: ['a.js'] }), TypeError, 'the util assumes both lists are present'); + assert.strictEqual(this.scripts().length, 1, 'the scripts injected before the failure are still in the DOM'); + }); + + test('it throws when the scripts list is missing', function (assert) { + assert.throws(() => loadAssets({ basePath: 'dist', stylesheets: [] }), TypeError); + }); + + test('repeated calls re-inject rather than dedupe', async function (assert) { + loadAssets({ basePath: 'dist', scripts: ['a.js'], stylesheets: ['a.css'] }); + loadAssets({ basePath: 'dist', scripts: ['a.js'], stylesheets: ['a.css'] }); + + assert.strictEqual(this.appended.length, 6, 'each call injects its own bootstrap, script and stylesheet'); + assert.deepEqual( + this.scripts().map((node) => node.getAttribute('src')), + ['/dist/a.js', '/dist/a.js'], + 'there is no de-duplication of already requested assets' + ); + + await settled(); + }); + + test('every injected node is a freshly created element', async function (assert) { + loadAssets({ basePath: 'dist', scripts: ['a.js', 'b.js'], stylesheets: [] }); + + const [bootstrap, first, second] = this.appended; + + assert.notStrictEqual(first, second, 'each script gets its own element'); + assert.notStrictEqual(bootstrap, first); + assert.strictEqual(first.parentNode, null, 'the stubbed boundary means nothing was really attached'); + + await settled(); }); }); diff --git a/tests/unit/utils/load-leaflet-plugins-test.js b/tests/unit/utils/load-leaflet-plugins-test.js index 2a8c6f26..68d2f54b 100644 --- a/tests/unit/utils/load-leaflet-plugins-test.js +++ b/tests/unit/utils/load-leaflet-plugins-test.js @@ -1,10 +1,113 @@ -// import loadLeafletPlugins from 'dummy/utils/load-leaflet-plugins'; +import loadLeafletPlugins from '@fleetbase/ember-ui/utils/load-leaflet-plugins'; +import { settled } from '@ember/test-helpers'; import { module, test } from 'qunit'; -module('Unit | Utility | load-leaflet-plugins', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - // let result = loadLeafletPlugins(); - assert.ok(true); +const INDICATOR_KEY = '__loadLeafletPluginsTestIndicator'; + +module('Unit | Utility | load-leaflet-plugins', function (hooks) { + hooks.beforeEach(function () { + this.appended = []; + + // Intercept the DOM injection boundary so nothing is executed or fetched. + this.originalAppendChild = document.body.appendChild; + document.body.appendChild = (node) => { + this.appended.push(node); + return node; + }; + + this.srcs = () => this.appended.filter((node) => node.tagName === 'SCRIPT' && node.src).map((node) => node.getAttribute('src')); + this.hrefs = () => this.appended.filter((node) => node.tagName === 'LINK').map((node) => node.getAttribute('href')); + }); + + hooks.afterEach(async function () { + document.body.appendChild = this.originalAppendChild; + await settled(); + delete window[INDICATOR_KEY]; + }); + + test('it defaults the base path to the leaflet engine dist directory', async function (assert) { + loadLeafletPlugins({ scripts: ['leaflet.js', 'leaflet.draw.js'], stylesheets: ['leaflet.css'] }); + + assert.deepEqual(this.srcs(), ['/engines-dist/leaflet/leaflet.js', '/engines-dist/leaflet/leaflet.draw.js']); + assert.deepEqual(this.hrefs(), ['/engines-dist/leaflet/leaflet.css']); + + await settled(); + }); + + test('an explicit base path overrides the default', async function (assert) { + loadLeafletPlugins({ basePath: 'vendor/leaflet', scripts: ['leaflet.js'], stylesheets: ['leaflet.css'] }); + + assert.deepEqual(this.srcs(), ['/vendor/leaflet/leaflet.js']); + assert.deepEqual(this.hrefs(), ['/vendor/leaflet/leaflet.css']); + + await settled(); + }); + + test('an explicit null base path resolves to the site root', async function (assert) { + // The spread of `assets` happens after the computed default, so an explicit + // null key wins and the assets end up at the root. + loadLeafletPlugins({ basePath: null, scripts: ['leaflet.js'], stylesheets: [] }); + + assert.deepEqual(this.srcs(), ['/leaflet.js']); + + await settled(); + }); + + test('it still injects the window.exports bootstrap', async function (assert) { + loadLeafletPlugins({ scripts: [], stylesheets: [] }); + + assert.strictEqual(this.appended.length, 1); + assert.strictEqual(this.appended[0].innerHTML, 'window.exports = window.exports || {};'); + + await settled(); + }); + + test('calling it with no arguments injects only the bootstrap', async function (assert) { + loadLeafletPlugins(); + + assert.deepEqual(this.srcs(), [], 'the default asset lists are empty'); + assert.deepEqual(this.hrefs(), []); + assert.strictEqual(this.appended.length, 1); + + await settled(); + }); + + test('it forwards the global indicator key and the callback', async function (assert) { + const observed = []; + + loadLeafletPlugins({ scripts: ['leaflet.js'], stylesheets: [], globalIndicatorKey: INDICATOR_KEY }, () => observed.push(window[INDICATOR_KEY])); + + assert.false(window[INDICATOR_KEY], 'the indicator starts false'); + assert.deepEqual(observed, [], 'the callback is deferred'); + + await settled(); + + assert.true(window[INDICATOR_KEY], 'the indicator is flipped when loading settles'); + assert.deepEqual(observed, [true], 'the callback fires once, after the indicator is set'); + }); + + test('it propagates the underlying TypeError for malformed asset lists', function (assert) { + assert.throws(() => loadLeafletPlugins({ scripts: ['leaflet.js'] }), TypeError, 'a missing stylesheets list still throws'); + assert.throws(() => loadLeafletPlugins({ stylesheets: [] }), TypeError, 'a missing scripts list still throws'); + }); + + test('repeated calls re-inject without de-duplication', async function (assert) { + loadLeafletPlugins({ scripts: ['leaflet.js'], stylesheets: [] }); + loadLeafletPlugins({ scripts: ['leaflet.js'], stylesheets: [] }); + + assert.deepEqual(this.srcs(), ['/engines-dist/leaflet/leaflet.js', '/engines-dist/leaflet/leaflet.js']); + + await settled(); + }); + + test('the caller asset object is not mutated', async function (assert) { + const assets = { scripts: ['leaflet.js'], stylesheets: [] }; + + loadLeafletPlugins(assets); + + assert.deepEqual(assets, { scripts: ['leaflet.js'], stylesheets: [] }, 'no basePath is written back onto the caller object'); + assert.false('basePath' in assets); + + await settled(); }); }); diff --git a/tests/unit/utils/noop-test.js b/tests/unit/utils/noop-test.js index 3f8017c7..21cc1b04 100644 --- a/tests/unit/utils/noop-test.js +++ b/tests/unit/utils/noop-test.js @@ -1,10 +1,47 @@ -import noop from 'dummy/utils/noop'; +import noop from '@fleetbase/ember-ui/utils/noop'; import { module, test } from 'qunit'; module('Unit | Utility | noop', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = noop(); - assert.equal(result, undefined); + test('it is a zero-argument function', function (assert) { + assert.strictEqual(typeof noop, 'function'); + assert.strictEqual(noop.length, 0, 'it declares no parameters'); + }); + + test('it always returns undefined', function (assert) { + assert.strictEqual(noop(), undefined); + assert.strictEqual(noop(1, 'two', { three: 3 }), undefined, 'arguments are ignored'); + assert.strictEqual(noop(null), undefined); + assert.strictEqual(noop(undefined), undefined); + }); + + test('it never throws, whatever it is handed', function (assert) { + const circular = {}; + circular.self = circular; + + noop(circular); + noop(NaN, Infinity, -0); + noop(...new Array(100).fill('x')); + + assert.strictEqual(noop(circular), undefined, 'a circular structure is still a no-op'); + }); + + test('it produces no side effects on its receiver', function (assert) { + const context = { touched: false, keys: 1 }; + + noop.call(context, 'a', 'b'); + noop.apply(context, ['c']); + + assert.deepEqual(context, { touched: false, keys: 1 }, 'the bound context is unchanged'); + }); + + test('it is safe to call repeatedly and can be used as a default callback', function (assert) { + const results = []; + + for (let i = 0; i < 5; i++) { + results.push(noop(i)); + } + + assert.deepEqual(results, [undefined, undefined, undefined, undefined, undefined]); + assert.strictEqual([1, 2, 3].map(noop).join(','), ',,', 'mapping through noop yields holes of undefined'); }); }); diff --git a/tests/unit/utils/numbers-only-test.js b/tests/unit/utils/numbers-only-test.js index ba5b3c7d..f94c21c5 100644 --- a/tests/unit/utils/numbers-only-test.js +++ b/tests/unit/utils/numbers-only-test.js @@ -1,4 +1,4 @@ -import numbersOnly from 'dummy/utils/numbers-only'; +import numbersOnly from '@fleetbase/ember-ui/utils/numbers-only'; import { module, test } from 'qunit'; module('Unit | Utility | numbers-only', function () { @@ -7,4 +7,9 @@ module('Unit | Utility | numbers-only', function () { let result = numbersOnly(stringOfNumber); assert.strictEqual(result, '4321'); }); + test('decimals can be kept', function (assert) { + assert.strictEqual(numbersOnly('$1,250.75 USD', true), '1250.75', 'the decimal point survives'); + assert.strictEqual(numbersOnly('$1,250.75 USD'), '125075', 'and is stripped by default'); + assert.strictEqual(numbersOnly('no digits here', true), '', 'a string with nothing to keep comes back empty'); + }); }); diff --git a/tests/unit/utils/options-test.js b/tests/unit/utils/options-test.js index ee892e5b..7dd34200 100644 --- a/tests/unit/utils/options-test.js +++ b/tests/unit/utils/options-test.js @@ -1,10 +1,29 @@ -import options from 'dummy/utils/options'; +import options from '@fleetbase/ember-ui/utils/options'; import { module, test } from 'qunit'; module('Unit | Utility | options', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = options(); - assert.ok(result); + test('it is an exported function', function (assert) { + assert.strictEqual(typeof options, 'function'); + assert.strictEqual(options.length, 0, 'it declares no parameters'); + }); + + test('it returns the boolean true, not a truthy value', function (assert) { + const result = options(); + + assert.true(result); + assert.strictEqual(typeof result, 'boolean'); + }); + + test('it ignores every argument it is given', function (assert) { + assert.true(options(undefined)); + assert.true(options(null)); + assert.true(options(false)); + assert.true(options(0)); + assert.true(options({ some: 'config' }, ['a', 'b'], 'third')); + }); + + test('it returns the same value on repeated invocations', function (assert) { + assert.strictEqual(options(), options(), 'the util is pure and stateless'); + assert.true(options.call({ scope: 'other' }), 'the receiver does not matter'); }); }); diff --git a/tests/unit/utils/pagination/items-test.js b/tests/unit/utils/pagination/items-test.js index 0b5f5fd7..19ec6772 100644 --- a/tests/unit/utils/pagination/items-test.js +++ b/tests/unit/utils/pagination/items-test.js @@ -1,7 +1,173 @@ +import PaginationItems from '@fleetbase/ember-ui/utils/pagination/items'; import { module, test } from 'qunit'; +function plain(items) { + return [...items].map(({ page, current, dots }) => ({ page, current, dots })); +} + module('Unit | Utility | pagination/items', function () { - test('it works', function (assert) { - assert.ok(true); + module('pageItemsAll', function () { + test('it emits one entry per page, in order, flagging the current one', function (assert) { + const subject = PaginationItems.create({ currentPage: 2, totalPages: 3 }); + + assert.deepEqual(plain(subject.pageItemsAll), [ + { page: 1, current: false, dots: false }, + { page: 2, current: true, dots: false }, + { page: 3, current: false, dots: false }, + ]); + }); + + test('it never emits dots', function (assert) { + const subject = PaginationItems.create({ currentPage: 1, totalPages: 25 }); + const items = [...subject.pageItemsAll]; + + assert.strictEqual(items.length, 25, 'every page is listed'); + assert.true( + items.every((item) => item.dots === false), + 'the untruncated list has no ellipsis entries' + ); + assert.strictEqual(items.filter((item) => item.current).length, 1, 'exactly one page is current'); + }); + + test('it is empty when there are no pages', function (assert) { + assert.deepEqual(plain(PaginationItems.create({ currentPage: 1, totalPages: 0 }).pageItemsAll), []); + assert.deepEqual(plain(PaginationItems.create({}).pageItemsAll), [], 'missing properties are treated as zero'); + }); + + test('a single page is marked current', function (assert) { + assert.deepEqual(plain(PaginationItems.create({ currentPage: 1, totalPages: 1 }).pageItemsAll), [{ page: 1, current: true, dots: false }]); + }); + + test('an out-of-range current page simply flags nothing', function (assert) { + const items = plain(PaginationItems.create({ currentPage: 99, totalPages: 3 }).pageItemsAll); + + assert.strictEqual(items.length, 3); + assert.strictEqual(items.filter((item) => item.current).length, 0, 'no page is highlighted when the current page is beyond the end'); + }); + + test('numeric strings are parsed', function (assert) { + assert.deepEqual(plain(PaginationItems.create({ currentPage: '2', totalPages: '2' }).pageItemsAll), [ + { page: 1, current: false, dots: false }, + { page: 2, current: true, dots: false }, + ]); + }); + }); + + module('pageItemsTruncated', function () { + test('it marks a dots entry wherever the page sequence jumps', function (assert) { + const subject = PaginationItems.create({ currentPage: 5, totalPages: 10, numPagesToShow: 5, showFL: true }); + + assert.deepEqual(plain(subject.pageItemsTruncated), [ + { page: 1, current: false, dots: false }, + { page: 3, current: false, dots: true }, + { page: 4, current: false, dots: false }, + { page: 5, current: true, dots: false }, + { page: 6, current: false, dots: false }, + { page: 7, current: false, dots: false }, + { page: 10, current: false, dots: true }, + ]); + }); + + test('a contiguous window carries no dots', function (assert) { + const subject = PaginationItems.create({ currentPage: 1, totalPages: 10, numPagesToShow: 5, showFL: false }); + + assert.deepEqual(plain(subject.pageItemsTruncated), [ + { page: 1, current: true, dots: false }, + { page: 2, current: false, dots: false }, + { page: 3, current: false, dots: false }, + { page: 4, current: false, dots: false }, + { page: 5, current: false, dots: false }, + ]); + }); + + test('only the leading gap is flagged when the window sits at the end', function (assert) { + const subject = PaginationItems.create({ currentPage: 10, totalPages: 10, numPagesToShow: 5, showFL: true }); + + assert.deepEqual( + plain(subject.pageItemsTruncated).map((item) => [item.page, item.dots]), + [ + [1, false], + [5, true], + [6, false], + [7, false], + [8, false], + [9, false], + [10, false], + ] + ); + }); + + test('the current page is always present even with no pages at all', function (assert) { + const subject = PaginationItems.create({ currentPage: 1, totalPages: 0, numPagesToShow: 5 }); + + assert.deepEqual(plain(subject.pageItemsTruncated), [{ page: 1, current: true, dots: false }], 'the truncated list always anchors on the current page'); + }); + }); + + module('pageItems', function () { + test('it returns the untruncated list by default', function (assert) { + const subject = PaginationItems.create({ currentPage: 1, totalPages: 8, numPagesToShow: 3, showFL: false }); + + assert.strictEqual([...subject.pageItems].length, 8, 'truncatePages defaults to falsy'); + assert.deepEqual(plain(subject.pageItems), plain(subject.pageItemsAll)); + }); + + test('it returns the truncated list when truncatePages is enabled', function (assert) { + const subject = PaginationItems.create({ currentPage: 1, totalPages: 8, numPagesToShow: 3, showFL: false, truncatePages: true }); + + assert.deepEqual( + plain(subject.pageItems).map((item) => item.page), + [1, 2, 3] + ); + assert.deepEqual(plain(subject.pageItems), plain(subject.pageItemsTruncated)); + }); + + test('it switches lists when truncatePages changes', function (assert) { + const subject = PaginationItems.create({ currentPage: 1, totalPages: 8, numPagesToShow: 3, showFL: false }); + + assert.strictEqual([...subject.pageItems].length, 8); + + subject.set('truncatePages', true); + assert.strictEqual([...subject.pageItems].length, 3, 'enabling truncation recomputes the list'); + + subject.set('truncatePages', false); + assert.strictEqual([...subject.pageItems].length, 8, 'and disabling it restores the full list'); + }); + + test('it recomputes when the current page moves', function (assert) { + const subject = PaginationItems.create({ currentPage: 1, totalPages: 8, numPagesToShow: 3, showFL: false, truncatePages: true }); + + assert.deepEqual( + plain(subject.pageItems).map((item) => item.page), + [1, 2, 3] + ); + + subject.set('currentPage', 5); + + const items = plain(subject.pageItems); + assert.deepEqual( + items.map((item) => item.page), + [4, 5, 6], + 'the window follows the current page' + ); + assert.deepEqual( + items.map((item) => item.current), + [false, true, false] + ); + }); + }); + + test('instances do not share state', function (assert) { + const a = PaginationItems.create({ currentPage: 1, totalPages: 3 }); + const b = PaginationItems.create({ currentPage: 3, totalPages: 3 }); + + assert.deepEqual( + plain(a.pageItemsAll).map((item) => item.current), + [true, false, false] + ); + assert.deepEqual( + plain(b.pageItemsAll).map((item) => item.current), + [false, false, true] + ); }); }); diff --git a/tests/unit/utils/pagination/truncate-pages-test.js b/tests/unit/utils/pagination/truncate-pages-test.js index 06c94250..cb1e045f 100644 --- a/tests/unit/utils/pagination/truncate-pages-test.js +++ b/tests/unit/utils/pagination/truncate-pages-test.js @@ -1,7 +1,158 @@ +import PaginationTruncatePages from '@fleetbase/ember-ui/utils/pagination/truncate-pages'; import { module, test } from 'qunit'; +function pages(options) { + return [...PaginationTruncatePages.create(options).pagesToShow]; +} + module('Unit | Utility | pagination/truncate-pages', function () { - test('it works', function (assert) { - assert.ok(true); + module('isValidPage', function () { + test('it accepts pages within 1..totalPages', function (assert) { + const subject = PaginationTruncatePages.create({ totalPages: 10 }); + + assert.true(subject.isValidPage(1), 'the first page is valid'); + assert.true(subject.isValidPage(5)); + assert.true(subject.isValidPage(10), 'the last page is valid'); + }); + + test('it rejects pages outside the range', function (assert) { + const subject = PaginationTruncatePages.create({ totalPages: 10 }); + + assert.false(subject.isValidPage(0), 'pages are one-based'); + assert.false(subject.isValidPage(-1)); + assert.false(subject.isValidPage(11), 'one past the end is invalid'); + assert.false(subject.isValidPage(9999)); + }); + + test('it parses string and fractional input', function (assert) { + const subject = PaginationTruncatePages.create({ totalPages: '10' }); + + assert.true(subject.isValidPage('5'), 'numeric strings are parsed'); + assert.true(subject.isValidPage('10'), 'totalPages is parsed too'); + assert.true(subject.isValidPage(2.9), 'fractions truncate toward zero'); + assert.false(subject.isValidPage('11')); + }); + + test('it rejects unparseable input', function (assert) { + const subject = PaginationTruncatePages.create({ totalPages: 10 }); + + assert.false(subject.isValidPage('abc'), 'NaN comparisons are always false'); + assert.false(subject.isValidPage(undefined)); + assert.false(subject.isValidPage(null)); + }); + + test('nothing is valid when there are no pages', function (assert) { + const subject = PaginationTruncatePages.create({ totalPages: 0 }); + + assert.false(subject.isValidPage(1)); + assert.false(subject.isValidPage(0)); + }); + }); + + module('pagesToShow without first/last anchors', function () { + test('it fills forward from the first page', function (assert) { + assert.deepEqual(pages({ currentPage: 1, totalPages: 10, numPagesToShow: 5, showFL: false }), [1, 2, 3, 4, 5]); + }); + + test('it centers the window on the current page', function (assert) { + assert.deepEqual(pages({ currentPage: 5, totalPages: 10, numPagesToShow: 5, showFL: false }), [3, 4, 5, 6, 7]); + }); + + test('it fills backward from the last page', function (assert) { + assert.deepEqual(pages({ currentPage: 10, totalPages: 10, numPagesToShow: 5, showFL: false }), [6, 7, 8, 9, 10], 'the window shifts back so it stays full'); + }); + + test('a single page yields just that page', function (assert) { + assert.deepEqual(pages({ currentPage: 1, totalPages: 1, numPagesToShow: 5, showFL: false }), [1]); + }); + + test('the window never exceeds the number of pages available', function (assert) { + assert.deepEqual(pages({ currentPage: 3, totalPages: 3, numPagesToShow: 5, showFL: false }), [1, 2, 3]); + assert.deepEqual(pages({ currentPage: 1, totalPages: 10, numPagesToShow: 10, showFL: false }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 'showing every page is allowed'); + }); + + test('an even window size biases toward the following pages', function (assert) { + assert.deepEqual(pages({ currentPage: 5, totalPages: 10, numPagesToShow: 4, showFL: false }), [3, 4, 5, 6]); + }); + + test('a window of one shows only the current page', function (assert) { + assert.deepEqual(pages({ currentPage: 2, totalPages: 10, numPagesToShow: 1, showFL: false }), [2]); + }); + }); + + module('pagesToShow with first/last anchors', function () { + test('it anchors both ends around a centered window', function (assert) { + assert.deepEqual(pages({ currentPage: 5, totalPages: 10, numPagesToShow: 5, showFL: true }), [1, 3, 4, 5, 6, 7, 10], 'page 1 and page 10 are prepended/appended'); + }); + + test('it does not duplicate the first page when already at the start', function (assert) { + assert.deepEqual(pages({ currentPage: 1, totalPages: 10, numPagesToShow: 5, showFL: true }), [1, 2, 3, 4, 5, 6, 10]); + }); + + test('it does not duplicate the last page when already at the end', function (assert) { + assert.deepEqual(pages({ currentPage: 10, totalPages: 10, numPagesToShow: 5, showFL: true }), [1, 5, 6, 7, 8, 9, 10]); + }); + + test('it anchors a window in the middle of a long range', function (assert) { + assert.deepEqual(pages({ currentPage: 6, totalPages: 20, numPagesToShow: 5, showFL: true }), [1, 4, 5, 6, 7, 8, 20]); + }); + + test('a small window still keeps both anchors', function (assert) { + assert.deepEqual(pages({ currentPage: 7, totalPages: 10, numPagesToShow: 3, showFL: true }), [1, 6, 7, 8, 10]); + }); + }); + + module('degenerate input', function () { + test('the current page is always included even when it is out of range', function (assert) { + assert.deepEqual(pages({ currentPage: 1, totalPages: 0, numPagesToShow: 5, showFL: false }), [1], 'zero total pages still reports the current page'); + }); + + test('missing properties are treated as zero', function (assert) { + assert.deepEqual(pages({}), [0], 'getInt falls back to 0 for absent properties'); + }); + + test('numeric strings are parsed like numbers', function (assert) { + assert.deepEqual(pages({ currentPage: '5', totalPages: '10', numPagesToShow: '5', showFL: false }), [3, 4, 5, 6, 7]); + }); + }); + + test('pagesToShow recomputes when its inputs change', function (assert) { + const subject = PaginationTruncatePages.create({ currentPage: 1, totalPages: 10, numPagesToShow: 5, showFL: false }); + + assert.deepEqual([...subject.pagesToShow], [1, 2, 3, 4, 5]); + + subject.set('currentPage', 5); + assert.deepEqual([...subject.pagesToShow], [3, 4, 5, 6, 7], 'moving the current page slides the window'); + + subject.set('numPagesToShow', 3); + assert.deepEqual([...subject.pagesToShow], [4, 5, 6], 'shrinking the window narrows the result'); + + subject.set('showFL', true); + assert.deepEqual([...subject.pagesToShow], [1, 4, 5, 6, 10], 'enabling first/last adds the anchors'); + }); + + test('repeated reads return an equal, stable result', function (assert) { + const subject = PaginationTruncatePages.create({ currentPage: 5, totalPages: 10, numPagesToShow: 5, showFL: false }); + const first = subject.pagesToShow; + const second = subject.pagesToShow; + + assert.deepEqual([...first], [3, 4, 5, 6, 7]); + assert.strictEqual(second, first, 'the computed property is cached between reads'); + }); + + test('instances do not share computed state', function (assert) { + const a = PaginationTruncatePages.create({ currentPage: 1, totalPages: 10, numPagesToShow: 5, showFL: false }); + const b = PaginationTruncatePages.create({ currentPage: 9, totalPages: 10, numPagesToShow: 5, showFL: false }); + + assert.deepEqual([...a.pagesToShow], [1, 2, 3, 4, 5]); + assert.deepEqual([...b.pagesToShow], [6, 7, 8, 9, 10]); + }); + // With the window centred at page 3 it already spans 1..5, so the showFL pass must NOT + // prepend a second page one. + test('the first page is not prepended when the window already starts there', function (assert) { + const shown = pages({ currentPage: 3, totalPages: 10, numPagesToShow: 5, showFL: true }); + + assert.strictEqual(shown[0], 1, 'page one leads the list'); + assert.strictEqual(shown.filter((page) => page === 1).length, 1, 'and appears exactly once rather than being prepended again'); }); }); diff --git a/tests/unit/utils/permission-check-test.js b/tests/unit/utils/permission-check-test.js index 8da83d62..db15c12e 100644 --- a/tests/unit/utils/permission-check-test.js +++ b/tests/unit/utils/permission-check-test.js @@ -1,10 +1,282 @@ -// import {evaluatePermission} from 'dummy/utils/permission-check'; +import { resolveResource, resolveAction, checkPermission, evaluatePermission } from '@fleetbase/ember-ui/utils/permission-check'; import { module, test } from 'qunit'; +function abilitiesWithCan(impl) { + return { can: impl }; +} + module('Unit | Utility | permission-check', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = true; - assert.ok(result); + module('resolveResource', function () { + test('it dasherizes an explicit resource string', function (assert) { + assert.strictEqual(resolveResource({ resource: 'order' }), 'order'); + assert.strictEqual(resolveResource({ resource: 'FuelReport' }), 'fuel-report', 'camel case becomes dasherized'); + assert.strictEqual(resolveResource({ resource: 'fuel_report' }), 'fuel-report', 'underscores become dashes'); + assert.strictEqual(resolveResource({ resource: 'service rate' }), 'service-rate', 'spaces become dashes'); + }); + + test('an explicit resource wins over the model', function (assert) { + const model = { constructor: { modelName: 'vehicle' } }; + + assert.strictEqual(resolveResource({ resource: 'Driver', model }), 'driver'); + }); + + test('it falls back to the Ember Data modelName on the constructor', function (assert) { + const model = { constructor: { modelName: 'fuelReport' } }; + + assert.strictEqual(resolveResource({ model }), 'fuel-report'); + }); + + test('it falls back to the legacy _internalModel lookup last', function (assert) { + const model = { + get(path) { + return path === '_internalModel.modelName' ? 'legacy_thing' : undefined; + }, + }; + + assert.strictEqual(resolveResource({ model }), 'legacy-thing'); + }); + + test('it ignores non-string and empty resource values', function (assert) { + assert.strictEqual(resolveResource({ resource: 123 }), null, 'a number is not a usable resource'); + assert.strictEqual(resolveResource({ resource: '' }), null, 'an empty string falls through'); + assert.strictEqual(resolveResource({ resource: {} }), null); + }); + + test('it returns null when nothing can be resolved', function (assert) { + assert.strictEqual(resolveResource({}), null); + assert.strictEqual(resolveResource({ model: null }), null); + assert.strictEqual(resolveResource({ model: undefined }), null); + assert.strictEqual(resolveResource({ model: {} }), null, 'a POJO with no modelName resolves to null'); + assert.strictEqual(resolveResource({ model: { constructor: { modelName: '' } } }), null, 'a blank modelName is not usable'); + }); + }); + + module('resolveAction', function () { + test('an explicit action always wins', function (assert) { + assert.strictEqual(resolveAction({ explicit: 'approve' }), 'approve'); + assert.strictEqual(resolveAction({ explicit: 'approve', kind: 'delete' }), 'approve', 'explicit beats kind'); + assert.strictEqual(resolveAction({ explicit: 'approve', kind: 'write', model: { isNew: true } }), 'approve'); + }); + + test('the delete kind resolves to delete', function (assert) { + assert.strictEqual(resolveAction({ kind: 'delete' }), 'delete'); + assert.strictEqual(resolveAction({ kind: 'delete', model: { isNew: true } }), 'delete', 'the model is irrelevant for delete'); + }); + + test('the write kind infers create or update from model.isNew', function (assert) { + assert.strictEqual(resolveAction({ kind: 'write', model: { isNew: true } }), 'create'); + assert.strictEqual(resolveAction({ kind: 'write', model: { isNew: false } }), 'update'); + }); + + test('the write kind cannot infer without a boolean isNew', function (assert) { + assert.strictEqual(resolveAction({ kind: 'write' }), null, 'no model at all'); + assert.strictEqual(resolveAction({ kind: 'write', model: {} }), null, 'isNew missing'); + assert.strictEqual(resolveAction({ kind: 'write', model: { isNew: 'true' } }), null, 'a string isNew is not a boolean'); + assert.strictEqual(resolveAction({ kind: 'write', model: null }), null); + }); + + test('unknown and custom kinds require an explicit action', function (assert) { + assert.strictEqual(resolveAction({ kind: 'custom' }), null); + assert.strictEqual(resolveAction({ kind: 'nonsense' }), null); + assert.strictEqual(resolveAction({}), null); + assert.strictEqual(resolveAction({ explicit: '' }), null, 'an empty explicit action is falsy and falls through'); + }); + }); + + module('checkPermission', function () { + test('it prefers the can() interface and coerces to a boolean', function (assert) { + const calls = []; + const abilities = abilitiesWithCan((permission, subject) => { + calls.push([permission, subject]); + return 'truthy but not a boolean'; + }); + const subject = { id: 1 }; + + const result = checkPermission(abilities, 'fleet-ops view order', subject); + + assert.true(result, 'the truthy result is coerced to true'); + assert.deepEqual(calls, [['fleet-ops view order', subject]], 'the permission string and subject are forwarded verbatim'); + }); + + test('a falsy can() result denies', function (assert) { + assert.false( + checkPermission( + abilitiesWithCan(() => false), + 'p', + null + ) + ); + assert.false( + checkPermission( + abilitiesWithCan(() => undefined), + 'p', + null + ) + ); + assert.false( + checkPermission( + abilitiesWithCan(() => 0), + 'p', + null + ) + ); + }); + + test('it falls back to the cannot() interface and inverts it', function (assert) { + const calls = []; + const abilities = { + cannot(permission, subject) { + calls.push([permission, subject]); + return false; + }, + }; + + assert.true(checkPermission(abilities, 'fleet-ops delete order', 'subject')); + assert.deepEqual(calls, [['fleet-ops delete order', 'subject']]); + assert.false(checkPermission({ cannot: () => true }, 'p', null), 'cannot() true denies'); + }); + + test('it supports a bare function ability service', function (assert) { + const seen = []; + const abilities = function (permission, subject) { + seen.push([permission, subject]); + return 1; + }; + + assert.true(checkPermission(abilities, 'fleet-ops view order', 'model')); + assert.deepEqual(seen, [['fleet-ops view order', 'model']]); + assert.false( + checkPermission(() => null, 'p', null), + 'a falsy return denies' + ); + }); + + test('it denies when the service or permission is missing', function (assert) { + assert.false(checkPermission(null, 'p', null), 'no service'); + assert.false(checkPermission(undefined, 'p', null)); + assert.false( + checkPermission( + abilitiesWithCan(() => true), + '', + null + ), + 'empty permission string' + ); + assert.false( + checkPermission( + abilitiesWithCan(() => true), + null, + null + ), + 'null permission' + ); + assert.false(checkPermission({}, 'p', null), 'a service with no recognized interface'); + assert.false(checkPermission({ can: 'not-a-function' }, 'p', null), 'a non-callable can property'); + }); + + test('it never invokes the service when it short-circuits', function (assert) { + let called = 0; + const abilities = abilitiesWithCan(() => { + called++; + return true; + }); + + checkPermission(abilities, '', null); + checkPermission(abilities, undefined, null); + + assert.strictEqual(called, 0, 'a missing permission never reaches the abilities service'); + }); + }); + + module('evaluatePermission', function () { + test('it builds a "schema action resource" permission string', function (assert) { + const seen = []; + const abilitiesService = abilitiesWithCan((permission, subject) => { + seen.push([permission, subject]); + return true; + }); + const model = { isNew: true, constructor: { modelName: 'fuelReport' } }; + + const result = evaluatePermission({ abilitiesService, kind: 'write', model }); + + assert.true(result); + assert.strictEqual(seen[0][0], 'fleet-ops create fuel-report', 'schema defaults to fleet-ops'); + assert.strictEqual(seen[0][1], model, 'the model is passed as the policy subject'); + }); + + test('it honors a custom schema, explicit action and explicit resource', function (assert) { + const seen = []; + const abilitiesService = abilitiesWithCan((permission) => { + seen.push(permission); + return true; + }); + + evaluatePermission({ abilitiesService, schema: 'storefront', action: 'approve', resource: 'PurchaseOrder' }); + + assert.deepEqual(seen, ['storefront approve purchase-order']); + }); + + test('it resolves update for persisted records on a write', function (assert) { + const seen = []; + const abilitiesService = abilitiesWithCan((permission) => { + seen.push(permission); + return true; + }); + + evaluatePermission({ abilitiesService, kind: 'write', model: { isNew: false, constructor: { modelName: 'order' } } }); + + assert.deepEqual(seen, ['fleet-ops update order']); + }); + + test('it returns defaultWhenUnknown when the action cannot be resolved', function (assert) { + const abilitiesService = abilitiesWithCan(() => true); + const model = { constructor: { modelName: 'order' } }; + + assert.false(evaluatePermission({ abilitiesService, kind: 'write', model }), 'defaults to deny'); + assert.true(evaluatePermission({ abilitiesService, kind: 'write', model, defaultWhenUnknown: true }), 'and to allow when configured'); + }); + + test('it returns defaultWhenUnknown when the resource cannot be resolved', function (assert) { + const abilitiesService = abilitiesWithCan(() => true); + + assert.false(evaluatePermission({ abilitiesService, action: 'approve' })); + assert.true(evaluatePermission({ abilitiesService, action: 'approve', defaultWhenUnknown: true })); + }); + + test('it returns defaultWhenUnknown when the schema is blank', function (assert) { + const abilitiesService = abilitiesWithCan(() => true); + + assert.false(evaluatePermission({ abilitiesService, schema: '', action: 'view', resource: 'order' })); + assert.true(evaluatePermission({ abilitiesService, schema: null, action: 'view', resource: 'order', defaultWhenUnknown: true })); + }); + + test('it coerces a non-boolean defaultWhenUnknown', function (assert) { + const abilitiesService = abilitiesWithCan(() => true); + + assert.true(evaluatePermission({ abilitiesService, defaultWhenUnknown: 'yes' }), 'truthy defaults coerce to true'); + assert.false(evaluatePermission({ abilitiesService, defaultWhenUnknown: 0 }), 'falsy defaults coerce to false'); + }); + + test('it never calls the abilities service when resolution fails', function (assert) { + let called = 0; + const abilitiesService = abilitiesWithCan(() => { + called++; + return true; + }); + + evaluatePermission({ abilitiesService, kind: 'custom' }); + + assert.strictEqual(called, 0); + }); + + test('a denied ability check overrides defaultWhenUnknown', function (assert) { + const abilitiesService = abilitiesWithCan(() => false); + + assert.false(evaluatePermission({ abilitiesService, action: 'view', resource: 'order', defaultWhenUnknown: true }), 'a resolvable permission is actually checked'); + }); + + test('a missing abilities service denies once the permission resolves', function (assert) { + assert.false(evaluatePermission({ action: 'view', resource: 'order' })); + }); }); }); diff --git a/tests/unit/utils/place-address-test.js b/tests/unit/utils/place-address-test.js index b373c1a5..c40caeef 100644 --- a/tests/unit/utils/place-address-test.js +++ b/tests/unit/utils/place-address-test.js @@ -1,6 +1,14 @@ import placeAddress from 'dummy/utils/place-address'; import { module, test } from 'qunit'; +function lines(result) { + return String(result) + .replace(/^]*>|<\/address>$/g, '') + .split('
      ') + .filter(Boolean) + .map((chunk) => chunk.replace(/^]*>/, '')); +} + module('Unit | Utility | place-address', function () { test('it returns a safe address string', function (assert) { const result = placeAddress({ @@ -13,4 +21,81 @@ module('Unit | Utility | place-address', function () { assert.true(String(result).includes('North Dock')); assert.true(String(result).includes('100 Harbor Road')); }); + + test('every part of a full address is rendered in order', function (assert) { + const result = placeAddress({ + name: 'North Dock', + street1: '100 Harbor Road', + street2: 'Unit 4', + city: 'Singapore', + province: 'Central', + postal_code: '018956', + country_name: 'Singapore', + }); + + assert.deepEqual(lines(result), ['North Dock', '100 Harbor Road', 'Unit 4', 'Singapore, Central, 018956', 'Singapore']); + }); + + test('a blank city, province or postal code is skipped rather than leaving stray commas', function (assert) { + const result = placeAddress({ street1: '100 Harbor Road', province: 'Central' }); + + assert.deepEqual(lines(result), ['100 Harbor Road', 'Central']); + }); + + module('the title line', function () { + test('a name identical to the street is not repeated', function (assert) { + const result = placeAddress({ name: '100 Harbor Road', street1: '100 Harbor Road', city: 'Singapore' }); + + assert.deepEqual(lines(result), ['100 Harbor Road', 'Singapore'], 'the street is promoted to the title line instead'); + }); + + test('showTitle false drops the name but keeps the street', function (assert) { + const result = placeAddress({ name: 'North Dock', street1: '100 Harbor Road' }, { showTitle: false }); + + assert.deepEqual(lines(result), ['100 Harbor Road']); + }); + + test('an address with no street and no name renders only what it has', function (assert) { + const result = placeAddress({ city: 'Singapore' }); + + assert.deepEqual(lines(result), ['Singapore']); + }); + }); + + module('the country line', function () { + test('country_name wins when both are present', function (assert) { + const result = placeAddress({ street1: '100 Harbor Road', country_name: 'Singapore', country: 'SG' }); + + assert.deepEqual(lines(result), ['100 Harbor Road', 'Singapore']); + }); + + test('the country code stands in when there is no name', function (assert) { + const result = placeAddress({ street1: '100 Harbor Road', country: 'SG' }); + + assert.deepEqual(lines(result), ['100 Harbor Road', 'SG']); + }); + }); + + module('what counts as an address', function () { + test('a place wrapper is unwrapped', function (assert) { + const result = placeAddress({ place: { street1: '100 Harbor Road' } }); + + assert.deepEqual(lines(result), ['100 Harbor Road']); + }); + + test('nothing at all renders an empty string', function (assert) { + assert.strictEqual(String(placeAddress()), '', 'no arguments'); + assert.strictEqual(String(placeAddress(null)), '', 'a null place'); + // `place?.place ?? place` falls back to the wrapper itself, which is truthy, so an + // empty wrapper renders an empty
      rather than an empty string. + assert.deepEqual(lines(placeAddress({ place: null })), [], 'a wrapper around nothing has no lines'); + }); + }); + + test('address content is escaped rather than injected', function (assert) { + const result = placeAddress({ street1: '' }); + + assert.false(String(result).includes('