aleeusgr / potential-robot

minimal dApp testing suite
MIT License
3 stars 1 forks source link

review #120

Closed aleeusgr closed 10 months ago

aleeusgr commented 1 year ago
import {
    Address,
    Crypto,
    NetworkEmulator,
    NetworkParams,
    Program,
    Tx,
    TxId,
    UplcProgram,
    Wallet,
    WalletEmulator,
} from "@hyperionbt/helios";
import * as helios from "@hyperionbt/helios";

import { promises as fs } from "fs";
import { Vitest, vitest, TestContext } from "vitest";

type paramsBase = Record<string, any>;
type actorMap = Record<string, WalletEmulator>;

export type HelperFunctions = Record<string, (...args:any) => Promise<any>>

export const ADA = 1_000_000n; // lovelace
export interface HeliosTestingContext<
    H extends HelperFunctions,
> {
    helios: typeof helios;
    myself?: Wallet;
    network: NetworkEmulator;
    liveSlotParams: NetworkParams;
    networkParams: NetworkParams;
    h: H, // alias for helpers
    helpers: H, 
    delay: typeof delay,
    state: Record<string, any>
    addActor: typeof addActor;
    waitUntil: typeof waitUntil;
    currentSlot: typeof currentSlot;
    actors: actorMap;
    mkRandomBytes: typeof mkRandomBytes;
    //! it has a seed for mkRandomBytes, which must be set by caller
    randomSeed?: number;
    //! it makes a rand() function based on the randomSeed after first call to mkRandomBytes
    rand?: () => number;

    //! it manifests some details only after createContract()
    address?: Address;
    submitTx: typeof submitTx
}

async function delay(ms) { 
    return new Promise((res) => setTimeout(res, ms)) 
}

export async function addTestContext<H extends HelperFunctions>(
    context: TestContext,
    helpers: H
) {
    const tc = await mkContext(helpers, context);

    Object.assign(context, tc);
}

type enhancedNetworkParams = NetworkParams & {
    slotToTimestamp: typeof slotToTimestamp;
};
function slotToTimestamp(s: bigint) {
    const num = parseInt(BigInt.asIntN(52, s * 1000n).toString());
    return new Date(num);
}

// function mkRandom(s: bigint) {
//     const num = parseInt(BigInt.asIntN(52, s * 1000n).toString());
//     return new Date(num);
// }

const preProdParams = JSON.parse(
    await fs.readFile("./src/preprod.json", "utf8")
);
// emuParams.liveSlot;

export function mkNetwork(): [NetworkEmulator, enhancedNetworkParams] {
    const theNetwork = new NetworkEmulator();

    const emuParams = theNetwork.initNetworkParams(
        preProdParams
    ) as enhancedNetworkParams;

    emuParams.timeToSlot = function (t) {
        const seconds = BigInt(t / 1000n);
        return seconds;
    };
    emuParams.slotToTimestamp = slotToTimestamp;

    return [theNetwork, emuParams];
}

export async function mkContext<
    H extends HelperFunctions,
>(
    helpers: H,
    ctx
): Promise<HeliosTestingContext<H>> {
    const optimize = false;

    //! it explicitly binds the helper functions' `this` to the context object,
    //   to match the type-hints
    const h : HelperFunctions= Object.fromEntries(
        Object.entries(helpers).map(
            ([name, func]) => [name, func.bind(ctx)] 
        )
    );
    // let address;
    // try {
    //     address = Address.fromValidatorHash(uplc.validatorHash);
    // } catch (e) {
    //     if (e.message !== "unexpected") throw e;

    //     address = Address.fromValidatorHash(uplc.mintingPolicyHash);
    // }
    const [theNetwork, emuParams] = mkNetwork();
    const networkParams = new NetworkParams(preProdParams);

    const context = {
        helios,
        h, // alias
        helpers: h, //formal name
        actors: {},
        // address,
        // related,
        network: theNetwork,
        delay,
        liveSlotParams: emuParams,
        networkParams,
        // addRelatedContract,
        addActor,
        waitUntil,
        currentSlot,
        mkRandomBytes,
        submitTx,
        state:{},
    };
    const now = new Date();
    context.waitUntil(now);
    //@ts-expect-error - TODO verify whether the warning "could be instantiated with a different subtype" actually is a practical problem
    return context;
}

async function submitTx(
    this: HeliosTestingContext<any>,    
    tx: Tx,
    force? : "force"
) : Promise<TxId> { 
    const tina = this.actors.tina.address
    try {
        await tx.finalize(this.networkParams, tina);
    } catch(e) {
        throw (e)
    }

    const txId = this.network.submitTx(tx);
    this.network.tick(1n)
    // await this.delay(1000)
    // debugger
    // this.network.dump();
    return txId
}

function mkRandomBytes(
    this: HeliosTestingContext<any>,
    length: number
): number[] {
    if (!this.randomSeed)
        throw new Error(
            `test must set context.randomSeed for deterministic randomness in tests`
        );
    if (!this.rand) this.rand = Crypto.rand(this.randomSeed);

    const bytes: number[] = [];
    for (let i = 0; i < length; i++) {
        bytes.push(Math.floor(this.rand() * 256));
    }
    return bytes;
}

function addActor(
    this: HeliosTestingContext<any>,
    roleName: string,
    walletBalance: bigint
) {
    if (this.actors[roleName])
        throw new Error(`duplicate role name '${roleName}'`);
    //! it instantiates a wallet with the indicated balance pre-set
    const a = this.network.createWallet(walletBalance);
    console.log( `+🎭 Actor: ${roleName}: ${a.address.toBech32().substring(0,18)} ${walletBalance} `)

    //! it makes collateral for each actor, above and beyond the initial balance,
    //  ... so that the full balance is spendable and the actor can immediately
    //  ... engage in smart-contract interactions.

    this.network.tick(BigInt(2));
    this.network.createUtxo(a, 5n * ADA);

    this.actors[roleName] = a;
    return a;
}

function currentSlot(this: HeliosTestingContext<any>) {
    return this.liveSlotParams.liveSlot;
}

function waitUntil(this: HeliosTestingContext<any>, time: Date) {
    const targetTimeMillis = BigInt(time.getTime());
    const targetSlot = this.liveSlotParams.timeToSlot(targetTimeMillis);
    const c = this.currentSlot();

    const slotsToWait = targetSlot - c;
    if (slotsToWait < 1) {
        throw new Error(`the indicated time is not in the future`);
    }
    // console.warn(`waiting ${slotsToWait} slots -> ${time}`);

    this.network.tick(slotsToWait);
    return slotsToWait;
}
aleeusgr commented 1 year ago

scoped variables and scoped helper functions can provide re-usability in setup, and also cleaner/ modular setup code

aleeusgr commented 1 year ago
describe("vesting contract", () => {
  let vestingProgram, initiatorWallet, recipientWallet;
  beforeEach(() => { 
    vestingProgram = new helios.Program( ... ); 
     ...
  })
  it ("tests things while reusing provided `vestingProgram`", async () => {
    ...
  })
})
aleeusgr commented 1 year ago

https://discord.com/channels/@me/960928001791512576/1107677305406509057

aleeusgr commented 1 year ago

I'll suggest starting a lightweight outline of testable expectations.

Formatting test files using that kind of outline provides good organization and structure for the testing code.

outline in test DSL:

describe("vesting contract"), () => {
  describe("contract initiation", () => {
    it("holds assets for vesting", async() => {
    })
    it("lets the initiator take their own funds back, until the contract is claimed", async () => {})
  })
  describe("contract claim", async() => {
     it("allows the recipient to mint a claim token they can hold in their wallet" async() => {
})
     it("doesn't let the initiator withdraw funds once claimed", async() => {
})
  })

  describe("gradual maturation", () => {
    ...
  })

  describe("reclaiming funds after long period of inactivity" () => {
    ...
  })
})