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/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/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/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'); + }); }); 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); 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'); + }); });