Skip to content

Commit

Permalink
feat: Add client.pending and headless render tests. (#629)
Browse files Browse the repository at this point in the history
* test: Add headless render tests using JSDOM.

* test: Add longer timout for render test.

* test: Include navigator global for render test.

* feat: Add pending result property to client.

* test: Extend vgplot render test, use client pending property.

* test: Use vitest file snapshot feature.
  • Loading branch information
jheer authored Dec 16, 2024
1 parent 5740dec commit 9bebdc3
Show file tree
Hide file tree
Showing 21 changed files with 710 additions and 34 deletions.
316 changes: 316 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"esbuild": "^0.24.0",
"eslint": "^9.17.0",
"eslint-plugin-jsdoc": "^50.6.1",
"jsdom": "^25.0.1",
"lerna": "^8.1.9",
"nodemon": "^3.1.9",
"rimraf": "^6.0.1",
Expand Down
28 changes: 11 additions & 17 deletions packages/core/src/Coordinator.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ import { voidLogger } from './util/void-logger.js';
import { MosaicClient } from './MosaicClient.js';
import { QueryManager, Priority } from './QueryManager.js';

/**
* @typedef {import('@uwdata/mosaic-sql').Query
* | import('@uwdata/mosaic-sql').DescribeQuery
* | string} QueryType
*/

/**
* The singleton Coordinator instance.
* @type {Coordinator}
Expand Down Expand Up @@ -116,13 +110,13 @@ export class Coordinator {

/**
* Issue a query for which no result (return value) is needed.
* @param {QueryType | QueryType[]} query The query or an array of queries.
* @param { import('./types.js').QueryType[] |
* import('./types.js').QueryType} query The query or an array of queries.
* Each query should be either a Query builder object or a SQL string.
* @param {object} [options] An options object.
* @param {number} [options.priority] The query priority, defaults to
* `Priority.Normal`.
* @returns {QueryResult} A query result
* promise.
* @returns {QueryResult} A query result promise.
*/
exec(query, { priority = Priority.Normal } = {}) {
query = Array.isArray(query) ? query.filter(x => x).join(';\n') : query;
Expand All @@ -132,8 +126,8 @@ export class Coordinator {
/**
* Issue a query to the backing database. The submitted query may be
* consolidate with other queries and its results may be cached.
* @param {QueryType} query The query as either a Query builder object
* or a SQL string.
* @param {import('./types.js').QueryType} query The query as either a Query
* builder object or a SQL string.
* @param {object} [options] An options object.
* @param {'arrow' | 'json'} [options.type] The query result format type.
* @param {boolean} [options.cache=true] If true, cache the query result
Expand All @@ -156,8 +150,8 @@ export class Coordinator {
/**
* Issue a query to prefetch data for later use. The query result is cached
* for efficient future access.
* @param {QueryType} query The query as either a Query builder object
* or a SQL string.
* @param {import('./types.js').QueryType} query The query as either a Query
* builder object or a SQL string.
* @param {object} [options] An options object.
* @param {'arrow' | 'json'} [options.type] The query result format type.
* @returns {QueryResult} A query result promise.
Expand Down Expand Up @@ -196,13 +190,13 @@ export class Coordinator {
* Update client data by submitting the given query and returning the
* data (or error) to the client.
* @param {MosaicClient} client A Mosaic client.
* @param {QueryType} query The data query.
* @param {import('./types.js').QueryType} query The data query.
* @param {number} [priority] The query priority.
* @returns {Promise} A Promise that resolves upon completion of the update.
*/
updateClient(client, query, priority = Priority.Normal) {
client.queryPending();
return this.query(query, { priority })
return client._pending = this.query(query, { priority })
.then(
data => client.queryResult(data).update(),
err => { this._logger.error(err); client.queryError(err); }
Expand All @@ -215,7 +209,7 @@ export class Coordinator {
* the client is simply updated. Otherwise `updateClient` is called. As a
* side effect, this method clears the current preaggregator state.
* @param {MosaicClient} client The client to update.
* @param {QueryType | null} [query] The query to issue.
* @param {import('./types.js').QueryType | null} [query] The query to issue.
*/
requestQuery(client, query) {
this.preaggregator.clear();
Expand All @@ -238,7 +232,7 @@ export class Coordinator {
client.coordinator = this;

// initialize client lifecycle
this.initializeClient(client);
client._pending = this.initializeClient(client);

// connect filter selection
connectSelection(this, client.filterBy, client);
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/MosaicClient.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Coordinator } from './Coordinator.js';
import { Selection } from './Selection.js';
import { throttle } from './util/throttle.js';

/**
Expand All @@ -11,9 +13,13 @@ export class MosaicClient {
* the client when the selection updates.
*/
constructor(filterSelection) {
/** @type {Selection} */
this._filterBy = filterSelection;
this._requestUpdate = throttle(() => this.requestQuery(), true);
/** @type {Coordinator} */
this._coordinator = null;
/** @type {Promise<any>} */
this._pending = Promise.resolve();
}

/**
Expand All @@ -30,6 +36,13 @@ export class MosaicClient {
this._coordinator = coordinator;
}

/**
* Return a Promise that resolves once the client has updated.
*/
get pending() {
return this._pending;
}

/**
* Return this client's filter selection.
*/
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/QueryManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export class QueryManager {
constructor(
maxConcurrentRequests = 32
) {
/** @type {PriorityQueue} */
this.queue = new PriorityQueue(3);
this.db = null;
this.clientCache = null;
Expand All @@ -18,11 +19,12 @@ export class QueryManager {
this._consolidate = null;
/**
* Requests pending with the query manager.
*
* @type {QueryResult[]}
*/
this.pendingResults = [];
/** @type {number} */
this.maxConcurrentRequests = maxConcurrentRequests;
/** @type {boolean} */
this.pendingExec = false;
}

Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { ExprNode } from '@uwdata/mosaic-sql';
import type { DescribeQuery, ExprNode, Query } from '@uwdata/mosaic-sql';

/** Query type accepted by a coordinator. */
export type QueryType =
| string
| Query
| DescribeQuery;

/** String indicating a JavaScript data type. */
export type JSType =
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/util/throttle.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ const NIL = {};
* a Promise. Upon repeated invocation, the callback will not be invoked
* until a prior Promise resolves. If multiple invocations occurs while
* waiting, only the most recent invocation will be pending.
* @param {(event: *) => Promise} callback The callback function.
* @template E, T
* @param {(event: E) => Promise<T>} callback The callback function.
* @param {boolean} [debounce=true] Flag indicating if invocations
* should also be debounced within the current animation frame.
* @returns A new function that throttles access to the callback.
* @returns {(event: E) => void} A new function that throttles
* access to the callback.
*/
export function throttle(callback, debounce = false) {
let curr;
Expand Down
6 changes: 3 additions & 3 deletions packages/core/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ describe('MosaicClient', () => {
}
queryPending() {
// add result promise to global pending queue
this.pending = new QueryResult();
pending.push(this.pending);
this.pendingResult = new QueryResult();
pending.push(this.pendingResult);
}
queryResult(data) {
// fulfill pending promise with sorted data
this.pending.fulfill(
this.pendingResult.fulfill(
data.toArray().sort((a, b) => a.key - b.key)
);
return this;
Expand Down
12 changes: 6 additions & 6 deletions packages/inputs/src/Table.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class Table extends MosaicClient {

this.offset = 0;
this.limit = +rowBatch;
this.pending = false;
this.isPending = false;

this.selection = as;
this.currentRow = -1;
Expand All @@ -59,15 +59,15 @@ export class Table extends MosaicClient {

let prevScrollTop = -1;
this.element.addEventListener('scroll', evt => {
const { pending, loaded } = this;
const { isPending, loaded } = this;
const { scrollHeight, scrollTop, clientHeight } = evt.target;

const back = scrollTop < prevScrollTop;
prevScrollTop = scrollTop;
if (back || pending || loaded) return;
if (back || isPending || loaded) return;

if (scrollHeight - scrollTop < 2 * clientHeight) {
this.pending = true;
this.isPending = true;
this.requestData(this.offset + this.limit);
}
});
Expand Down Expand Up @@ -168,7 +168,7 @@ export class Table extends MosaicClient {
}

queryResult(data) {
if (!this.pending) {
if (!this.isPending) {
// data is not from an internal request, so reset table
this.loaded = false;
this.data = [];
Expand Down Expand Up @@ -204,7 +204,7 @@ export class Table extends MosaicClient {
this.loaded = true;
}

this.pending = false;
this.isPending = false;
return this;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/plot/src/marks/Mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export function markQuery(channels, table, skip = []) {
if (skip.includes(channel)) continue;

if (channel === 'orderby') {
q.orderby(c.value);
q.orderby(c.value ?? field);
} else if (field) {
if (isAggregateExpression(field)) {
aggr = true;
Expand Down
17 changes: 14 additions & 3 deletions packages/plot/src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,30 @@ const DEFAULT_ATTRIBUTES = {
};

export class Plot {
/**
* @param {HTMLElement} [element]
*/
constructor(element) {
/** @type {Record<string, any>} */
this.attributes = { ...DEFAULT_ATTRIBUTES };
this.listeners = null;
this.interactors = [];
/** @type {{ legend: import('./legend.js').Legend, include: boolean }[]} */
this.legends = [];
/** @type {import('./marks/Mark.js').Mark[]} */
this.marks = [];
/** @type {Set<import('./marks/Mark.js').Mark> | null} */
this.markset = null;
/** @type {Map<import('@uwdata/mosaic-core').Param, import('./marks/Mark.js').Mark[]>} */
this.params = new Map;
/** @type {ReturnType<synchronizer>} */
this.synch = synchronizer();

/** @type {HTMLElement} */
this.element = element || document.createElement('div');
this.element.setAttribute('class', 'plot');
this.element.style.display = 'flex';
this.element.value = this;
this.params = new Map;
this.synch = synchronizer();
Object.assign(this.element, { value: this });
}

margins() {
Expand Down
Loading

0 comments on commit 9bebdc3

Please sign in to comment.