diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml new file mode 100644 index 0000000..3d1fb9c --- /dev/null +++ b/.github/workflows/js-tests.yml @@ -0,0 +1,18 @@ +name: JavaScript tests + +on: + push: + branches: [develop] + + pull_request: + branches: [develop, main] + +jobs: + node-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - run: node --test "tests/js/*.test.mjs" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..a9c3fc2 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,20 @@ +name: Python tests + +on: + push: + branches: [develop] + + pull_request: + branches: [develop, main] + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: pip install -r requirements.txt + - run: python -m pytest tests -q diff --git a/js/tool.js b/js/tool.js index 92831d6..189a0d2 100644 --- a/js/tool.js +++ b/js/tool.js @@ -57,7 +57,7 @@ function onEnter(step) { if (step === STEP.ALPHA) refit(); else if (step === STEP.SCATTER) renderScatter(false); else if (step === STEP.FIT) renderScatter(true); - else if (step === STEP.ODYSSEY) updateOdyssey(); + else if (step === STEP.ODYSSEY) enterOdyssey(); } function refit() { @@ -136,12 +136,25 @@ function refreshGate() { /* ── Step 5: Odyssey sliders ── */ function buildSliders() { - const fabula = document.getElementById("fabulaSlider"); - const syuzhet = document.getElementById("syuzhetSlider"); - fabula.value = pipeline.odyssey.fabula; - syuzhet.value = pipeline.odyssey.syuzhet; - fabula.addEventListener("input", updateOdyssey); - syuzhet.addEventListener("input", updateOdyssey); + document.getElementById("fabulaSlider").addEventListener("input", updateOdyssey); + document.getElementById("syuzhetSlider").addEventListener("input", updateOdyssey); +} + +// Cached per fit: invalidated with `personal` whenever ratings change. +function optimalFor(p) { + return (p.optimal ??= computeOptimal(p)); +} + +function enterOdyssey() { + if (!personal) refit(); + // Seed the sliders with this person's ideal — the same values the share + // card shows. Once seeded for a given fit, keep their manual tweaks. + if (!personal.optimal) { + const optimal = optimalFor(personal); + document.getElementById("fabulaSlider").value = optimal.fabula; + document.getElementById("syuzhetSlider").value = optimal.syuzhet; + } + updateOdyssey(); } function updateOdyssey() { @@ -191,7 +204,7 @@ function initShare() { async function openShare() { if (!personal) refit(); - const optimal = computeOptimal(personal); + const optimal = optimalFor(personal); const counts = oscarCounts(pipeline.oscars.coeffs, optimal.fabula, optimal.syuzhet); const canvas = document.createElement("canvas"); diff --git a/src/plotting/style.py b/src/plotting/style.py index 35ccf1a..1fb1058 100644 --- a/src/plotting/style.py +++ b/src/plotting/style.py @@ -18,6 +18,7 @@ GREEN_LIGHT = "#89dd9d" RUST = "#c4694d" RUST_DARK = "#a3502e" +GOLD = "#d4a017" GRID = "#d8d8d8" BORDER = "#cccccc" INK = "#1a1a1a" @@ -109,6 +110,15 @@ def fit_line(ax, x, y, color: str = RUST, label: str = None, **kwargs): return ax.plot(x, y, **defaults) +def equation_box(ax, text: str): + """Small italic caption describing the fit, boxed to match the panel.""" + return ax.text(0.03, 0.95, text, transform=ax.transAxes, + fontsize=11, style="italic", fontfamily=FONT_SERIF, + color=INK, ha="left", va="top", + bbox=dict(boxstyle="round,pad=0.45", facecolor=BG, + edgecolor=BORDER, alpha=0.9)) + + def add_legend(ax): """Legend styled to match the panel.""" leg = ax.legend(fontsize=11.5, loc="lower left", diff --git a/tests/js/share.test.mjs b/tests/js/share.test.mjs index f437da6..57bda97 100644 --- a/tests/js/share.test.mjs +++ b/tests/js/share.test.mjs @@ -38,6 +38,18 @@ test("returned rating matches predictAt at the returned point", () => { assert.equal(opt.rating, predictAt(p, opt.fabula, opt.syuzhet)); }); +test("the optimum lands on the sliders' grid (0..10, step 0.1)", () => { + // tool.js seeds the range inputs with these values, so they must be + // in-range multiples of the 0.1 step or the browser would clamp/snap them + for (const coeffs of [[0.25, 0.5], [-0.25, 4.5], [-0.1, 1.2, 1.0]]) { + const opt = computeOptimal(personal(coeffs)); + for (const v of [opt.fabula, opt.syuzhet]) { + assert.ok(v >= 0 && v <= 10, `${v} out of range`); + assert.ok(Math.abs(v * 10 - Math.round(v * 10)) < 1e-9, `${v} off-grid`); + } + } +}); + test("the card URL is the real domain", () => { assert.equal(SITE_URL, "willtheodysseybegood.com"); }); diff --git a/tool.html b/tool.html index 46cb6e3..90e64c5 100644 --- a/tool.html +++ b/tool.html @@ -120,7 +120,8 @@

The vision emerges

The Odyssey (2026)

- Use the sliders to see how you'll like The Odyssey at different + The sliders start at your ideal scores for fabula and syuzhet. + Drag them around to see how you'll like The Odyssey at different degrees of insanity.