lodash#sum TypeScript Examples

The following examples show how to use lodash#sum. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: analysis-utils.ts    From prism-frontend with MIT License 7 votes vote down vote up
operations = {
  min: (data: number[]) => min(data),
  max: (data: number[]) => max(data),
  sum, // sum method directly from lodash
  mean, // mean method directly from lodash
  median: (data: number[]) => {
    // eslint-disable-next-line fp/no-mutating-methods
    const sortedValues = [...data].sort();
    // Odd cases we use the middle value
    if (sortedValues.length % 2 !== 0) {
      return sortedValues[Math.floor(sortedValues.length / 2)];
    }
    // Even cases we average the two middles
    const floor = sortedValues.length / 2 - 1;
    const ceil = sortedValues.length / 2;
    return (sortedValues[floor] + sortedValues[ceil]) / 2;
  },
}
Example #2
Source File: index.tsx    From nebula-dashboard with Apache License 2.0 6 votes vote down vote up
updateChart = () => {
    const { data } = this.state;
    if (data.length > 0) {
      const total = sum(data.map(i => i.count));
      const chartData = data.map(item => ({
        type: item.name,
        value: round(item.count / total, 2),
      }));
      this.chartInstance.data(chartData).render();
    }
  };
Example #3
Source File: math.ts    From aqualink-app with MIT License 5 votes vote down vote up
getAverage = (numbers: number[], round = false) => {
  if (numbers.length === 0) {
    return undefined;
  }
  const average = sum(numbers) / numbers.length;
  return round ? Math.round(average) : average;
}
Example #4
Source File: settings.state.ts    From nextclade with MIT License 5 votes vote down vote up
resultsTableTotalWidthAtom = selector<number>({
  key: 'resultsTableTotalWidth',
  get({ get }) {
    const dynamicColumnsWidthTotal = get(cladeNodeAttrKeysAtom).length * get(resultsTableDynamicColumnWidthAtom)
    return sum(Object.values(COLUMN_WIDTHS)) + dynamicColumnsWidthTotal
  },
})
Example #5
Source File: attach-to-one-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('attachToOneSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: i };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag}`;
			const searchCreated = await subscribeToOneSearch(query, { filter: { dateRange: { start, end } } });
			const search = await attachToOneSearch(searchCreated.searchID);

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer w/ count module',
		integrationTest(async () => {
			// Create a macro to expand to "value" to test .query vs .effectiveQuery
			const macroName = uuidv4().toUpperCase();
			const createOneMacro = makeCreateOneMacro(TEST_BASE_API_CONTEXT);
			const deleteOneMacro = makeDeleteOneMacro(TEST_BASE_API_CONTEXT);
			const createdMacro = await createOneMacro({ name: macroName, expansion: 'value' });

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json $${macroName} | count`;
			//const effectiveQuery = `tag=${tag} json value | count`;
			const metadata = { test: 'abc' };

			const searchCreated = await subscribeToOneSearch(query, { metadata, filter: { dateRange: { start, end } } });
			const search = await attachToOneSearch(searchCreated.searchID);

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as TextSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const progressP = lastValueFrom(
				search.progress$.pipe(
					takeWhile(v => v < 100, true),
					toArray(),
				),
			);

			const statsP = lastValueFrom(search.stats$.pipe(takeWhile(s => !s.finished, true)));

			const [textEntries, progress, stats] = await Promise.all([textEntriesP, progressP, statsP]);

			////
			// Check stats
			////
			expect(stats.pipeline.length)
				.withContext('there should be two modules for this query: json and count')
				.toEqual(2);
			const [jsonModule, countModule] = stats.pipeline;

			expect(jsonModule.module).toEqual('json');
			expect(jsonModule.input.entries).withContext('json module should accept 100 entries of input').toEqual(count);
			expect(jsonModule.output.entries).withContext('json module should produce 100 entries of output').toEqual(count);

			expect(countModule.module).toEqual('count');
			expect(countModule.input.entries).withContext('count module should accept 100 entries of input').toEqual(count);
			expect(countModule.output.entries)
				.withContext('count module should produce 1 entry of output -- the count')
				.toEqual(1);

			expect(stats.metadata)
				.withContext('the search metadata should be present in the stats and unchanged')
				.toEqual(metadata);
			expect(stats.query).withContext(`Stats should contain the user query`).toBe(query);
			// TODO: Waiting on gravwell/gravwell#3677
			// expect(stats.effectiveQuery).withContext(`Stats should contain the effective query`).toBe(effectiveQuery);

			expect(stats.downloadFormats.sort())
				.withContext(`Download formats should include .json', .text', .csv' and .archive`)
				.toEqual(['archive', 'csv', 'json', 'text']);

			////
			// Check progress
			////
			if (progress.length > 1) {
				expect(progress[0].valueOf())
					.withContext('If more than one progress was emitted, the first should be 0')
					.toEqual(0);
			}
			expect(lastElt(progress)?.valueOf()).withContext('The last progress emitted should be 100%').toEqual(100);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('There should be only one entry, since we used the count module')
				.toEqual(1);
			const lastEntry = textEntries.data[0];
			expect(lastEntry).toBeDefined();
			expect(base64.decode(lastEntry.data))
				.withContext('The total count of entries should equal what we ingested')
				.toEqual(`count ${count}`);

			await deleteOneMacro(createdMacro.id);
		}),
		25000,
	);

	it(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searchCreated = await subscribeToOneSearch(query, { filter });
			const search = await attachToOneSearch(searchCreated.searchID, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
				const _value = enumeratedValues.find(v => v.name === 'value')!;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			expect(stats[0].tags).withContext('Tag should match tag from query').toEqual([tag]);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it(
		'Should treat multiple searches with the same query independently',
		integrationTest(async () => {
			// Number of multiple searches to create at the same time
			const SEARCHES_N = 4;

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searchesCreated = await Promise.all(
				Array.from({ length: SEARCHES_N }).map(() => subscribeToOneSearch(query, { filter })),
			);
			const searches = await Promise.all(
				searchesCreated.map(searchCreated => attachToOneSearch(searchCreated.searchID, { filter })),
			);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			const testsP = searches.map(async (search, i) => {
				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length)
					.withContext('The number of entries should equal the total ingested')
					.toEqual(count);

				if (isUndefined(textEntries.filter) === false) {
					expect(textEntries.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
					if (isUndefined(entry) || isUndefined(original)) {
						fail('Exptected all entries and original data to be defined');
						return;
					}

					const value: Entry = JSON.parse(base64.decode(entry.data));
					const enumeratedValues = entry.values;
					const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
					const _value = enumeratedValues.find(v => v.name === 'value')!;

					expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
						isEnumerated: true,
						name: 'timestamp',
						value: original.timestamp,
					});

					expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
						isEnumerated: true,
						name: 'value',
						value: original.value.toString(),
					});

					expect(value.value)
						.withContext('Each value should match its index, descending')
						.toEqual(count - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).toBeGreaterThan(0);

				if (isUndefined(stats[0].filter) === false) {
					expect(stats[0].filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
			});

			await Promise.all(testsP);
		}),
		25000,
	);

	it(
		'Should reject on an inexistent search ID',
		integrationTest(async () => {
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);
			const searchID = `4723947892379482378`;
			await expectAsync(attachToOneSearch(searchID)).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject searches with invalid search IDs without affecting good ones',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const goodSearchID = (await subscribeToOneSearch(`tag=${tag}`)).searchID;
			const badSearchID = `4723947892379482378`;

			// Attach to a bunch of search subscriptions with different search IDs to race them
			await Promise.all([
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(goodSearchID)).withContext('valid search ID should resolve').toBeResolved(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should work with several searches initiated simultaneously',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const searchCreatedID = (await subscribeToOneSearch(`tag=${tag}`)).searchID;

			// Attach to a bunch of search subscriptions to race them
			await Promise.all(
				rangeLeft(0, 20).map(x =>
					expectAsync(attachToOneSearch(searchCreatedID)).withContext('good query should resolve').toBeResolved(),
				),
			);
		}),
		25000,
	);

	describe('stats', () => {
		it(
			'Should be evenly spread over a window matching the zoom/overview granularity',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const minutes = 90;
				const dateRange = { start, end: addMinutes(start, minutes) };

				const searchCreated = await subscribeToOneSearch(query, { filter: { dateRange } });
				const search = await attachToOneSearch(searchCreated.searchID);

				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e?.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length).withContext("Should be 90 entries since it's a 90 minute window").toEqual(90);
				textEntries.data.forEach((entry, index) => {
					const value: Entry = JSON.parse(base64.decode(entry.data));
					expect(value.value).toEqual(minutes - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).withContext('expect to receive >0 stats from the stats observable').toBeGreaterThan(0);
				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsOverview should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsOverview.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsOverview element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsZoom should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsZoom.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsZoom element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter });
				const search = await attachToOneSearch(searchCreated.searchID, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 640;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than the total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter });
				const search = await attachToOneSearch(searchCreated.searchID, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should provide the minimum zoom window',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const dateRange = { start, end };

				// Issue a query where the minzoomwindow is predictable (1 second)
				const query1s = `tag=${tag} json value | stats mean(value) over 1s`;
				const filter1s: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange };

				const search1sCreated = await subscribeToOneSearch(query1s, { filter: filter1s });
				const search1s = await attachToOneSearch(search1sCreated.searchID, { filter: filter1s });

				const stats1s = await lastValueFrom(search1s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats1s.minZoomWindow).toEqual(1);
				if (isUndefined(stats1s.filter) === false) {
					expect(stats1s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1s);
				}

				// Issue a query where the minzoomwindow is predictable (33 seconds, why not)
				const query33s = `tag=${tag} json value | stats mean(value) over 33s`;
				const filter33s = { entriesOffset: { index: 0, count: count }, dateRange };

				const search33sCreated = await subscribeToOneSearch(query33s, { filter: filter33s });
				const search33s = await attachToOneSearch(search33sCreated.searchID, { filter: filter33s });

				const stats33s = await lastValueFrom(search33s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats33s.minZoomWindow).toEqual(33);
				if (isUndefined(stats33s.filter) === false) {
					expect(stats33s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter33s);
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should keep the dateRange when update the filter multiple times',
			integrationTest(
				makeKeepDataRangeTest({
					start,
					end,
					count,
					createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
						const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
						const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

						const query = `tag=*`;
						const searchCreated = await subscribeToOneSearch(query, { filter: initialFilter });
						return await attachToOneSearch(searchCreated.searchID, { filter: initialFilter });
					},
				}),
			),
			25000,
		);
	});
});
Example #6
Source File: subscribe-to-one-explorer-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('subscribeToOneExplorerSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: { foo: i } };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}

		// Create an AX definition for the generated tag
		const createOneAutoExtractor = makeCreateOneAutoExtractor(TEST_BASE_API_CONTEXT);
		await createOneAutoExtractor({
			tag: tag,
			name: `${tag} - JSON`,
			description: '-',
			module: 'json',
			parameters: 'timestamp value value.foo',
		});
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const search = await subscribeToOneExplorerSearch(query, { filter: { dateRange: { start, end } } });

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	it(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} ax | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneExplorerSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries & { explorerEntries: Array<DataExplorerEntry> }),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			const explorerEntries = textEntries.explorerEntries;
			expect(isArray(explorerEntries) && explorerEntries.every(isDataExplorerEntry))
				.withContext('Expect a promise of an array of data explorer entries')
				.toBeTrue();
			expect(explorerEntries.length).withContext(`Expect ${count} entries`).toBe(count);

			for (const entry of explorerEntries) {
				expect(entry.tag).withContext(`Expect entry tag to be "${tag}"`).toBe(tag);

				expect(entry.elements.length)
					.withContext(`Expect to have 2 data explorer elements on first depth level`)
					.toBe(2);
				expect(entry.elements.map(el => el.name).sort())
					.withContext(`Expect first depth data explorer elements to be "value" and "timestamp"`)
					.toEqual(['timestamp', 'value']);
				expect(entry.elements.map(el => el.module))
					.withContext(`Expect explorer module to be JSON`)
					.toEqual(['json', 'json']);

				const timestampEl = entry.elements.find(el => el.name === 'timestamp')!;
				const valueEl = entry.elements.find(el => el.name === 'value')!;

				expect(timestampEl.children.length).withContext(`Expect the timestamp element to not have children`).toBe(0);
				expect(valueEl.children.length).withContext(`Expect the value element to have one children`).toBe(1);
				expect(valueEl.children[0].name)
					.withContext(`Expect the value element child to be value.foo`)
					.toBe('value.foo');
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp');
				const _value = enumeratedValues.find(v => v.name === 'value');

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value)
					.withContext(`Each entry should have an enumerated value called "value"`)
					.toEqual({
						isEnumerated: true,
						name: 'value',
						value: JSON.stringify(original.value),
					});

				expect(value.value.foo)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it('Should be able to apply element filters', async () => {
		const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);

		const unfilteredQuery = `tag=${tag} raw`;
		const elementFilters: Array<ElementFilter> = [
			{ path: 'value.foo', operation: '!=', value: '50', tag, module: 'json', arguments: null },
		];
		const query = `tag=${tag} json "value.foo" != "50" as "foo" | raw`;
		const countAfterFilter = count - 1;

		const filter: SearchFilter = {
			entriesOffset: { index: 0, count: count },
			elementFilters,
			dateRange: { start, end },
		};
		const search = await subscribeToOneExplorerSearch(unfilteredQuery, { filter });

		const textEntriesP = lastValueFrom(
			search.entries$.pipe(
				map(e => e as RawSearchEntries & { explorerEntries: Array<DataExplorerEntry> }),
				takeWhile(e => !e.finished, true),
			),
		);

		const statsP = lastValueFrom(
			search.stats$.pipe(
				takeWhile(e => !e.finished, true),
				toArray(),
			),
		);

		const [textEntries, stats] = await Promise.all([textEntriesP, statsP]);

		////
		// Check entries
		////
		expect(textEntries.data.length)
			.withContext('The number of entries should equal the total ingested')
			.toEqual(countAfterFilter);

		if (isUndefined(textEntries.filter) === false) {
			expect(textEntries.filter)
				.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
				.toPartiallyEqual(filter);
		}

		const explorerEntries = textEntries.explorerEntries;
		expect(isArray(explorerEntries) && explorerEntries.every(isDataExplorerEntry))
			.withContext('Expect a promise of an array of data explorer entries')
			.toBeTrue();
		expect(explorerEntries.length).withContext(`Expect ${countAfterFilter} entries`).toBe(countAfterFilter);

		////
		// Check stats
		////
		expect(stats.length).toBeGreaterThan(0);
		expect(stats[0].query).toBe(query);
	});

	it(
		'Should reject on a bad query string',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `this is an invalid query`;
			const range: [Date, Date] = [start, end];
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			await expectAsync(subscribeToOneExplorerSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject on a bad query range (end is before start)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: { start, end: subMinutes(start, 10) },
			};

			await expectAsync(subscribeToOneExplorerSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (different queries)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions with different queries to race them
			await Promise.all([
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "a"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "b"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "c"`, { filter: { ...baseFilter, dateRange: goodRange } }),
				)
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "d"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "e"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (same query)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions to race them
			await Promise.all([
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should send error over error$ when Last is less than First',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} chart`;

			// Use an invalid filter, where Last is less than First
			const filter: SearchFilter = { entriesOffset: { index: 1, count: -1 }, dateRange: { start, end } };

			const search = await subscribeToOneExplorerSearch(query, { filter });

			// Non-error observables should error
			await Promise.all([
				expectAsync(lastValueFrom(search.progress$)).withContext('progress$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.entries$)).withContext('entries$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.stats$)).withContext('stats$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsOverview$)).withContext('statsOverview$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsZoom$)).withContext('statsZoom$ should error').toBeRejected(),
			]);

			// errors$ should emit one item (the error) and resolve
			const error = await lastValueFrom(search.errors$);

			expect(error).toBeDefined();
			expect(error.name.length).toBeGreaterThan(0);
			expect(error.message.length).toBeGreaterThan(0);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer and preview flag',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: 'preview',
			};
			const search = await subscribeToOneExplorerSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should be less than the total ingested for preview mode')
				.toBeLessThan(count);
			expect(textEntries.data.length).withContext('The number of entries should be more than zero').toBeGreaterThan(0);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			// Zip the results with the orignal, slicing the original to the length of the results, since
			// the preview flag limits the number of results we get back
			const trimmedOriginal = reversedData.slice(0, textEntries.data.length);
			expect(trimmedOriginal.length)
				.withContext('Lengths should match (sanity check)')
				.toEqual(textEntries.data.length);

			expect(
				zip(trimmedOriginal.slice(0, trimmedOriginal.length - 1), trimmedOriginal.slice(1)).reduce(
					(isDesc, [prev, cur]) => {
						if (prev === undefined || cur === undefined) {
							throw new Error('Zipped values were not the same length.');
						}
						return prev.value.foo > cur.value.foo && isDesc;
					},
					true,
				),
			)
				.withContext('original (trimmed and reversed) data should have values in descending order')
				.toBeTrue();

			expect(
				zip(textEntries.data.slice(0, textEntries.data.length - 1), textEntries.data.slice(1)).reduce(
					(isDesc, [prevEntry, curEntry]) => {
						if (prevEntry === undefined || curEntry === undefined) {
							throw new Error('Zipped values were not the same length.');
						}
						const prevValue: Entry = JSON.parse(base64.decode(prevEntry.data));
						const curValue: Entry = JSON.parse(base64.decode(curEntry.data));

						return prevValue.value.foo > curValue.value.foo && isDesc;
					},
					true,
				),
			)
				.withContext('received entry data should have values in descending order')
				.toBeTrue();

			zip(textEntries.data, trimmedOriginal).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail("All data should be defined, since we've sliced the original data to match the preview results");
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const [_timestamp, _value] = enumeratedValues;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value)
					.withContext(`Each entry should have an enumerated value called "value"`)
					.toEqual({
						isEnumerated: true,
						name: 'value',
						value: JSON.stringify(original.value),
					});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual({ foo: count - index - 1 });
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext(
					'The sum of counts from statsOverview should be less than the total count ingested in preview mode',
				)
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsOverview.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsOverview should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should be less than the total count ingested in preview mode')
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsZoom.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsZoom should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);

			// See if we can change the date range
			const lastEntriesP = lastValueFrom(
				search.entries$.pipe(
					takeWhile(e => datesAreEqual(e.start, start) === false, true),
					last(),
				),
			);
			search.setFilter({ dateRange: { start, end } });
			const lastEntries = await lastEntriesP;

			expect(datesAreEqual(lastEntries.start, start))
				.withContext(`Start date should be the one we just set`)
				.toBeTrue();
			expect(datesAreEqual(lastEntries.end, end)).withContext(`End date should be the one we just set`).toBeTrue();
		}),
		25000,
	);

	it(
		'Should keep the dateRange when update the filter multiple times',
		integrationTest(
			makeKeepDataRangeTest({
				start,
				end,
				count,
				createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
					const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);

					const query = `tag=*`;

					return await subscribeToOneExplorerSearch(query, { filter: initialFilter });
				},
			}),
		),
		25000,
	);
});
Example #7
Source File: subscribe-to-one-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('subscribeToOneSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: i };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const search = await subscribeToOneSearch(query, { filter: { dateRange: { start, end } } });

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer w/ count module',
		integrationTest(async () => {
			// Create a macro to expand to "value" to test .query vs .effectiveQuery
			const macroName = uuidv4().toUpperCase();
			const createOneMacro = makeCreateOneMacro(TEST_BASE_API_CONTEXT);
			const deleteOneMacro = makeDeleteOneMacro(TEST_BASE_API_CONTEXT);
			const createdMacro = await createOneMacro({ name: macroName, expansion: 'value' });

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json $${macroName} | count`;
			const effectiveQuery = `tag=${tag} json value | count`;
			const metadata = { test: 'abc' };
			const search = await subscribeToOneSearch(query, { metadata, filter: { dateRange: { start, end } } });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as TextSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const progressP = lastValueFrom(
				search.progress$.pipe(
					takeWhile(v => v < 100, true),
					toArray(),
				),
			);

			const statsP = lastValueFrom(search.stats$.pipe(takeWhile(s => !s.finished, true)));

			const [textEntries, progress, stats] = await Promise.all([textEntriesP, progressP, statsP]);

			////
			// Check stats
			////
			expect(stats.pipeline.length)
				.withContext('there should be two modules for this query: json and count')
				.toEqual(2);
			const [jsonModule, countModule] = stats.pipeline;

			expect(jsonModule.module).toEqual('json');
			expect(jsonModule.input.entries).withContext('json module should accept 100 entries of input').toEqual(count);
			expect(jsonModule.output.entries).withContext('json module should produce 100 entries of output').toEqual(count);

			expect(countModule.module).toEqual('count');
			expect(countModule.input.entries).withContext('count module should accept 100 entries of input').toEqual(count);
			expect(countModule.output.entries)
				.withContext('count module should produce 1 entry of output -- the count')
				.toEqual(1);

			expect(stats.metadata)
				.withContext('the search metadata should be present in the stats and unchanged')
				.toEqual(metadata);

			expect(stats.query).withContext(`Stats should contain the user query`).toBe(query);
			expect(stats.effectiveQuery).withContext(`Stats should contain the effective query`).toBe(effectiveQuery);

			expect(stats.downloadFormats.sort())
				.withContext(`Download formats should include .json', .text', .csv' and .archive`)
				.toEqual(['archive', 'csv', 'json', 'text']);

			////
			// Check progress
			////
			if (progress.length > 1) {
				expect(progress[0].valueOf())
					.withContext('If more than one progress was emitted, the first should be 0')
					.toEqual(0);
			}
			expect(lastElt(progress)?.valueOf()).withContext('The last progress emitted should be 100%').toEqual(100);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('There should be only one entry, since we used the count module')
				.toEqual(1);
			const lastEntry = textEntries.data[0];
			expect(lastEntry).toBeDefined();
			expect(base64.decode(lastEntry.data))
				.withContext('The total count of entries should equal what we ingested')
				.toEqual(`count ${count}`);

			await deleteOneMacro(createdMacro.id);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
				const _value = enumeratedValues.find(v => v.name === 'value')!;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			expect(stats[0].tags).withContext('Tag should match tag from query').toEqual([tag]);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it(
		'Should treat multiple searches with the same query independently',
		integrationTest(async () => {
			// Number of multiple searches to create at the same time
			const SEARCHES_N = 4;

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searches = await Promise.all(
				Array.from({ length: SEARCHES_N }).map(() => subscribeToOneSearch(query, { filter })),
			);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			const testsP = searches.map(async (search, i) => {
				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length)
					.withContext('The number of entries should equal the total ingested')
					.toEqual(count);

				if (isUndefined(textEntries.filter) === false) {
					expect(textEntries.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
					if (isUndefined(entry) || isUndefined(original)) {
						fail('Exptected all entries and original data to be defined');
						return;
					}

					const value: Entry = JSON.parse(base64.decode(entry.data));
					const enumeratedValues = entry.values;
					const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
					const _value = enumeratedValues.find(v => v.name === 'value')!;

					expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
						isEnumerated: true,
						name: 'timestamp',
						value: original.timestamp,
					});

					expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
						isEnumerated: true,
						name: 'value',
						value: original.value.toString(),
					});

					expect(value.value)
						.withContext('Each value should match its index, descending')
						.toEqual(count - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).toBeGreaterThan(0);

				if (isUndefined(stats[0].filter) === false) {
					expect(stats[0].filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
			});

			await Promise.all(testsP);
		}),
		25000,
	);

	it(
		'Should reject on a bad query string',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `this is an invalid query`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			await expectAsync(subscribeToOneSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject on a bad query range (end is before start)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: { start, end: subMinutes(start, 10) },
			};

			await expectAsync(subscribeToOneSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (different queries)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions with different queries to race them
			await Promise.all([
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "a"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "b"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "c"`, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "d"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "e"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (same query)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions to race them
			await Promise.all([
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should work with several searches initiated simultaneously',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			// Start a bunch of search subscriptions to race them
			await Promise.all(
				rangeLeft(0, 20).map(x =>
					expectAsync(subscribeToOneSearch(`tag=${tag} regex ${x}`, { filter }))
						.withContext('good query should resolve')
						.toBeResolved(),
				),
			);
		}),
		25000,
	);

	it(
		'Should send error over error$ when Last is less than First',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} chart`;

			// Use an invalid filter, where Last is less than First
			const filter: SearchFilter = { entriesOffset: { index: 1, count: -1 }, dateRange: { start, end } };

			const search = await subscribeToOneSearch(query, { filter });

			// Non-error observables should error
			await Promise.all([
				expectAsync(lastValueFrom(search.progress$)).withContext('progress$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.entries$)).withContext('entries$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.stats$)).withContext('stats$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsOverview$)).withContext('statsOverview$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsZoom$)).withContext('statsZoom$ should error').toBeRejected(),
			]);

			// errors$ should emit one item (the error) and resolve
			const error = await lastValueFrom(search.errors$);

			expect(error).toBeDefined();
			expect(error.name.length).toBeGreaterThan(0);
			expect(error.message.length).toBeGreaterThan(0);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer and preview flag',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: 'preview',
			};
			const search = await subscribeToOneSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should be less than the total ingested for preview mode')
				.toBeLessThan(count);
			expect(textEntries.data.length).withContext('The number of entries should be more than zero').toBeGreaterThan(0);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			// Zip the results with the orignal, slicing the original to the length of the results, since
			// the preview flag limits the number of results we get back
			const trimmedOriginal = reversedData.slice(0, textEntries.data.length);
			expect(trimmedOriginal.length)
				.withContext('Lengths should match (sanity check)')
				.toEqual(textEntries.data.length);

			zip(textEntries.data, trimmedOriginal).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail("All data should be defined, since we've sliced the original data to match the preview results");
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const [_timestamp, _value] = enumeratedValues;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext(
					'The sum of counts from statsOverview should be less than the total count ingested in preview mode',
				)
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsOverview.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsOverview should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should be less than the total count ingested in preview mode')
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsZoom.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsZoom should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);

			// See if we can change the date range
			const lastEntriesP = lastValueFrom(
				search.entries$.pipe(takeWhile(e => datesAreEqual(e.start, start) === false, true)),
			);
			search.setFilter({ dateRange: { start, end } });
			const lastEntries = await lastEntriesP;

			expect(datesAreEqual(lastEntries.start, start))
				.withContext(`Start date should be the one we just set`)
				.toBeTrue();
			expect(datesAreEqual(lastEntries.end, end)).withContext(`End date should be the one we just set`).toBeTrue();
		}),
		25000,
	);

	describe('stats', () => {
		it(
			'Should be evenly spread over a window matching the zoom/overview granularity',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const minutes = 90;
				const dateRange = { start, end: addMinutes(start, minutes) };
				const search = await subscribeToOneSearch(query, { filter: { dateRange } });

				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length).withContext("Should be 90 entries since it's a 90 minute window").toEqual(90);
				textEntries.data.forEach((entry, index) => {
					const value: Entry = JSON.parse(base64.decode(entry.data));
					expect(value.value).toEqual(minutes - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).withContext('expect to receive >0 stats from the stats observable').toBeGreaterThan(0);
				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsOverview should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsOverview.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsOverview element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsZoom should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsZoom.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsZoom element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 640;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than the total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should provide the minimum zoom window',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);

				const dateRange = { start, end };

				// Issue a query where the minzoomwindow is predictable (1 second)
				const query1s = `tag=${tag} json value | stats mean(value) over 1s`;
				const filter1s: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange };
				const search1s = await subscribeToOneSearch(query1s, { filter: filter1s });

				const stats1s = await lastValueFrom(search1s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats1s.minZoomWindow).toEqual(1);
				if (isUndefined(stats1s.filter) === false) {
					expect(stats1s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1s);
				}

				// Issue a query where the minzoomwindow is predictable (33 seconds, why not)
				const query33s = `tag=${tag} json value | stats mean(value) over 33s`;
				const filter33s = { entriesOffset: { index: 0, count: count }, dateRange };
				const search33s = await subscribeToOneSearch(query33s, { filter: filter33s });

				const stats33s = await lastValueFrom(search33s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats33s.minZoomWindow).toEqual(33);
				if (isUndefined(stats33s.filter) === false) {
					expect(stats33s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter33s);
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter1);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should keep the dateRange when update the filter multiple times',
			integrationTest(
				makeKeepDataRangeTest({
					start,
					end,
					count,
					createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
						const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);

						const query = `tag=*`;

						return await subscribeToOneSearch(query, { filter: initialFilter });
					},
				}),
			),
			25000,
		);
	});
});
Example #8
Source File: index.tsx    From admin with MIT License 4 votes vote down vote up
OrderDetails = ({ id }) => {
  const dialog = useImperativeDialog()

  const [addressModal, setAddressModal] = useState<null | {
    address: Address
    type: "billing" | "shipping"
  }>(null)

  const [emailModal, setEmailModal] = useState<null | {
    email: string
  }>(null)

  const [showFulfillment, setShowFulfillment] = useState(false)
  const [showRefund, setShowRefund] = useState(false)
  const [fullfilmentToShip, setFullfilmentToShip] = useState(null)

  const { order, isLoading } = useAdminOrder(id)

  const capturePayment = useAdminCapturePayment(id)
  const cancelOrder = useAdminCancelOrder(id)
  const updateOrder = useAdminUpdateOrder(id)

  // @ts-ignore
  const { region } = useAdminRegion(order?.region_id, {
    enabled: !!order?.region_id,
  })

  const notification = useNotification()

  const [, handleCopy] = useClipboard(`${order?.display_id!}`, {
    successDuration: 5500,
    onCopied: () => notification("Success", "Order ID copied", "success"),
  })

  const [, handleCopyEmail] = useClipboard(order?.email!, {
    successDuration: 5500,
    onCopied: () => notification("Success", "Email copied", "success"),
  })

  // @ts-ignore
  useHotkeys("esc", () => navigate("/a/orders"))
  useHotkeys("command+i", handleCopy)

  const {
    hasMovements,
    swapAmount,
    manualRefund,
    swapRefund,
    returnRefund,
  } = useMemo(() => {
    let manualRefund = 0
    let swapRefund = 0
    let returnRefund = 0

    const swapAmount = sum(order?.swaps.map((s) => s.difference_due) || [0])

    if (order?.refunds?.length) {
      order.refunds.forEach((ref) => {
        if (ref.reason === "other" || ref.reason === "discount") {
          manualRefund += ref.amount
        }
        if (ref.reason === "return") {
          returnRefund += ref.amount
        }
        if (ref.reason === "swap") {
          swapRefund += ref.amount
        }
      })
    }
    return {
      hasMovements: swapAmount + manualRefund + swapRefund + returnRefund !== 0,
      swapAmount,
      manualRefund,
      swapRefund,
      returnRefund,
    }
  }, [order])

  const handleDeleteOrder = async () => {
    const shouldDelete = await dialog({
      heading: "Cancel order",
      text: "Are you sure you want to cancel the order?",
    })

    if (!shouldDelete) {
      return
    }

    return cancelOrder.mutate(void {}, {
      onSuccess: () =>
        notification("Success", "Successfully canceled order", "success"),
      onError: (err) => notification("Error", getErrorMessage(err), "error"),
    })
  }

  const handleUpdateAddress = async ({ data, type }) => {
    const updateObj = {}

    if (type === "shipping") {
      updateObj["shipping_address"] = {
        ...data,
      }
    } else {
      updateObj["billing_address"] = {
        ...data,
      }
    }

    return updateOrder.mutate(updateObj, {
      onSuccess: () => {
        notification("Success", "Successfully updated address", "success")
        setAddressModal(null)
      },
      onError: (err) => notification("Error", getErrorMessage(err), "error"),
    })
  }

  const handleUpdateEmail = async ({ email }) => {
    const updateObj = email ? { email } : {}

    return updateOrder.mutate(updateObj, {
      onSuccess: () => {
        notification(
          "Success",
          "Successfully updated the email address",
          "success"
        )
        setEmailModal(null)
      },
      onError: (err) => notification("Error", getErrorMessage(err), "error"),
    })
  }

  const allFulfillments = gatherAllFulfillments(order)

  const customerActionables = [
    {
      label: "Edit Shipping Address",
      icon: <TruckIcon size={"20"} />,
      onClick: () =>
        setAddressModal({
          address: order?.shipping_address,
          type: "shipping",
        }),
    },
    {
      label: "Go to Customer",
      icon: <DetailsIcon size={"20"} />,
      onClick: () => navigate(`/a/customers/${order?.customer.id}`),
    },
  ]

  if (order?.billing_address) {
    customerActionables.push({
      label: "Edit Billing Address",
      icon: <DollarSignIcon size={"20"} />,
      onClick: () => {
        if (order.billing_address) {
          setAddressModal({
            address: order?.billing_address,
            type: "billing",
          })
        }
      },
    })
  }

  if (order?.email) {
    customerActionables.push({
      label: "Edit Email Address",
      icon: <MailIcon size={"20"} />,
      onClick: () => {
        setEmailModal({
          email: order?.email,
        })
      },
    })
  }

  return (
    <div>
      <Breadcrumb
        currentPage={"Order Details"}
        previousBreadcrumb={"Orders"}
        previousRoute="/a/orders"
      />
      {isLoading || !order ? (
        <BodyCard className="w-full pt-2xlarge flex items-center justify-center">
          <Spinner size={"large"} variant={"secondary"} />
        </BodyCard>
      ) : (
        <div className="flex space-x-4">
          <div className="flex flex-col w-7/12 h-full">
            <BodyCard
              className={"w-full mb-4 min-h-[200px]"}
              customHeader={
                <Tooltip side="top" content={"Copy ID"}>
                  <button
                    className="inter-xlarge-semibold text-grey-90 active:text-violet-90 cursor-pointer gap-x-2 flex items-center"
                    onClick={handleCopy}
                  >
                    #{order.display_id} <ClipboardCopyIcon size={16} />
                  </button>
                </Tooltip>
              }
              subtitle={moment(order.created_at).format("d MMMM YYYY hh:mm a")}
              status={<OrderStatusComponent status={order?.status} />}
              forceDropdown={true}
              actionables={[
                {
                  label: "Cancel Order",
                  icon: <CancelIcon size={"20"} />,
                  variant: "danger",
                  onClick: () => handleDeleteOrder(),
                },
              ]}
            >
              <div className="flex mt-6 space-x-6 divide-x">
                <div className="flex flex-col">
                  <div className="inter-smaller-regular text-grey-50 mb-1">
                    Email
                  </div>
                  <button
                    className="text-grey-90 active:text-violet-90 cursor-pointer gap-x-1 flex items-center"
                    onClick={handleCopyEmail}
                  >
                    {order?.email}
                    <ClipboardCopyIcon size={12} />
                  </button>
                </div>
                <div className="flex flex-col pl-6">
                  <div className="inter-smaller-regular text-grey-50 mb-1">
                    Phone
                  </div>
                  <div>{order?.shipping_address?.phone || "N/A"}</div>
                </div>
                <div className="flex flex-col pl-6">
                  <div className="inter-smaller-regular text-grey-50 mb-1">
                    Payment
                  </div>
                  <div>
                    {order?.payments
                      ?.map((p) => capitalize(p.provider_id))
                      .join(", ")}
                  </div>
                </div>
              </div>
            </BodyCard>
            <BodyCard className={"w-full mb-4 min-h-0 h-auto"} title="Summary">
              <div className="mt-6">
                {order?.items?.map((item, i) => (
                  <OrderLine key={i} item={item} region={order?.region} />
                ))}
                <DisplayTotal
                  currency={order?.currency_code}
                  totalAmount={order?.subtotal}
                  totalTitle={"Subtotal"}
                />
                {order?.discounts?.map((discount, index) => (
                  <DisplayTotal
                    key={index}
                    currency={order?.currency_code}
                    totalAmount={-1 * order?.discount_total}
                    totalTitle={
                      <div className="flex inter-small-regular text-grey-90 items-center">
                        Discount:{" "}
                        <Badge className="ml-3" variant="default">
                          {discount.code}
                        </Badge>
                      </div>
                    }
                  />
                ))}
                {order?.gift_cards?.map((giftCard, index) => (
                  <DisplayTotal
                    key={index}
                    currency={order?.currency_code}
                    totalAmount={-1 * order?.gift_card_total}
                    totalTitle={
                      <div className="flex inter-small-regular text-grey-90 items-center">
                        Gift card:{" "}
                        <Badge className="ml-3" variant="default">
                          {giftCard.code}
                        </Badge>
                        <div className="ml-2">
                          <CopyToClipboard
                            value={giftCard.code}
                            showValue={false}
                            iconSize={16}
                          />
                        </div>
                      </div>
                    }
                  />
                ))}
                <DisplayTotal
                  currency={order?.currency_code}
                  totalAmount={order?.shipping_total}
                  totalTitle={"Shipping"}
                />
                <DisplayTotal
                  currency={order?.currency_code}
                  totalAmount={order?.tax_total}
                  totalTitle={`Tax`}
                />
                <DisplayTotal
                  variant={"large"}
                  currency={order?.currency_code}
                  totalAmount={order?.total}
                  totalTitle={hasMovements ? "Original Total" : "Total"}
                />
                <PaymentDetails
                  manualRefund={manualRefund}
                  swapAmount={swapAmount}
                  swapRefund={swapRefund}
                  returnRefund={returnRefund}
                  paidTotal={order?.paid_total}
                  refundedTotal={order?.refunded_total}
                  currency={order?.currency_code}
                />
              </div>
            </BodyCard>
            <BodyCard
              className={"w-full mb-4 min-h-0 h-auto"}
              title="Payment"
              status={<PaymentStatusComponent status={order?.payment_status} />}
              customActionable={
                <PaymentActionables
                  order={order}
                  capturePayment={capturePayment}
                  showRefundMenu={() => setShowRefund(true)}
                />
              }
            >
              <div className="mt-6">
                {order?.payments.map((payment) => (
                  <div className="flex flex-col">
                    <DisplayTotal
                      currency={order?.currency_code}
                      totalAmount={payment?.amount}
                      totalTitle={payment.id}
                      subtitle={`${moment(payment?.created_at).format(
                        "DD MMM YYYY hh:mm"
                      )}`}
                    />
                    {!!payment.amount_refunded && (
                      <div className="flex justify-between mt-4">
                        <div className="flex">
                          <div className="text-grey-40 mr-2">
                            <CornerDownRightIcon />
                          </div>
                          <div className="inter-small-regular text-grey-90">
                            Refunded
                          </div>
                        </div>
                        <div className="flex">
                          <div className="inter-small-regular text-grey-90 mr-3">
                            -
                            {formatAmountWithSymbol({
                              amount: payment?.amount_refunded,
                              currency: order?.currency_code,
                            })}
                          </div>
                          <div className="inter-small-regular text-grey-50">
                            {order?.currency_code.toUpperCase()}
                          </div>
                        </div>
                      </div>
                    )}
                  </div>
                ))}
                <div className="flex justify-between mt-4">
                  <div className="inter-small-semibold text-grey-90">
                    Total Paid
                  </div>
                  <div className="flex">
                    <div className="inter-small-semibold text-grey-90 mr-3">
                      {formatAmountWithSymbol({
                        amount: order?.paid_total - order?.refunded_total,
                        currency: order?.currency_code,
                      })}
                    </div>
                    <div className="inter-small-regular text-grey-50">
                      {order?.currency_code.toUpperCase()}
                    </div>
                  </div>
                </div>
              </div>
            </BodyCard>
            <BodyCard
              className={"w-full mb-4 min-h-0 h-auto"}
              title="Fulfillment"
              status={
                <FulfillmentStatusComponent
                  status={order?.fulfillment_status}
                />
              }
              customActionable={
                order.fulfillment_status !== "fulfilled" &&
                order.status !== "canceled" &&
                order.fulfillment_status !== "shipped" && (
                  <Button
                    variant="secondary"
                    size="small"
                    onClick={() => setShowFulfillment(true)}
                  >
                    Create Fulfillment
                  </Button>
                )
              }
            >
              <div className="mt-6">
                {order?.shipping_methods.map((method) => (
                  <div className="flex flex-col">
                    <span className="inter-small-regular text-grey-50">
                      Shipping Method
                    </span>
                    <span className="inter-small-regular text-grey-90 mt-2">
                      {method?.shipping_option?.name || ""}
                    </span>
                    <div className="flex flex-col min-h-[100px] mt-8 bg-grey-5 px-3 py-2 h-full">
                      <span className="inter-base-semibold">
                        Data{" "}
                        <span className="text-grey-50 inter-base-regular">
                          (1 item)
                        </span>
                      </span>
                      <div className="flex flex-grow items-center mt-4">
                        <ReactJson
                          name={false}
                          collapsed={true}
                          src={method?.data}
                        />
                      </div>
                    </div>
                  </div>
                ))}
                <div className="mt-6 inter-small-regular ">
                  {allFulfillments.map((fulfillmentObj, i) => (
                    <FormattedFulfillment
                      key={i}
                      order={order}
                      fulfillmentObj={fulfillmentObj}
                      setFullfilmentToShip={setFullfilmentToShip}
                    />
                  ))}
                </div>
              </div>
            </BodyCard>
            <BodyCard
              className={"w-full mb-4 min-h-0 h-auto"}
              title="Customer"
              actionables={customerActionables}
            >
              <div className="mt-6">
                <div className="flex w-full space-x-4 items-center">
                  <div className="flex w-[40px] h-[40px] ">
                    <Avatar
                      user={order?.customer}
                      font="inter-large-semibold"
                      color="bg-fuschia-40"
                    />
                  </div>
                  <div>
                    <h1 className="inter-large-semibold text-grey-90">
                      {`${order?.shipping_address.first_name} ${order?.shipping_address.last_name}`}
                    </h1>
                    <span className="inter-small-regular text-grey-50">
                      {order?.shipping_address.city},{" "}
                      {order?.shipping_address.country_code}
                    </span>
                  </div>
                </div>
                <div className="flex mt-6 space-x-6 divide-x">
                  <div className="flex flex-col">
                    <div className="inter-small-regular text-grey-50 mb-1">
                      Contact
                    </div>
                    <div className="flex flex-col inter-small-regular">
                      <span>{order?.email}</span>
                      <span>{order?.shipping_address?.phone || ""}</span>
                    </div>
                  </div>
                  <FormattedAddress
                    title={"Shipping"}
                    addr={order?.shipping_address}
                  />
                  <FormattedAddress
                    title={"Billing"}
                    addr={order?.billing_address}
                  />
                </div>
              </div>
            </BodyCard>
            <div className="mt-large">
              <RawJSON data={order} title="Raw order" />
            </div>
          </div>
          <Timeline orderId={order.id} />
        </div>
      )}
      {addressModal && (
        <AddressModal
          handleClose={() => setAddressModal(null)}
          handleSave={(obj) => handleUpdateAddress(obj)}
          address={addressModal.address}
          type={addressModal.type}
          allowedCountries={region?.countries}
        />
      )}
      {emailModal && (
        <EmailModal
          handleClose={() => setEmailModal(null)}
          handleSave={(obj) => handleUpdateEmail(obj)}
          email={emailModal.email}
        />
      )}
      {showFulfillment && order && (
        <CreateFulfillmentModal
          orderToFulfill={order as any}
          handleCancel={() => setShowFulfillment(false)}
          orderId={order.id}
        />
      )}
      {showRefund && order && (
        <CreateRefundModal
          order={order}
          onDismiss={() => setShowRefund(false)}
        />
      )}
      {fullfilmentToShip && order && (
        <MarkShippedModal
          orderToShip={order as any}
          handleCancel={() => setFullfilmentToShip(null)}
          fulfillment={fullfilmentToShip}
          orderId={order.id}
        />
      )}
    </div>
  )
}