From 868e1a64435e8ca06a9a38a47df545f8ce4793bd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 22 Aug 2026 01:53:49 +0800 Subject: [PATCH 1/3] countdown, tab-navigation and table: cover the second state of things already tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests, one defect recorded. countdown 14 gaps -> 12, tab-navigation 15 -> 14, table 17 -> 13. The tests share a shape worth naming, because it predicts where the remaining gaps in a mature suite live: each covers the SECOND state of something the suite already tested. - countdown: @display given as a plain string or an array. The normaliser handles three input shapes and only the comma-string form had a test. - tab-navigation: tabs appearing AFTER an empty start, which is what happens when tab data loads asynchronously. Every existing test begins with a populated array, so the component was never observed transitioning from nothing to something. - table: sticky columns while SCROLLED. The suite covers them at rest — offsets, positions, z-indexes — so `at-natural-position` was only ever seen being added, never removed. The same shape explains the two gaps found in the previous batch: an argument cleared rather than set, and a dialog confirmed rather than merely shown. The table one is worth a note. Earlier in this campaign I reported sticky columns as broken; that was wrong, and tracing it properly showed the class is applied imperatively from td.js/th.js. Having traced it then, the untested half was obvious now — the feature works, and the scrolled state that no test exercised is covered. DEFECTS.md #8: countdown's `restartCountdown` is never called — not an @action, not in the template, not referenced anywhere in the class. Same family as #6 and the dead getters #2/#3/#7. Six such items are now open, five of them the same shape: code written for an intent the wiring never delivered. Each is a permanent hole in a 100% gate, since uncalled code cannot be covered and excluding it would hide the question rather than answer it. Full suite 5043 pass / 0 fail / 0 skip. Coverage 94.54% statements, 90.72% branches, 97.49% functions, 94.92% lines. --- DEFECTS.md | 14 +++++++++++ .../integration/components/countdown-test.js | 15 +++++++++++ .../components/tab-navigation-test.js | 25 +++++++++++++++++++ tests/integration/components/table-test.js | 23 +++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/DEFECTS.md b/DEFECTS.md index 6dcd9197..9ada5934 100644 --- a/DEFECTS.md +++ b/DEFECTS.md @@ -120,6 +120,20 @@ heading everywhere a caller omits the argument, a visible change — or delete t that the heading is opt-in. Product call. Blocks the gate while it exists: an uncalled getter cannot be covered. +## 8. `addon/components/countdown.js` — `restartCountdown` is never called + +**Status:** NEEDS DECISION +**Found:** the whole method reported as never invoked. +**Evidence:** `grep -rn restartCountdown addon/` returns only the declaration and its docblock. It +is not an `@action`, not referenced by `countdown.hbs`, and not called from anywhere in the class. +**Impact:** none today — the countdown starts from the constructor and ticks correctly. But there is +no way to restart one without re-rendering the component, which is presumably what the method was +written for. +**Fix:** either delete it, or expose it (as an `@action`, or via a `@onRestart`-style yield) so +consumers can restart a countdown in place. Same family as #6 and the dead getters #2/#3/#7: code +written for an intent the wiring never delivered. +Blocks the gate while it exists — an uncalled method cannot be covered. + --- ## Settled — not defects, recorded so they are not "fixed" again diff --git a/tests/integration/components/countdown-test.js b/tests/integration/components/countdown-test.js index 47729921..df956531 100644 --- a/tests/integration/components/countdown-test.js +++ b/tests/integration/components/countdown-test.js @@ -160,4 +160,19 @@ module('Integration | Component | countdown', function (hooks) { assert.true(remaining().includes(String(expected)), `${expected} seconds, got ${remaining()}`); }); }); + // @display is normalised three ways: a comma string is split, an array is taken as-is, and + // anything else is ignored. Only the comma-string path had a test. + test('a plain display string is ignored rather than split', async function (assert) { + await render(hbs``); + + assert.dom('.countdown-container').exists('the countdown still renders'); + }); + + test('an array display is used as given', async function (assert) { + this.set('display', ['minutes', 'seconds']); + + await render(hbs``); + + assert.dom('.countdown-container').exists(); + }); }); diff --git a/tests/integration/components/tab-navigation-test.js b/tests/integration/components/tab-navigation-test.js index 21c6c94b..3db918c4 100644 --- a/tests/integration/components/tab-navigation-test.js +++ b/tests/integration/components/tab-navigation-test.js @@ -569,4 +569,29 @@ module('Integration | Component | tab-navigation', function (hooks) { assert.true(link.getAttribute('href').includes('/console/notifications')); }); }); + // The overflow calculation has an empty-list path, and argsDidChange defaults a missing + // @tabs to an empty list. Neither is reached while a populated array is always supplied. + test('an empty tab list renders and recalculates without error', async function (assert) { + this.set('tabs', []); + + await render(TEMPLATE); + + assert.dom('[role="tablist"]').exists('the tab strip still renders'); + assert.strictEqual(findAll('[role="tab"]').length, 0, 'with no tabs in it'); + }); + + test('tabs appearing after an empty start select the first one', async function (assert) { + this.set('tabs', []); + + await render(TEMPLATE); + + this.set('tabs', [ + { id: 'details', label: 'Details' }, + { id: 'files', label: 'Files' }, + ]); + await settled(); + + assert.strictEqual(findAll('[role="tab"]').length, 2, 'the new tabs render'); + assert.dom('[role="tab"][aria-selected="true"]').hasText('Details', 'and the first becomes active'); + }); }); diff --git a/tests/integration/components/table-test.js b/tests/integration/components/table-test.js index d1910866..72831e87 100644 --- a/tests/integration/components/table-test.js +++ b/tests/integration/components/table-test.js @@ -684,6 +684,29 @@ module('Integration | Component | table imperative api', function (hooks) { assert.strictEqual(notes._stickyOffset, 0); }); + test('scrolling away from an edge drops the at-natural-position marker', async function (assert) { + // The scroll handler adds the marker while a sticky column sits at its natural edge and + // removes it once scrolled away. Only the at-rest state had coverage. + await render(TEMPLATE); + + const wrapper = find('.next-table-wrapper'); + const left = wrapper.querySelector('.sticky-left'); + assert.ok(left, 'a left sticky cell is rendered'); + assert.dom(left).hasClass('at-natural-position', 'it starts at the edge'); + + Object.defineProperty(wrapper, 'scrollWidth', { value: 1000, configurable: true }); + Object.defineProperty(wrapper, 'clientWidth', { value: 300, configurable: true }); + Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true }); + wrapper.dispatchEvent(new Event('scroll')); + await settled(); + + assert.dom(left).doesNotHaveClass('at-natural-position', 'scrolled away, the marker is dropped'); + + const right = wrapper.querySelector('.sticky-right'); + assert.ok(right, 'a right sticky cell is rendered'); + assert.dom(right).doesNotHaveClass('at-natural-position', 'and the right side is not at its end either'); + }); + test('a sticky checkbox column shifts the first left offset', async function (assert) { this.set('checkboxSticky', true); From 712cc086c445eb096702b2773f89a53a91f3de07 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 22 Aug 2026 02:31:26 +0800 Subject: [PATCH 2/3] table/empty-state and report/find-select: one file needed tests, the other needed none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit empty-state 14 gaps -> 8, find-select 12 -> 2. Lines are now past 94.99%, branches past 90.88%. empty-state — three tests, all of them the "second state" shape: - the existing filtered-copy test EXTENDED. The component has four filtered variants and that test supplied two, so it read as "filtered copy is covered" while `filteredNote` and `filteredIcon` were never exercised. Extending beats adding a parallel test: the concept stays in one place. - a context flagged `isFiltered` with no search term, which is the other operand of `hasSearchOrFilter`. - the docs link falling back to its own defaults. `docsText`, `docsTitle` and `docsSource` all have `??` fallbacks that every existing docs test overrode with explicit values. find-select — no tests, five exclusions, and that is the correct output. Every remaining gap is foreclosed by the component's own template: - Select is `@disabled={{includes report.id …selectedReports}}`, so the `alreadySelected` guard cannot fire; - Remove is `@disabled={{not (includes …)}}`, so the "nothing was filtered out" guard cannot fire; - both handlers are only invoked as `(fn this.… report)` from the `{{#each}}`, so neither `!report` guard can fire; - `onInput` is wired via `{{on "input"}}`, which always delivers an event. Worth stating as the counterweight to the pattern the last few commits leaned on: not every uncovered branch has a second state to reach. Testing these would have meant calling the actions directly, bypassing the very UI that makes them unreachable — coverage that proves nothing about the component. Full suite 5045 pass / 0 fail / 0 skip. Coverage 94.61% statements, 90.88% branches, 97.53% functions, 94.99% lines. --- addon/components/report/find-select.js | 11 +++++++ .../components/table/empty-state-test.js | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/addon/components/report/find-select.js b/addon/components/report/find-select.js index 04b8ee36..4d04188e 100644 --- a/addon/components/report/find-select.js +++ b/addon/components/report/find-select.js @@ -33,6 +33,8 @@ export default class ReportFindSelectComponent extends Component { } @action selectReport(report) { + /* istanbul ignore next -- only invoked as `(fn this.selectReport report)` from the + `{{#each}}`, so a report is always supplied. */ if (!report) { return; } @@ -41,7 +43,10 @@ export default class ReportFindSelectComponent extends Component { return; } + /* istanbul ignore next -- the Select button is rendered `@disabled={{includes report.id …}}`, + so an already-selected report cannot be selected again. */ const alreadySelected = this.selectedReports.some((selectedReport) => selectedReport.id === report.id); + /* istanbul ignore next -- see above. */ if (alreadySelected) { return; } @@ -51,11 +56,15 @@ export default class ReportFindSelectComponent extends Component { } @action removeReport(report) { + /* istanbul ignore next -- only invoked as `(fn this.removeReport report)` from the + `{{#each}}`, so a report is always supplied. */ if (!report) { return; } const selectedReports = this.selectedReports.filter((selectedReport) => selectedReport.id !== report.id); + /* istanbul ignore next -- the Remove button is rendered `@disabled={{not (includes …)}}`, + so only a selected report can be removed and the filter always drops one. */ if (selectedReports.length === this.selectedReports.length) { return; } @@ -65,6 +74,8 @@ export default class ReportFindSelectComponent extends Component { } @action onInput(event) { + /* istanbul ignore next -- wired as `{{on "input" this.onInput}}`, which always delivers an + event carrying the target. */ this.searchTerm = event?.target?.value ?? ''; this.searchTask.perform(this.searchTerm); } diff --git a/tests/integration/components/table/empty-state-test.js b/tests/integration/components/table/empty-state-test.js index 0a6b6a4c..aacf50ae 100644 --- a/tests/integration/components/table/empty-state-test.js +++ b/tests/integration/components/table/empty-state-test.js @@ -68,12 +68,19 @@ module('Integration | Component | table/empty-state', function (hooks) { @description="Create the first record." @filteredTitle="No records match your search" @filteredDescription="Adjust search or filters." + @note="Records appear here once created." + @filteredNote="Try a broader search." + @icon="inbox" + @filteredIcon="magnifying-glass" /> `); assert.dom('.next-table-empty-state').includesText('No records match your search'); assert.dom('.next-table-empty-state').includesText('Adjust search or filters.'); assert.dom('.next-table-empty-state').doesNotIncludeText('Create the first record.'); + assert.dom('.next-table-empty-state').includesText('Try a broader search.', 'the filtered note replaces the default'); + assert.dom('.next-table-empty-state').doesNotIncludeText('Records appear here once created.'); + assert.dom('.next-table-empty-state svg').hasClass('fa-magnifying-glass', 'and the filtered icon replaces the default'); }); test('it renders the compact variant', async function (assert) { @@ -120,4 +127,26 @@ module('Integration | Component | table/empty-state', function (hooks) { source: 'fleet-ops-empty-vehicles', }); }); + test('a context flagged as filtered uses the filtered copy without a search term', async function (assert) { + this.set('context', { isFiltered: true }); + + await render(hbs` + + `); + + assert.dom('.next-table-empty-state').includesText('Nothing matches those filters'); + }); + + test('the docs link falls back to its own defaults', async function (assert) { + // Every other docs case passes explicit text, title and source. + await render(hbs``); + + assert.dom('.next-table-empty-state-docs-action').hasText('Read guide', 'the default link text'); + + await click('.next-table-empty-state-docs-action'); + + const docsPanel = this.owner.lookup('service:docs-panel'); + assert.strictEqual(docsPanel.lastTarget, 'fleet-ops/vehicles'); + assert.deepEqual(docsPanel.lastOptions, { title: 'Add your first vehicle', source: 'table-empty-state' }, 'title falls back to the empty-state title, source to the component name'); + }); }); From f8e7c5506949abc3c55ae91e1fdc90caaa4910c1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 22 Aug 2026 03:10:08 +0800 Subject: [PATCH 3/3] array-input and docs-panel: lines pass 95% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit array-input 11 gaps -> 5, docs-panel 9 -> 6. Tests: - the component rendered with NO @onDataChanged at all, which covers all three `typeof … === 'function'` guards in one case rather than three; - a field cleared to empty, reaching the `if (value)` guard's false arm in onChange; - rendering with no @data, so the constructor's destructuring defaults apply. The empty-value test took two attempts and the failure is worth recording. Dispatching a bare `change` event at the input did NOT produce an empty value — the field still held 'alpha', so the handler saw something truthy and reported a change. Simulating the event is not the same as producing the state the event carries. The assertion was falsifiable and the handler really ran; it just described a different scenario than the branch needed. `fillIn(input, '')` is what actually clears the field. Exclusions: array-input's two @tracked fields (assigned in the constructor), and two in the docs-panel service — the `hasRegistration('service:theme') === false` guard, since the theme service is registered in this addon, and the lookup catch, which cannot throw for a registered service. Left uncovered deliberately: dropdown-button's trigger-insert fallback, `if (renderInPlace === true || _onInsertFired === false)`, reads [419,0]. Reaching the false arm needs renderInPlace falsy AND the button's insert hook already fired — an ordering condition inside ember-basic-dropdown's render sequence that I have not traced. Both easy answers are wrong here: a contrived test that forces the flag fabricates coverage, and an exclusion claims an unreachability I have not proven. Recorded as a gap until the ordering is understood. Full suite 5048 pass / 0 fail / 0 skip. Coverage 94.65% statements, 90.95% branches, 97.53% functions, 95.04% lines. --- addon/components/array-input.js | 2 + addon/services/docs-panel.js | 4 ++ .../components/array-input-test.js | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/addon/components/array-input.js b/addon/components/array-input.js index b8673da0..f56b2720 100644 --- a/addon/components/array-input.js +++ b/addon/components/array-input.js @@ -3,7 +3,9 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; export default class ArrayInputComponent extends Component { + /* istanbul ignore next -- the constructor assigns both before anything reads them. */ @tracked data = []; + /* istanbul ignore next -- see above. */ @tracked disabled = false; constructor(owner, { data = [], disabled = false }) { diff --git a/addon/services/docs-panel.js b/addon/services/docs-panel.js index 6f82975e..5f436df5 100644 --- a/addon/services/docs-panel.js +++ b/addon/services/docs-panel.js @@ -115,12 +115,16 @@ export default class DocsPanelService extends Service { try { const owner = getOwner(this); + /* istanbul ignore next -- the theme service is registered in this addon, so + `hasRegistration` never reports false here. */ if (owner?.hasRegistration?.('service:theme') === false) { return 'light'; } themeService = owner?.lookup?.('service:theme'); } catch { + /* istanbul ignore next -- neither `getOwner` nor `lookup` throws for a registered + service, so this fallback is defensive only. */ themeService = null; } diff --git a/tests/integration/components/array-input-test.js b/tests/integration/components/array-input-test.js index 9d77930d..2ee28f9a 100644 --- a/tests/integration/components/array-input-test.js +++ b/tests/integration/components/array-input-test.js @@ -81,4 +81,45 @@ module('Integration | Component | array-input', function (hooks) { assert.dom('input[aria-label="Data Input"]').isDisabled(); assert.dom('.array-input .text-red-500').isDisabled(); }); + // Every case above supplies @onDataChanged; the component has to work without one, and its + // change handlers ignore an empty value. + module('with no onDataChanged handler', function () { + test('adding, editing and removing all work', async function (assert) { + this.set('data', ['alpha']); + + await render(hbs``); + + await click('button.btn-default, button'); + assert.dom('input[aria-label="Data Input"]').exists({ count: 2 }, 'a row is added without a handler to report to'); + + const inputs = this.element.querySelectorAll('input[aria-label="Data Input"]'); + await fillIn(inputs[1], 'beta'); + assert.strictEqual(inputs[1].value, 'beta', 'the value is editable'); + + await click(this.element.querySelector('button[type="button"]')); + assert.dom('.array-input').exists('and removal does not throw'); + }); + }); + + test('an empty value is ignored rather than written into the array', async function (assert) { + const changes = []; + this.set('data', ['alpha']); + this.set('onDataChanged', (data) => changes.push([...data])); + + await render(hbs``); + + const input = this.element.querySelector('input[aria-label="Data Input"]'); + // Clearing the field is what produces an empty value on the change event. + await fillIn(input, ''); + + assert.deepEqual(changes, [], 'a change carrying no value reports nothing'); + }); + test('it renders with no @data at all', async function (assert) { + // The constructor defaults both arguments; every other case supplies them. + await render(hbs``); + + assert.dom('.array-input').exists('the component renders empty'); + assert.dom('input[aria-label="Data Input"]').doesNotExist('with no rows'); + assert.dom('.array-input').doesNotHaveClass('disabled', 'and enabled by default'); + }); });