From 051ad980e75165d73ba4136065e990e818d84d38 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Thu, 21 Nov 2024 20:26:50 +0100 Subject: [PATCH] feat(file-picker): add fuzzy search to file picker Also restyles the file picker list slightly --- libs/eslint-plugin-mte/index.js | 3 +- package-lock.json | 8 + packages/elements/.vscode/launch.json | 23 +++ packages/elements/package.json | 1 + .../src/components/app/app.component.ts | 17 +- .../elements/src/components/breadcrumb.ts | 4 +- .../file-picker/file-picker.component.ts | 168 ++++++++++-------- .../metrics-table/metrics-table.component.ts | 4 +- .../result-status-bar/result-status-bar.ts | 80 +++------ .../test-file/test-file.component.ts | 6 +- .../theme-switch/theme-switch.component.ts | 2 +- .../components/breadcrumb.component.spec.ts | 12 +- .../drawer-mutant.component.spec.ts | 6 +- .../components/drawer-test.component.spec.ts | 9 +- .../unit/components/drawer.component.spec.ts | 4 +- .../components/file-picker.component.spec.ts | 100 ++++++++--- .../unit/components/file.component.spec.ts | 2 +- .../unit/components/totals.component.spec.ts | 6 +- .../test/unit/helpers/CustomElementFixture.ts | 4 +- 19 files changed, 264 insertions(+), 195 deletions(-) diff --git a/libs/eslint-plugin-mte/index.js b/libs/eslint-plugin-mte/index.js index 1ea249c9d..ad00d0e08 100644 --- a/libs/eslint-plugin-mte/index.js +++ b/libs/eslint-plugin-mte/index.js @@ -22,12 +22,11 @@ export default [ reportUnusedDisableDirectives: 'error', }, rules: { + eqeqeq: 'error', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/consistent-type-imports': 'error', - // Not useful for a lot of stuff, but mainly `.shadowRoot` - '@typescript-eslint/no-non-null-assertion': 'off', }, }, { diff --git a/package-lock.json b/package-lock.json index c7e59a1f0..cb02c403d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7924,6 +7924,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -16514,6 +16521,7 @@ "esbuild": "0.24.0", "eslint-config-mte": "*", "express": "4.21.1", + "fuzzysort": "3.1.0", "lit": "3.2.1", "mutation-testing-metrics": "3.3.0", "mutation-testing-real-time": "3.3.0", diff --git a/packages/elements/.vscode/launch.json b/packages/elements/.vscode/launch.json index 8708ae224..41851823c 100644 --- a/packages/elements/.vscode/launch.json +++ b/packages/elements/.vscode/launch.json @@ -18,6 +18,29 @@ "url": "http://localhost:5173", "webRoot": "${workspaceFolder}", "sourceMaps": true + }, + { + "type": "node", + "request": "launch", + "name": "Run unit tests", + "program": "${workspaceFolder:parent}/node_modules/vitest/vitest.mjs", + "console": "integratedTerminal", + "skipFiles": ["/**"], + "args": ["--inspect-brk", "--browser", "--no-file-parallelism"] + }, + { + "type": "chrome", + "request": "attach", + "name": "Attach to unit test browser", + "skipFiles": ["/**"], + "port": 9229 + } + ], + "compounds": [ + { + "name": "Debug unit tests", + "configurations": ["Attach to unit test browser", "Run unit tests"], + "stopAll": true } ] } diff --git a/packages/elements/package.json b/packages/elements/package.json index 5a0105e96..165e16c13 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -59,6 +59,7 @@ "esbuild": "0.24.0", "eslint-config-mte": "*", "express": "4.21.1", + "fuzzysort": "3.1.0", "lit": "3.2.1", "mutation-testing-metrics": "3.3.0", "mutation-testing-real-time": "3.3.0", diff --git a/packages/elements/src/components/app/app.component.ts b/packages/elements/src/components/app/app.component.ts index 3d75bc7bc..de870a5ac 100644 --- a/packages/elements/src/components/app/app.component.ts +++ b/packages/elements/src/components/app/app.component.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ import type { PropertyValues } from 'lit'; -import { html, nothing, unsafeCSS, isServer } from 'lit'; +import { html, isServer, nothing, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref } from 'lit/directives/ref.js'; import type { FileUnderTestModel, Metrics, @@ -24,10 +26,9 @@ import { mutantChanges } from '../../lib/mutant-changes.js'; import { locationChange$, View } from '../../lib/router.js'; import type { Theme } from '../../lib/theme.js'; import { globals, tailwind } from '../../style/index.js'; +import { type MutationTestReportFilePickerComponent } from '../file-picker/file-picker.component.js'; import { RealTimeElement } from '../real-time-element.js'; import theme from './theme.scss?inline'; -import { type MutationTestReportFilePickerComponent } from '../file-picker/file-picker.component.js'; -import { createRef, ref } from 'lit/directives/ref.js'; interface BaseContext { path: string[]; @@ -360,11 +361,11 @@ export class MutationTestReportAppComponent extends RealTimeElement { .path="${this.context.path}" > ${this.context.view === 'mutant' && this.context.result ? html`${searchIcon} `; + return html` + + `; } #dispatchFilePickerOpenEvent() { diff --git a/packages/elements/src/components/file-picker/file-picker.component.ts b/packages/elements/src/components/file-picker/file-picker.component.ts index 1fb1fa231..fd86f9c50 100644 --- a/packages/elements/src/components/file-picker/file-picker.component.ts +++ b/packages/elements/src/components/file-picker/file-picker.component.ts @@ -1,30 +1,38 @@ +import fuzzysort from 'fuzzysort'; +import type { TemplateResult } from 'lit'; import { html, LitElement, nothing, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; - -import { TestFileModel } from 'mutation-testing-metrics'; import type { FileUnderTestModel, Metrics, MetricsResult, MutationTestMetricsResult, TestMetrics } from 'mutation-testing-metrics'; +import { TestFileModel } from 'mutation-testing-metrics'; -import { mutantFileIcon, searchIcon, testFileIcon } from '../../lib/svg-icons.js'; +import { isNotNullish } from '../../../../metrics/src/helpers/is-not-nullish.js'; import { renderIf, toAbsoluteUrl } from '../../lib/html-helpers.js'; -import { tailwind } from '../../style/index.js'; import { View } from '../../lib/router.js'; +import { mutantFileIcon, searchIcon, testFileIcon } from '../../lib/svg-icons.js'; +import { tailwind } from '../../style/index.js'; + +interface ModelEntry { + name: string; + file: FileUnderTestModel | TestFileModel; +} @customElement('mte-file-picker') export class MutationTestReportFilePickerComponent extends LitElement { static styles = [tailwind]; #abortController = new AbortController(); - #searchMap = new Map(); + #searchTargets: (ModelEntry & { name: string; prepared: Fuzzysort.Prepared })[] = []; + #originalDocumentOverflow = ''; - @property({ type: Object }) - public declare rootModel: MutationTestMetricsResult; + @property({ attribute: false }) + public declare rootModel: MutationTestMetricsResult | undefined; @state() public declare openPicker: boolean; @state() - public declare filteredFiles: { name: string; file: FileUnderTestModel | TestFileModel }[]; + public declare filteredFiles: (ModelEntry & { template?: (string | TemplateResult)[] })[]; @state() public declare fileIndex: number; @@ -38,29 +46,38 @@ export class MutationTestReportFilePickerComponent extends LitElement { connectedCallback(): void { super.connectedCallback(); + this.#originalDocumentOverflow = document.body.style.overflow; - window.addEventListener('keydown', (e) => this.#handleKeyDown(e), { signal: this.#abortController.signal }); + window.addEventListener('keydown', this.#handleKeyDown, { signal: this.#abortController.signal }); } disconnectedCallback(): void { super.disconnectedCallback(); + fuzzysort.cleanup(); this.#abortController.abort(); } willUpdate(changedProperties: PropertyValues): void { - super.willUpdate(changedProperties); - if (changedProperties.has('rootModel')) { this.#prepareMap(); this.#filter(''); } } + updated(changedProperties: PropertyValues): void { + if (changedProperties.has('openPicker')) { + if (this.openPicker) { + document.body.style.overflow = 'hidden'; + this.#focusInput(); + } else { + document.body.style.overflow = this.#originalDocumentOverflow; + } + } + } + open() { this.openPicker = true; - - void this.updateComplete.then(() => this.#focusInput()); } render() { @@ -71,28 +88,31 @@ export class MutationTestReportFilePickerComponent extends LitElement { return html`
`; @@ -100,27 +120,37 @@ export class MutationTestReportFilePickerComponent extends LitElement { #renderFoundFiles() { return html` -
    +
      ${renderIf(this.filteredFiles.length === 0, () => html`
    • No files found
    • `)} ${repeat( this.filteredFiles, (item) => item.name, - ({ name, file }, index) => { + ({ name, file, template }, index) => { const view = this.#getView(file); return html` -
    • +
    • ${this.#renderTestOrMutantIndication(view)} ${file.result?.name} - ${name} + ${template ?? name}
    • `; @@ -131,39 +161,34 @@ export class MutationTestReportFilePickerComponent extends LitElement { } #renderTestOrMutantIndication(view: View) { - return html`${view === View.mutant ? mutantFileIcon : testFileIcon}`; - } - - #handleFocus() { - this.shadowRoot?.querySelector('input')?.focus(); + return view === View.mutant ? mutantFileIcon : testFileIcon; } #prepareMap() { - if (this.rootModel == null) { + if (!this.rootModel) { return; } + // Clear previous search targets + this.#searchTargets = []; const prepareFiles = ( result: MetricsResult | undefined, parentPath: string | null = null, allFilesKey: string, ) => { - if (result === undefined) { + if (!result) { return; } - if (result.file !== undefined && result.file !== null && result.name !== allFilesKey) { - this.#searchMap.set(parentPath == null ? result.name : `${parentPath}/${result.name}`, result.file); - } - - if (result.childResults.length === 0) { - return; + if (isNotNullish(result.file) && result.name !== allFilesKey) { + const name = !parentPath ? result.name : `${parentPath}/${result.name}`; + this.#searchTargets.push({ name, file: result.file, prepared: fuzzysort.prepare(name) }); } result.childResults.forEach((child) => { - if (parentPath !== allFilesKey && parentPath !== null && result.name !== null) { + if (parentPath !== allFilesKey && parentPath && result.name) { prepareFiles(child, `${parentPath}/${result.name}`, allFilesKey); - } else if ((parentPath === allFilesKey || parentPath === null) && result.name !== allFilesKey) { + } else if ((parentPath === allFilesKey || !parentPath) && result.name !== allFilesKey) { prepareFiles(child, result.name, allFilesKey); } else { prepareFiles(child, null, allFilesKey); @@ -175,7 +200,7 @@ export class MutationTestReportFilePickerComponent extends LitElement { prepareFiles(this.rootModel.testMetrics, null, 'All tests'); } - #handleKeyDown(event: KeyboardEvent) { + #handleKeyDown = (event: KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key === 'k') { this.#togglePicker(event); } else if (!this.openPicker && event.key === '/') { @@ -189,7 +214,7 @@ export class MutationTestReportFilePickerComponent extends LitElement { } if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { - setTimeout(() => { + void this.updateComplete.then(() => { this.#scrollActiveLinkInView(); }); } @@ -197,10 +222,10 @@ export class MutationTestReportFilePickerComponent extends LitElement { if (event.key === 'Enter') { this.#handleEnter(); } - } + }; #scrollActiveLinkInView() { - const activeLink = this.shadowRoot?.querySelector('a[data-active]'); + const activeLink = this.renderRoot.querySelector('[aria-selected="true"] a'); activeLink?.scrollIntoView({ block: 'nearest' }); } @@ -232,49 +257,44 @@ export class MutationTestReportFilePickerComponent extends LitElement { this.#closePicker(); } - #togglePicker(event: KeyboardEvent | null = null) { + #togglePicker = (event: KeyboardEvent | null = null) => { event?.preventDefault(); event?.stopPropagation(); this.openPicker = !this.openPicker; + }; - if (!this.openPicker) { - document.body.style.overflow = 'auto'; - } else { - document.body.style.overflow = 'hidden'; - } - - void this.updateComplete.then(() => this.#focusInput()); - } - - #focusInput() { - this.shadowRoot?.querySelector('input')?.focus(); - } + #focusInput = () => { + this.renderRoot.querySelector('input')?.focus(); + }; - #closePicker() { - document.body.style.overflow = 'auto'; + #closePicker = () => { this.openPicker = false; this.fileIndex = 0; this.#filter(''); - } + }; - #handleSearch(event: KeyboardEvent) { + #handleSearch = (event: InputEvent) => { if (!this.openPicker) { return; } - if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Tab') { - return; - } - this.#filter((event.target as HTMLInputElement).value); this.fileIndex = 0; - } + }; #filter(filterKey: string) { - this.filteredFiles = Array.from(this.#searchMap.keys()) - .filter((file) => file.toLowerCase().includes(filterKey.toLowerCase())) - .map((file) => ({ name: file, file: this.#searchMap.get(file)! })); + if (!filterKey) { + this.filteredFiles = this.#searchTargets; + } else { + this.filteredFiles = fuzzysort.go(filterKey, this.#searchTargets, { key: 'prepared', threshold: 0.3, limit: 500 }).map((result) => ({ + file: result.obj.file, + name: result.obj.name, + template: result.highlight( + (m) => html`${m}`, + ), + })); + } } #getView(file: FileUnderTestModel | TestFileModel): View { diff --git a/packages/elements/src/components/metrics-table/metrics-table.component.ts b/packages/elements/src/components/metrics-table/metrics-table.component.ts index 92994393b..3e3ac896f 100644 --- a/packages/elements/src/components/metrics-table/metrics-table.component.ts +++ b/packages/elements/src/components/metrics-table/metrics-table.component.ts @@ -61,11 +61,11 @@ export class MutationTestReportTestMetricsTable extends RealTime } public render() { - return html`${this.model + return this.model ? html`
      ${this.renderTableHeadRow()}${this.renderTableBody(this.model)}
      ` - : nothing}`; + : nothing; } private renderTableHeadRow() { diff --git a/packages/elements/src/components/result-status-bar/result-status-bar.ts b/packages/elements/src/components/result-status-bar/result-status-bar.ts index cd4a7f664..398a90999 100644 --- a/packages/elements/src/components/result-status-bar/result-status-bar.ts +++ b/packages/elements/src/components/result-status-bar/result-status-bar.ts @@ -1,5 +1,6 @@ -import { LitElement, html } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { IntersectionController } from '@lit-labs/observers/intersection-controller.js'; +import { LitElement, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import { tailwind } from '../../style/index.js'; type ProgressType = 'detected' | 'survived' | 'no coverage' | 'pending'; @@ -14,25 +15,22 @@ interface ProgressMetric { export class ResultStatusBar extends LitElement { public static styles = [tailwind]; - @property({ attribute: false }) - public declare detected; + @property({ type: Number }) + public declare detected: number; - @property({ attribute: false }) - public declare noCoverage; + @property({ type: Number }) + public declare noCoverage: number; - @property({ attribute: false }) - public declare pending; + @property({ type: Number }) + public declare pending: number; - @property({ attribute: false }) - public declare survived; + @property({ type: Number }) + public declare survived: number; - @property({ attribute: false }) - public declare total; + @property({ type: Number }) + public declare total: number; - @state() - private declare shouldBeSmall; - - #observer: IntersectionObserver | undefined; + #shouldBeSmallController: IntersectionController; public constructor() { super(); @@ -41,75 +39,51 @@ export class ResultStatusBar extends LitElement { this.pending = 0; this.survived = 0; this.total = 0; - this.shouldBeSmall = false; - } - - public connectedCallback(): void { - super.connectedCallback(); // This code is responsible for making the small progress-bar show up. // Once this element (the standard progress-bar) is no longer intersecting (visible) the viewable window, // the smaller progress-bar will show up at the top if the window. // If this element is visible, the smaller progress-bar will fade out and it will no longer be visible. - this.#observer = new window.IntersectionObserver(([entry]) => { - if (entry.isIntersecting) { - this.shouldBeSmall = false; - } else { - this.shouldBeSmall = true; - } - - this.requestUpdate(); + this.#shouldBeSmallController = new IntersectionController(this, { + callback: ([entry]) => { + return !entry.isIntersecting; + }, }); - this.#observer.observe(this); - } - - public disconnectedCallback(): void { - super.disconnectedCallback(); - - this.#observer?.disconnect(); } public render() { return html` ${this.#renderSmallParts()}
      -
      -
      ${this.#renderParts()}
      -
      +
      ${this.#renderParts()}
      `; } #renderSmallParts() { return html`
      -
      ${this.#getMetrics().map((metric) => this.#renderSmallPart(metric))}
      +
      ${this.#getMetrics().map((metric) => this.#renderPart(metric, true))}
      `; } - #renderSmallPart(metric: ProgressMetric) { - return html`
      `; - } - #renderParts() { - return html`${this.#getMetrics().map((metric) => this.#renderPart(metric))}`; + return this.#getMetrics().map((metric) => this.#renderPart(metric, false)); } - #renderPart(metric: ProgressMetric) { + #renderPart(metric: ProgressMetric, shouldBeSmall: boolean) { return html`
      - ${metric.amount} + : 'opacity-1'} relative flex items-center overflow-hidden" + >${shouldBeSmall ? nothing : html`${metric.amount}`}
      `; } diff --git a/packages/elements/src/components/test-file/test-file.component.ts b/packages/elements/src/components/test-file/test-file.component.ts index 4dd84e501..64d04f13a 100644 --- a/packages/elements/src/components/test-file/test-file.component.ts +++ b/packages/elements/src/components/test-file/test-file.component.ts @@ -85,7 +85,7 @@ export class TestFileComponent extends RealTimeElement { } this.selectedTest = test; this.dispatchEvent(createCustomEvent('test-selected', { selected: true, test })); - scrollToCodeFragmentIfNeeded(this.shadowRoot!.querySelector(`[test-id="${test.id}"]`)); + scrollToCodeFragmentIfNeeded(this.renderRoot.querySelector(`[test-id="${test.id}"]`)); } } @@ -229,8 +229,6 @@ export class TestFileComponent extends RealTimeElement { } }); } - - super.willUpdate(changes); } private updateFileRepresentation() { @@ -254,7 +252,7 @@ export class TestFileComponent extends RealTimeElement { } #animateTestToggle(test: TestModel) { - beginElementAnimation(this.shadowRoot!, 'test-id', test.id); + beginElementAnimation(this.renderRoot, 'test-id', test.id); } } function title(test: TestModel): string { diff --git a/packages/elements/src/components/theme-switch/theme-switch.component.ts b/packages/elements/src/components/theme-switch/theme-switch.component.ts index 1903d68c2..a15f3f6d7 100644 --- a/packages/elements/src/components/theme-switch/theme-switch.component.ts +++ b/packages/elements/src/components/theme-switch/theme-switch.component.ts @@ -19,7 +19,7 @@ export class MutationTestReportThemeSwitchComponent extends LitElement { public render() { return html`
      - +
      `; diff --git a/packages/elements/test/unit/components/breadcrumb.component.spec.ts b/packages/elements/test/unit/components/breadcrumb.component.spec.ts index 3efc0f808..49d3b1e41 100644 --- a/packages/elements/test/unit/components/breadcrumb.component.spec.ts +++ b/packages/elements/test/unit/components/breadcrumb.component.spec.ts @@ -18,7 +18,7 @@ describe(MutationTestReportBreadcrumbComponent.name, () => { it('should show the root item as "All files" for the "mutant" view', () => { const elements = sut.$$('li'); expect(elements).lengthOf(1); - expect(elements[0].textContent?.trim()).eq('All files'); + expect(elements[0]).toHaveTextContent('All files'); expect(elements[0].querySelector('a')).eq(null); }); @@ -27,7 +27,7 @@ describe(MutationTestReportBreadcrumbComponent.name, () => { await sut.whenStable(); const elements = sut.$$('li'); expect(elements).lengthOf(1); - expect(elements[0].textContent?.trim()).eq('All tests'); + expect(elements[0]).toHaveTextContent('All tests'); expect(elements[0].querySelector('a')).eq(null); }); @@ -39,7 +39,7 @@ describe(MutationTestReportBreadcrumbComponent.name, () => { const anchor = elements[0].querySelector('a')!; expect(anchor).ok; expect(anchor.href).eq(href('#mutant')); - expect(elements[1].textContent?.trim()).eq('foo.js'); + expect(elements[1]).toHaveTextContent('foo.js'); expect(elements[1].querySelector('a')).null; }); @@ -51,17 +51,17 @@ describe(MutationTestReportBreadcrumbComponent.name, () => { const rootLink = elements[0].querySelector('a')!; expect(rootLink).ok; expect(rootLink.href).eq(href('#mutant')); - expect(elements[1].textContent?.trim()).eq('bar'); + expect(elements[1]).toHaveTextContent('bar'); const barAnchor = elements[1].querySelector('a')!; expect(barAnchor).ok; expect(barAnchor.href).eq(href('#mutant/bar')); - expect(elements[2].textContent?.trim()).eq('foo.js'); + expect(elements[2]).toHaveTextContent('foo.js'); expect(elements[2].querySelector('a')).null; }); it('should dispatch open-file-picker event', async () => { // Act - const event = await sut.catchCustomEvent('mte-file-picker-open', () => sut.element.shadowRoot?.querySelector('button')?.click()); + const event = await sut.catchCustomEvent('mte-file-picker-open', () => sut.element.renderRoot.querySelector('button')?.click()); // Assert expect(event).ok; diff --git a/packages/elements/test/unit/components/drawer-mutant.component.spec.ts b/packages/elements/test/unit/components/drawer-mutant.component.spec.ts index ac0ae0a50..f3257747d 100644 --- a/packages/elements/test/unit/components/drawer-mutant.component.spec.ts +++ b/packages/elements/test/unit/components/drawer-mutant.component.spec.ts @@ -149,9 +149,9 @@ describe(MutationTestReportDrawerMutant.name, () => { await sut.whenStable(); const listItems = sut.$$('[slot="detail"] ul li'); expect(listItems).lengthOf(3); - expect(listItems[0].textContent).eq('🎯 foo should bar'); - expect(listItems[1].textContent).eq('🎯 baz should qux'); - expect(listItems[2].textContent).eq('☂️ quux should corge'); + expect(listItems[0]).toHaveTextContent('🎯 foo should bar'); + expect(listItems[1]).toHaveTextContent('🎯 baz should qux'); + expect(listItems[2]).toHaveTextContent('☂️ quux should corge'); }); function detailText() { diff --git a/packages/elements/test/unit/components/drawer-test.component.spec.ts b/packages/elements/test/unit/components/drawer-test.component.spec.ts index f9748e0b7..d172d7d9b 100644 --- a/packages/elements/test/unit/components/drawer-test.component.spec.ts +++ b/packages/elements/test/unit/components/drawer-test.component.spec.ts @@ -47,8 +47,7 @@ describe(MutationTestReportDrawerTestComponent.name, () => { it('should render the header correctly', async () => { sut.element.test = test; await sut.whenStable(); - const headerText = sut.$('[slot="header"]').textContent; - expect(headerText).contains('🌧 foo should bar [NotCovering]'); + expect(sut.$('[slot="header"]')).toHaveTextContent('🌧 foo should bar [NotCovering]'); }); it('should render closed by default', () => { @@ -131,9 +130,9 @@ describe(MutationTestReportDrawerTestComponent.name, () => { await sut.whenStable(); const listItems = sut.$$('[slot="detail"] ul li'); expect(listItems).lengthOf(3); - expect(listItems[0].textContent).contains('🎯 const a = false'); - expect(listItems[1].textContent).contains('🎯 const b = true'); - expect(listItems[2].textContent).contains('☂️ if(1 <= 5)'); + expect(listItems[0]).toHaveTextContent('🎯 const a = false'); + expect(listItems[1]).toHaveTextContent('🎯 const b = true'); + expect(listItems[2]).toHaveTextContent('☂️ if(1 <= 5)'); }); function detailText() { diff --git a/packages/elements/test/unit/components/drawer.component.spec.ts b/packages/elements/test/unit/components/drawer.component.spec.ts index 6a027d677..1435e989e 100644 --- a/packages/elements/test/unit/components/drawer.component.spec.ts +++ b/packages/elements/test/unit/components/drawer.component.spec.ts @@ -45,7 +45,7 @@ describe(MutationTestReportDrawer.name, () => { it('should render the read-more toggle', async () => { sut.element.mode = 'half'; await sut.whenStable(); - expect(readMoreToggle().textContent).eq('🔼 More'); + expect(readMoreToggle()).toHaveTextContent('🔼 More'); }); it('should expand to full size when read-more is clicked', async () => { @@ -58,7 +58,7 @@ describe(MutationTestReportDrawer.name, () => { it('should change the label of the read-more toggle when fully expanded', async () => { sut.element.mode = 'open'; await sut.whenStable(); - expect(readMoreToggle().textContent).eq('🔽 Less'); + expect(readMoreToggle()).toHaveTextContent('🔽 Less'); }); it('should show the detail when opened', async () => { diff --git a/packages/elements/test/unit/components/file-picker.component.spec.ts b/packages/elements/test/unit/components/file-picker.component.spec.ts index 01acbe0fc..bf6157310 100644 --- a/packages/elements/test/unit/components/file-picker.component.spec.ts +++ b/packages/elements/test/unit/components/file-picker.component.spec.ts @@ -1,10 +1,9 @@ import { userEvent } from '@vitest/browser/context'; import { calculateMutationTestMetrics } from 'mutation-testing-metrics'; +import { MutationTestReportFilePickerComponent } from '../../../src/components/file-picker/file-picker.component.js'; import { CustomElementFixture } from '../helpers/CustomElementFixture.js'; import { createReport } from '../helpers/factory.js'; -import { tick } from '../helpers/tick.js'; -import { MutationTestReportFilePickerComponent } from '../../../src/components/file-picker/file-picker.component.js'; describe(MutationTestReportFilePickerComponent.name, () => { let sut: CustomElementFixture; @@ -22,7 +21,7 @@ describe(MutationTestReportFilePickerComponent.name, () => { await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('#picker')).to.eq(null); + expect(getPicker()).not.toBeInTheDocument(); }); it('should show the picker when keycombo is pressed', async () => { @@ -35,7 +34,7 @@ describe(MutationTestReportFilePickerComponent.name, () => { await openPicker(); // Assert - expect(sut.element.shadowRoot?.querySelector('#picker')).toBeVisible(); + expect(getPicker()).toBeVisible(); }); it('should show the picker when macos keycombo is pressed', async () => { @@ -49,7 +48,20 @@ describe(MutationTestReportFilePickerComponent.name, () => { await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('#picker')).toBeVisible(); + expect(getPicker()).toBeVisible(); + }); + + it('should show the picker when / is pressed', async () => { + // Arrange + sut.element.rootModel = calculateMutationTestMetrics(createReport()); + + // Act + sut.connect(); + await userEvent.keyboard('/'); + await sut.whenStable(); + + // Assert + expect(getPicker()).toBeVisible(); }); describe('when the picker is open', () => { @@ -73,7 +85,7 @@ describe(MutationTestReportFilePickerComponent.name, () => { await openPicker(); // Assert - expect(sut.element.shadowRoot?.querySelector('#picker')).to.eq(null); + expect(getPicker()).not.toBeInTheDocument(); }); it('should close the picker when the escape key is pressed', async () => { @@ -82,17 +94,17 @@ describe(MutationTestReportFilePickerComponent.name, () => { await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('#picker')).to.eq(null); + expect(getPicker()).not.toBeInTheDocument(); }); it('should close the picker when clicking outside the dialog', async () => { // Act - const backdrop = sut.element.shadowRoot?.querySelector('#backdrop'); - (backdrop as HTMLElement).click(); + const backdrop = sut.$('#backdrop'); + backdrop.click(); await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('#picker')).to.eq(null); + expect(getPicker()).not.toBeInTheDocument(); }); describe('when not typing in the search box', () => { @@ -101,29 +113,29 @@ describe(MutationTestReportFilePickerComponent.name, () => { await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('a[data-active]')?.textContent?.trim()).include('append.js'); + expect(getActiveItem()).toHaveTextContent('append.js'); + expect(sut.$$('#files li')).toHaveLength(3); + expect(sut.$('#files')).not.toHaveTextContent('No files found'); }); }); describe('when typing in the search box', () => { it('should select the first item when searching with the letter "i"', async () => { // Arrange - const input = sut.element.shadowRoot?.querySelector('#file-picker-input'); + const input = getFilePickerInput(); // Act - (input as HTMLInputElement).value = 'i'; - await userEvent.type(input as HTMLInputElement, 'i'); + await userEvent.fill(input, 'i'); await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('a[data-active]')?.textContent?.trim()).include('index.html'); + expect(getActiveItem()).toHaveTextContent('index.ts'); }); it('should redirect to mutant when pressing enter', async () => { // Arrange - const input = sut.element.shadowRoot?.querySelector('#file-picker-input'); - (input as HTMLInputElement).value = 'index.html'; - await userEvent.type(input as HTMLInputElement, 'l'); + const input = getFilePickerInput(); + await userEvent.fill(input, 'l'); await sut.whenStable(); // Act @@ -133,22 +145,42 @@ describe(MutationTestReportFilePickerComponent.name, () => { // Assert expect(window.location.hash).to.eq('#mutant/index.html'); }); - }); - describe('when pressing the arrow keys', () => { - beforeEach(() => { - const input = sut.element.shadowRoot?.querySelector('#file-picker-input'); - (input as HTMLInputElement).value = ''; + it('should support fuzzy-search', async () => { + // Arrange + const input = getFilePickerInput(); + + // Ac + await userEvent.fill(input, 'indx.hml'); + await sut.whenStable(); + + // Assert + expect(getActiveItem()).toHaveTextContent('index.html'); }); + it('should show when no files are found', async () => { + // Arrange + const input = getFilePickerInput(); + + // Act + await userEvent.fill(input, 'non-existing-file'); + await sut.whenStable(); + + // Assert + expect(getActiveItem()).not.toBeInTheDocument(); + expect(sut.$('#files')).toHaveTextContent('No files found'); + expect(sut.$$('#files')).toHaveLength(1); + }); + }); + + describe('when pressing the arrow keys', () => { it('should move active item to the next item when pressing down', async () => { // Arrange & Act await userEvent.keyboard('{arrowdown}'); await sut.whenStable(); - await tick(); // Assert - expect(sut.element.shadowRoot?.querySelector('a[data-active]')?.textContent?.trim()).include('index.html'); + expect(getActiveItem()).toHaveTextContent('index.html'); }); it('should move to the first item when pressing down on the last item', async () => { @@ -159,7 +191,7 @@ describe(MutationTestReportFilePickerComponent.name, () => { await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('a[data-active]')?.textContent?.trim()).include('append.js'); + expect(getActiveItem()).toHaveTextContent('append.js'); }); it('should move active item to the previous item when pressing up', async () => { @@ -171,7 +203,7 @@ describe(MutationTestReportFilePickerComponent.name, () => { await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('a[data-active]')?.textContent?.trim()).include('index.html'); + expect(getActiveItem()).toHaveTextContent('index.html'); }); it('should move to the last item when pressing up on the first item', async () => { @@ -183,7 +215,7 @@ describe(MutationTestReportFilePickerComponent.name, () => { await sut.whenStable(); // Assert - expect(sut.element.shadowRoot?.querySelector('a[data-active]')?.textContent?.trim()).include('index.ts'); + expect(getActiveItem()).toHaveTextContent('index.ts'); }); }); }); @@ -192,4 +224,16 @@ describe(MutationTestReportFilePickerComponent.name, () => { await userEvent.keyboard('{Control>}{k}'); await sut.whenStable(); } + + function getPicker() { + return sut.$('#picker'); + } + + function getActiveItem() { + return sut.$('[aria-selected="true"] a'); + } + + function getFilePickerInput() { + return sut.$('#file-picker-input'); + } }); diff --git a/packages/elements/test/unit/components/file.component.spec.ts b/packages/elements/test/unit/components/file.component.spec.ts index 7f98ecc5b..6c14953d4 100644 --- a/packages/elements/test/unit/components/file.component.spec.ts +++ b/packages/elements/test/unit/components/file.component.spec.ts @@ -25,7 +25,7 @@ describe(FileComponent.name, () => { }); it('should show the code', () => { - expect(sut.$('code').textContent?.trim()).eq(fileResult.source); + expect(sut.$('code')).toHaveTextContent(fileResult.source); }); it('should highlight the code', () => { diff --git a/packages/elements/test/unit/components/totals.component.spec.ts b/packages/elements/test/unit/components/totals.component.spec.ts index 888adaa4c..2701251ef 100644 --- a/packages/elements/test/unit/components/totals.component.spec.ts +++ b/packages/elements/test/unit/components/totals.component.spec.ts @@ -96,7 +96,7 @@ describe(MutationTestReportTestMetricsTable.name, () => { expect(table).ok; const rows = table.querySelectorAll('tbody tr'); expect(rows).lengthOf(2); - expect((rows.item(1) as HTMLTableRowElement).cells.item(0)!.textContent).contains('baz/foo.js'); + expect((rows.item(1) as HTMLTableRowElement).cells.item(0)!).toHaveTextContent('baz/foo.js'); }); it('should show N/A when no mutation score is available', async () => { @@ -109,7 +109,7 @@ describe(MutationTestReportTestMetricsTable.name, () => { await sut.whenStable(); const table = sut.$('table'); expect(table).ok; - expect(table.querySelectorAll('td span.font-bold')[0].textContent).contains('N/A'); + expect(table.querySelectorAll('td span.font-bold')[0]).toHaveTextContent('N/A'); }); it('should show a progress bar when there is a score', async () => { @@ -124,7 +124,7 @@ describe(MutationTestReportTestMetricsTable.name, () => { const table = sut.$('table'); expect(table).ok; expect(table.querySelector('[role=progressbar]')!.getAttribute('aria-valuenow')).contains(mutationScore); - expect(table.querySelector('.text-red-700')!.textContent).contains(mutationScore); + expect(table.querySelector('.text-red-700')!).toHaveTextContent(mutationScore.toString()); }); it('should show no progress bar when score is NaN', async () => { diff --git a/packages/elements/test/unit/helpers/CustomElementFixture.ts b/packages/elements/test/unit/helpers/CustomElementFixture.ts index 840d9bca0..a57b5d7fe 100644 --- a/packages/elements/test/unit/helpers/CustomElementFixture.ts +++ b/packages/elements/test/unit/helpers/CustomElementFixture.ts @@ -61,14 +61,14 @@ export class CustomElementFixture { public $(selector: string, inShadow = true): TElement { if (inShadow) { - return this.element.shadowRoot!.querySelector(selector)!; + return this.element.renderRoot.querySelector(selector)!; } else { return this.element.querySelector(selector)!; } } public $$(selector: string): TElement[] { - return [...this.element.shadowRoot!.querySelectorAll(selector)]; + return [...this.element.renderRoot.querySelectorAll(selector)]; } public get style(): CSSStyleDeclaration {