// Tests for the dust threshold field in Settings (issue #233). // // Two halves: what the parse accepts, and what the settings view does with a // rejection. The view half runs against the real change handler with the DOM // helpers stubbed out, because the bug was not in the parse — it was that a // rejection said nothing. const { DUST_THRESHOLD_MESSAGE, parseDustThresholdGwei, } = require("../src/popup/dustThreshold"); describe("parsing the dust threshold", () => { test("accepts a whole number of gwei", () => { expect(parseDustThresholdGwei("100000")).toBe(100000); expect(parseDustThresholdGwei("1")).toBe(1); }); // Zero is a real setting, not an empty field: it hides nothing. test("accepts zero", () => { expect(parseDustThresholdGwei("0")).toBe(0); }); test("accepts surrounding whitespace", () => { expect(parseDustThresholdGwei(" 250 ")).toBe(250); }); test("rejects an empty field", () => { expect(parseDustThresholdGwei("")).toBe(null); expect(parseDustThresholdGwei(" ")).toBe(null); }); test("rejects a negative threshold", () => { expect(parseDustThresholdGwei("-1")).toBe(null); }); // parseInt used to read this as 1, which is not what was typed. test("rejects a fractional value", () => { expect(parseDustThresholdGwei("1.5")).toBe(null); expect(parseDustThresholdGwei("1.0")).toBe(null); }); // parseInt used to read this as 100. The unit is printed beside the // field already. test("rejects a value carrying its unit", () => { expect(parseDustThresholdGwei("100 gwei")).toBe(null); }); // Number() reads this as 16. Storing 16 for a field that was told to // want a whole number of gwei would be the same silent substitution the // message exists to end. test("rejects hex notation", () => { expect(parseDustThresholdGwei("0x10")).toBe(null); }); // Number() reads this as 1000. test("rejects exponent notation", () => { expect(parseDustThresholdGwei("1e3")).toBe(null); }); test("rejects other non-numeric input", () => { expect(parseDustThresholdGwei("lots")).toBe(null); expect(parseDustThresholdGwei("+5")).toBe(null); expect(parseDustThresholdGwei("Infinity")).toBe(null); expect(parseDustThresholdGwei(undefined)).toBe(null); expect(parseDustThresholdGwei(5)).toBe(null); }); // Beyond 2^53 the digits would round on the way in, so the stored // threshold would not be the one typed. test("rejects a value too large to hold exactly", () => { expect(parseDustThresholdGwei("9007199254740993")).toBe(null); }); }); describe("the rejection message", () => { // README, Language & Labeling: error messages are full sentences. test("is a full sentence naming the constraint", () => { expect(DUST_THRESHOLD_MESSAGE).toMatch(/^[A-Z].*\.$/); expect(DUST_THRESHOLD_MESSAGE).toContain("whole number of gwei"); expect(DUST_THRESHOLD_MESSAGE).toContain("zero or greater"); }); }); describe("showing the message shifts no layout", () => { const fs = require("fs"); const path = require("path"); const POPUP_HTML = fs.readFileSync( path.join(__dirname, "..", "src", "popup", "index.html"), "utf8", ); // README, No Layout Shift: the message goes to the always-present flash // line, whose height is reserved whether or not it holds text. Nothing // is inserted next to the field itself. test("the flash line is always present with its height reserved", () => { const flashLine = POPUP_HTML.match( / { let elements; let flashes; let saves; let state; // A stand-in for one DOM node: enough of an element for init() to set // properties on it and hang listeners off it. function fakeElement() { return { value: "", checked: false, textContent: "", href: "", style: {}, dataset: {}, classList: { add() {}, remove() {} }, listeners: {}, addEventListener(event, handler) { this.listeners[event] = handler; }, querySelectorAll: () => [], }; } function loadSettingsView() { elements = {}; flashes = []; saves = 0; jest.resetModules(); jest.doMock("../src/popup/views/helpers", () => ({ $: (id) => (elements[id] ||= fakeElement()), showView: () => {}, updateDebugBanner: () => {}, showFlash: (msg) => flashes.push(msg), escapeHtml: (s) => s, flashCopyFeedback: () => {}, goBack: () => {}, pushCurrentView: () => {}, onViewLeave: () => {}, VIEWS: [], })); state = require("../src/shared/state").state; state.dustThresholdGwei = 100000; const settings = require("../src/popup/views/settings"); settings.init({}); return elements["settings-dust-threshold"]; } beforeEach(() => { globalThis.chrome = { runtime: { sendMessage: () => {} }, storage: { local: { get: async () => ({}), set: async () => { saves++; }, }, }, }; }); afterEach(() => { jest.dontMock("../src/popup/views/helpers"); delete globalThis.chrome; }); async function change(field, typed) { field.value = typed; await field.listeners.change(); } test("a valid value is stored and says nothing", async () => { const field = loadSettingsView(); await change(field, "250"); expect(state.dustThresholdGwei).toBe(250); expect(field.value).toBe(250); expect(flashes).toEqual([]); expect(saves).toBe(1); }); test("a rejected value shows the message and is not stored", async () => { const field = loadSettingsView(); await change(field, "1.5"); expect(state.dustThresholdGwei).toBe(100000); expect(flashes).toEqual([DUST_THRESHOLD_MESSAGE]); expect(saves).toBe(0); }); // The snap-back is the behaviour the message explains, so it stays. test("a rejected value still resyncs the field to what is stored", async () => { const field = loadSettingsView(); await change(field, "100 gwei"); expect(field.value).toBe(100000); }); test("every rejected notation gets the same one message", async () => { for (const typed of ["", "-1", "1.5", "100 gwei", "0x10", "1e3"]) { const field = loadSettingsView(); await change(field, typed); expect(flashes).toEqual([DUST_THRESHOLD_MESSAGE]); expect(state.dustThresholdGwei).toBe(100000); } }); test("zero is accepted, not treated as an empty field", async () => { const field = loadSettingsView(); await change(field, "0"); expect(state.dustThresholdGwei).toBe(0); expect(flashes).toEqual([]); }); });