/**
 * SHADES — tiny test harness for selftest.html. Lane A owns this file.
 *
 * Deliberately not a framework. The only rules that matter:
 *   - No rAF. requestAnimationFrame is throttled to a crawl in a hidden tab,
 *     and a selftest that only passes while you watch it is not a selftest.
 *     Drive time with fixedLoop() below.
 *   - No Date.now(), no Math.random() in anything under test.
 *
 * Each lane owns js/tests/<lane>.test.js and nobody edits selftest.html.
 */

export function assert(cond, msg = 'assertion failed') {
  if (!cond) throw new Error(msg);
}

export function assertEq(actual, expected, msg = '') {
  if (!Object.is(actual, expected)) {
    throw new Error(`${msg || 'not equal'}: got ${format(actual)}, want ${format(expected)}`);
  }
}

export function assertClose(actual, expected, eps = 1e-6, msg = '') {
  if (!(Math.abs(actual - expected) <= eps)) {
    throw new Error(`${msg || 'not close'}: got ${actual}, want ${expected} ±${eps}`);
  }
}

export function assertLess(a, b, msg = '') {
  if (!(a < b)) throw new Error(`${msg || 'not less'}: ${a} is not < ${b}`);
}

function format(v) {
  if (typeof v === 'number') return String(v);
  try { return JSON.stringify(v); } catch { return String(v); }
}

/**
 * Drive a sim forward in fixed steps. This is how you fast-forward a storm.
 *
 * @param {number} seconds   Sim seconds to advance.
 * @param {number} dt        Step size — pass contracts.FIXED_DT.
 * @param {(dt:number, t:number) => void} fn  Called with the time at step start.
 */
export function fixedLoop(seconds, dt, fn) {
  const steps = Math.round(seconds / dt);
  let t = 0;
  for (let i = 0; i < steps; i++) {
    fn(dt, t);
    t += dt;
  }
  return t;
}

/** Collects results for one lane. */
export class Suite {
  constructor(lane) {
    this.lane = lane;
    /** @type {{lane:string, label:string, status:'pass'|'fail'|'skip', err?:string, ms:number}[]} */
    this.results = [];
  }

  /**
   * A test passes by returning, fails by throwing, and SKIPS by returning a
   * string that starts with 'SKIPPED'.
   *
   * That last clause is not sugar — it is a repair. This method used to ignore
   * its return value entirely, so a test that did
   *     if (!ok) return `SKIPPED — harness dispute: ...`;
   * was recorded as a PASS. Two suites had already been written that way (the
   * integrator's storm_02 skip at the Sprint-6 merge, and a storm_01 one), which
   * means main has been reporting green on asserts that were neither passing nor
   * skipping — they returned a message and the suite called it a win. `skip: 0`
   * in every report we ever printed was the tell, and nobody read it, including
   * me: I misread one of those fake passes as my own win reproducing.
   *
   * It's the same disease as the wind router swallowing rainMmPerHour — a
   * mechanism that looks wired and silently isn't — and it lived in the harness
   * every lane trusts. A skip must be visible or it is a lie with good manners.
   */
  test(label, fn) {
    const t0 = performance.now();
    try {
      const out = fn();
      if (typeof out === 'string' && out.startsWith('SKIPPED')) {
        this.results.push({
          lane: this.lane, label, status: 'skip',
          err: out.replace(/^SKIPPED\s*[—-]?\s*/, ''), ms: performance.now() - t0,
        });
        return;
      }
      if (out && typeof out.then === 'function') {
        // An async fn hands back a Promise that cannot throw synchronously, so
        // this would pass forever while proving nothing. Lane B called this out
        // in balance.test.js's header; make it impossible rather than a warning.
        throw new Error(`test "${label}" is async — Suite.test() cannot await it. ` +
          `Do the awaiting in run() and assert synchronously.`);
      }
      this.results.push({ lane: this.lane, label, status: 'pass', ms: performance.now() - t0 });
    } catch (err) {
      this.results.push({
        lane: this.lane, label, status: 'fail',
        err: err?.message ?? String(err), ms: performance.now() - t0,
      });
    }
  }

  /** For a lane that hasn't landed yet. Shows up in the report, doesn't fail it. */
  skip(label) {
    this.results.push({ lane: this.lane, label, status: 'skip', ms: 0 });
  }
}

/**
 * @param {[string, (t: Suite) => void|Promise<void>][]} lanes
 * @returns {Promise<{pass:number, fail:number, skip:number, ok:boolean, results:object[]}>}
 */
export async function runAll(lanes) {
  const results = [];
  for (const [lane, run] of lanes) {
    const suite = new Suite(lane);
    try {
      await run(suite);
    } catch (err) {
      // A lane whose module fails to import or throws at setup must not take
      // the whole report down with it — that's how you lose the signal from
      // every other lane during a merge.
      suite.results.push({
        lane, label: '(suite threw during setup)', status: 'fail',
        err: err?.message ?? String(err), ms: 0,
      });
    }
    results.push(...suite.results);
  }
  const pass = results.filter((r) => r.status === 'pass').length;
  const fail = results.filter((r) => r.status === 'fail').length;
  const skip = results.filter((r) => r.status === 'skip').length;
  return { pass, fail, skip, ok: fail === 0, results };
}
