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