2023-05-17 03:28:40 +03:00
|
|
|
/**
|
|
|
|
* @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<T extends HTMLElement> {
|
|
|
|
/**
|
|
|
|
* A `querySelector` result that returns the tested element.
|
|
|
|
*
|
|
|
|
* @param root The root element to query from.
|
|
|
|
* @return `root.querySelector('md-component')`
|
|
|
|
*/
|
2023-10-25 21:58:21 +03:00
|
|
|
queryControl(root: Element): T | null;
|
2023-05-17 03:28:40 +03:00
|
|
|
/**
|
|
|
|
* 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<T>, ...Array<ResetTest<T>>];
|
|
|
|
/**
|
|
|
|
* 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<T>, ...Array<RestoreTest<T>>];
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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<T extends HTMLElement>(
|
2023-10-25 21:58:21 +03:00
|
|
|
options: FormTestsOptions<T>,
|
|
|
|
) {
|
2023-05-17 03:28:40 +03:00
|
|
|
// 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<ElementInternals['setFormValue']>;
|
|
|
|
}
|
|
|
|
|
|
|
|
function getInternals(element: HTMLElement) {
|
|
|
|
return (element as unknown as HTMLElementWithInternals)[INTERNALS];
|
|
|
|
}
|
|
|
|
|
|
|
|
beforeAll(() => {
|
2023-10-25 21:58:21 +03:00
|
|
|
HTMLElement.prototype.attachInternals = function (this: HTMLElement) {
|
2023-05-17 03:28:40 +03:00
|
|
|
const internals = originalAttachInternals.call(this);
|
|
|
|
spyOn(internals, 'setFormValue').and.callThrough();
|
|
|
|
(this as unknown as HTMLElementWithInternals)[INTERNALS] =
|
2023-10-25 21:58:21 +03:00
|
|
|
internals as SpiedElementInternals;
|
2023-05-17 03:28:40 +03:00
|
|
|
return internals;
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
afterAll(() => {
|
|
|
|
HTMLElement.prototype.attachInternals = originalAttachInternals;
|
|
|
|
});
|
|
|
|
|
2023-10-25 21:58:21 +03:00
|
|
|
let root: HTMLElement | undefined;
|
2023-05-17 03:28:40 +03:00
|
|
|
|
|
|
|
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`<form>${content}</form>`, root);
|
|
|
|
const form = root.querySelector('form');
|
|
|
|
if (!form) {
|
|
|
|
throw new Error('Could not query rendered <form>');
|
|
|
|
}
|
|
|
|
|
2023-10-25 21:58:21 +03:00
|
|
|
const control = options.queryControl(root) as
|
|
|
|
| (T & ExpectedFormAssociatedElement)
|
|
|
|
| null;
|
2023-05-17 03:28:40 +03:00
|
|
|
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)
|
2023-10-25 21:58:21 +03:00
|
|
|
.withContext('control.constructor.formAssociated')
|
|
|
|
.toBeTrue();
|
2023-05-17 03:28:40 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
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 <form>', 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)
|
2023-10-25 21:58:21 +03:00
|
|
|
.withContext('control.labels')
|
|
|
|
.toBeInstanceOf(NodeList);
|
2023-05-17 03:28:40 +03:00
|
|
|
const labels = Array.from(control.labels);
|
|
|
|
expect(labels)
|
2023-10-25 21:58:21 +03:00
|
|
|
.withContext('should contain parent label element')
|
|
|
|
.toContain(labelParent);
|
2023-05-17 03:28:40 +03:00
|
|
|
expect(labels)
|
2023-10-25 21:58:21 +03:00
|
|
|
.withContext('should contain label element with for attribute')
|
|
|
|
.toContain(labelFor);
|
2023-05-17 03:28:40 +03:00
|
|
|
});
|
|
|
|
|
2023-10-25 21:58:21 +03:00
|
|
|
it('should return empty NodeList for `labels` when not part of a <form>', 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');
|
|
|
|
});
|
2023-05-17 03:28:40 +03:00
|
|
|
|
|
|
|
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);
|
2023-06-02 19:27:52 +03:00
|
|
|
await control?.updateComplete;
|
2023-05-17 03:28:40 +03:00
|
|
|
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());
|
2023-10-25 21:58:21 +03:00
|
|
|
const controls = Array.from(
|
|
|
|
form.elements,
|
|
|
|
) as ExpectedFormAssociatedElement[];
|
2023-05-17 03:28:40 +03:00
|
|
|
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.
|
2023-10-25 21:58:21 +03:00
|
|
|
const [value, state] = getInternals(
|
|
|
|
control,
|
|
|
|
).setFormValue.calls.mostRecent()?.args ?? [null, null];
|
2023-05-17 03:28:40 +03:00
|
|
|
|
2023-10-25 21:58:21 +03:00
|
|
|
const newControl = document.createElement(
|
|
|
|
control.tagName,
|
|
|
|
) as ExpectedFormAssociatedElement;
|
2023-09-14 08:44:18 +03:00
|
|
|
// Include any children for controls like `<select>`
|
|
|
|
newControl.append(...control.children);
|
2023-05-17 03:28:40 +03:00
|
|
|
control.remove();
|
|
|
|
form.appendChild(newControl);
|
2023-10-25 21:58:21 +03:00
|
|
|
let restoreState: FormState | null | FormData = state ?? value;
|
2023-05-17 03:28:40 +03:00
|
|
|
if (restoreState instanceof FormData) {
|
2023-06-02 19:27:52 +03:00
|
|
|
restoreState = Array.from(restoreState.entries());
|
2023-05-17 03:28:40 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
newControl?.formStateRestoreCallback(restoreState, 'restore');
|
|
|
|
await newControl?.updateComplete;
|
|
|
|
}
|
|
|
|
|
|
|
|
const control = options.queryControl(form);
|
|
|
|
if (!control) {
|
|
|
|
throw new Error('`queryControl` must return an element.');
|
|
|
|
}
|
|
|
|
|
|
|
|
restoreTest.assertRestored(control);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The expected interface of a Form Associated Custom Element. Used for type
|
|
|
|
* checking in this file only.
|
|
|
|
*/
|
|
|
|
interface ExpectedFormAssociatedElement extends HTMLElement {
|
2023-10-25 21:58:21 +03:00
|
|
|
new (): ExpectedFormAssociatedElement;
|
|
|
|
constructor: (new () => ExpectedFormAssociatedElement) & {
|
|
|
|
readonly formAssociated: true;
|
|
|
|
};
|
2023-05-17 03:28:40 +03:00
|
|
|
prototype: ExpectedFormAssociatedElement;
|
2023-10-25 21:58:21 +03:00
|
|
|
form: HTMLFormElement | null;
|
2023-05-17 03:28:40 +03:00
|
|
|
labels: NodeList;
|
|
|
|
name: string;
|
|
|
|
formStateRestoreCallback(
|
2023-10-25 21:58:21 +03:00
|
|
|
state: FormState | null,
|
|
|
|
reason: 'restore' | 'autocomplete',
|
|
|
|
): void;
|
2023-05-17 03:28:40 +03:00
|
|
|
updateComplete?: Promise<void>;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* `formStateRestoreCallback` type for `state`. May be a string, `File`,
|
|
|
|
* `FormData` entries, or null.
|
|
|
|
*/
|
2023-10-25 21:58:21 +03:00
|
|
|
type FormState = FormDataEntryValue | Array<[string, FormDataEntryValue]>;
|
2023-05-17 03:28:40 +03:00
|
|
|
|
|
|
|
/**
|
|
|
|
* A test for `FormData` values.
|
|
|
|
*/
|
|
|
|
interface ValueTest {
|
|
|
|
/**
|
|
|
|
* The name of the test.
|
|
|
|
*/
|
|
|
|
name: string;
|
|
|
|
/**
|
|
|
|
* Renders a form element with or without a value for form submission.
|
|
|
|
*/
|
|
|
|
render(): TemplateResult;
|
|
|
|
/**
|
|
|
|
* Asserts that the form's `FormData` contains or does not contain the form
|
|
|
|
* element's value.
|
|
|
|
*/
|
|
|
|
assertValue(formData: FormData): void;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A test for `formResetCallback`.
|
|
|
|
*/
|
|
|
|
interface ResetTest<T extends HTMLElement> {
|
|
|
|
/**
|
|
|
|
* The name of the test.
|
|
|
|
*/
|
|
|
|
name: string;
|
|
|
|
/**
|
|
|
|
* Renders a form element with some initial state.
|
|
|
|
*/
|
|
|
|
render(): TemplateResult;
|
|
|
|
/**
|
|
|
|
* Changes the state of a form element.
|
|
|
|
*/
|
|
|
|
change(control: T): void;
|
|
|
|
/**
|
|
|
|
* Asserts that the control was reset to its initial state.
|
|
|
|
*/
|
|
|
|
assertReset(control: T): void;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A test form `formStateRestoreCallback`.
|
|
|
|
*/
|
|
|
|
interface RestoreTest<T extends HTMLElement> {
|
|
|
|
/**
|
|
|
|
* The name of the test.
|
|
|
|
*/
|
|
|
|
name: string;
|
|
|
|
/**
|
|
|
|
* Renders a form element with some initial state.
|
|
|
|
*/
|
|
|
|
render(): TemplateResult;
|
|
|
|
/**
|
|
|
|
* Asserts that the newly created control was restored to the original
|
|
|
|
* control's state.
|
|
|
|
*/
|
|
|
|
assertRestored(control: T): void;
|
|
|
|
}
|