diff --git a/src/collections/index.js b/src/collections/index.js index 739be1a26..48cf8c752 100644 --- a/src/collections/index.js +++ b/src/collections/index.js @@ -2,10 +2,12 @@ import { LookupMap } from "./lookup-map"; import { Vector } from "./vector"; import { LookupSet } from "./lookup-set"; import { UnorderedMap } from "./unordered-map"; +import { UnorderedSet } from "./unordered-set"; export { LookupMap, Vector, LookupSet, - UnorderedMap + UnorderedMap, + UnorderedSet } \ No newline at end of file diff --git a/src/collections/unordered-set.js b/src/collections/unordered-set.js new file mode 100644 index 000000000..e00a82e3a --- /dev/null +++ b/src/collections/unordered-set.js @@ -0,0 +1,108 @@ +import * as near from '../api' +import {u8ArrayToString, stringToU8Array} from '../utils' +import { Vector } from './vector' + +const ERR_INCONSISTENT_STATE = "The collection is an inconsistent state. Did previous smart contract execution terminate unexpectedly?" + +export class UnorderedSet { + constructor(prefix) { + this.length = 0 + this.elementIndexPrefix = prefix + 'i' + let elementsPrefix = prefix + 'e' + this.elements = new Vector(elementsPrefix) + } + + len() { + return this.elements.len() + } + + isEmpty() { + return this.elements.isEmpty() + } + + serializeIndex(index) { + let data = new Uint32Array([index]) + let array = new Uint8Array(data.buffer) + return u8ArrayToString(array) + } + + deserializeIndex(rawIndex) { + let array = stringToU8Array(rawIndex) + let data = new Uint32Array(array.buffer) + return data[0] + } + + contains(element) { + let indexLookup = this.elementIndexPrefix + element + return near.jsvmStorageHasKey(indexLookup) + } + + set(element) { + let indexLookup = this.elementIndexPrefix + element + if (near.jsvmStorageRead(indexLookup)) { + return false + } else { + let nextIndex = this.len() + let nextIndexRaw = this.serializeIndex(nextIndex) + near.jsvmStorageWrite(indexLookup, nextIndexRaw) + this.elements.push(element) + return true + } + } + + remove(element) { + let indexLookup = this.elementIndexPrefix + element + let indexRaw = near.jsvmStorageRead(indexLookup) + if (indexRaw) { + if (this.len() == 1) { + // If there is only one element then swap remove simply removes it without + // swapping with the last element. + near.jsvmStorageRemove(indexLookup) + } else { + // If there is more than one element then swap remove swaps it with the last + // element. + let lastElementRaw = this.elements.get(this.len() - 1) + if (!lastElementRaw) { + throw new Error(ERR_INCONSISTENT_STATE) + } + near.jsvmStorageRemove(indexLookup) + // If the removed element was the last element from keys, then we don't need to + // reinsert the lookup back. + if (lastElementRaw != element) { + let lastLookupElement = this.elementIndexPrefix + lastElementRaw + near.jsvmStorageWrite(lastLookupElement, indexRaw) + } + } + let index = this.deserializeIndex(indexRaw) + this.elements.swapRemove(index) + return true + } + return false + } + + clear() { + for (let element of this.elements) { + let indexLookup = this.elementIndexPrefix + element + near.jsvmStorageRemove(indexLookup) + } + this.elements.clear() + } + + toArray() { + let ret = [] + for (let v of this) { + ret.push(v) + } + return ret + } + + [Symbol.iterator]() { + return this.elements[Symbol.iterator]() + } + + extend(elements) { + for (let element of elements) { + this.set(element) + } + } +} diff --git a/src/index.js b/src/index.js index c9095e4c3..fabad2d1a 100644 --- a/src/index.js +++ b/src/index.js @@ -13,7 +13,8 @@ import { LookupMap, Vector, LookupSet, - UnorderedMap + UnorderedMap, + UnorderedSet } from './collections' export { @@ -25,5 +26,6 @@ export { LookupMap, Vector, LookupSet, - UnorderedMap + UnorderedMap, + UnorderedSet } \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..95f17681e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,14 @@ +# NEAR-SDK-JS Tests + +This tests the functionality of high level APIs of NEAR-SDK-JS. Currently, it directly tests all collections and indirectly tests all decorators, serialization/deserialization, utils, code generation and some important APIs. Majority of near-sdk-js can be seen as tested. + +# Run tests +``` +yarn +yarn build +yarn test +``` + +# Add a new test + +Create a test contract that covers the API you want to test in `src/`. Add a build command in `build.sh`. Write ava test in `__tests__`. \ No newline at end of file diff --git a/tests/__tests__/unordered-set.ava.js b/tests/__tests__/unordered-set.ava.js new file mode 100644 index 000000000..67bb8b0a8 --- /dev/null +++ b/tests/__tests__/unordered-set.ava.js @@ -0,0 +1,175 @@ +import { Worker } from 'near-workspaces'; +import { readFile } from 'fs/promises' +import test from 'ava'; + +function encodeCall(contract, method, args) { + return Buffer.concat([Buffer.from(contract), Buffer.from([0]), Buffer.from(method), Buffer.from([0]), Buffer.from(JSON.stringify(args))]) +} + +test.beforeEach(async t => { + // Init the worker and start a Sandbox server + const worker = await Worker.init(); + + // Prepare sandbox for tests, create accounts, deploy contracts, etx. + const root = worker.rootAccount; + + // Deploy the jsvm contract. + const jsvm = await root.createAndDeploy( + root.getSubAccount('jsvm').accountId, + '../res/jsvm.wasm', + ); + + // Deploy test JS contract + const testContract = await root.createSubAccount('test-contract'); + let contract_base64 = (await readFile('build/unordered-set.base64')).toString(); + await testContract.call(jsvm, 'deploy_js_contract', Buffer.from(contract_base64, 'base64'), { attachedDeposit: '400000000000000000000000' }); + await testContract.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'init', []), { attachedDeposit: '400000000000000000000000' }); + + // Test users + const ali = await root.createSubAccount('ali'); + const bob = await root.createSubAccount('bob'); + const carl = await root.createSubAccount('carl'); + + // Save state for test runs + t.context.worker = worker; + t.context.accounts = { root, jsvm, testContract, ali, bob, carl }; +}); + +test.afterEach(async t => { + await t.context.worker.tearDown().catch(error => { + console.log('Failed to tear down the worker:', error); + }); +}); + +test('UnorderedSet is empty by default', async t => { + const { root, jsvm, testContract } = t.context.accounts; + const result = await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'len', [root.accountId])); + t.is(result, 0); + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'isEmpty', [])), + true + ); +}); + +test('UnorderedSet set() contains()', async t => { + const { ali, jsvm, testContract } = t.context.accounts; + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'contains', ['hello'])), + false + ); + + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'set', ['hello']), { attachedDeposit: '100000000000000000000000' }); + + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'contains', ['hello'])), + true + ); +}); + + +test('UnorderedSet insert, len and iterate', async t => { + const { ali, jsvm, testContract } = t.context.accounts; + + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'len', [])), + 0 + ); + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + [] + ); + + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'set', ['hello']), { attachedDeposit: '100000000000000000000000' }); + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'len', [])), + 1 + ); + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'set', ['hello1']), { attachedDeposit: '100000000000000000000000' }); + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'len', [])), + 2 + ); + + // insert the same value, len shouldn't change + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'set', ['hello1']), { attachedDeposit: '100000000000000000000000' }); + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'len', [])), + 2 + ); + + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + ['hello', 'hello1'] + ); +}); + +test('UnorderedSet extend, remove, clear', async t => { + const { ali, jsvm, testContract } = t.context.accounts; + + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'extend', [['hello', 'world', 'hello1']]), { attachedDeposit: '100000000000000000000000' }); + + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + ['hello', 'world', 'hello1'] + ); + + // remove non existing element should not error + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'remove', ['hello3']), { attachedDeposit: '100000000000000000000000' }); + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + ['hello', 'world', 'hello1'] + ); + + // remove not the last one should work + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'remove', ['hello']), { attachedDeposit: '100000000000000000000000' }); + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + ['hello1', 'world'] + ); + + // remove the last one should work + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'remove', ['world']), { attachedDeposit: '100000000000000000000000' }); + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + ['hello1'] + ); + + // remove when length is 1 should work + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'len', [])), + 1 + ); + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'isEmpty', [])), + false + ); + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'remove', ['hello1']), { attachedDeposit: '100000000000000000000000' }); + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + [] + ); + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'isEmpty', [])), + true + ); + + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'extend', [['hello', 'world', 'hello1']]), { attachedDeposit: '100000000000000000000000' }); + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + ['hello', 'world', 'hello1'] + ); + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'isEmpty', [])), + false + ); + // clear should work + await ali.call(jsvm, 'call_js_contract', encodeCall(testContract.accountId, 'clear', []), { attachedDeposit: '100000000000000000000000' }); + t.deepEqual( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'toArray', [])), + [] + ); + t.is( + await jsvm.view('view_js_contract', encodeCall(testContract.accountId, 'isEmpty', [])), + true + ); +}) \ No newline at end of file diff --git a/tests/build.sh b/tests/build.sh index 6c17fab91..5e55f51b4 100755 --- a/tests/build.sh +++ b/tests/build.sh @@ -5,3 +5,4 @@ near-sdk build src/unordered-map.js build/unordered-map.base64 near-sdk build src/vector.js build/vector.base64 near-sdk build src/lookup-map.js build/lookup-map.base64 near-sdk build src/lookup-set.js build/lookup-set.base64 +near-sdk build src/unordered-set.js build/unordered-set.base64 diff --git a/tests/src/unordered-set.js b/tests/src/unordered-set.js new file mode 100644 index 000000000..6d9bc6a1e --- /dev/null +++ b/tests/src/unordered-set.js @@ -0,0 +1,63 @@ +import { + NearContract, + NearBindgen, + call, + view, + UnorderedSet, + Vector +} from 'near-sdk-js' + +@NearBindgen +class UnorderedSetTestContract extends NearContract { + constructor() { + super() + this.unorderedSet = new UnorderedSet('a'); + } + + deserialize() { + super.deserialize() + this.unorderedSet.elements = Object.assign(new Vector, this.unorderedSet.elements) + this.unorderedSet = Object.assign(new UnorderedSet, this.unorderedSet) + } + + @view + len() { + return this.unorderedSet.len(); + } + + @view + isEmpty() { + return this.unorderedSet.isEmpty(); + } + + @view + contains(element) { + return this.unorderedSet.contains(element); + } + + @call + set(element) { + this.unorderedSet.set(element); + } + + @call + remove(element) { + this.unorderedSet.remove(element); + } + + @call + clear() { + this.unorderedSet.clear(); + } + + @view + toArray() { + return this.unorderedSet.toArray(); + } + + @call + extend(elements) { + this.unorderedSet.extend(elements); + } +} +