/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // import 'jasmine'; (google3-only) import {html, render, TemplateResult} from 'lit'; /** * Options for creating form tests. */ export interface FormTestsOptions { /** * A `querySelector` result that returns the tested element. * * @param root The root element to query from. * @return `root.querySelector('md-component')` */ queryControl(root: Element): T | null; /** * Tests for `setFormValue`. Tests should render a form element, then * assert that the form's `FormData` matches the expected value (or lack of * one). * * There must be at least one value test. */ valueTests: [ValueTest, ...ValueTest[]]; /** * Tests for `formResetCallback`. Tests should render a form element with an * initial state, change the value of the element, then assert that the * control was reset to its initial value. * * There must be at least one reset test. */ resetTests: [ResetTest, ...Array>]; /** * Tests for `formStateRestoreCallback`. Tests should render a form element * with an initial state, then assert that a new control was restored with the * same state. * * There must be at least one restore test. */ restoreTests: [RestoreTest, ...Array>]; } /** * Creates a series of tests that ensure an element works with forms as a form * associated custom element. * * @param options Options for creating tests, including use cases. */ export function createFormTests( options: FormTestsOptions, ) { // Patch attachInternals in order to spy on `setFormValue()` for simulating // form state restoration. const originalAttachInternals = HTMLElement.prototype.attachInternals; const INTERNALS = Symbol('internals'); interface HTMLElementWithInternals { [INTERNALS]: SpiedElementInternals; } interface SpiedElementInternals extends ElementInternals { setFormValue: jasmine.Spy; } function getInternals(element: HTMLElement) { return (element as unknown as HTMLElementWithInternals)[INTERNALS]; } beforeAll(() => { HTMLElement.prototype.attachInternals = function (this: HTMLElement) { const internals = originalAttachInternals.call(this); spyOn(internals, 'setFormValue').and.callThrough(); (this as unknown as HTMLElementWithInternals)[INTERNALS] = internals as SpiedElementInternals; return internals; }; }); afterAll(() => { HTMLElement.prototype.attachInternals = originalAttachInternals; }); let root: HTMLElement | undefined; beforeEach(() => { root = document.createElement('div'); document.body.appendChild(root); }); afterEach(() => { root?.remove(); }); async function setupTest(content = options.valueTests[0].render()) { if (!root) { throw new Error('root was not set up correctly.'); } render(html`
${content}
`, root); const form = root.querySelector('form'); if (!form) { throw new Error('Could not query rendered
'); } const control = options.queryControl(root) as | (T & ExpectedFormAssociatedElement) | null; if (!control) { throw new Error('`queryControl` must return an element.'); } await control?.updateComplete; return {form, control}; } it('should have `static formAssociated = true;`', async () => { const {control} = await setupTest(); expect(control.constructor.formAssociated) .withContext('control.constructor.formAssociated') .toBeTrue(); }); it('should return associated form for `form` property', async () => { const {form, control} = await setupTest(); expect(control.form).withContext('control.form').toBe(form); }); it('should return null for `form` when not part of a ', async () => { const {form, control} = await setupTest(); form.parentElement?.append(control); expect(control.form).withContext('control.form').toBeNull(); }); it('should return associated labels for `labels` property', async () => { const {form, control} = await setupTest(); const labelFor = document.createElement('label'); const labelParent = document.createElement('label'); labelFor.htmlFor = 'control'; control.id = 'control'; form.append(labelFor); labelParent.appendChild(control); form.appendChild(labelParent); expect(control.labels) .withContext('control.labels') .toBeInstanceOf(NodeList); const labels = Array.from(control.labels); expect(labels) .withContext('should contain parent label element') .toContain(labelParent); expect(labels) .withContext('should contain label element with for attribute') .toContain(labelFor); }); it('should return empty NodeList for `labels` when not part of a ', async () => { const {form, control} = await setupTest(); form.parentElement?.append(control); expect(control.labels) .withContext('control.labels') .toBeInstanceOf(NodeList); expect(control.labels.length).withContext('control.labels.length').toBe(0); }); it('should have a name property that reflects to the name attribute', async () => { const {control} = await setupTest(); control.name = 'control'; await control?.updateComplete; expect(control.getAttribute('name')) .withContext('"name" reflected attribute') .toBe('control'); }); it('should not add a form value without a name', async () => { const {form, control} = await setupTest(); control.name = ''; await control?.updateComplete; const data = new FormData(form); expect(data).withContext('data should be empty').toHaveSize(0); }); for (const valueTest of options.valueTests) { it(`should pass the "${valueTest.name}" value test`, async () => { const {form} = await setupTest(valueTest.render()); valueTest.assertValue(new FormData(form)); }); } for (const resetTest of options.resetTests) { it(`it should pass the "${resetTest.name}" reset test`, async () => { const {form, control} = await setupTest(resetTest.render()); resetTest.change(control); await control?.updateComplete; form.reset(); resetTest.assertReset(control); }); } for (const restoreTest of options.restoreTests) { it(`it should pass the "${restoreTest.name}" restore test`, async () => { const {form} = await setupTest(restoreTest.render()); const controls = Array.from( form.elements, ) as ExpectedFormAssociatedElement[]; for (const control of controls) { // Simulate restoring a new set of controls. For each control, we // grab its value and state from its internals. Then, we remove it from // the form, add a new control, and simulate restoring the state and // value for that control. const [value, state] = getInternals( control, ).setFormValue.calls.mostRecent()?.args ?? [null, null]; const newControl = document.createElement( control.tagName, ) as ExpectedFormAssociatedElement; // Include any children for controls like `