import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, '..'); test('userscript embeds a copy of src/pure.js that is present and parseable', () => { const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8'); // The bundle must reference all four pure function names. for (const name of ['parseTarget', 'computeEstimate', 'pruneHistory', 'summary']) { assert.ok(bundle.includes('function ' + name), 'missing ' + name + ' in bundle'); } // The bundle must include the Tampermonkey header. assert.ok(bundle.includes('// ==UserScript=='), 'missing Tampermonkey header'); // The bundle must include the @match directive. assert.ok(bundle.includes('@match'), 'missing @match'); assert.ok(bundle.includes('torn.com/gym.php'), 'missing torn.com/gym.php match'); }); test('userscript self-test block is wired to #tat-test', () => { const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8'); assert.ok(bundle.includes("location.hash === '#tat-test'"), 'self-test guard missing'); assert.ok(bundle.includes('runSelfTest'), 'runSelfTest function missing'); }); test('bundle summary behavior matches src/pure.js (catches Math.floor-style regressions)', () => { // Read both the source and the bundle. const src = readFileSync(join(root, 'src/pure.js'), 'utf8'); const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8'); // Extract the body of `summary` from src/pure.js between `function summary(` and the next `}`. // Use a simple state machine: find the function header, then count braces. function extractBody(source, fnName) { const start = source.indexOf('function ' + fnName + '('); if (start === -1) throw new Error('could not find ' + fnName + ' in source'); // Find the opening brace of the function body. let i = source.indexOf('{', start); if (i === -1) throw new Error('no opening brace for ' + fnName); let depth = 1; i++; while (i < source.length && depth > 0) { const c = source[i]; if (c === '{') depth++; else if (c === '}') depth--; i++; } return source.slice(start, i); } // Eval-extract the `summary` function from both sources. The src/pure.js // version references the module-level `MS_PER_DAY` constant, so inject // it into the eval scope; the bundle inlines the literal value, so the // injected binding is a harmless no-op there. const srcSummary = eval('(function() { const MS_PER_DAY = 86_400_000; ' + extractBody(src, 'summary') + ' ; return summary; })()'); const bundleSummary = eval('(function() { const MS_PER_DAY = 86_400_000; ' + extractBody(bundle, 'summary') + ' ; return summary; })()'); // Run both with a known input that exercises perDay flooring. const NOW = 1_700_000_000_000; const entries = [ { ts: NOW - 1000, delta: 247 }, { ts: NOW - 2000, delta: 247 }, ]; const srcResult = srcSummary(entries, NOW); const bundleResult = bundleSummary(entries, NOW); // The bundle MUST match the source exactly. assert.deepEqual(bundleResult, srcResult, 'bundle summary differs from src/pure.js summary'); // And specifically: perDay must be the floored integer 70, not the float 70.57. assert.equal(bundleResult.perDay, 70, 'perDay should be floored to 70'); }); test('bundle parseTarget behavior matches src/pure.js', () => { const src = readFileSync(join(root, 'src/pure.js'), 'utf8'); const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8'); function extractBody(source, fnName) { const start = source.indexOf('function ' + fnName + '('); if (start === -1) throw new Error('could not find ' + fnName + ' in source'); let i = source.indexOf('{', start); if (i === -1) throw new Error('no opening brace for ' + fnName); let depth = 1; i++; while (i < source.length && depth > 0) { const c = source[i]; if (c === '{') depth++; else if (c === '}') depth--; i++; } return source.slice(start, i); } // Both src and bundle reference module-scope `SUFFIXES`, so inject it for both. const srcFn = eval('(function() { const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }; ' + extractBody(src, 'parseTarget') + ' ; return parseTarget; })()'); const bundleFn = eval('(function() { const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }; ' + extractBody(bundle, 'parseTarget') + ' ; return parseTarget; })()'); for (const input of [25, 25000000, '25M', '1.5B', '25,000,000', 'abc', null, undefined, 0, -1]) { assert.equal(bundleFn(input), srcFn(input), 'parseTarget drift on input: ' + JSON.stringify(input)); } }); test('bundle computeEstimate behavior matches src/pure.js', () => { const src = readFileSync(join(root, 'src/pure.js'), 'utf8'); const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8'); function extractBody(source, fnName) { const start = source.indexOf('function ' + fnName + '('); if (start === -1) throw new Error('could not find ' + fnName + ' in source'); let i = source.indexOf('{', start); if (i === -1) throw new Error('no opening brace for ' + fnName); let depth = 1; i++; while (i < source.length && depth > 0) { const c = source[i]; if (c === '{') depth++; else if (c === '}') depth--; i++; } return source.slice(start, i); } // The bundle inlines the 86_400_000 constant; src/pure.js uses MS_PER_DAY. // Both versions call `Date.now()` inside the function for the `eta` Date, so // we strip `eta` from the comparison (it would differ by sub-millisecond // jitter between the two evals) and only compare the numeric fields. const srcFn = eval('(function() { const MS_PER_DAY = 86_400_000; ' + extractBody(src, 'computeEstimate') + ' ; return computeEstimate; })()'); const bundleFn = eval('(function() { ' + extractBody(bundle, 'computeEstimate') + ' ; return computeEstimate; })()'); function stripEta(r) { return { remaining: r.remaining, trainsToGo: r.trainsToGo, days: r.days, etaDays: r.days }; } for (const args of [ [14_328_501, 25_000_000, 247, 4520], [25_000_000, 25_000_000, 247, 4520], [30_000_000, 25_000_000, 247, 4520], [100, 200, 0, 50], [100, 200, 50, 0], ]) { const [c, t, pt, pd] = args; const srcResult = srcFn(c, t, pt, pd); const bundleResult = bundleFn(c, t, pt, pd); // Both eta fields should be null together, or both should be Date objects. assert.equal(bundleResult.eta === null, srcResult.eta === null, 'eta nullity drift on args: ' + JSON.stringify(args)); assert.deepEqual(stripEta(bundleResult), stripEta(srcResult), 'computeEstimate drift on args: ' + JSON.stringify(args)); } }); test('bundle pruneHistory behavior matches src/pure.js (catches strict-boundary regressions)', () => { const src = readFileSync(join(root, 'src/pure.js'), 'utf8'); const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8'); function extractBody(source, fnName) { const start = source.indexOf('function ' + fnName + '('); if (start === -1) throw new Error('could not find ' + fnName + ' in source'); let i = source.indexOf('{', start); if (i === -1) throw new Error('no opening brace for ' + fnName); let depth = 1; i++; while (i < source.length && depth > 0) { const c = source[i]; if (c === '{') depth++; else if (c === '}') depth--; i++; } return source.slice(start, i); } // The bundle inlines 30 * 86_400_000; src uses THIRTY_DAYS_MS = 30 * MS_PER_DAY. // Both must be visible to the extracted function body. const srcFn = eval('(function() { const MS_PER_DAY = 86_400_000; const THIRTY_DAYS_MS = 30 * MS_PER_DAY; ' + extractBody(src, 'pruneHistory') + ' ; return pruneHistory; })()'); const bundleFn = eval('(function() { const THIRTY_DAYS_MS = 30 * 86_400_000; ' + extractBody(bundle, 'pruneHistory') + ' ; return pruneHistory; })()'); const NOW = 1_700_000_000_000; const DAY = 86_400_000; // Critical case: entry at exactly 30 days. Source drops it (strict >), bundle should too. const boundaryEntries = [ { ts: NOW, delta: 1 }, { ts: NOW - 1 * DAY, delta: 1 }, { ts: NOW - 29 * DAY, delta: 1 }, { ts: NOW - 30 * DAY, delta: 1 }, // exactly 30 days — should be DROPPED { ts: NOW - 31 * DAY, delta: 1 }, ]; const srcResult = srcFn(boundaryEntries, NOW); const bundleResult = bundleFn(boundaryEntries, NOW); assert.deepEqual(bundleResult, srcResult, 'pruneHistory drift on 30-day boundary'); assert.equal(srcResult.length, 3, 'source should keep 3 entries (drop the 30-day one)'); assert.equal(bundleResult.length, 3, 'bundle should keep 3 entries (drop the 30-day one)'); });