// What the screens that READ a stored token balance do with a holding whose // scale nothing knows. // // https://git.eeqj.de/sneak/AutistMask/issues/349 stopped `fetchTokenBalances()` // fabricating a scale of 18, so a row it cannot state a quantity for is now // stored with `balance: null`. Every reader of that field therefore has two // distinct inputs where it used to have one, and the property that has to hold // at each of them is the same one this codebase keeps losing: // // null (unknown) and 0 (genuinely zero) must produce DIFFERENT output. // // Losing it is what https://git.eeqj.de/sneak/AutistMask/issues/246, // https://git.eeqj.de/sneak/AutistMask/issues/306, // https://git.eeqj.de/sneak/AutistMask/issues/322, // https://git.eeqj.de/sneak/AutistMask/issues/359 and // https://git.eeqj.de/sneak/AutistMask/issues/364 each were. So every case // below asserts the pair, not just that the null branch does something // reasonable: an assertion on the null alone still passes on a build that // renders both as zero, which is precisely the build being guarded against. // // The writer half — that the fetcher stores null rather than 18 — is in // tests/fabricatedDecimals.test.js, and the Send and confirmation screens are // in tests/unknownScaleSend.test.js. "use strict"; // helpers.js reaches for both at module scope through the modules it pulls in. globalThis.chrome = { storage: { local: { get: () => Promise.resolve({}), set: () => Promise.resolve(), }, }, runtime: { sendMessage: () => {} }, }; globalThis.document = { getElementById: () => null, createElement: () => ({ style: {}, classList: { toggle() {} } }), body: { prepend: () => {} }, addEventListener: () => {}, }; const { balanceLine, balanceLinesForAddress, addressHoldsFunds, } = require("../src/popup/views/helpers"); const { prices, clearPrices, getAddressValue, } = require("../src/shared/prices"); const { state } = require("../src/shared/state"); const NOVEL = "0x1111111111111111111111111111111111111111"; // One stored tokenBalances row. `balance: null` is what balances.js writes for // a holding whose scale nothing knows; "0.0" is a quantity that was actually // established and is zero. function holding(balance) { return { address: NOVEL, symbol: "NOVEL", decimals: balance === null ? null : 18, balance, holders: 50000, }; } function address(balance) { return { address: "0x" + "a".repeat(40), balance: "0", tokenBalances: [holding(balance)], }; } // The quantity cell of a rendered row, which is the second of the two spans // inside the fixed-width span. function quantities(html) { return [...html.matchAll(/([^<]*)<\/span>/g)].map((m) => m[1]); } beforeEach(() => { clearPrices(); state.wallets = []; state.trackedTokens = []; state.activeAddress = null; }); afterEach(() => { clearPrices(); }); describe("balanceLine", () => { test("an unknown quantity and a zero one render differently", () => { const unknown = balanceLine("NOVEL", null, null, NOVEL); const zero = balanceLine("NOVEL", 0, null, NOVEL); expect(unknown).not.toBe(zero); expect(quantities(unknown)).toEqual(["NOVEL", "quantity unknown"]); expect(quantities(zero)).toEqual(["NOVEL", "0.0000"]); }); test("an unknown quantity produces no fiat figure, a zero one does", () => { prices.NOVEL = 3; const unknown = balanceLine("NOVEL", null, 3, NOVEL); const zero = balanceLine("NOVEL", 0, 3, NOVEL); // A price times an unknown quantity is not $0.00: that is the same // claim of "nothing here" the quantity cell just refused to make. expect(unknown).toContain( ' ', ); expect(zero).toContain( '$0.00', ); }); }); describe("balanceLinesForAddress", () => { // The show-zero setting is a statement about zeroes. An unknown quantity // is not one, so hiding the row would assert the zero nobody established // and the holding would vanish from the list entirely. test("hiding zero balances hides the zero row and keeps the unknown one", () => { const unknown = balanceLinesForAddress(address(null), [], false); const zero = balanceLinesForAddress(address("0.0"), [], false); expect(unknown).not.toBe(zero); expect(unknown).toContain("quantity unknown"); expect(unknown).toContain("NOVEL"); expect(zero).not.toContain("NOVEL"); }); test("showing zero balances still tells the two apart", () => { const unknown = balanceLinesForAddress(address(null), [], true); const zero = balanceLinesForAddress(address("0.0"), [], true); expect(unknown).not.toBe(zero); expect(quantities(unknown)).toEqual([ "ETH", "0.0000", "NOVEL", "quantity unknown", ]); expect(quantities(zero)).toEqual(["ETH", "0.0000", "NOVEL", "0.0000"]); }); }); describe("addressHoldsFunds", () => { // Read by deleteAddress.js to decide whether removing the address is // warned about. balances.js drops a row of zero base units before any // scale is consulted, so a row that survived with no quantity is holding // something, and the warning must err towards warning. test("an unknown balance holds funds, a zero balance does not", () => { expect(addressHoldsFunds(address(null))).toBe(true); expect(addressHoldsFunds(address("0.0"))).toBe(false); }); }); describe("getAddressValue", () => { // `usd` is the value of what could be priced and `partial` says it is a // floor rather than the total. An unpriceable holding is exactly what // `partial` exists for; a holding of zero can neither add to the total nor // make it incomplete. test("an unknown balance makes the total partial, a zero balance does not", () => { prices.ETH = 2000; prices.NOVEL = 3; const unknown = getAddressValue(address(null)); const zero = getAddressValue(address("0.0")); expect(unknown).not.toEqual(zero); expect(unknown).toEqual({ usd: 0, partial: true }); expect(zero).toEqual({ usd: 0, partial: false }); }); test("an unknown balance is not priced as zero of the token", () => { prices.ETH = 2000; prices.NOVEL = 3; // The same row with a real quantity of 10 is worth $30. Neither that // figure nor a confident $0.00 may be stated for the unknown one. expect(getAddressValue(address("10.0"))).toEqual({ usd: 30, partial: false, }); expect(getAddressValue(address(null)).usd).toBe(0); expect(getAddressValue(address(null)).partial).toBe(true); }); });