Skip to content

Commit

Permalink
some tests for Autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
ExpHP committed Jul 16, 2021
1 parent e5419fb commit 2e75514
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 58 deletions.
2 changes: 1 addition & 1 deletion css/main.less
Expand Up @@ -124,7 +124,7 @@ code { font-family: @monospace; }
}
}

li {
.content-paper li {
margin-bottom: 0.5625em;
margin-top: 0.5625em;
}
Expand Down
5 changes: 5 additions & 0 deletions css/struct-viewer.less
@@ -1,5 +1,10 @@
@import "defs.less";

.struct-nav .thing-selector + .thing-selector {
// don't let the label and line overlap
margin-top: 6px;
}

.struct-view.use-grid {
display: grid;
.row { display: contents; }
Expand Down
191 changes: 191 additions & 0 deletions js/struct-viewer/Navigation.test.tsx
@@ -0,0 +1,191 @@
/**
* @jest-environment jsdom
*/

import React from 'react';
import {MemoryRouter, Router, useHistory} from 'react-router-dom';
import {createMemoryHistory} from 'history';
import type {History} from 'history';
import {render, fireEvent, waitFor, screen, act, within} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

import {StructDatabase, TypeName, Version, VersionLevel} from './database';
import {Navigation, useNavigationPropsFromUrl} from './Navigation';

const PROMISE_THAT_NEVER_RESOLVES = new Promise<never>(() => {});

const SIMPLE_STRUCT = [
['0x0', 'number', 'int32_t'],
['0x4', '__end', null],
];
const DEFAULT_DB_DATA: Record<string, any> = {
'db-head.json': {
'version': 1,
'versions': [
{version: 'v1.0', level: 'primary'},
{version: 'v2.0-pre', level: 'secondary'},
{version: 'v2.0', level: 'primary'},
],
},
'data/v1.0/type-structs-own.json': {
'Common': SIMPLE_STRUCT,
'OnlyInV1': SIMPLE_STRUCT,
},
'data/v2.0-pre/type-structs-own.json': {
'Common': SIMPLE_STRUCT,
'OnlyInPre': SIMPLE_STRUCT,
},
'data/v2.0/type-structs-own.json': {
'Common': SIMPLE_STRUCT,
'OnlyInV2': SIMPLE_STRUCT,
},
};

function testPathReader(data: Record<string, any>) {
return async (path: string) => {
if (path in data) {
return JSON.stringify(data[path]);
} else {
throw new Error(`no such file: '${path}'`)
}
};
}

function pathReaderThatNeverLoads(data: Record<string, any>) {
return async (path: string) => {
if (path === 'db-head.json') {
return JSON.stringify(data[path]);
} else {
return await PROMISE_THAT_NEVER_RESOLVES;
}
};
}

const getTestDb = (data: Record<string, any>) => new StructDatabase(testPathReader(data));
const getDbThatNeverLoads = (data: Record<string, any> = DEFAULT_DB_DATA) => new StructDatabase(pathReaderThatNeverLoads(data));

// =============================================================================

const LABEL_STRUCT = 'Struct';
const LABEL_VERSION = 'Version';
type Label = typeof LABEL_STRUCT | typeof LABEL_VERSION;
function getUnavailableCheckbox() {
return screen.getByLabelText('Show unavailable');
}
function getTextInput(labelText: Label): HTMLInputElement {
const input = screen.getByLabelText(labelText);
expect(input).toBeTruthy();
return input as HTMLInputElement;
}
function getClearButton(labelText: Label) {
// Material UI places the button as a sibling element
const parent = screen.getByLabelText(labelText)?.parentElement;
return parent && within(parent).getByTitle("Clear");
}
function getOpenButton(labelText: Label) {
// Material UI places the button as a sibling element
const parent = screen.getByLabelText(labelText)?.parentElement;
return parent && within(parent).getByTitle("Open");
}

function hasLoadIndicator() {
return screen.queryAllByRole('progressbar').length > 0;
}

function waitForLoad() {
return new Promise<void>((resolve, reject) => {
// give progressbar a chance to appear if it's going to
setTimeout(async () => {
// now wait for it to be gone
await waitFor(() => expect(hasLoadIndicator()).toBe(false));
resolve();
}, 1);
setTimeout(() => reject(new Error("timeout waiting for db to load")), 3000);
});
}

function TestNavigation(props: {
db?: StructDatabase,
history?: History,
dbData?: Record<string, any>,
versionFromUrl?: string | null,
structFromUrl?: string | null,
minLevel?: VersionLevel,
}) {
const {
history = createMemoryHistory(),
versionFromUrl = null,
structFromUrl = null,
minLevel = 'primary',
dbData = DEFAULT_DB_DATA,
db = getTestDb(dbData),
} = props;

const url = urlFromParts(structFromUrl as TypeName | null, versionFromUrl as Version | null);
history.push(url);
return <Router history={history}>
<TestNavigationInner {...{minLevel, db}} />
</Router>;
}

function TestNavigationInner({minLevel, db}: {minLevel: VersionLevel, db: StructDatabase}) {
const {version, setVersion, struct, setStruct} = useNavigationPropsFromUrl();
return <Navigation {...{version, setVersion, struct, setStruct, minLevel, db}} />;
}

function urlFromParts(struct: TypeName | null, version: Version | null) {
const search = new URLSearchParams();
if (struct) search.set('t', struct);
if (version) search.set('v', version);
return `/struct?${search.toString()}`;
}

// =============================================================================

test('it loads', async () => {
await act(async () => {
render(<TestNavigation structFromUrl='Common' versionFromUrl='v2.0'/>);
await waitForLoad();
});
expect(getTextInput(LABEL_STRUCT).value).toBe('Common');
expect(getTextInput(LABEL_VERSION).value).toBe('v2.0');
});

test('it has a load indicator', async () => {
await act(async () => {
render(<TestNavigation db={getDbThatNeverLoads()} structFromUrl='Common' versionFromUrl='v2.0'/>);
});
expect(hasLoadIndicator()).toBe(true);
});

test("it doesn't get stuck after clearing something", async () => {
await act(async () => {
render(<TestNavigation structFromUrl='Common' versionFromUrl='v2.0'/>);
await waitForLoad();
});

await act(async () => {
const button = getClearButton(LABEL_VERSION);
expect(button).toBeTruthy();
fireEvent.click(button!);
});
await new Promise((resolve) => setTimeout(resolve, 10));
await act(() => waitForLoad());
});

test("input can be cleared", async () => {
await act(async () => {
render(<TestNavigation structFromUrl='Common' versionFromUrl='v2.0'/>);
await waitForLoad();
});

await act(async () => {
const button = getClearButton(LABEL_VERSION);
expect(button).toBeTruthy();
fireEvent.click(button!);
});
await new Promise((resolve) => setTimeout(resolve, 10));
await act(() => waitForLoad());
expect(getTextInput(LABEL_STRUCT).value).toBe('Common');
expect(getTextInput(LABEL_VERSION).value).toBe('');
});
102 changes: 87 additions & 15 deletions js/struct-viewer/Selectors.tsx → js/struct-viewer/Navigation.tsx
@@ -1,5 +1,8 @@
import React from 'react';
import {useHistory, useLocation} from 'react-router-dom';
import clsx from 'clsx';
import history from 'history';
import type {History} from 'history';
import Checkbox from '@material-ui/core/Checkbox';
import FormLabel from '@material-ui/core/FormLabel';
import FormControl from '@material-ui/core/FormControl';
Expand All @@ -11,7 +14,24 @@ import Autocomplete from '@material-ui/lab/Autocomplete';

import {StructDatabase, TypeName, Version, VersionLevel} from './database';

export function Selectors(props: {
export function useNavigationPropsFromUrl() {
const history = useHistory();
const location = useLocation();

console.debug(location);
const searchParams = new URLSearchParams(location.search.substring(1));
const struct = searchParams.get('t') as TypeName | null;
const version = searchParams.get('v') as Version | null;

const setStruct = React.useCallback((struct: TypeName | null) => navigateToStruct(history, struct), [history]);
const setVersion = React.useCallback((version: Version | null) => navigateToVersion(history, version), [history]);

return React.useMemo(() => ({
struct, version, setStruct, setVersion,
}), [struct, version, setStruct, setVersion]);
}

export function Navigation(props: {
db: StructDatabase,
setStruct: React.Dispatch<TypeName | null>,
setVersion: React.Dispatch<Version | null>,
Expand All @@ -28,7 +48,7 @@ export function Selectors(props: {
// FIXME: Error for incompatible struct/version
const error = struct === null || version === null;

return <FormControl component="fieldset" error={error}>
return <FormControl component="fieldset" error={error} classes={{root: "struct-nav"}}>
<FormLabel component="legend">Navigation</FormLabel>
<FormControlLabel
control={<Checkbox
Expand All @@ -45,6 +65,30 @@ export function Selectors(props: {
</FormControl>;
}

function navigateToStruct(history: History, struct: TypeName) {
const location = history.location;
history.push(setOrDeleteSearchParam(location, 't', struct));
console.debug(history.location);
}

function navigateToVersion(history: History, version: Version) {
const location = history.location;
history.push(setOrDeleteSearchParam(location, 'v', version));
console.debug(history.location);
}

function setOrDeleteSearchParam<S extends history.State>(location: history.Location<S>, key: string, value: string | null): history.Location<S> {
const search = new URLSearchParams(location.search.substring(1));
if (value) {
search.set(key, value);
} else {
search.delete(key);
}

const string = search.toString();
return {...location, search: (string.length ? '?' : '') + string};
}

export function StructPicker(props: {
onChange: React.Dispatch<TypeName | null>,
db: StructDatabase,
Expand All @@ -69,7 +113,7 @@ export function VersionPicker(props: {
showUnavailable: boolean,
struct: TypeName | null,
version: Version | null,
minLevel: VersionLevel,
minLevel: VersionLevel
}) {
const {onChange, db, showUnavailable, version, struct, minLevel} = props;
const optionsPromise = React.useMemo(() => (
Expand Down Expand Up @@ -98,47 +142,75 @@ type AsyncSelectorProps<T> = Omit<SelectorProps<T>, "options" | "loading"> & {

function AsyncSelector<T extends string>(props: AsyncSelectorProps<T>) {
const {optionsPromise} = props;
// Begin with an empty option list until the promise resolves.
const [options, setOptions] = React.useState<Option<T>[]>([]);
// FIXME: loading indicator never disappears after clearing one of the selectors.
const [loading, setLoading] = React.useState(false);

// Until we can upgrade to React 18 for update batching, we need a single
// combined State so that our updates can be atomic.
type State = { options: Option<T>[]; loading: boolean; };

const [state, setState] = React.useState<State>({
// Begin with an empty option list until the promise resolves.
options: [], loading: true,
});

React.useEffect(() => {
const abortController = new AbortController();
setLoading(true);
// set loading to true, being careful not to trigger unnecessary rerenders
setState((state) => state.loading ? state : {...state, loading: true});
optionsPromise.then((options) => {
if (!abortController.signal.aborted) {
setLoading(false);
setOptions(options);
setState({loading: false, options});
}
});

return () => abortController.abort();
}, [optionsPromise])

return <Selector {...props} {...{options, loading}} />
return <Selector {...props} {...state} />
}

function Selector<T extends string>(props: SelectorProps<T>) {
const {onChange, options, current, loading, label} = props;
const currentOption = useOptionFromLabel(options, ({value}) => value, current);

const handleChange = React.useCallback((_, option: null | Option<T> | string) => {
if (typeof option === 'string') {
// must be in freeSolo. Box is disabled during freeSolo so this call must be spurious.
} else {
onChange(option && option.value);
}
}, [onChange]);

const getOptionLabel = React.useCallback((option: Option<T> | string) => {
if (typeof option === 'string') {
return option;
} else {
return option.value;
}
}, [onChange]);

return (
<Autocomplete
// Autocomplete isn't designed to change between 'freeSolo' and normal mode,
// so change its key when it does.
key={`${loading}`}
classes={{root: "thing-selector"}}
style={{ width: 300 }}
value={currentOption}
size="small"
disabled={loading}
freeSolo={loading}
value={loading ? current : currentOption}
{...(loading ? {inputValue: current || ""} : undefined)}
getOptionSelected={(s: Option<T>) => s.value === current}
onChange={(_, option) => onChange(option && option.value as T)}
onChange={handleChange}
options={options}
renderOption={({available, value}) => (
<span className={clsx({'unavailable': !available})}>{value}</span>
)}
getOptionLabel={({value}) => value}
getOptionLabel={getOptionLabel}
renderInput={(params) => (
<TextField
{...params}
label={label}
size="small"
variant="outlined"
InputProps={{
...params.InputProps,
Expand Down

0 comments on commit 2e75514

Please sign in to comment.