From 3472ad20bd2e34322dcb4ea32fb68319942b4f5b Mon Sep 17 00:00:00 2001 From: kukabi Date: Thu, 14 Sep 2023 14:07:02 +0300 Subject: [PATCH] Tests completed. --- jest.config.js | 2 +- src/client/data/data-store.ts | 38 +++--- .../service/rpc/rpc-subscription-service.ts | 5 +- src/client/util/format.ts | 6 +- tests/data-store.test.ts | 125 ++++++++++++++++++ tests/event-bus.test.ts | 27 ++++ tests/format.test.ts | 18 +++ tests/identicon.test.ts | 13 ++ tests/object.test.ts | 19 +++ tests/rpc-subscription-service.test.ts | 81 ++++++++++++ tests/ui-util.test.ts | 14 ++ 11 files changed, 328 insertions(+), 20 deletions(-) create mode 100644 tests/event-bus.test.ts create mode 100644 tests/format.test.ts create mode 100644 tests/identicon.test.ts create mode 100644 tests/object.test.ts create mode 100644 tests/rpc-subscription-service.test.ts create mode 100644 tests/ui-util.test.ts diff --git a/jest.config.js b/jest.config.js index 5d5d390..a5e01ff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,5 +2,5 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testTimeout: 15_000, + testTimeout: 30_000, }; diff --git a/src/client/data/data-store.ts b/src/client/data/data-store.ts index 25fb37c..b37d67d 100644 --- a/src/client/data/data-store.ts +++ b/src/client/data/data-store.ts @@ -436,7 +436,7 @@ class DataStore { * @param header new block header * @param done completion callback */ - private async processNewBlock(header: Header, done: AsyncLock.AsyncLockDoneCallback) { + private async processNewBlock(header: Header, done?: AsyncLock.AsyncLockDoneCallback) { if ( this.blocks.findIndex((block) => block.block.header.toHex() == header.hash.toHex()) >= 0 ) { @@ -450,7 +450,9 @@ class DataStore { while (this.blocks.length > Constants.MAX_BLOCK_COUNT) { this.eventBus.dispatch(ChainvizEvent.DISCARDED_BLOCK, this.blocks.pop()); } - done(); + if (done) { + done(); + } } /** @@ -481,7 +483,7 @@ class DataStore { */ private async processFinalizedBlock( header: Header, - done: AsyncLock.AsyncLockDoneCallback, + done?: AsyncLock.AsyncLockDoneCallback, ) { // find unfinalized blocks before this one & discard & finalize const removeIndices: number[] = []; @@ -499,18 +501,20 @@ class DataStore { this.eventBus.dispatch(ChainvizEvent.DISCARDED_BLOCK, removed[0]); } - let number = header.number.toNumber() - 1; - while ( - this.blocks.findIndex((block) => block.block.header.number.toNumber() == number) < 0 - ) { - const block = await this.getBlockByNumber(number); - if (block) { - block.isFinalized = true; - this.insertBlock(block); - this.eventBus.dispatch(ChainvizEvent.FINALIZED_BLOCK, block); - number--; - } else { - break; + if (this.blocks.length > 0) { + let number = header.number.toNumber() - 1; + while ( + this.blocks.findIndex((block) => block.block.header.number.toNumber() == number) < 0 + ) { + const block = await this.getBlockByNumber(number); + if (block) { + block.isFinalized = true; + this.insertBlock(block); + this.eventBus.dispatch(ChainvizEvent.FINALIZED_BLOCK, block); + number--; + } else { + break; + } } } @@ -528,7 +532,9 @@ class DataStore { this.eventBus.dispatch(ChainvizEvent.FINALIZED_BLOCK, block); } } - done(); + if (done) { + done(); + } } /** diff --git a/src/client/service/rpc/rpc-subscription-service.ts b/src/client/service/rpc/rpc-subscription-service.ts index 16dc38e..5fbf7c8 100644 --- a/src/client/service/rpc/rpc-subscription-service.ts +++ b/src/client/service/rpc/rpc-subscription-service.ts @@ -1,5 +1,6 @@ import ReconnectingWebSocket from 'reconnecting-websocket'; import camelcaseKeysDeep from 'camelcase-keys-deep'; +import ws from 'ws'; /** * JSON-RPC 2.0 request. @@ -79,7 +80,7 @@ class RPCSubscriptionService { private onMessage(event: MessageEvent) { const json = JSON.parse(event.data); if (Object.prototype.hasOwnProperty.call(json, 'result')) { - if (isNaN(json['result'])) { + if (isNaN(parseFloat(json['result']))) { this.state = RPCSubscriptionServiceState.Connected; this.listener.onUnsubscribed(this.subscriptionId); this.subscriptionId = 0; @@ -102,7 +103,7 @@ class RPCSubscriptionService { connect() { this.connection = new ReconnectingWebSocket(this.url, [], { - // WebSocket: WebSocket, + WebSocket: ws, connectionTimeout: 5000, }); this.connection.onopen = () => { diff --git a/src/client/util/format.ts b/src/client/util/format.ts index 2d7aeb9..32b8681 100644 --- a/src/client/util/format.ts +++ b/src/client/util/format.ts @@ -38,7 +38,11 @@ function formatNumber( } const decimalPart = formatted.substring(formatted.length - formatDecimals); - formatted = `${integerPart}${Constants.DECIMAL_SEPARATOR}${decimalPart}`; + if (decimalPart.length > 0) { + formatted = `${integerPart}${Constants.DECIMAL_SEPARATOR}${decimalPart}`; + } else { + formatted = integerPart; + } if (ticker) { return `${formatted} ${ticker}`; } else { diff --git a/tests/data-store.test.ts b/tests/data-store.test.ts index 906293b..f0c4d44 100644 --- a/tests/data-store.test.ts +++ b/tests/data-store.test.ts @@ -197,4 +197,129 @@ describe('data store', () => { expect(block!.events.length).toBe(44); await dataStore.disconnectSubstrateClient(); }); + test('inserts blocks at correct indices', async () => { + const dataStore = new DataStore(); + await dataStore.setNetwork(Kusama); + await dataStore.connectSubstrateRPC(); + const block10 = await dataStore.getBlockByNumber(10); + dataStore['insertBlock'](block10!); + const block30 = await dataStore.getBlockByNumber(30); + dataStore['insertBlock'](block30!); + const block20 = await dataStore.getBlockByNumber(20); + dataStore['insertBlock'](block20!); + const block15 = await dataStore.getBlockByNumber(15); + dataStore['insertBlock'](block15!); + expect(dataStore['blocks'][0].block.header.number.toNumber()).toBe(30); + expect(dataStore['blocks'][1].block.header.number.toNumber()).toBe(20); + expect(dataStore['blocks'][2].block.header.number.toNumber()).toBe(15); + expect(dataStore['blocks'][3].block.header.number.toNumber()).toBe(10); + }); + test('discards older blocks', async () => { + const dataStore = new DataStore(); + await dataStore.setNetwork(Kusama); + await dataStore.connectSubstrateRPC(); + const initialBlockNumber = 10; + const excessBlockCount = 5; + for (let i = 0; i < Constants.MAX_BLOCK_COUNT + 5; i++) { + const block = await dataStore.getBlockByNumber(initialBlockNumber + i); + dataStore['insertBlock'](block!); + } + for (let i = 0; i < excessBlockCount; i++) { + const block = await dataStore.getBlockByNumber(10 + Constants.MAX_BLOCK_COUNT + i); + dataStore['processNewBlock'](block!.block.header); + } + expect(dataStore['blocks'].length).toBe(Constants.MAX_BLOCK_COUNT); + }); + test('gets missing finalized blocks', async () => { + const dataStore = new DataStore(); + await dataStore.setNetwork(Kusama); + await dataStore.connectSubstrateRPC(); + const eventBus = EventBus.getInstance(); + let finalizedBlockEventCount = 0; + eventBus.register(ChainvizEvent.FINALIZED_BLOCK, (_block: Block) => { + finalizedBlockEventCount++; + }); + const initialBlockNumber = 10; + const firstBlock = await dataStore.getBlockByNumber(initialBlockNumber); + firstBlock!.isFinalized = true; + dataStore['insertBlock'](firstBlock!); + const lastBlock = await dataStore.getBlockByNumber(initialBlockNumber + 5); + await dataStore['processFinalizedBlock'](lastBlock!.block.header); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + expect(finalizedBlockEventCount).toBe(5); + }); + test('removes unfinalized blocks and replaces with finalized blocks', async () => { + const dataStore = new DataStore(); + await dataStore.setNetwork(Kusama); + await dataStore.connectSubstrateRPC(); + const eventBus = EventBus.getInstance(); + let discardedBlockEventCount = 0; + eventBus.register(ChainvizEvent.DISCARDED_BLOCK, (_block: Block) => { + discardedBlockEventCount++; + }); + let finalizedBlockEventCount = 0; + eventBus.register(ChainvizEvent.FINALIZED_BLOCK, (_block: Block) => { + finalizedBlockEventCount++; + }); + const block10 = await dataStore.getBlockByNumber(10); + block10!.isFinalized = true; + dataStore['insertBlock'](block10!); + const block11 = await dataStore.getBlockByNumber(11); + dataStore['insertBlock'](block11!); + const block12 = await dataStore.getBlockByNumber(12); + dataStore['insertBlock'](block12!); + const block13 = await dataStore.getBlockByNumber(13); + await dataStore['processFinalizedBlock'](block13!.block.header); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + expect(discardedBlockEventCount).toBe(2); + expect(finalizedBlockEventCount).toBe(3); + }); + test('only removes unfinalized blocks if no finalized blocks prior', async () => { + const dataStore = new DataStore(); + await dataStore.setNetwork(Kusama); + await dataStore.connectSubstrateRPC(); + const eventBus = EventBus.getInstance(); + let discardedBlockEventCount = 0; + eventBus.register(ChainvizEvent.DISCARDED_BLOCK, (_block: Block) => { + discardedBlockEventCount++; + }); + let finalizedBlockEventCount = 0; + eventBus.register(ChainvizEvent.FINALIZED_BLOCK, (_block: Block) => { + finalizedBlockEventCount++; + }); + const block10 = await dataStore.getBlockByNumber(10); + dataStore['insertBlock'](block10!); + const block11 = await dataStore.getBlockByNumber(11); + dataStore['insertBlock'](block11!); + const block12 = await dataStore.getBlockByNumber(12); + dataStore['insertBlock'](block12!); + const block13 = await dataStore.getBlockByNumber(13); + await dataStore['processFinalizedBlock'](block13!.block.header); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + expect(discardedBlockEventCount).toBe(3); + expect(finalizedBlockEventCount).toBe(1); + }); + test('can get XCM transfers', async () => { + const dataStore = new DataStore(); + await dataStore.setNetwork(Kusama); + await dataStore.getXCMTransfers(); + expect(dataStore.xcmTransfers.length).toBeGreaterThan(0); + expect(dataStore.xcmTransfers.length).toBeLessThanOrEqual(Constants.XCM_DISPLAY_LIMIT); + for (const xcmTransfer of dataStore.xcmTransfers) { + expect(xcmTransfer.relayChain.relayChain).toBe(Kusama.id); + expect(xcmTransfer.origination).toBeDefined(); + expect(xcmTransfer.destination).toBeDefined(); + } + clearTimeout(dataStore['xcmTransferGetTimeout']); + }); + test('can get XCM transfer by origin hash', async () => { + const dataStore = new DataStore(); + await dataStore.setNetwork(Kusama); + await dataStore.getXCMTransfers(); + const xcmTransfer = dataStore.getXCMTransferByOriginExtrinsicHash( + dataStore.xcmTransfers[0].origination.extrinsicHash, + ); + expect(xcmTransfer).toBeDefined(); + clearTimeout(dataStore['xcmTransferGetTimeout']); + }); }); diff --git a/tests/event-bus.test.ts b/tests/event-bus.test.ts new file mode 100644 index 0000000..b104ce4 --- /dev/null +++ b/tests/event-bus.test.ts @@ -0,0 +1,27 @@ +import { EventBus } from '../src/client/event/event-bus'; +import { describe, expect, test } from '@jest/globals'; + +interface EventObject { + a: number; + b: string; + c: bigint; +} + +describe('event bus', () => { + test('pub/sub works', async () => { + const eventBus = EventBus.getInstance(); + const eventName = 'some_event'; + const eventObject: EventObject = { + a: 1, + b: 'two', + c: 3n, + }; + let eventReceived = false; + eventBus.register(eventName, (receivedEventObject: EventObject) => { + eventReceived = eventObject == receivedEventObject; + }); + eventBus.dispatch(eventName, eventObject); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(eventReceived).toBeTruthy(); + }); +}); diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..25a656e --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,18 @@ +import { Kusama } from '../src/client/model/substrate/network'; +import { formatNumber } from '../src/client/util/format'; +import { describe, expect, test } from '@jest/globals'; + +describe('format', () => { + test('number formatting works with ticker', async () => { + const formatted = formatNumber(123456789n, 5, 2, Kusama.tokenTicker); + expect(formatted).toBe('1,234.56 KSM'); + }); + test('number formatting works without ticker', async () => { + const formatted = formatNumber(123456789n, 5, 2); + expect(formatted).toBe('1,234.56'); + }); + test('number formatting works with no format decimals', async () => { + const formatted = formatNumber(123456789n, 5, 0); + expect(formatted).toBe('1,234'); + }); +}); diff --git a/tests/identicon.test.ts b/tests/identicon.test.ts new file mode 100644 index 0000000..305ac11 --- /dev/null +++ b/tests/identicon.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from '@jest/globals'; +import { generateIdenticonSVGHTML } from '../src/client/util/identicon'; + +describe('identicon', () => { + test('generates correct identicon', async () => { + const expectedSVG = ``; + const generatedSVG = generateIdenticonSVGHTML( + '21vLqCuvXweuKw9nw6qfAQnFkmBnxLWA3RU5cMBGuzsdEJ4A', + 25, + ); + expect(generatedSVG).toBe(expectedSVG); + }); +}); diff --git a/tests/object.test.ts b/tests/object.test.ts new file mode 100644 index 0000000..ed32b79 --- /dev/null +++ b/tests/object.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from '@jest/globals'; +import { cloneJSONSafeObject } from '../src/client/util/object'; +import { validators } from './data/validators'; + +// prettier-ignore +(BigInt.prototype as any).toJSON = function () { // eslint-disable-line @typescript-eslint/no-explicit-any + return this.toString(); +}; + +describe('object', () => { + test('clone object works', async () => { + const cloneValidator1 = cloneJSONSafeObject(validators[0]); + expect(JSON.stringify(cloneValidator1)).toEqual(JSON.stringify(validators[0])); + }); + test('clone array works', async () => { + const cloneValidators = cloneJSONSafeObject(validators); + expect(JSON.stringify(cloneValidators)).toEqual(JSON.stringify(validators)); + }); +}); diff --git a/tests/rpc-subscription-service.test.ts b/tests/rpc-subscription-service.test.ts new file mode 100644 index 0000000..c1b5e8a --- /dev/null +++ b/tests/rpc-subscription-service.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test, jest } from '@jest/globals'; +import { + RPCSubscriptionService, + RPCSubscriptionServiceListener, +} from '../src/client/service/rpc/rpc-subscription-service'; +import { NetworkStatusUpdate } from '../src/client/model/subvt/network-status'; +import { Kusama } from '../src/client/model/substrate/network'; +import { ValidatorListUpdate } from '../src/client/model/subvt/validator-summary'; + +describe('rpc subscription service', () => { + test('network status service subscription works', async () => { + const onSubscribed = jest.fn((_subscriptionId: number) => {}); + const onUnsubscribed = jest.fn((_subscriptionId: number) => {}); + const onDisconnected = jest.fn(); + const onUpdate = jest.fn((update: NetworkStatusUpdate) => { + expect(update.diff != undefined || update.status != undefined).toBeTruthy(); + }); + const onError = jest.fn((_code: number, _message: string) => {}); + const networkStatusListener: RPCSubscriptionServiceListener = { + onConnected: () => { + networkStatusClient.subscribe(); + }, + onSubscribed, + onUnsubscribed, + onDisconnected, + onUpdate, + onError, + }; + const networkStatusClient = new RPCSubscriptionService( + Kusama.networkStatusServiceURL, + 'subscribe_networkStatus', + 'unsubscribe_networkStatus', + networkStatusListener, + ); + networkStatusClient.connect(); + await new Promise((resolve) => setTimeout(resolve, 10_000)); + networkStatusClient.unsubscribe(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + networkStatusClient.disconnect(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + expect(onSubscribed).toBeCalled(); + expect(onUpdate).toBeCalled(); + expect(onUnsubscribed).toBeCalled(); + expect(onDisconnected).toBeCalled(); + expect(onError).not.toBeCalled(); + }); + test('active validator list service subscription works', async () => { + const onSubscribed = jest.fn((_subscriptionId: number) => {}); + const onUnsubscribed = jest.fn((_subscriptionId: number) => {}); + const onDisconnected = jest.fn(); + const onUpdate = jest.fn((_update: ValidatorListUpdate) => {}); + const onError = jest.fn((_code: number, _message: string) => {}); + const activeValidatorListListener: RPCSubscriptionServiceListener = { + onConnected: () => { + activeValidatorListClient.subscribe(); + }, + onSubscribed, + onUnsubscribed, + onDisconnected, + onUpdate, + onError, + }; + const activeValidatorListClient = new RPCSubscriptionService( + Kusama.activeValidatorListServiceURL, + 'subscribe_validatorList', + 'unsubscribe_validatorList', + activeValidatorListListener, + ); + activeValidatorListClient.connect(); + await new Promise((resolve) => setTimeout(resolve, 10_000)); + activeValidatorListClient.unsubscribe(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + activeValidatorListClient.disconnect(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + expect(onSubscribed).toBeCalled(); + expect(onUpdate).toBeCalled(); + expect(onUnsubscribed).toBeCalled(); + expect(onDisconnected).toBeCalled(); + expect(onError).not.toBeCalled(); + }); +}); diff --git a/tests/ui-util.test.ts b/tests/ui-util.test.ts new file mode 100644 index 0000000..07f94ab --- /dev/null +++ b/tests/ui-util.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from '@jest/globals'; +import { validators } from './data/validators'; +import { getValidatorSummaryDisplay } from '../src/client/util/ui-util'; + +describe('ui util', () => { + test('generates correct validator summary display with identity', async () => { + const expectedDisplay = '🏔 HELIKON 🏔 / ISTANBUL'; + expect(getValidatorSummaryDisplay(validators[0])).toEqual(expectedDisplay); + }); + test('generates correct validator summary display without identity', async () => { + const expectedDisplay = 'HTauJM...DG6ef2'; + expect(getValidatorSummaryDisplay(validators[4])).toEqual(expectedDisplay); + }); +});