Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions DEFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions addon/components/array-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
11 changes: 11 additions & 0 deletions addon/components/report/find-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions addon/services/docs-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
41 changes: 41 additions & 0 deletions tests/integration/components/array-input-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`<ArrayInput @data={{this.data}} />`);

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`<ArrayInput @data={{this.data}} @onDataChanged={{this.onDataChanged}} />`);

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`<ArrayInput />`);

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');
});
});
15 changes: 15 additions & 0 deletions tests/integration/components/countdown-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`<Countdown @seconds={{30}} @display="seconds" />`);

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`<Countdown @minutes={{2}} @display={{this.display}} />`);

assert.dom('.countdown-container').exists();
});
});
25 changes: 25 additions & 0 deletions tests/integration/components/tab-navigation-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
23 changes: 23 additions & 0 deletions tests/integration/components/table-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
29 changes: 29 additions & 0 deletions tests/integration/components/table/empty-state-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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`
<Table::EmptyState @context={{this.context}} @title="No records" @filteredTitle="Nothing matches those filters" />
`);

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`<Table::EmptyState @title="Add your first vehicle" @docsSlug="fleet-ops/vehicles" />`);

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